diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8f87f6a166..fad12709d3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -338,6 +338,16 @@ Trilium provides powerful user scripting capabilities: - **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json` - **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped +### Storing User Preferences +- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices +- To add a new user preference: + 1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts` + 2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array + 3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates) + 4. Use `useTriliumOption("optionName")` hook in React components to read/write the option +- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson` +- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation + ## Testing Conventions - **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests diff --git a/CLAUDE.md b/CLAUDE.md index ab6067e058..1b90b02881 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,16 @@ Trilium provides powerful user scripting capabilities: - **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS - Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead +### Storing User Preferences +- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices +- To add a new user preference: + 1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts` + 2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array + 3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates) + 4. Use `useTriliumOption("optionName")` hook in React components to read/write the option +- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson` +- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation + ### Shared Types Policy - Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`) - Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules diff --git a/apps/client/src/services/attributes.spec.ts b/apps/client/src/services/attributes.spec.ts index 7a949eebc6..f9caa6906d 100644 --- a/apps/client/src/services/attributes.spec.ts +++ b/apps/client/src/services/attributes.spec.ts @@ -6,10 +6,8 @@ import froca from "./froca"; import server from "./server.js"; // Spy on server methods to track calls -// @ts-expect-error the generic typing is causing issues here -server.put = vi.fn(async (url: string, data?: T) => ({} as T)); -// @ts-expect-error the generic typing is causing issues here -server.remove = vi.fn(async (url: string) => ({} as T)); +server.put = vi.fn(async () => ({})) as typeof server.put; +server.remove = vi.fn(async () => ({})) as typeof server.remove; describe("Set boolean with inheritance", () => { beforeEach(() => { diff --git a/apps/client/src/services/render.tsx b/apps/client/src/services/render.tsx index 682efa8871..31450b3a82 100644 --- a/apps/client/src/services/render.tsx +++ b/apps/client/src/services/render.tsx @@ -18,6 +18,10 @@ async function render(note: FNote, $el: JQuery, onError?: ErrorHand for (const renderNoteId of renderNoteIds) { const bundle = await server.postWithSilentInternalServerError(`script/bundle/${renderNoteId}`); + if (!bundle) { + throw new Error(`Script note '${renderNoteId}' could not be loaded. It may be protected and require an active protected session.`); + } + const $scriptContainer = $("
"); $el.append($scriptContainer); diff --git a/apps/client/src/services/syntax_highlight.ts b/apps/client/src/services/syntax_highlight.ts index 3b2c35f682..1aa9084ce4 100644 --- a/apps/client/src/services/syntax_highlight.ts +++ b/apps/client/src/services/syntax_highlight.ts @@ -33,6 +33,14 @@ export async function formatCodeBlocks($container: JQuery) { applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType); } } + + // Add click-to-copy for inline code (code elements not inside pre) + if (glob.device !== "print") { + const inlineCodeElements = $container.find("code:not(pre code)"); + for (const inlineCode of inlineCodeElements) { + applyInlineCodeCopy($(inlineCode)); + } + } } export function applyCopyToClipboardButton($codeBlock: JQuery) { @@ -51,6 +59,23 @@ export function applyCopyToClipboardButton($codeBlock: JQuery) { $codeBlock.parent().append($copyButton); } +export function applyInlineCodeCopy($inlineCode: JQuery) { + $inlineCode + .addClass("copyable-inline-code") + .attr("title", t("code_block.click_to_copy")) + .off("click") + .on("click", (e) => { + e.stopPropagation(); + + const text = $inlineCode.text(); + if (!isShare) { + copyTextWithToast(text); + } else { + copyText(text); + } + }); +} + /** * Applies syntax highlight to the given code block (assumed to be
), using highlight.js.
  */
diff --git a/apps/client/src/services/ws.ts b/apps/client/src/services/ws.ts
index 47ed90341b..b8c606d43b 100644
--- a/apps/client/src/services/ws.ts
+++ b/apps/client/src/services/ws.ts
@@ -134,7 +134,7 @@ async function handleMessage(event: MessageEvent) {
     } else if (message.type === "api-log-messages") {
         appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
     } else if (message.type === "toast") {
-        toast.showMessage(message.message);
+        toast.showMessage(message.message, message.timeout);
     } else if (message.type === "execute-script") {
         const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
 
diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css
index 536db2d9b5..baff2d14dc 100644
--- a/apps/client/src/stylesheets/style.css
+++ b/apps/client/src/stylesheets/style.css
@@ -1230,6 +1230,43 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
     width: 100%;
 }
 
