diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 7bc544e7eb..b8a6fc18f4 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -28,7 +28,7 @@ import TouchBarComponent from "./touch_bar.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; import { StartupChecks } from "./startup_checks.js"; -import type { CreateNoteOpts } from "../services/note_create.js"; +import type { CreateNoteOpts, CreateNoteWithUrlOpts } from "../services/note_create.js"; import { ColumnComponent } from "tabulator-tables"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import type RootContainer from "../widgets/containers/root_container.js"; @@ -357,8 +357,7 @@ export type CommandMappings = { // Table view addNewRow: CommandData & { - customOpts: CreateNoteOpts; - parentNotePath?: string; + customOpts?: CreateNoteWithUrlOpts; }; addNewTableColumn: CommandData & { columnToEdit?: ColumnComponent; diff --git a/apps/client/src/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 8a902666f9..ea3b2844e0 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -11,6 +11,7 @@ import froca from "../services/froca.js"; import linkService from "../services/link.js"; import { t } from "../services/i18n.js"; import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons"; +import noteCreateService from "../services/note_create.js"; export default class Entrypoints extends Component { constructor() { @@ -24,23 +25,9 @@ export default class Entrypoints extends Component { } async createNoteIntoInboxCommand() { - const inboxNote = await dateNoteService.getInboxNote(); - if (!inboxNote) { - console.warn("Missing inbox note."); - return; - } - - const { note } = await server.post(`notes/${inboxNote.noteId}/children?target=into`, { - content: "", - type: "text", - isProtected: inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable() - }); - - await ws.waitForMaxKnownEntityChangeId(); - - await appContext.tabManager.openTabWithNoteWithHoisting(note.noteId, { activate: true }); - - appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true }); + await noteCreateService.createNote( + { target: "inbox" } + ); } async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) { diff --git a/apps/client/src/components/main_tree_executors.ts b/apps/client/src/components/main_tree_executors.ts index 9bc037ccf3..df573acb1c 100644 --- a/apps/client/src/components/main_tree_executors.ts +++ b/apps/client/src/components/main_tree_executors.ts @@ -48,10 +48,15 @@ export default class MainTreeExecutors extends Component { return; } - await noteCreateService.createNote(activeNoteContext.notePath, { - isProtected: activeNoteContext.note.isProtected, - saveSelection: false - }); + await noteCreateService.createNote( + { + target: "into", + parentNoteUrl: activeNoteContext.notePath, + isProtected: activeNoteContext.note.isProtected, + saveSelection: false, + promptForType: false, + } + ); } async createNoteAfterCommand() { @@ -72,11 +77,14 @@ export default class MainTreeExecutors extends Component { return; } - await noteCreateService.createNote(parentNotePath, { - target: "after", - targetBranchId: node.data.branchId, - isProtected: isProtected, - saveSelection: false - }); + await noteCreateService.createNote( + { + target: "after", + parentNoteUrl: parentNotePath, + targetBranchId: node.data.branchId, + isProtected: isProtected, + saveSelection: false + } + ); } } diff --git a/apps/client/src/components/root_command_executor.ts b/apps/client/src/components/root_command_executor.ts index 632eb0a887..d2e50adc99 100644 --- a/apps/client/src/components/root_command_executor.ts +++ b/apps/client/src/components/root_command_executor.ts @@ -233,14 +233,18 @@ export default class RootCommandExecutor extends Component { // Create a new AI Chat note at the root level const rootNoteId = "root"; - const result = await noteCreateService.createNote(rootNoteId, { - title: "New AI Chat", - type: "aiChat", - content: JSON.stringify({ - messages: [], - title: "New AI Chat" - }) - }); + const result = await noteCreateService.createNote( + { + parentNoteUrl: rootNoteId, + target: "into", + title: "New AI Chat", + type: "aiChat", + content: JSON.stringify({ + messages: [], + title: "New AI Chat" + }), + } + ); if (!result.note) { toastService.showError("Failed to create AI Chat note"); diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 7384573d85..e060c8f09e 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -273,21 +273,31 @@ export default class TreeContextMenu implements SelectMenuItemEventListener((res, rej) => { +async function autocompleteSourceForCKEditor( + queryText: string, + createMode: CreateMode +): Promise { + // Wrap the callback-based autocompleteSource in a Promise for async/await + const rows = await new Promise((resolve) => { autocompleteSource( queryText, - (rows) => { - res( - rows.map((row) => { - return { - action: row.action, - noteTitle: row.noteTitle, - id: `@${row.notePathTitle}`, - name: row.notePathTitle || "", - link: `#${row.notePath}`, - notePath: row.notePath, - highlightedNotePathTitle: row.highlightedNotePathTitle - }; - }) - ); - }, + (suggestions) => resolve(suggestions), { - allowCreatingNotes: true + createMode, } ); }); + + // Map internal suggestions to CKEditor mention feed items + return rows.map((row): ExtendedMentionFeedObjectItem => ({ + action: row.action?.toString(), + noteTitle: row.noteTitle, + id: `@${row.notePathTitle}`, + name: row.notePathTitle || "", + link: `#${row.notePath}`, + notePath: row.notePath, + parentNoteId: row.parentNoteId, + highlightedNotePathTitle: row.highlightedNotePathTitle + })); } -async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { +async function autocompleteSource( + term: string, + callback: (rows: Suggestion[]) => void, + options: Options = {} +) { // Check if we're in command mode if (options.isCommandPalette && term.startsWith(">")) { const commandQuery = term.substring(1).trim(); // Get commands (all if no query, filtered if query provided) - const commands = commandQuery.length === 0 - ? commandRegistry.getAllCommands() - : commandRegistry.searchCommands(commandQuery); + const commands = + commandQuery.length === 0 + ? commandRegistry.getAllCommands() + : commandRegistry.searchCommands(commandQuery); // Convert commands to suggestions - const commandSuggestions: Suggestion[] = commands.map(cmd => ({ - action: "command", + const commandSuggestions: Suggestion[] = commands.map((cmd) => ({ + action: SuggestionAction.Command, commandId: cmd.id, noteTitle: cmd.name, notePathTitle: `>${cmd.name}`, highlightedNotePathTitle: cmd.name, commandDescription: cmd.description, commandShortcut: cmd.shortcut, - icon: cmd.icon + icon: cmd.icon, })); - cb(commandSuggestions); + callback(commandSuggestions); return; } - const fastSearch = options.fastSearch === false ? false : true; - if (fastSearch === false) { - if (term.trim().length === 0) { - return; - } - cb([ + const fastSearch = options.fastSearch !== false; + const trimmedTerm = term.trim(); + const activeNoteId = appContext.tabManager.getActiveContextNoteId(); + + if (!fastSearch && trimmedTerm.length === 0) return; + + if (!fastSearch) { + callback([ { - noteTitle: term, - highlightedNotePathTitle: t("quick-search.searching") - } + noteTitle: trimmedTerm, + highlightedNotePathTitle: t("quick-search.searching"), + }, ]); } - const activeNoteId = appContext.tabManager.getActiveContextNoteId(); - const length = term.trim().length; - - let results = await server.get(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`); + let results = await server.get( + `autocomplete?query=${encodeURIComponent(trimmedTerm)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}` + ); options.fastSearch = true; - if (length >= 1 && options.allowCreatingNotes) { - results = [ - { - action: "create-note", - noteTitle: term, - parentNoteId: activeNoteId || "root", - highlightedNotePathTitle: t("note_autocomplete.create-note", { term }) - } as Suggestion - ].concat(results); + // --- Create Note suggestions --- + if (trimmedTerm.length >= 1) { + switch (options.createMode) { + case CreateMode.CreateOnly: { + results = [ + { + action: SuggestionAction.CreateNote, + noteTitle: trimmedTerm, + parentNoteId: "inbox", + highlightedNotePathTitle: t("note_autocomplete.create-note", { term: trimmedTerm }), + }, + { + action: SuggestionAction.CreateChildNote, + noteTitle: trimmedTerm, + parentNoteId: activeNoteId || "root", + highlightedNotePathTitle: t("note_autocomplete.create-child-note", { term: trimmedTerm }), + }, + ...results, + ]; + break; + } + + case CreateMode.CreateAndLink: { + results = [ + { + action: SuggestionAction.CreateAndLinkNote, + noteTitle: trimmedTerm, + parentNoteId: "inbox", + highlightedNotePathTitle: t("note_autocomplete.create-and-link-note", { term: trimmedTerm }), + }, + { + action: SuggestionAction.CreateAndLinkChildNote, + noteTitle: trimmedTerm, + parentNoteId: activeNoteId || "root", + highlightedNotePathTitle: t("note_autocomplete.create-and-link-child-note", { term: trimmedTerm }), + }, + ...results, + ]; + break; + } + + default: + // CreateMode.None or undefined → no creation suggestions + break; + } } - if (length >= 1 && options.allowJumpToSearchNotes) { - results = results.concat([ + // --- Jump to Search Notes --- + if (trimmedTerm.length >= 1 && options.allowJumpToSearchNotes) { + results = [ + ...results, { - action: "search-notes", - noteTitle: term, - highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} Ctrl+Enter` - } - ]); + action: SuggestionAction.SearchNotes, + noteTitle: trimmedTerm, + highlightedNotePathTitle: `${t("note_autocomplete.search-for", { + term: trimmedTerm, + })} Ctrl+Enter`, + }, + ]; } - if (term.match(/^[a-z]+:\/\/.+/i) && options.allowExternalLinks) { + // --- External Link suggestion --- + if (/^[a-z]+:\/\/.+/i.test(trimmedTerm) && options.allowExternalLinks) { results = [ { - action: "external-link", - externalLink: term, - highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term }) - } as Suggestion - ].concat(results); + action: SuggestionAction.ExternalLink, + externalLink: trimmedTerm, + highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term: trimmedTerm }), + }, + ...results, + ]; } - cb(results); + callback(results); } function clearText($el: JQuery) { @@ -198,6 +290,64 @@ function fullTextSearch($el: JQuery, options: Options) { $el.autocomplete("val", searchString); } +function renderCommandSuggestion(s: Suggestion): string { + const icon = s.icon || "bx bx-terminal"; + const shortcut = s.commandShortcut + ? `${s.commandShortcut}` + : ""; + + return ` +
+ +
+
${s.highlightedNotePathTitle}
+ ${s.commandDescription ? `
${s.commandDescription}
` : ""} +
+ ${shortcut} +
+ `; +} + +function renderNoteSuggestion(s: Suggestion): string { + const actionClass = + s.action === SuggestionAction.SearchNotes ? "search-notes-action" : ""; + + const iconClass = (() => { + switch (s.action) { + case SuggestionAction.SearchNotes: + return "bx bx-search"; + case SuggestionAction.CreateAndLinkNote: + case SuggestionAction.CreateNote: + return "bx bx-plus"; + case SuggestionAction.CreateAndLinkChildNote: + case SuggestionAction.CreateChildNote: + return "bx bx-plus"; + case SuggestionAction.ExternalLink: + return "bx bx-link-external"; + default: + return s.icon ?? "bx bx-note"; + } + })(); + + return ` +
+ + + ${s.highlightedNotePathTitle} + ${s.highlightedAttributeSnippet + ? `${s.highlightedAttributeSnippet}` + : ""} + +
+ `; +} + +function renderSuggestion(suggestion: Suggestion): string { + return suggestion.action === SuggestionAction.Command + ? renderCommandSuggestion(suggestion) + : renderNoteSuggestion(suggestion); +} + function initNoteAutocomplete($el: JQuery, options?: Options) { if ($el.hasClass("note-autocomplete-input")) { // clear any event listener added in previous invocation of this function @@ -283,24 +433,21 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { $el.autocomplete( { ...autocompleteOptions, - appendTo: document.querySelector("body"), + appendTo: document.body, hint: false, autoselect: true, - // openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces - // re-querying of the autocomplete source which then changes the currently selected suggestion openOnFocus: false, minLength: 0, - tabAutocomplete: false + tabAutocomplete: false, }, [ { - source: (term, cb) => { + source: (term, callback) => { clearTimeout(debounceTimeoutId); debounceTimeoutId = setTimeout(() => { - if (isComposingInput) { - return; + if (!isComposingInput) { + autocompleteSource(term, callback, options); } - autocompleteSource(term, cb, options); }, searchDelay); if (searchDelay === 0) { @@ -308,109 +455,126 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { } }, displayKey: "notePathTitle", - templates: { - suggestion: (suggestion) => { - if (suggestion.action === "command") { - let html = `
`; - html += ``; - html += `
`; - html += `
${suggestion.highlightedNotePathTitle}
`; - if (suggestion.commandDescription) { - html += `
${suggestion.commandDescription}
`; - } - html += `
`; - if (suggestion.commandShortcut) { - html += `${suggestion.commandShortcut}`; - } - html += '
'; - return html; - } - // Add special class for search-notes action - const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : ""; - - // Choose appropriate icon based on action - let iconClass = suggestion.icon ?? "bx bx-note"; - if (suggestion.action === "search-notes") { - iconClass = "bx bx-search"; - } else if (suggestion.action === "create-note") { - iconClass = "bx bx-plus"; - } else if (suggestion.action === "external-link") { - iconClass = "bx bx-link-external"; - } - - // Simplified HTML structure without nested divs - let html = `
`; - html += ``; - html += ``; - html += `${suggestion.highlightedNotePathTitle}`; - - // Add attribute snippet inline if available - if (suggestion.highlightedAttributeSnippet) { - html += `${suggestion.highlightedAttributeSnippet}`; - } - - html += ``; - html += `
`; - return html; - } - }, - // we can't cache identical searches because notes can be created / renamed, new recent notes can be added - cache: false - } + templates: { suggestion: renderSuggestion }, + cache: false, + }, ] ); // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { - if (suggestion.action === "command") { - $el.autocomplete("close"); - $el.trigger("autocomplete:commandselected", [suggestion]); - return; - } + $el.setSelectedNotePath(suggestion.notePath); + $el.setSelectedExternalLink(null); + $el.autocomplete("val", suggestion.noteTitle); - if (suggestion.action === "external-link") { - $el.setSelectedNotePath(null); - $el.setSelectedExternalLink(suggestion.externalLink); + switch (suggestion.action) { + case SuggestionAction.Command: { + break; + } - $el.autocomplete("val", suggestion.externalLink); + case SuggestionAction.ExternalLink: { + break; + } - $el.autocomplete("close"); + // --- CREATE NOTE INTO INBOX --- + case SuggestionAction.CreateNote: { + const { note } = await noteCreateService.createNote( + { + target: "inbox", + title: suggestion.noteTitle, + activate: true, + promptForType: true, + } + ); - $el.trigger("autocomplete:externallinkselected", [suggestion]); + if (!note) + break; - return; - } + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + break; + } + + case SuggestionAction.CreateAndLinkNote: { + const { note } = await noteCreateService.createNote( + { + target: "inbox", + title: suggestion.noteTitle, + activate: false, + promptForType: true, + } + ); - if (suggestion.action === "create-note") { - const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType(); - if (!success) { + if (!note) + break; + + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + + $el.autocomplete("close"); + $el.trigger("autocomplete:noteselected", [suggestion]); return; } - const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, { - title: suggestion.noteTitle, - activate: false, - type: noteType, - templateNoteId: templateNoteId - }); - - const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); - } - if (suggestion.action === "search-notes") { - const searchString = suggestion.noteTitle; - appContext.triggerCommand("searchNotes", { searchString }); - return; - } + case SuggestionAction.CreateChildNote: { + if (!suggestion.parentNoteId) { + console.warn("Missing parentNoteId for CreateNoteIntoPath"); + return; + } + const { note } = await noteCreateService.createNote( + { + target: "into", + parentNoteUrl: suggestion.parentNoteId, + title: suggestion.noteTitle, + activate: true, + promptForType: true, + }, + ); - $el.setSelectedNotePath(suggestion.notePath); - $el.setSelectedExternalLink(null); + if (!note) break; - $el.autocomplete("val", suggestion.noteTitle); + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + break; + } - $el.autocomplete("close"); + case SuggestionAction.CreateAndLinkChildNote: { + if (!suggestion.parentNoteId) { + console.warn("Missing parentNoteId for CreateNoteIntoPath"); + return; + } + const { note } = await noteCreateService.createNote( + { + target: "into", + parentNoteUrl: suggestion.parentNoteId, + title: suggestion.noteTitle, + activate: false, + promptForType: true, + } + ); + + if (!note) break; + + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + $el.autocomplete("close"); + $el.trigger("autocomplete:noteselected", [suggestion]); + return; + } - $el.trigger("autocomplete:noteselected", [suggestion]); + case SuggestionAction.SearchNotes: { + const searchString = suggestion.noteTitle; + appContext.triggerCommand("searchNotes", { searchString }); + break; + } + + default: { + } + } + if (suggestion.notePath) { + $el.trigger("autocomplete:noteselected", [suggestion]); + } + $el.autocomplete("close"); }); $el.on("autocomplete:closed", () => { diff --git a/apps/client/src/services/note_create.ts b/apps/client/src/services/note_create.ts index 00ae717d21..bd4d80dabe 100644 --- a/apps/client/src/services/note_create.ts +++ b/apps/client/src/services/note_create.ts @@ -10,8 +10,62 @@ import type FNote from "../entities/fnote.js"; import type FBranch from "../entities/fbranch.js"; import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; +import dateNoteService from "../services/date_notes.js"; -export interface CreateNoteOpts { +/** + * Defines the type hierarchy and rules for valid argument combinations + * accepted by `note_create`. + * + * ## Overview + * Each variant extends `CreateNoteOpts` and enforces specific constraints to + * ensure only valid note creation options are allowed at compile time. + * + * ## Type Safety + * The `PromptingRule` ensures that `promptForType` and `type` stay mutually + * exclusive (if prompting, `type` is undefined). + * + * The type system prevents invalid argument mixes by design — successful type + * checks guarantee a valid state, following Curry–Howard correspondence + * principles (types as proofs). + * + * ## Maintenance + * If adding or modifying `Opts`, ensure: + * - All valid combinations are represented (avoid *false negatives*). + * - No invalid ones slip through (avoid *false positives*). + * + * Hierarchy (general → specific): + * - CreateNoteOpts + * - CreateNoteWithUrlOpts + * - CreateNoteIntoInboxOpts + */ + +/** enforces a truth rule: + * - If `promptForType` is true → `type` must be undefined. + * - If `promptForType` is false → `type` must be defined. + */ +type PromptingRule = { + promptForType: true; + type?: never; +} | { + promptForType?: false; + /** + * The note type (e.g. "text", "code", "image", "mermaid", etc.). + * + * If omitted, the server will automatically default to `"text"`. + * TypeScript still enforces explicit typing unless `promptForType` is true, + * to encourage clarity at the call site. + */ + type?: string; +}; + + +/** + * Base type for all note creation options (domain hypernym). + * All specific note option types extend from this. + * + * Combine with `&` to ensure valid logical combinations. + */ +type CreateNoteBase = { isProtected?: boolean; saveSelection?: boolean; title?: string | null; @@ -21,10 +75,33 @@ export interface CreateNoteOpts { templateNoteId?: string; activate?: boolean; focus?: "title" | "content"; - target?: string; - targetBranchId?: string; textEditor?: CKTextEditor; -} +} & PromptingRule; + +/* + * Defines options for creating a note at a specific path. + * Serves as a base for "into", "before", and "after" variants, + * sharing common URL-related fields. + */ +export type CreateNoteWithUrlOpts = + | (CreateNoteBase & { + target: "into"; + parentNoteUrl?: string; + // No branch ID needed for "into" + }) + | (CreateNoteBase & { + target: "before" | "after"; + parentNoteUrl?: string; + // Required for "before"/"after" + targetBranchId: string; + }); + +export type CreateNoteIntoInboxOpts = CreateNoteBase & { + target: "inbox"; + parentNoteUrl?: never; +}; + +export type CreateNoteOpts = CreateNoteWithUrlOpts | CreateNoteIntoInboxOpts; interface Response { // TODO: Deduplicate with server once we have client/server architecture. @@ -37,7 +114,70 @@ interface DuplicateResponse { note: FNote; } -async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) { +async function createNote( + options: CreateNoteOpts +): Promise<{ note: FNote | null; branch: FBranch | undefined }> { + + let resolvedOptions = { ...options }; + + // handle prompts centrally to write once fix for all + if (options.promptForType) { + const maybeResolvedOptions = await promptForType(options); + if (!maybeResolvedOptions) { + return { note: null, branch: undefined }; + } + + resolvedOptions = maybeResolvedOptions; + } + + + switch(resolvedOptions.target) { + case "inbox": + return createNoteIntoInbox(resolvedOptions); + case "into": + case "before": + case "after": + return createNoteWithUrl(resolvedOptions); + } +} + +async function promptForType( + options: CreateNoteOpts +) : Promise { + const { success, noteType, templateNoteId, notePath } = await chooseNoteType(); + + if (!success) { + return null; + } + + let resolvedOptions: CreateNoteOpts = { + ...options, + promptForType: false, + type: noteType, + templateNoteId, + }; + + if (notePath) { + resolvedOptions = { + ...resolvedOptions, + target: "into", + parentNoteUrl: notePath, + }; + } + + return resolvedOptions; +} + +/** + * Creates a new note under a specified parent note path. + * + * @param target - Mirrors the `createNote` API in apps/server/src/routes/api/notes.ts. + * @param options - Note creation options + * @returns A promise resolving with the created note and its branch. + */ +async function createNoteWithUrl( + options: CreateNoteWithUrlOpts +): Promise<{ note: FNote | null; branch: FBranch | undefined }> { options = Object.assign( { activate: true, @@ -61,7 +201,8 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot [options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml()); } - const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath); + const parentNoteUrl = options.parentNoteUrl; + const parentNoteId = treeService.getNoteIdFromUrl(parentNoteUrl); if (options.type === "mermaid" && !options.content && !options.templateNoteId) { options.content = `graph TD; @@ -71,7 +212,12 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot C-->D;`; } - const { note, branch } = await server.post(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, { + const query = + options.target === "into" + ? `target=${options.target}` + : `target=${options.target}&targetBranchId=${options.targetBranchId}`; + + const { note, branch } = await server.post(`notes/${parentNoteId}/children?${query}`, { title: options.title, content: options.content || "", isProtected: options.isProtected, @@ -89,7 +235,7 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot const activeNoteContext = appContext.tabManager.getActiveContext(); if (activeNoteContext && options.activate) { - await activeNoteContext.setNote(`${parentNotePath}/${note.noteId}`); + await activeNoteContext.setNote(`${parentNoteId}/${note.noteId}`); if (options.focus === "title") { appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true }); @@ -107,23 +253,44 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot }; } -async function chooseNoteType() { - return new Promise((res) => { - appContext.triggerCommand("chooseNoteType", { callback: res }); - }); -} -async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) { - const { success, noteType, templateNoteId, notePath } = await chooseNoteType(); +/** + * Creates a new note inside the user's Inbox. + * + * @param {CreateNoteIntoInboxOpts} [options] - Optional settings such as title, type, template, or content. + * @returns {Promise<{ note: FNote | null; branch: FBranch | undefined }>} + * Resolves with the created note and its branch, or `{ note: null, branch: undefined }` if the inbox is missing. + */ +async function createNoteIntoInbox( + options: CreateNoteIntoInboxOpts +): Promise<{ note: FNote | null; branch: FBranch | undefined }> { + const inboxNote = await dateNoteService.getInboxNote(); + if (!inboxNote) { + console.warn("Missing inbox note."); + // always return a defined object + return { note: null, branch: undefined }; + } - if (!success) { - return; + if (options.isProtected === undefined) { + options.isProtected = + inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable(); } - options.type = noteType; - options.templateNoteId = templateNoteId; + const result = await createNoteWithUrl( + { + ...options, + target: "into", + parentNoteUrl: inboxNote.noteId, + } + ); + + return result; +} - return await createNote(notePath || parentNotePath, options); +async function chooseNoteType() { + return new Promise((res) => { + appContext.triggerCommand("chooseNoteType", { callback: res }); + }); } /* If the first element is heading, parse it out and use it as a new heading. */ @@ -159,7 +326,5 @@ async function duplicateSubtree(noteId: string, parentNotePath: string) { export default { createNote, - createNoteWithTypePrompt, duplicateSubtree, - chooseNoteType }; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f89e42f477..4131885bc7 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1883,11 +1883,13 @@ }, "note_autocomplete": { "search-for": "Search for \"{{term}}\"", - "create-note": "Create and link child note \"{{term}}\"", + "create-child-note": "Create child note \"{{term}}\"", + "create-note": "Create note \"{{term}}\"", + "create-and-link-child-note": "Create and link child note \"{{term}}\"", + "create-and-link-note": "Create and link note \"{{term}}\"", "insert-external-link": "Insert external link to \"{{term}}\"", "clear-text-field": "Clear text field", - "show-recent-notes": "Show recent notes", - "full-text-search": "Full text search" + "show-recent-notes": "Show recent notes" }, "note_tooltip": { "note-has-been-deleted": "Note has been deleted.", diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 2a7a55aef1..8f144f63d8 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -3,7 +3,7 @@ import server from "../../services/server.js"; import froca from "../../services/froca.js"; import linkService from "../../services/link.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; -import noteAutocompleteService from "../../services/note_autocomplete.js"; +import noteAutocompleteService, { CreateMode } from "../../services/note_autocomplete.js"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; import NoteContextAwareWidget from "../note_context_aware_widget.js"; import SpacedUpdate from "../../services/spaced_update.js"; @@ -429,7 +429,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.$rowTargetNote = this.$widget.find(".attr-row-target-note"); this.$inputTargetNote = this.$widget.find(".attr-input-target-note"); - noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, { allowCreatingNotes: true }).on("autocomplete:noteselected", (event, suggestion, dataset) => { + noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, { createMode: CreateMode.CreateAndLink }).on("autocomplete:noteselected", (event, suggestion, dataset) => { if (!suggestion.notePath) { return false; } diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 90d41d7c49..ddc6308321 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -6,7 +6,7 @@ import branches from "../../../services/branches"; import { executeBulkActions } from "../../../services/bulk_action"; import froca from "../../../services/froca"; import { t } from "../../../services/i18n"; -import note_create from "../../../services/note_create"; +import note_create from "../../../services/note_create.js"; import server from "../../../services/server"; import { ColumnMap } from "./data"; @@ -28,9 +28,11 @@ export default class BoardApi { const parentNotePath = this.parentNote.noteId; // Create a new note as a child of the parent note - const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, { + const { note: newNote, branch: newBranch } = await note_create.createNote({ + target: "into", + parentNoteUrl: parentNotePath, activate: false, - title + title, }); if (newNote && newBranch) { @@ -129,13 +131,17 @@ export default class BoardApi { async insertRowAtPosition( column: string, relativeToBranchId: string, - direction: "before" | "after") { - const { note, branch } = await note_create.createNote(this.parentNote.noteId, { - activate: false, - targetBranchId: relativeToBranchId, - target: direction, - title: t("board_view.new-item") - }); + direction: "before" | "after" + ) { + const { note, branch } = await note_create.createNote( + { + target: direction, + parentNoteUrl: this.parentNote.noteId, + activate: false, + targetBranchId: relativeToBranchId, + title: t("board_view.new-item"), + } + ); if (!note || !branch) { throw new Error("Failed to create note"); diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index 0c818a1112..c339ec8406 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -56,12 +56,18 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo { title: t("board_view.insert-above"), uiIcon: "bx bx-list-plus", - handler: () => api.insertRowAtPosition(column, branchId, "before") + handler: () => api.insertRowAtPosition( + column, + branchId, + "before") }, { title: t("board_view.insert-below"), uiIcon: "bx bx-empty", - handler: () => api.insertRowAtPosition(column, branchId, "after") + handler: () => api.insertRowAtPosition( + column, + branchId, + "after") }, { kind: "separator" }, { diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 74db6ddb71..60985cfe9c 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -6,6 +6,7 @@ import Icon from "../../react/Icon.jsx"; import { useEffect, useRef, useState } from "preact/hooks"; import froca from "../../../services/froca.js"; import NoteAutocomplete from "../../react/NoteAutocomplete.jsx"; +import { CreateMode } from "../../../services/note_autocomplete.js"; type ColumnType = LabelType | "relation"; @@ -227,7 +228,7 @@ function RelationEditor({ cell, success }: EditorOpts) { inputRef={inputRef} noteId={cell.getValue()} opts={{ - allowCreatingNotes: true, + createMode: CreateMode.CreateAndLink, hideAllButtons: true }} noteIdChanged={success} diff --git a/apps/client/src/widgets/collections/table/context_menu.ts b/apps/client/src/widgets/collections/table/context_menu.ts index eb0a303ae6..044575da37 100644 --- a/apps/client/src/widgets/collections/table/context_menu.ts +++ b/apps/client/src/widgets/collections/table/context_menu.ts @@ -180,8 +180,8 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro uiIcon: "bx bx-horizontal-left bx-rotate-90", enabled: !sorters.length, handler: () => parentComponent?.triggerCommand("addNewRow", { - parentNotePath: parentNoteId, customOpts: { + parentNoteUrl: parentNoteId, target: "before", targetBranchId: rowData.branchId, } @@ -193,9 +193,12 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro handler: async () => { const branchId = row.getData().branchId; const note = await froca.getBranch(branchId)?.getNote(); + if (!note) { + return; + } parentComponent?.triggerCommand("addNewRow", { - parentNotePath: note?.noteId, customOpts: { + parentNoteUrl: note.noteId, target: "after", targetBranchId: branchId, } @@ -207,8 +210,8 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro uiIcon: "bx bx-horizontal-left bx-rotate-270", enabled: !sorters.length, handler: () => parentComponent?.triggerCommand("addNewRow", { - parentNotePath: parentNoteId, customOpts: { + parentNoteUrl: parentNoteId, target: "after", targetBranchId: rowData.branchId, } diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index 22ef0e7e48..9ad7da5883 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -1,6 +1,6 @@ import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables"; import { CommandListenerData } from "../../../components/app_context"; -import note_create, { CreateNoteOpts } from "../../../services/note_create"; +import note_create from "../../../services/note_create"; import { useLegacyImperativeHandlers } from "../../react/hooks"; import { RefObject } from "preact"; import { setAttribute, setLabel } from "../../../services/attributes"; @@ -9,17 +9,23 @@ import server from "../../../services/server"; import branches from "../../../services/branches"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; +/** + * Hook for handling row table editing, including adding new rows. + */ export default function useRowTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial { - // Adding new rows useLegacyImperativeHandlers({ - addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) { - const notePath = customNotePath ?? parentNotePath; - if (notePath) { - const opts: CreateNoteOpts = { - activate: false, - ...customOpts - } - note_create.createNote(notePath, opts).then(({ branch }) => { + addNewRowCommand({ customOpts }: CommandListenerData<"addNewRow">) { + if (!customOpts) { + customOpts = { + target: "into", + }; + } + + const noteUrl = customOpts.parentNoteUrl ?? parentNotePath; + if (noteUrl) { + customOpts.parentNoteUrl = noteUrl; + customOpts.activate = false; + note_create.createNote(customOpts).then(({ branch }) => { if (branch) { setTimeout(() => { if (!api.current) return; @@ -27,6 +33,7 @@ export default function useRowTableEditing(api: RefObject, attributeD }, 100); } }) + } } }); diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 43cfa4e4e3..63b98dc88b 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup"; import NoteAutocomplete from "../react/NoteAutocomplete"; import { useRef, useState, useEffect } from "preact/hooks"; import tree from "../../services/tree"; -import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; +import note_autocomplete, { CreateMode, Suggestion } from "../../services/note_autocomplete.js"; import { default as TextTypeWidget } from "../type_widgets/editable_text.js"; import { logError } from "../../services/ws"; import FormGroup from "../react/FormGroup.js"; @@ -131,7 +131,7 @@ export default function AddLinkDialog() { onChange={setSuggestion} opts={{ allowExternalLinks: true, - allowCreatingNotes: true + createMode: CreateMode.CreateAndLink, }} /> diff --git a/apps/client/src/widgets/dialogs/include_note.tsx b/apps/client/src/widgets/dialogs/include_note.tsx index 911ed0dc09..d458b48d74 100644 --- a/apps/client/src/widgets/dialogs/include_note.tsx +++ b/apps/client/src/widgets/dialogs/include_note.tsx @@ -5,7 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup"; import Modal from "../react/Modal"; import NoteAutocomplete from "../react/NoteAutocomplete"; import Button from "../react/Button"; -import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete"; +import { CreateMode, Suggestion, triggerRecentNotes } from "../../services/note_autocomplete.js"; import tree from "../../services/tree"; import froca from "../../services/froca"; import EditableTextTypeWidget, { type BoxSize } from "../type_widgets/editable_text"; @@ -49,7 +49,7 @@ export default function IncludeNoteDialog() { inputRef={autoCompleteRef} opts={{ hideGoToSelectedNoteButton: true, - allowCreatingNotes: true + createMode: CreateMode.CreateOnly, }} /> @@ -83,4 +83,4 @@ async function includeNote(notePath: string, textTypeWidget: EditableTextTypeWid } else { textTypeWidget.addIncludeNote(noteId, boxSize); } -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx index 89c4388039..94f3f37aa0 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.tsx +++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx @@ -3,7 +3,7 @@ import Button from "../react/Button"; import NoteAutocomplete from "../react/NoteAutocomplete"; import { t } from "../../services/i18n"; import { useRef, useState } from "preact/hooks"; -import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; +import note_autocomplete, { CreateMode, Suggestion } from "../../services/note_autocomplete.js"; import appContext from "../../components/app_context"; import commandRegistry from "../../services/command_registry"; import { refToJQuerySelector } from "../react/react_utils"; @@ -12,34 +12,53 @@ import shortcutService from "../../services/shortcuts"; const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120; -type Mode = "last-search" | "recent-notes" | "commands"; +enum Mode { + LastSearch, + RecentNotes, + Commands, +} export default function JumpToNoteDialogComponent() { const [ mode, setMode ] = useState(); const [ lastOpenedTs, setLastOpenedTs ] = useState(0); const containerRef = useRef(null); const autocompleteRef = useRef(null); - const [ isCommandMode, setIsCommandMode ] = useState(mode === "commands"); + const [ isCommandMode, setIsCommandMode ] = useState(mode === Mode.Commands); const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : ""); const actualText = useRef(initialText); const [ shown, setShown ] = useState(false); - - async function openDialog(commandMode: boolean) { + + async function openDialog(requestedMode: Mode) { let newMode: Mode; let initialText = ""; - if (commandMode) { - newMode = "commands"; - initialText = ">"; - } else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { - // if you open the Jump To dialog soon after using it previously, it can often mean that you - // actually want to search for the same thing (e.g., you opened the wrong note at first try) - // so we'll keep the content. - // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. - newMode = "last-search"; - initialText = actualText.current; - } else { - newMode = "recent-notes"; + switch (requestedMode) { + case Mode.Commands: + newMode = Mode.Commands; + initialText = ">"; + break; + + case Mode.LastSearch: + // if you open the Jump To dialog soon after using it previously, it can often mean that you + // actually want to search for the same thing (e.g., you opened the wrong note at first try) + // so we'll keep the content. + // if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead. + if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { + newMode = Mode.LastSearch; + initialText = actualText.current; + } else { + newMode = Mode.RecentNotes; + } + break; + + default: + if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { + newMode = Mode.LastSearch; + initialText = actualText.current; + } else { + newMode = Mode.RecentNotes; + } + break; } if (mode !== newMode) { @@ -51,14 +70,14 @@ export default function JumpToNoteDialogComponent() { setLastOpenedTs(Date.now()); } - useTriliumEvent("jumpToNote", () => openDialog(false)); - useTriliumEvent("commandPalette", () => openDialog(true)); + useTriliumEvent("jumpToNote", () => openDialog(Mode.RecentNotes)); + useTriliumEvent("commandPalette", () => openDialog(Mode.Commands)); async function onItemSelected(suggestion?: Suggestion | null) { if (!suggestion) { return; } - + setShown(false); if (suggestion.notePath) { appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath); @@ -70,12 +89,12 @@ export default function JumpToNoteDialogComponent() { function onShown() { const $autoComplete = refToJQuerySelector(autocompleteRef); switch (mode) { - case "last-search": + case Mode.LastSearch: break; - case "recent-notes": + case Mode.RecentNotes: note_autocomplete.showRecentNotes($autoComplete); break; - case "commands": + case Mode.Commands: note_autocomplete.showAllCommands($autoComplete); break; } @@ -83,7 +102,7 @@ export default function JumpToNoteDialogComponent() { $autoComplete .trigger("focus") .trigger("select"); - + // Add keyboard shortcut for full search shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => { if (!isCommandMode) { @@ -91,7 +110,7 @@ export default function JumpToNoteDialogComponent() { } }); } - + async function showInFullSearch() { try { setShown(false); @@ -116,7 +135,7 @@ export default function JumpToNoteDialogComponent() { container={containerRef} text={initialText} opts={{ - allowCreatingNotes: true, + createMode: CreateMode.CreateOnly, hideGoToSelectedNoteButton: true, allowJumpToSearchNotes: true, isCommandPalette: true @@ -129,9 +148,9 @@ export default function JumpToNoteDialogComponent() { />} onShown={onShown} onHidden={() => setShown(false)} - footer={!isCommandMode &&