trilium/apps/client/src/services/note_create.ts
2025-11-28 22:52:00 +01:00

335 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import appContext from "../components/app_context.js";
import protectedSessionHolder from "./protected_session_holder.js";
import server from "./server.js";
import ws from "./ws.js";
import froca from "./froca.js";
import treeService from "./tree.js";
import toastService from "./toast.js";
import { t } from "./i18n.js";
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";
/**
* Defines the type hierarchy and rules for valid argument combinations
* accepted by `note_create`.
*
* ## Overview
* Each variant (e.g. `CreateNoteIntoUrlOpts`, `CreateNoteBeforeUrlOpts`, etc.)
* 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 CurryHoward 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
* - CreateNoteAtUrlOpts
* - CreateNoteIntoUrlOpts
* - CreateNoteBeforeUrlOpts
* - CreateNoteAfterUrlOpts
* - 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;
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.
*/
export type CreateNoteOpts = {
target: "into" | "after" | "before" | "inbox";
isProtected?: boolean;
saveSelection?: boolean;
title?: string | null;
content?: string | null;
type?: string;
mime?: string;
templateNoteId?: string;
activate?: boolean;
focus?: "title" | "content";
textEditor?: CKTextEditor;
} & PromptingRule;
/*
* Defines options for creating a note at a specific path.
* Serves as a base (not exported) for "into", "before", and "after" variants,
* sharing common URL-related fields.
*/
type CreateNoteAtUrlOpts = CreateNoteOpts & {
// `Url` may refer to either parentNotePath or parentNoteId.
// The vocabulary is inspired by existing function getNoteIdFromUrl.
parentNoteUrl: string;
// Disambiguates the position for cloned notes.
targetBranchId: string;
}
export type CreateNoteIntoUrlOpts = CreateNoteAtUrlOpts;
export type CreateNoteBeforeUrlOpts = CreateNoteAtUrlOpts;
export type CreateNoteAfterUrlOpts = CreateNoteAtUrlOpts;
type NeverDefineParentNoteUrlRule = {
parentNoteUrl?: never;
};
export type CreateNoteIntoInboxOpts = CreateNoteOpts & NeverDefineParentNoteUrlRule;
interface Response {
// TODO: Deduplicate with server once we have client/server architecture.
note: FNote;
branch: FBranch;
}
interface DuplicateResponse {
// TODO: Deduplicate with server once we have client/server architecture.
note: FNote;
}
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) {
let maybeResolvedOptions = await promptForType(options);
if (!maybeResolvedOptions) {
return {
note: null, branch: undefined
};
}
resolvedOptions = maybeResolvedOptions;
}
switch (resolvedOptions.target) {
case "into":
return await createNoteAtNote("into", {...options} as CreateNoteAtUrlOpts);
case "before":
return await createNoteAtNote("before", resolvedOptions as CreateNoteBeforeUrlOpts);
case "after":
return await createNoteAtNote("after", resolvedOptions as CreateNoteAfterUrlOpts);
case "inbox":
return await createNoteIntoInbox(resolvedOptions as CreateNoteIntoInboxOpts);
default: {
console.warn("[createNote] Unknown target:", options.target, resolvedOptions);
toastService.showMessage("Unknown note creation target."); // optional
return { note: null, branch: undefined };
}
}
}
async function promptForType(
options: CreateNoteOpts
) : Promise<CreateNoteOpts | null> {
const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
if (!success) {
return null;
}
let resolvedOptions = {
...options,
promptForType: false,
type: noteType,
templateNoteId,
} as CreateNoteOpts;
if (notePath) {
resolvedOptions = resolvedOptions as CreateNoteIntoUrlOpts;
resolvedOptions = {
...resolvedOptions,
target: "into",
parentNoteUrl: notePath,
} as CreateNoteIntoUrlOpts;
}
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 createNoteAtNote(
target: "into" | "after" | "before",
options: CreateNoteAtUrlOpts
): Promise<{ note: FNote | null; branch: FBranch | undefined }> {
options = Object.assign(
{
activate: true,
focus: "title",
target: "into"
},
options
);
// if isProtected isn't available (user didn't enter password yet), then note is created as unencrypted,
// but this is quite weird since the user doesn't see WHERE the note is being created, so it shouldn't occur often
if (!options.isProtected || !protectedSessionHolder.isProtectedSessionAvailable()) {
options.isProtected = false;
}
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
options.saveSelection = false;
}
if (options.saveSelection && options.textEditor) {
[options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml());
}
const parentNoteUrl = options.parentNoteUrl;
const parentNoteId = treeService.getNoteIdFromUrl(parentNoteUrl);
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
options.content = `graph TD;
A-->B;
A-->C;
B-->D;
C-->D;`;
}
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${target}&targetBranchId=${options.targetBranchId || ""}`, {
title: options.title,
content: options.content || "",
isProtected: options.isProtected,
type: options.type,
mime: options.mime,
templateNoteId: options.templateNoteId
});
if (options.saveSelection) {
// we remove the selection only after it was saved to server to make sure we don't lose anything
options.textEditor?.removeSelection();
}
await ws.waitForMaxKnownEntityChangeId();
const activeNoteContext = appContext.tabManager.getActiveContext();
if (activeNoteContext && options.activate) {
await activeNoteContext.setNote(`${parentNoteId}/${note.noteId}`);
if (options.focus === "title") {
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true });
} else if (options.focus === "content") {
appContext.triggerEvent("focusOnDetail", { ntxId: activeNoteContext.ntxId });
}
}
const noteEntity = await froca.getNote(note.noteId);
const branchEntity = froca.getBranch(branch.branchId);
return {
note: noteEntity,
branch: branchEntity
};
}
/**
* 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 (options.isProtected === undefined) {
options.isProtected =
inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable();
}
const result = await createNoteAtNote("into",
{
...options,
parentNoteUrl: inboxNote.noteId,
} as CreateNoteIntoUrlOpts
);
return result;
}
async function chooseNoteType() {
return new Promise<ChooseNoteTypeResponse>((res) => {
appContext.triggerCommand("chooseNoteType", { callback: res });
});
}
/* If the first element is heading, parse it out and use it as a new heading. */
function parseSelectedHtml(selectedHtml: string) {
const dom = $.parseHTML(selectedHtml);
// TODO: tagName and outerHTML appear to be missing.
//@ts-ignore
if (dom.length > 0 && dom[0].tagName && dom[0].tagName.match(/h[1-6]/i)) {
const title = $(dom[0]).text();
// remove the title from content (only first occurrence)
// TODO: tagName and outerHTML appear to be missing.
//@ts-ignore
const content = selectedHtml.replace(dom[0].outerHTML, "");
return [title, content];
} else {
return [null, selectedHtml];
}
}
async function duplicateSubtree(noteId: string, parentNotePath: string) {
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
const { note } = await server.post<DuplicateResponse>(`notes/${noteId}/duplicate/${parentNoteId}`);
await ws.waitForMaxKnownEntityChangeId();
appContext.tabManager.getActiveContext()?.setNote(`${parentNotePath}/${note.noteId}`);
const origNote = await froca.getNote(noteId);
toastService.showMessage(t("note_create.duplicated", { title: origNote?.title }));
}
export default {
createNote,
duplicateSubtree,
};