+/* Expandable include note styles */
+.include-note-title-row {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    cursor: pointer;
+}
+
+.include-note-title-row .include-note-title {
+    margin: 0;
+}
+
+.include-note-toggle {
+    background: none;
+    border: none;
+    padding: 2px;
+    cursor: pointer;
+    font-size: 1.2em;
+    color: var(--main-text-color);
+    transition: transform 0.2s ease;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.include-note-toggle:hover {
+    color: var(--main-link-color);
+}
+
+.include-note-toggle.expanded {
+    transform: rotate(90deg);
+}
+
+.include-note[data-box-size="expandable"] .include-note-content {
+    margin-top: 10px;
+}
+
 .alert {
     padding: 8px 14px;
     width: auto;
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 5eef6b9c69..6931954e65 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -88,17 +88,23 @@
     "also_delete_note": "Also delete the note"
   },
   "delete_notes": {
-    "delete_notes_preview": "Delete notes preview",
+    "title": "Delete notes",
     "close": "Close",
+    "clones_label": "Clones",
+    "delete_clones_description_one": "Also delete {{count}} other clone. Can be undone in recent changes.",
+    "delete_clones_description_other": "Also delete {{count}} other clones. Can be undone in recent changes.",
     "delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
-    "erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
+    "erase_notes_label": "Erase permanently",
+    "erase_notes_description": "Erase notes immediately instead of soft deletion. This cannot be undone and will force application reload.",
     "erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
-    "notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
+    "notes_to_be_deleted": "Notes to be deleted ({{notesCount}})",
     "no_note_to_delete": "No note will be deleted (only clones).",
-    "broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
+    "broken_relations_to_be_deleted": "Broken relations ({{relationCount}})",
+    "table_note_with_relation": "Note with relation",
+    "table_relation": "Relation",
+    "table_points_to": "Points to (deleted)",
     "cancel": "Cancel",
-    "ok": "OK",
-    "deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
+    "delete": "Delete"
   },
   "export": {
     "export_note_title": "Export note",
@@ -209,6 +215,7 @@
     "box_size_small": "small (~ 10 lines)",
     "box_size_medium": "medium (~ 30 lines)",
     "box_size_full": "full (box shows complete text)",
+    "box_size_expandable": "expandable (collapsed by default)",
     "button_include": "Include note"
   },
   "info": {
@@ -1876,7 +1883,8 @@
     "theme_none": "No syntax highlighting",
     "theme_group_light": "Light themes",
     "theme_group_dark": "Dark themes",
-    "copy_title": "Copy to clipboard"
+    "copy_title": "Copy to clipboard",
+    "click_to_copy": "Click to copy"
   },
   "classic_editor_toolbar": {
     "title": "Formatting"
diff --git a/apps/client/src/widgets/collections/board/data.spec.ts b/apps/client/src/widgets/collections/board/data.spec.ts
index b8f0f50341..316efbcba2 100644
--- a/apps/client/src/widgets/collections/board/data.spec.ts
+++ b/apps/client/src/widgets/collections/board/data.spec.ts
@@ -27,7 +27,7 @@ describe("Board data", () => {
         froca.branches["note1_note2"] = branch;
         froca.getNoteFromCache("note1")!.addChild("note2", "note1_note2", false);
         const data = await getBoardData(parentNote, "status", {}, false);
-        const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId);
+        const noteIds = [...data.byColumn.values()].flat().map(item => item.note.noteId);
         expect(noteIds.length).toBe(3);
     });
 });
diff --git a/apps/client/src/widgets/dialogs/delete_notes.css b/apps/client/src/widgets/dialogs/delete_notes.css
new file mode 100644
index 0000000000..864fcc2e0c
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/delete_notes.css
@@ -0,0 +1,30 @@
+.delete-notes-dialog .tn-card {
+    margin-bottom: 16px;
+}
+
+.delete-notes-dialog .tn-card:last-child {
+    margin-bottom: 0;
+}
+
+.delete-notes-dialog .preview-list {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+    max-height: 200px;
+    overflow: auto;
+}
+
+.delete-notes-dialog .preview-list li {
+    padding: 6px 16px;
+    border-bottom: 1px solid var(--main-border-color);
+}
+
+.delete-notes-dialog .preview-list li:last-child {
+    border-bottom: none;
+}
+
+.delete-notes-dialog .preview-list small {
+    margin-inline-start: 8px;
+    font-size: 0.8em;
+    color: var(--muted-text-color);
+}
diff --git a/apps/client/src/widgets/dialogs/delete_notes.tsx b/apps/client/src/widgets/dialogs/delete_notes.tsx
index c58d440b72..2a5cb0c0b5 100644
--- a/apps/client/src/widgets/dialogs/delete_notes.tsx
+++ b/apps/client/src/widgets/dialogs/delete_notes.tsx
@@ -1,15 +1,22 @@
-import { useRef, useState, useEffect } from "preact/hooks";
-import { t } from "../../services/i18n.js";
-import FormCheckbox from "../react/FormCheckbox.js";
-import Modal from "../react/Modal.js";
+import "./delete_notes.css";
+
 import type { DeleteNotesPreview } from "@triliumnext/commons";
-import server from "../../services/server.js";
+import { useEffect, useRef, useState } from "preact/hooks";
+
 import froca from "../../services/froca.js";
-import FNote from "../../entities/fnote.js";
-import link from "../../services/link.js";
+import { t } from "../../services/i18n.js";
+import server from "../../services/server.js";
 import Button from "../react/Button.jsx";
-import Alert from "../react/Alert.jsx";
+import { Card, CardSection } from "../react/Card.js";
+import FormToggle from "../react/FormToggle.js";
 import { useTriliumEvent } from "../react/hooks.jsx";
+import Modal from "../react/Modal.js";
+import NoteLink from "../react/NoteLink.js";
+import OptionsRow from "../type_widgets/options/components/OptionsRow.js";
+
+interface CloneInfo {
+    totalCloneCount: number;
+}
 
 export interface ResolveOptions {
     proceed: boolean;
@@ -24,9 +31,9 @@ interface ShowDeleteNotesDialogOpts {
 }
 
 interface BrokenRelationData {
-    note: string;
-    relation: string;
-    source: string;
+    noteId: string;
+    relationName: string;
+    sourceNoteId: string;
 }
 
 export default function DeleteNotesDialog() {
@@ -34,20 +41,51 @@ export default function DeleteNotesDialog() {
     const [ deleteAllClones, setDeleteAllClones ] = useState(false);
     const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
     const [ brokenRelations, setBrokenRelations ] = useState([]);
-    const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState([]);    
+    const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState([]);
     const [ shown, setShown ] = useState(false);
+    const [ cloneInfo, setCloneInfo ] = useState({ totalCloneCount: 0 });
     const okButtonRef = useRef(null);
 
     useTriliumEvent("showDeleteNotesDialog", (opts) => {
         setOpts(opts);
+        setDeleteAllClones(false);
+        setEraseNotes(!!opts.forceDeleteAllClones);
         setShown(true);
-    })
+    });
+
+    // Calculate clone information when branches change
+    useEffect(() => {
+        const { branchIdsToDelete } = opts;
+        if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
+            setCloneInfo({ totalCloneCount: 0 });
+            return;
+        }
+
+        async function calculateCloneInfo() {
+            const branches = froca.getBranches(branchIdsToDelete!, true);
+            const uniqueNoteIds = [...new Set(branches.map(b => b.noteId))];
+            const notes = await froca.getNotes(uniqueNoteIds);
+
+            let totalCloneCount = 0;
+
+            for (const note of notes) {
+                const parentBranches = note.getParentBranches();
+                // Clones are additional parent branches beyond the one being deleted
+                const otherBranches = parentBranches.filter(b => !branchIdsToDelete!.includes(b.branchId));
+                totalCloneCount += otherBranches.length;
+            }
+
+            setCloneInfo({ totalCloneCount });
+        }
+
+        calculateCloneInfo();
+    }, [opts.branchIdsToDelete]);
 
     useEffect(() => {
         const { branchIdsToDelete, forceDeleteAllClones } = opts;
         if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
             return;
-        }        
+        }
 
         server.post("delete-notes-preview", {
             branchIdsToDelete,
@@ -63,16 +101,16 @@ export default function DeleteNotesDialog() {
             className="delete-notes-dialog"
             size="xl"
             scrollable
-            title={t("delete_notes.delete_notes_preview")}
+            title={t("delete_notes.title")}
             onShown={() => okButtonRef.current?.focus()}
             onHidden={() => {
-                opts.callback?.({ proceed: false })
+                opts.callback?.({ proceed: false });
                 setShown(false);
             }}
             footer={<>