mirror of
https://github.com/zadam/trilium.git
synced 2026-01-11 01:42:16 +01:00
Compare commits
163 Commits
lightweigh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de6a6cbb07 | ||
|
|
2dcb003909 | ||
|
|
d5c934a518 | ||
|
|
779909837c | ||
|
|
a4cb375a0f | ||
|
|
e2da8c28ca | ||
|
|
7808848f05 | ||
|
|
7c05109645 | ||
|
|
8f9e89b73b | ||
|
|
861a61a4d8 | ||
|
|
52d4083814 | ||
|
|
0272189b22 | ||
|
|
ddba0e823c | ||
|
|
be81acb9e7 | ||
|
|
3bb97385c9 | ||
|
|
a72cec0494 | ||
|
|
cb02198c6f | ||
|
|
298d438230 | ||
|
|
cb2f7932dd | ||
|
|
3354bd669f | ||
|
|
8ad779be66 | ||
|
|
f462034868 | ||
|
|
26c25cd4cd | ||
|
|
6398830c2d | ||
|
|
0b065063f2 | ||
|
|
a3a9de6fdd | ||
|
|
d77d30f29e | ||
|
|
5cabc6379d | ||
|
|
af537e6a48 | ||
|
|
faf3797663 | ||
|
|
db57f3ff62 | ||
|
|
0f77caad69 | ||
|
|
751b91e1b8 | ||
|
|
968a17fbfb | ||
|
|
712e87b39f | ||
|
|
0b40b42315 | ||
|
|
62996b1162 | ||
|
|
b67ccc6091 | ||
|
|
211d2dcf99 | ||
|
|
ee52e16a75 | ||
|
|
0c27bd25fa | ||
|
|
b6a6e78d01 | ||
|
|
92e6a29e70 | ||
|
|
acc8cee7cd | ||
|
|
afefbe154b | ||
|
|
83fa55b7d9 | ||
|
|
4f6c10d995 | ||
|
|
ed972d2601 | ||
|
|
6b57ee5654 | ||
|
|
e469af1ca5 | ||
|
|
6d41f076c2 | ||
|
|
8cff591746 | ||
|
|
b3ccf89094 | ||
|
|
d31c6b1627 | ||
|
|
1481356d1f | ||
|
|
a54661fd0a | ||
|
|
ae4a3f10ae | ||
|
|
fe3160e7a1 | ||
|
|
66659d4786 | ||
|
|
0b25b09040 | ||
|
|
0d41cc2660 | ||
|
|
f5e8822718 | ||
|
|
bdc220ec12 | ||
|
|
3eb68e5271 | ||
|
|
521952ebcc | ||
|
|
034091a696 | ||
|
|
ae881101d8 | ||
|
|
b11a30c49c | ||
|
|
4625efda7f | ||
|
|
3c168d750d | ||
|
|
5cc7b259ce | ||
|
|
f7ae046b20 | ||
|
|
02f43d6239 | ||
|
|
53e1fa1047 | ||
|
|
b1dc0e234f | ||
|
|
9d380dd828 | ||
|
|
1f77540dbb | ||
|
|
716612680d | ||
|
|
3800fb85eb | ||
|
|
d807984be4 | ||
|
|
2c92ae8898 | ||
|
|
3d8cbc81c4 | ||
|
|
d747c94450 | ||
|
|
a627d1f96e | ||
|
|
869db5e478 | ||
|
|
73e94d385e | ||
|
|
8f4ebeb335 | ||
|
|
263ee864be | ||
|
|
f078732624 | ||
|
|
fac1f6b16c | ||
|
|
a5841c1423 | ||
|
|
aaca18003d | ||
|
|
5ec521b024 | ||
|
|
b3c0be7559 | ||
|
|
d52b735b99 | ||
|
|
639b1f2863 | ||
|
|
aff4f7e010 | ||
|
|
dec4dafba6 | ||
|
|
d0cdcfc32c | ||
|
|
0867b81c7a | ||
|
|
bde6068f2d | ||
|
|
47fd2affa4 | ||
|
|
7f2cc885fe | ||
|
|
19a365a370 | ||
|
|
9a50da328e | ||
|
|
181e36a7c1 | ||
|
|
178508d245 | ||
|
|
d132d084cf | ||
|
|
494b55d685 | ||
|
|
51513d3779 | ||
|
|
5b95b9875b | ||
|
|
458398f2ca | ||
|
|
7a6cc4f51e | ||
|
|
f4ccce7de5 | ||
|
|
f8b5417d6c | ||
|
|
87ab41c80c | ||
|
|
d2391f94c0 | ||
|
|
050ddb8c55 | ||
|
|
bc23e0984a | ||
|
|
07de353207 | ||
|
|
c02491d2e6 | ||
|
|
a6ede8f905 | ||
|
|
22941a9ce0 | ||
|
|
633a09d414 | ||
|
|
29f0881c5a | ||
|
|
60debca37b | ||
|
|
30ea81d0fb | ||
|
|
b1d92c4fe6 | ||
|
|
70f46de2d8 | ||
|
|
f1b2d0b870 | ||
|
|
8a385972fc | ||
|
|
28dd85c1d1 | ||
|
|
827c8e0e72 | ||
|
|
162c076a14 | ||
|
|
9386465de7 | ||
|
|
acca22f3a1 | ||
|
|
f8d84814e0 | ||
|
|
c46cf41842 | ||
|
|
64ab1c4116 | ||
|
|
a6de1041c7 | ||
|
|
c8d34e65ea | ||
|
|
51db729546 | ||
|
|
d2052ad236 | ||
|
|
9c4301467f | ||
|
|
e7355dc0e4 | ||
|
|
4110fec94f | ||
|
|
d5e601eae9 | ||
|
|
4f044c4a57 | ||
|
|
5821c350e1 | ||
|
|
edba8188fe | ||
|
|
1471a72633 | ||
|
|
56834cb88a | ||
|
|
a0f16f9184 | ||
|
|
de80eb4806 | ||
|
|
48a4b81fbe | ||
|
|
e225794f72 | ||
|
|
4eef30f8b5 | ||
|
|
569b09609d | ||
|
|
39838c25c2 | ||
|
|
49e90c08a9 | ||
|
|
e777b06fb8 | ||
|
|
497ec2ac74 | ||
|
|
c5d282d203 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -11,6 +11,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sanity-check:
|
||||
name: Sanity Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Check version consistency
|
||||
run: pnpm tsx ${{ github.workspace }}/scripts/check-version-consistency.ts ${{ github.ref_name }}
|
||||
make-electron:
|
||||
name: Make Electron
|
||||
strategy:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ upload
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
scripts/translation/.language*.json
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.3",
|
||||
"archiver": "7.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.101.1",
|
||||
"version": "0.101.3",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -154,6 +154,7 @@ export type CommandMappings = {
|
||||
};
|
||||
openInTab: ContextMenuCommandData;
|
||||
openNoteInSplit: ContextMenuCommandData;
|
||||
openNoteInWindow: ContextMenuCommandData;
|
||||
openNoteInPopup: ContextMenuCommandData;
|
||||
toggleNoteHoisting: ContextMenuCommandData;
|
||||
insertNoteAfter: ContextMenuCommandData;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script>
|
||||
async function bootstrap() {
|
||||
await setupGlob();
|
||||
loadStylesheets();
|
||||
loadIcons();
|
||||
setBodyAttributes();
|
||||
await loadScripts();
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
const response = await fetch("./bootstrap");
|
||||
const json = await response.json();
|
||||
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
||||
const cssToLoad = [];
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
|
||||
if (themeCssUrl) {
|
||||
cssToLoad.push(themeCssUrl);
|
||||
}
|
||||
if (themeUseNextAsBase === "next") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
|
||||
} else if (themeUseNextAsBase === "next-dark") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
|
||||
} else if (themeUseNextAsBase === "next-light") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
|
||||
}
|
||||
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
|
||||
|
||||
for (const href of cssToLoad) {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = href;
|
||||
linkEl.rel = "stylesheet";
|
||||
document.body.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIcons() {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerText = window.glob.iconPackCss;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
`heading-style-${headingStyle}`,
|
||||
`layout-${layoutOrientation}`,
|
||||
`platform-${platform}`,
|
||||
isElectron && "isElectron",
|
||||
hasNativeTitleBar && "native-titlebar",
|
||||
hasBackgroundEffects && "background-effects"
|
||||
].filter(Boolean);
|
||||
|
||||
for (const classToSet of classesToSet) {
|
||||
document.body.classList.add(classToSet);
|
||||
}
|
||||
|
||||
document.body.lang = currentLocale.id;
|
||||
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
const assetPath = glob.assetPath;
|
||||
await import(`./${assetPath}/runtime.js`);
|
||||
await import(`./${assetPath}/desktop.js`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
</script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -616,7 +616,9 @@ export default class FNote {
|
||||
}
|
||||
|
||||
isFolder() {
|
||||
return this.type === "search" || this.getFilteredChildBranches().length > 0;
|
||||
if (this.isLabelTruthy("subtreeHidden")) return false;
|
||||
if (this.type === "search") return true;
|
||||
return this.getFilteredChildBranches().length > 0;
|
||||
}
|
||||
|
||||
getFilteredChildBranches() {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import NoteColorPicker from "./custom-items/NoteColorPicker.jsx";
|
||||
import treeService from "../services/tree.js";
|
||||
import froca from "../services/froca.js";
|
||||
import clipboard from "../services/clipboard.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js";
|
||||
import appContext, { type ContextMenuCommandData, type FilteredCommandNames } from "../components/app_context.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import attributes from "../services/attributes.js";
|
||||
import { executeBulkActions } from "../services/bulk_action.js";
|
||||
import clipboard from "../services/clipboard.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import noteTypesService from "../services/note_types.js";
|
||||
import server from "../services/server.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import dialogService from "../services/dialog.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import type FAttachment from "../entities/fattachment.js";
|
||||
import type { SelectMenuItemEventListener } from "../components/events.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import utils from "../services/utils.js";
|
||||
import attributes from "../services/attributes.js";
|
||||
import { executeBulkActions } from "../services/bulk_action.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js";
|
||||
import NoteColorPicker from "./custom-items/NoteColorPicker.jsx";
|
||||
|
||||
// TODO: Deduplicate once client/server is well split.
|
||||
interface ConvertToAttachmentResponse {
|
||||
@@ -72,6 +72,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
|
||||
|
||||
const notSearch = note?.type !== "search";
|
||||
const hasSubtreeHidden = note?.isLabelTruthy("subtreeHidden") ?? false;
|
||||
const isSpotlighted = this.node.extraClasses.includes("spotlighted-node");
|
||||
const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help");
|
||||
const parentNotSearch = !parentNote || parentNote.type !== "search";
|
||||
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
|
||||
@@ -79,17 +81,18 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
const items: (MenuItem<TreeCommandNames> | null)[] = [
|
||||
{ title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-a-new-window"), command: "openNoteInWindow", uiIcon: "bx bx-window-open", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes },
|
||||
|
||||
isHoisted
|
||||
? null
|
||||
: {
|
||||
title: `${t("tree-context-menu.hoist-note")}`,
|
||||
command: "toggleNoteHoisting",
|
||||
keyboardShortcut: "toggleNoteHoisting",
|
||||
uiIcon: "bx bxs-chevrons-up",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
title: `${t("tree-context-menu.hoist-note")}`,
|
||||
command: "toggleNoteHoisting",
|
||||
keyboardShortcut: "toggleNoteHoisting",
|
||||
uiIcon: "bx bxs-chevrons-up",
|
||||
enabled: noSelectedNotes && notSearch
|
||||
},
|
||||
!isHoisted || !isNotRoot
|
||||
? null
|
||||
: { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
|
||||
@@ -112,7 +115,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
keyboardShortcut: "createNoteInto",
|
||||
uiIcon: "bx bx-plus",
|
||||
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
|
||||
enabled: notSearch && noSelectedNotes && notOptionsOrHelp,
|
||||
enabled: notSearch && noSelectedNotes && notOptionsOrHelp && !hasSubtreeHidden && !isSpotlighted,
|
||||
columns: 2
|
||||
},
|
||||
|
||||
@@ -150,8 +153,17 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
{ kind: "separator" },
|
||||
|
||||
{ title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
{ title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
!hasSubtreeHidden && { title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
!hasSubtreeHidden && { title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
|
||||
{
|
||||
title: hasSubtreeHidden ? t("tree-context-menu.show-subtree") : t("tree-context-menu.hide-subtree"),
|
||||
uiIcon: "bx bx-show",
|
||||
handler: async () => {
|
||||
const note = await froca.getNote(this.node.data.noteId);
|
||||
if (!note) return;
|
||||
attributes.setBooleanWithInheritance(note, "subtreeHidden", !hasSubtreeHidden);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("tree-context-menu.sort-by"),
|
||||
command: "sortChildNotes",
|
||||
@@ -164,7 +176,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
|
||||
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
|
||||
]
|
||||
].filter(Boolean) as MenuItem<TreeCommandNames>[]
|
||||
},
|
||||
|
||||
{ kind: "separator" },
|
||||
@@ -292,25 +304,30 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
target: "after",
|
||||
targetBranchId: this.node.data.branchId,
|
||||
type: type,
|
||||
isProtected: isProtected,
|
||||
templateNoteId: templateNoteId
|
||||
type,
|
||||
isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
} else if (command === "insertChildNote") {
|
||||
const parentNotePath = treeService.getNotePath(this.node);
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
type: type,
|
||||
type,
|
||||
isProtected: this.node.data.isProtected,
|
||||
templateNoteId: templateNoteId
|
||||
templateNoteId
|
||||
});
|
||||
} else if (command === "openNoteInSplit") {
|
||||
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
|
||||
const { ntxId } = subContexts?.[subContexts.length - 1] ?? {};
|
||||
|
||||
this.treeWidget.triggerCommand("openNewNoteSplit", { ntxId, notePath });
|
||||
} else if (command === "openNoteInWindow") {
|
||||
appContext.triggerCommand("openInWindow", {
|
||||
notePath,
|
||||
hoistedNoteId: appContext.tabManager.getActiveContext()?.hoistedNoteId
|
||||
});
|
||||
} else if (command === "openNoteInPopup") {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath })
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath });
|
||||
} else if (command === "convertNoteToAttachment") {
|
||||
if (!(await dialogService.confirm(t("tree-context-menu.convert-to-attachment-confirm")))) {
|
||||
return;
|
||||
@@ -332,11 +349,11 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
toastService.showMessage(t("tree-context-menu.converted-to-attachments", { count: converted }));
|
||||
} else if (command === "copyNotePathToClipboard") {
|
||||
navigator.clipboard.writeText("#" + notePath);
|
||||
navigator.clipboard.writeText(`#${ notePath}`);
|
||||
} else if (command) {
|
||||
this.treeWidget.triggerCommand<TreeCommandNames>(command, {
|
||||
node: this.node,
|
||||
notePath: notePath,
|
||||
notePath,
|
||||
noteId: this.node.data.noteId,
|
||||
selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node),
|
||||
selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node)
|
||||
|
||||
139
apps/client/src/services/attributes.spec.ts
Normal file
139
apps/client/src/services/attributes.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildNote } from "../test/easy-froca";
|
||||
import { setBooleanWithInheritance } from "./attributes";
|
||||
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 <T> (url: string, data?: T) => ({} as T));
|
||||
// @ts-expect-error the generic typing is causing issues here
|
||||
server.remove = vi.fn(async <T> (url: string) => ({} as T));
|
||||
|
||||
describe("Set boolean with inheritance", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("doesn't call server if value matches directly", async () => {
|
||||
const noteWithLabel = buildNote({
|
||||
title: "New note",
|
||||
"#foo": ""
|
||||
});
|
||||
const noteWithoutLabel = buildNote({
|
||||
title: "New note"
|
||||
});
|
||||
|
||||
await setBooleanWithInheritance(noteWithLabel, "foo", true);
|
||||
await setBooleanWithInheritance(noteWithoutLabel, "foo", false);
|
||||
expect(server.put).not.toHaveBeenCalled();
|
||||
expect(server.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets boolean normally without inheritance", async () => {
|
||||
const standaloneNote = buildNote({
|
||||
title: "New note"
|
||||
});
|
||||
|
||||
await setBooleanWithInheritance(standaloneNote, "foo", true);
|
||||
expect(server.put).toHaveBeenCalledWith(`notes/${standaloneNote.noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("removes boolean normally without inheritance", async () => {
|
||||
const standaloneNote = buildNote({
|
||||
title: "New note",
|
||||
"#foo": ""
|
||||
});
|
||||
|
||||
const attributeId = standaloneNote.getLabel("foo")!.attributeId;
|
||||
await setBooleanWithInheritance(standaloneNote, "foo", false);
|
||||
expect(server.remove).toHaveBeenCalledWith(`notes/${standaloneNote.noteId}/attributes/${attributeId}`);
|
||||
});
|
||||
|
||||
it("doesn't call server if value matches inherited", async () => {
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
"#foo(inheritable)": "",
|
||||
"children": [
|
||||
{
|
||||
title: "Child note"
|
||||
}
|
||||
]
|
||||
});
|
||||
const childNote = froca.getNoteFromCache(parentNote.children[0])!;
|
||||
expect(childNote.isLabelTruthy("foo")).toBe(true);
|
||||
await setBooleanWithInheritance(childNote, "foo", true);
|
||||
expect(server.put).not.toHaveBeenCalled();
|
||||
expect(server.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("overrides boolean with inheritance", async () => {
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
"#foo(inheritable)": "",
|
||||
"children": [
|
||||
{
|
||||
title: "Child note"
|
||||
}
|
||||
]
|
||||
});
|
||||
const childNote = froca.getNoteFromCache(parentNote.children[0])!;
|
||||
expect(childNote.isLabelTruthy("foo")).toBe(true);
|
||||
await setBooleanWithInheritance(childNote, "foo", false);
|
||||
expect(server.put).toHaveBeenCalledWith(`notes/${childNote.noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: "foo",
|
||||
value: "false",
|
||||
isInheritable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides boolean with inherited false", async () => {
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
"#foo(inheritable)": "false",
|
||||
"children": [
|
||||
{
|
||||
title: "Child note"
|
||||
}
|
||||
]
|
||||
});
|
||||
const childNote = froca.getNoteFromCache(parentNote.children[0])!;
|
||||
expect(childNote.isLabelTruthy("foo")).toBe(false);
|
||||
await setBooleanWithInheritance(childNote, "foo", true);
|
||||
expect(server.put).toHaveBeenCalledWith(`notes/${childNote.noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes override boolean with inherited false with already existing value", async () => {
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
"#foo(inheritable)": "false",
|
||||
"children": [
|
||||
{
|
||||
title: "Child note",
|
||||
"#foo": "false",
|
||||
}
|
||||
]
|
||||
});
|
||||
const childNote = froca.getNoteFromCache(parentNote.children[0])!;
|
||||
expect(childNote.isLabelTruthy("foo")).toBe(false);
|
||||
await setBooleanWithInheritance(childNote, "foo", true);
|
||||
expect(server.put).toBeCalledWith(`notes/${childNote.noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,15 @@
|
||||
import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
import { AttributeType } from "@triliumnext/commons";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import froca from "./froca.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
import server from "./server.js";
|
||||
|
||||
async function addLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/attribute`, {
|
||||
type: "label",
|
||||
name: name,
|
||||
value: value,
|
||||
name,
|
||||
value,
|
||||
isInheritable
|
||||
});
|
||||
}
|
||||
@@ -16,8 +17,8 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
|
||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: name,
|
||||
value: value,
|
||||
name,
|
||||
value,
|
||||
isInheritable
|
||||
});
|
||||
}
|
||||
@@ -25,12 +26,42 @@ export async function setLabel(noteId: string, name: string, value: string = "",
|
||||
export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "relation",
|
||||
name: name,
|
||||
value: value,
|
||||
name,
|
||||
value,
|
||||
isInheritable
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a boolean label on the given note, taking inheritance into account. If the desired value matches the inherited
|
||||
* value, any owned label will be removed to allow the inherited value to take effect. If the desired value differs
|
||||
* from the inherited value, an owned label will be created or updated to reflect the desired value.
|
||||
*
|
||||
* When checking if the boolean value is set, don't use `note.hasLabel`; instead use `note.isLabelTruthy`.
|
||||
*
|
||||
* @param note the note on which to set the boolean label.
|
||||
* @param labelName the name of the label to set.
|
||||
* @param value the boolean value to set for the label.
|
||||
*/
|
||||
export async function setBooleanWithInheritance(note: FNote, labelName: string, value: boolean) {
|
||||
const actualValue = note.isLabelTruthy(labelName);
|
||||
if (actualValue === value) return;
|
||||
const hasInheritedValue = !note.hasOwnedLabel(labelName) && note.hasLabel(labelName);
|
||||
|
||||
if (hasInheritedValue) {
|
||||
if (value) {
|
||||
setLabel(note.noteId, labelName, "");
|
||||
} else {
|
||||
// Label is inherited - override to false.
|
||||
setLabel(note.noteId, labelName, "false");
|
||||
}
|
||||
} else if (value) {
|
||||
setLabel(note.noteId, labelName, "");
|
||||
} else {
|
||||
removeOwnedLabelByName(note, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttributeById(noteId: string, attributeId: string) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
@@ -142,6 +173,7 @@ export default {
|
||||
setLabel,
|
||||
setRelation,
|
||||
setAttribute,
|
||||
setBooleanWithInheritance,
|
||||
removeAttributeById,
|
||||
removeOwnedLabelByName,
|
||||
removeOwnedRelationByName,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import utils from "./utils.js";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import froca from "./froca.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import utils from "./utils.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
// TODO: Deduplicate type with server
|
||||
interface Response {
|
||||
@@ -66,7 +66,7 @@ async function moveAfterBranch(branchIdsToMove: string[], afterBranchId: string)
|
||||
}
|
||||
}
|
||||
|
||||
async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: string) {
|
||||
async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: string, componentId?: string) {
|
||||
const newParentBranch = froca.getBranch(newParentBranchId);
|
||||
if (!newParentBranch) {
|
||||
return;
|
||||
@@ -86,7 +86,7 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
|
||||
continue;
|
||||
}
|
||||
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-to/${newParentBranchId}`);
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-to/${newParentBranchId}`, undefined, componentId);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface RenderOptions {
|
||||
imageHasZoom?: boolean;
|
||||
/** If enabled, it will prevent the default behavior in which an empty note would display a list of children. */
|
||||
noChildrenList?: boolean;
|
||||
/** If enabled, it will prevent rendering of included notes. */
|
||||
noIncludedNotes?: boolean;
|
||||
/** If enabled, it will include archived notes when rendering children list. */
|
||||
includeArchivedNotes?: boolean;
|
||||
/** Set of note IDs that have already been seen during rendering to prevent infinite recursion. */
|
||||
seenNoteIds?: Set<string>;
|
||||
}
|
||||
|
||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||
|
||||
132
apps/client/src/services/content_renderer_text.spec.ts
Normal file
132
apps/client/src/services/content_renderer_text.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNote } from "../test/easy-froca";
|
||||
import renderText from "./content_renderer_text";
|
||||
|
||||
describe("Text content renderer", () => {
|
||||
it("renders included note", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
const includedNote = buildNote({
|
||||
title: "Included note",
|
||||
content: "<p>This is the included note.</p>"
|
||||
});
|
||||
const note = buildNote({
|
||||
title: "New note",
|
||||
content: trimIndentation`
|
||||
<p>
|
||||
Hi there
|
||||
</p>
|
||||
<section class="include-note" data-note-id="${includedNote.noteId}" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
`
|
||||
});
|
||||
await renderText(note, $(contentEl));
|
||||
expect(contentEl.querySelectorAll("section.include-note").length).toBe(1);
|
||||
expect(contentEl.querySelectorAll("section.include-note p").length).toBe(1);
|
||||
});
|
||||
|
||||
it("skips rendering included note", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
const includedNote = buildNote({
|
||||
title: "Included note",
|
||||
content: "<p>This is the included note.</p>"
|
||||
});
|
||||
const note = buildNote({
|
||||
title: "New note",
|
||||
content: trimIndentation`
|
||||
<p>
|
||||
Hi there
|
||||
</p>
|
||||
<section class="include-note" data-note-id="${includedNote.noteId}" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
`
|
||||
});
|
||||
await renderText(note, $(contentEl), { noIncludedNotes: true });
|
||||
expect(contentEl.querySelectorAll("section.include-note").length).toBe(0);
|
||||
});
|
||||
|
||||
it("doesn't enter infinite loop on direct recursion", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
const note = buildNote({
|
||||
title: "New note",
|
||||
id: "Y7mBwmRjQyb4",
|
||||
content: trimIndentation`
|
||||
<p>
|
||||
Hi there
|
||||
</p>
|
||||
<section class="include-note" data-note-id="Y7mBwmRjQyb4" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
<section class="include-note" data-note-id="Y7mBwmRjQyb4" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
`
|
||||
});
|
||||
await renderText(note, $(contentEl));
|
||||
expect(contentEl.querySelectorAll("section.include-note").length).toBe(0);
|
||||
});
|
||||
|
||||
it("doesn't enter infinite loop on indirect recursion", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
buildNote({
|
||||
id: "first",
|
||||
title: "Included note",
|
||||
content: trimIndentation`\
|
||||
<p>This is the included note.</p>
|
||||
<section class="include-note" data-note-id="second" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
`
|
||||
});
|
||||
const note = buildNote({
|
||||
id: "second",
|
||||
title: "New note",
|
||||
content: trimIndentation`
|
||||
<p>
|
||||
Hi there
|
||||
</p>
|
||||
<section class="include-note" data-note-id="first" data-box-size="medium">
|
||||
|
||||
</section>
|
||||
`
|
||||
});
|
||||
await renderText(note, $(contentEl));
|
||||
expect(contentEl.querySelectorAll("section.include-note").length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders children list when note is empty", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
children: [
|
||||
{ title: "Child note 1" },
|
||||
{ title: "Child note 2" }
|
||||
]
|
||||
});
|
||||
await renderText(parentNote, $(contentEl));
|
||||
const items = contentEl.querySelectorAll("a");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].textContent).toBe("Child note 1");
|
||||
expect(items[1].textContent).toBe("Child note 2");
|
||||
});
|
||||
|
||||
it("skips archived notes in children list", async () => {
|
||||
const contentEl = document.createElement("div");
|
||||
const parentNote = buildNote({
|
||||
title: "Parent note",
|
||||
children: [
|
||||
{ title: "Child note 1" },
|
||||
{ title: "Child note 2", "#archived": "" },
|
||||
{ title: "Child note 3" }
|
||||
]
|
||||
});
|
||||
await renderText(parentNote, $(contentEl));
|
||||
const items = contentEl.querySelectorAll("a");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].textContent).toBe("Child note 1");
|
||||
expect(items[1].textContent).toBe("Child note 3");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,14 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
|
||||
|
||||
if (blob && !isHtmlEmpty(blob.content)) {
|
||||
$renderedContent.append($('<div class="ck-content">').html(blob.content));
|
||||
await renderIncludedNotes($renderedContent[0]);
|
||||
|
||||
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
|
||||
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
|
||||
if (!options.noIncludedNotes) {
|
||||
await renderIncludedNotes($renderedContent[0], seenNoteIds);
|
||||
} else {
|
||||
$renderedContent.find("section.include-note").remove();
|
||||
}
|
||||
|
||||
if ($renderedContent.find("span.math-tex").length > 0) {
|
||||
renderMathInElement($renderedContent[0], { trust: true });
|
||||
@@ -35,11 +42,11 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
|
||||
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
|
||||
await formatCodeBlocks($renderedContent);
|
||||
} else if (note instanceof FNote && !options.noChildrenList) {
|
||||
await renderChildrenList($renderedContent, note);
|
||||
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderIncludedNotes(contentEl: HTMLElement) {
|
||||
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
|
||||
// TODO: Consider duplicating with server's share/content_renderer.ts.
|
||||
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
|
||||
|
||||
@@ -66,7 +73,15 @@ async function renderIncludedNotes(contentEl: HTMLElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderedContent = (await content_renderer.getRenderedContent(note)).$renderedContent;
|
||||
if (seenNoteIds.has(noteId)) {
|
||||
console.warn(`Skipping inclusion of ${noteId} to avoid circular reference.`);
|
||||
includeNoteEl.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderedContent = (await content_renderer.getRenderedContent(note, {
|
||||
seenNoteIds
|
||||
})).$renderedContent;
|
||||
includeNoteEl.replaceChildren(...renderedContent);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +113,7 @@ export async function applyInlineMermaid(container: HTMLDivElement) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote) {
|
||||
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
|
||||
let childNoteIds = note.getChildNoteIds();
|
||||
|
||||
if (!childNoteIds.length) {
|
||||
@@ -108,14 +123,16 @@ async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: F
|
||||
$renderedContent.css("padding", "10px");
|
||||
$renderedContent.addClass("text-with-ellipsis");
|
||||
|
||||
// just load the first 10 child notes
|
||||
if (childNoteIds.length > 10) {
|
||||
childNoteIds = childNoteIds.slice(0, 10);
|
||||
}
|
||||
|
||||
// just load the first 10 child notes
|
||||
const childNotes = await froca.getNotes(childNoteIds);
|
||||
|
||||
for (const childNote of childNotes) {
|
||||
if (childNote.isArchived && !includeArchivedNotes) continue;
|
||||
|
||||
$renderedContent.append(
|
||||
await link.createLink(`${note.noteId}/${childNote.noteId}`, {
|
||||
showTooltip: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AttachmentRow, EtapiTokenRow, NoteType, OptionNames } from "@triliumnext/commons";
|
||||
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
|
||||
@@ -135,7 +136,14 @@ export default class LoadResults {
|
||||
}
|
||||
|
||||
getBranchRows() {
|
||||
return this.branchRows.map((row) => this.getEntityRow("branches", row.branchId)).filter((branch) => !!branch);
|
||||
return this.branchRows.map((row) => {
|
||||
const branch = this.getEntityRow("branches", row.branchId);
|
||||
if (branch) {
|
||||
// Merge the componentId from the tracked row with the entity data
|
||||
return { ...branch, componentId: row.componentId };
|
||||
}
|
||||
return null;
|
||||
}).filter((branch) => !!branch) as BranchRow[];
|
||||
}
|
||||
|
||||
addNoteReordering(parentNoteId: string, componentId: string) {
|
||||
@@ -153,7 +161,14 @@ export default class LoadResults {
|
||||
getAttributeRows(componentId = "none"): AttributeRow[] {
|
||||
return this.attributeRows
|
||||
.filter((row) => row.componentId !== componentId)
|
||||
.map((row) => this.getEntityRow("attributes", row.attributeId))
|
||||
.map((row) => {
|
||||
const attr = this.getEntityRow("attributes", row.attributeId);
|
||||
if (attr) {
|
||||
// Merge the componentId from the tracked row with the entity data
|
||||
return { ...attr, componentId: row.componentId };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((attr) => !!attr) as AttributeRow[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
|
||||
@@ -69,24 +69,6 @@ export function buildNote(noteDef: NoteDefinition) {
|
||||
});
|
||||
note.getBlob = async () => blob;
|
||||
|
||||
// Manage children.
|
||||
if (noteDef.children) {
|
||||
for (const childDef of noteDef.children) {
|
||||
const childNote = buildNote(childDef);
|
||||
const branchId = `${note.noteId}_${childNote.noteId}`;
|
||||
const branch = new FBranch(froca, {
|
||||
branchId,
|
||||
noteId: childNote.noteId,
|
||||
parentNoteId: note.noteId,
|
||||
notePosition: childNotePosition,
|
||||
fromSearchNote: false
|
||||
});
|
||||
froca.branches[branchId] = branch;
|
||||
note.addChild(childNote.noteId, branchId, false);
|
||||
childNotePosition += 10;
|
||||
}
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
for (const [ key, value ] of Object.entries(noteDef)) {
|
||||
const attributeId = utils.randomString(12);
|
||||
@@ -136,5 +118,25 @@ export function buildNote(noteDef: NoteDefinition) {
|
||||
}
|
||||
noteAttributeCache.attributes[note.noteId].push(attribute);
|
||||
}
|
||||
|
||||
// Manage children.
|
||||
if (noteDef.children) {
|
||||
for (const childDef of noteDef.children) {
|
||||
const childNote = buildNote(childDef);
|
||||
const branchId = `${note.noteId}_${childNote.noteId}`;
|
||||
const branch = new FBranch(froca, {
|
||||
branchId,
|
||||
noteId: childNote.noteId,
|
||||
parentNoteId: note.noteId,
|
||||
notePosition: childNotePosition,
|
||||
fromSearchNote: false
|
||||
});
|
||||
froca.branches[branchId] = branch;
|
||||
note.addChild(childNote.noteId, branchId, false);
|
||||
childNote.addParent(note.noteId, branchId, false);
|
||||
childNotePosition += 10;
|
||||
}
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Abruf der Liste von Widgets vom Server ist fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"open-script-note": "Script-Notiz öffnen"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Link hinzufügen",
|
||||
@@ -208,7 +209,8 @@
|
||||
"info": {
|
||||
"modalTitle": "Infonachricht",
|
||||
"closeButton": "Schließen",
|
||||
"okButton": "OK"
|
||||
"okButton": "OK",
|
||||
"copy_to_clipboard": "In die Zwischenablage kopieren"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Suche im Volltext",
|
||||
@@ -695,7 +697,9 @@
|
||||
"export_as_image": "Als Bild exportieren",
|
||||
"export_as_image_png": "PNG (Raster)",
|
||||
"export_as_image_svg": "SVG (Vektor)",
|
||||
"note_map": "Notizen Karte"
|
||||
"note_map": "Notizen Karte",
|
||||
"view_revisions": "Notizrevisionen",
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Das Schaltflächen-Widget „{{componentId}}“ hat keinen definierten Klick-Handler"
|
||||
|
||||
@@ -800,7 +800,8 @@
|
||||
"geo-map": "Geo Map",
|
||||
"board": "Board",
|
||||
"presentation": "Presentation",
|
||||
"include_archived_notes": "Show archived notes"
|
||||
"include_archived_notes": "Show archived notes",
|
||||
"hide_child_notes": "Hide child notes in tree"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "No edited notes on this day yet...",
|
||||
@@ -1643,6 +1644,7 @@
|
||||
"tree-context-menu": {
|
||||
"open-in-a-new-tab": "Open in a new tab",
|
||||
"open-in-a-new-split": "Open in a new split",
|
||||
"open-in-a-new-window": "Open in a new window",
|
||||
"insert-note-after": "Insert note after",
|
||||
"insert-child-note": "Insert child note",
|
||||
"archive": "Archive",
|
||||
@@ -1655,6 +1657,8 @@
|
||||
"advanced": "Advanced",
|
||||
"expand-subtree": "Expand subtree",
|
||||
"collapse-subtree": "Collapse subtree",
|
||||
"hide-subtree": "Hide subtree",
|
||||
"show-subtree": "Show subtree",
|
||||
"sort-by": "Sort by...",
|
||||
"recent-changes-in-subtree": "Recent changes in subtree",
|
||||
"convert-to-attachment": "Convert to attachment",
|
||||
@@ -1772,7 +1776,12 @@
|
||||
"clone-indicator-tooltip": "This note has {{- count}} parents: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "This note is cloned (1 additional parent: {{- parent}})",
|
||||
"shared-indicator-tooltip": "This note is shared publicly",
|
||||
"shared-indicator-tooltip-with-url": "This note is shared publicly at: {{- url}}"
|
||||
"shared-indicator-tooltip-with-url": "This note is shared publicly at: {{- url}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} child note that is hidden from the tree",
|
||||
"subtree-hidden-tooltip_other": "{{count}} child notes that are hidden from the tree",
|
||||
"subtree-hidden-moved-title": "Added to {{title}}",
|
||||
"subtree-hidden-moved-description-collection": "This collection hides its child notes in the tree.",
|
||||
"subtree-hidden-moved-description-other": "Child notes are hidden in the tree for this note."
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Keep Window on Top"
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Hubo un fallo al cargar un script personalizado",
|
||||
"message": "El script de la nota con ID \"{{id}}\", titulado \"{{title}}\" no pudo ser ejecutado debido a:\n\n{{message}}"
|
||||
"message": "El script no pudo ser ejecutado debido a:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Hubo un fallo al obtener la lista de widgets del servidor"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Hubo un fallo al renderizar un widget personalizado de React"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@@ -162,7 +168,8 @@
|
||||
"other": "Otro",
|
||||
"quickSearch": "centrarse en la entrada de búsqueda rápida",
|
||||
"inPageSearch": "búsqueda en la página",
|
||||
"title": "Hoja de ayuda"
|
||||
"title": "Hoja de ayuda",
|
||||
"editShortcuts": "Editar atajos de teclado"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Importar a nota",
|
||||
@@ -690,7 +697,7 @@
|
||||
"convert_into_attachment_successful": "La nota '{{title}}' ha sido convertida a un archivo adjunto.",
|
||||
"convert_into_attachment_prompt": "¿Está seguro que desea convertir la nota '{{title}}' en un archivo adjunto de la nota padre?",
|
||||
"print_pdf": "Exportar como PDF...",
|
||||
"open_note_on_server": "Abrir nota en el servidor"
|
||||
"open_note_on_server": "Abrir nota en servidor"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "El widget de botón '{{componentId}}' no tiene un controlador de clics definido"
|
||||
@@ -736,7 +743,7 @@
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relación",
|
||||
"backlink_one": "{{count}} Vínculo de retroceso",
|
||||
"backlink_many": "",
|
||||
"backlink_many": "{{count}} Vínculos de retroceso",
|
||||
"backlink_other": "{{count}} vínculos de retroceso"
|
||||
},
|
||||
"mobile_detail_menu": {
|
||||
@@ -749,7 +756,10 @@
|
||||
"note_icon": {
|
||||
"change_note_icon": "Cambiar icono de nota",
|
||||
"search": "Búsqueda:",
|
||||
"reset-default": "Restablecer a icono por defecto"
|
||||
"reset-default": "Restablecer a icono por defecto",
|
||||
"search_placeholder_one": "Buscar {{number}} icono a través de {{count}} paquetes",
|
||||
"search_placeholder_many": "Buscar {{number}} iconos a través de {{count}} paquetes",
|
||||
"search_placeholder_other": "Buscar {{number}} iconos a través de {{count}} paquetes"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Tipo de nota",
|
||||
@@ -789,7 +799,7 @@
|
||||
"file_type": "Tipo de archivo",
|
||||
"file_size": "Tamaño del archivo",
|
||||
"download": "Descargar",
|
||||
"open": "Abrir",
|
||||
"open": "Abrir externamente",
|
||||
"upload_new_revision": "Subir nueva revisión",
|
||||
"upload_success": "Se ha subido una nueva revisión de archivo.",
|
||||
"upload_failed": "Error al cargar una nueva revisión de archivo.",
|
||||
@@ -1302,11 +1312,11 @@
|
||||
"code_mime_types": {
|
||||
"title": "Tipos MIME disponibles en el menú desplegable",
|
||||
"tooltip_syntax_highlighting": "Resaltado de sintaxis",
|
||||
"tooltip_code_block_syntax": "Bloques de código en notas de texto",
|
||||
"tooltip_code_note_syntax": "Notas de código"
|
||||
"tooltip_code_block_syntax": "Bloques de Código en notas de Texto",
|
||||
"tooltip_code_note_syntax": "Notas de Código"
|
||||
},
|
||||
"vim_key_bindings": {
|
||||
"use_vim_keybindings_in_code_notes": "Atajos de teclas de Vim",
|
||||
"use_vim_keybindings_in_code_notes": "Combinaciones de teclas Vim",
|
||||
"enable_vim_keybindings": "Habilitar los atajos de teclas de Vim en la notas de código (no es modo ex)"
|
||||
},
|
||||
"wrap_lines": {
|
||||
@@ -1571,7 +1581,7 @@
|
||||
"will_be_deleted_in": "Este archivo adjunto se eliminará automáticamente en {{time}}",
|
||||
"will_be_deleted_soon": "Este archivo adjunto se eliminará automáticamente pronto",
|
||||
"deletion_reason": ", porque el archivo adjunto no está vinculado en el contenido de la nota. Para evitar la eliminación, vuelva a agregar el enlace del archivo adjunto al contenido o convierta el archivo adjunto en una nota.",
|
||||
"role_and_size": "Rol: {{role}}, Tamaño: {{size}}",
|
||||
"role_and_size": "Rol: {{role}}, tamaño: {{size}}, MIME: {{- mimeType}}",
|
||||
"link_copied": "Enlace del archivo adjunto copiado al portapapeles.",
|
||||
"unrecognized_role": "Rol de archivo adjunto no reconocido '{{role}}'."
|
||||
},
|
||||
@@ -1622,7 +1632,7 @@
|
||||
"import-into-note": "Importar a nota",
|
||||
"apply-bulk-actions": "Aplicar acciones en lote",
|
||||
"converted-to-attachments": "{{count}} notas han sido convertidas en archivos adjuntos.",
|
||||
"convert-to-attachment-confirm": "¿Está seguro que desea convertir las notas seleccionadas en archivos adjuntos de sus notas padres?",
|
||||
"convert-to-attachment-confirm": "¿Está seguro que desea convertir las notas seleccionadas en archivos adjuntos de sus notas padres? Esta operación solo aplica a notas de Imagen, otras notas serán omitidas.",
|
||||
"open-in-popup": "Edición rápida",
|
||||
"archive": "Archivar",
|
||||
"unarchive": "Desarchivar"
|
||||
@@ -1717,7 +1727,10 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "No se pudo encontrar typeWidget para el tipo '{{type}}'",
|
||||
"printing": "Impresión en curso...",
|
||||
"printing_pdf": "Exportando a PDF en curso.."
|
||||
"printing_pdf": "Exportando a PDF en curso..",
|
||||
"print_report_collection_content_one": "{{count}} nota en la colección no se puede imprimir porque no son compatibles o está protegida.",
|
||||
"print_report_collection_content_many": "{{count}} notas en la colección no se pueden imprimir porque no son compatibles o están protegidas.",
|
||||
"print_report_collection_content_other": "{{count}} notas en la colección no se pueden imprimir porque no son compatibles o están protegidas."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "escriba el título de la nota aquí..."
|
||||
@@ -1929,7 +1942,7 @@
|
||||
"unknown_widget": "Widget desconocido para \"{{id}}\"."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "No establecido",
|
||||
"not_set": "Idioma no establecido",
|
||||
"configure-languages": "Configurar idiomas..."
|
||||
},
|
||||
"content_language": {
|
||||
@@ -1968,7 +1981,7 @@
|
||||
"hide-weekends": "Ocultar fines de semana",
|
||||
"show-scale": "Mostrar escala",
|
||||
"display-week-numbers": "Mostrar números de semana",
|
||||
"map-style": "Estilo de mapa:",
|
||||
"map-style": "Estilo de mapa",
|
||||
"max-nesting-depth": "Máxima profundidad de anidamiento:",
|
||||
"vector_light": "Vector (claro)",
|
||||
"vector_dark": "Vector (oscuro)",
|
||||
@@ -2097,5 +2110,36 @@
|
||||
"clear-color": "Borrar color de nota",
|
||||
"set-color": "Asignar color de nota",
|
||||
"set-custom-color": "Asignar color de nota personalizado"
|
||||
},
|
||||
"status_bar": {
|
||||
"backlinks_one": "{{count}} vínculo de retroceso",
|
||||
"backlinks_many": "{{count}} vínculos de retroceso",
|
||||
"backlinks_other": "{{count}} vínculos de retroceso",
|
||||
"backlinks_title_one": "Ver vínculo de retroceso",
|
||||
"backlinks_title_many": "Ver vínculos de retroceso",
|
||||
"backlinks_title_other": "Ver vínculos de retroceso",
|
||||
"attachments_one": "{{count}} adjunto",
|
||||
"attachments_many": "{{count}} adjuntos",
|
||||
"attachments_other": "{{count}} adjuntos",
|
||||
"attachments_title_one": "Ver adjunto en una nueva pestaña",
|
||||
"attachments_title_many": "Ver adjuntos en una nueva pestaña",
|
||||
"attachments_title_other": "Ver adjuntos en una nueva pestaña",
|
||||
"attributes_one": "{{count}} atributo",
|
||||
"attributes_many": "{{count}} atributos",
|
||||
"attributes_other": "{{count}} atributos",
|
||||
"note_paths_one": "{{count}} ruta",
|
||||
"note_paths_many": "{{count}} rutas",
|
||||
"note_paths_other": "{{count}} rutas"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} adjunto",
|
||||
"attachments_many": "{{count}} adjuntos",
|
||||
"attachments_other": "{{count}} adjuntos",
|
||||
"layers_one": "{{count}} capa",
|
||||
"layers_many": "{{count}} capas",
|
||||
"layers_other": "{{count}} capas",
|
||||
"pages_one": "{{count}} página",
|
||||
"pages_many": "{{count}} páginas",
|
||||
"pages_other": "{{count}} páginas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Echec du chargement d'un script personnalisé",
|
||||
"message": "Le script de la note avec l'ID \"{{id}}\", intitulé \"{{title}}\" n'a pas pu être exécuté à cause de\n\n{{message}}"
|
||||
"message": "Le script n'a pas pu être exécuté à cause de\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Impossible d'obtenir la liste des widgets depuis le serveur"
|
||||
|
||||
@@ -31,5 +31,17 @@
|
||||
},
|
||||
"add_link": {
|
||||
"note": "नोट"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "अन्य"
|
||||
},
|
||||
"clone_to": {
|
||||
"search_for_note_by_its_name": "नोट क नाम से नोट खोजें"
|
||||
},
|
||||
"confirm": {
|
||||
"also_delete_note": "नोट भी डिलीट करें"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "नोट्स प्रिव्यू डिलीट करें"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
"message": "A skript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "A Widget-ek letöltése sikertelen volt"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Nem sikerült renderelni a React widget-et"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
|
||||
@@ -1895,7 +1895,11 @@
|
||||
"create-child-note": "Crea nota figlio",
|
||||
"unhoist": "Sganciare",
|
||||
"toggle-sidebar": "Attiva/disattiva la barra laterale",
|
||||
"dropping-not-allowed": "Non è consentito lasciare appunti in questa posizione."
|
||||
"dropping-not-allowed": "Non è consentito lasciare appunti in questa posizione.",
|
||||
"clone-indicator-tooltip": "Questa nota ha {{- count}} genitori: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Questa nota è stata clonata (1 genitore aggiuntivo: {{- parent}})",
|
||||
"shared-indicator-tooltip": "Questa nota è condivisa pubblicamente",
|
||||
"shared-indicator-tooltip-with-url": "Questa nota è condivisa pubblicamente all'indirizzo: {{- url}}"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Mantieni la finestra in primo piano"
|
||||
@@ -2200,7 +2204,14 @@
|
||||
"execute_sql_description": "Questa nota è una nota SQL. Clicca per eseguire la query SQL.",
|
||||
"shared_copy_to_clipboard": "Copia link negli appunti",
|
||||
"shared_open_in_browser": "Apri il link nel browser",
|
||||
"shared_unshare": "Rimuovi condivisione"
|
||||
"shared_unshare": "Rimuovi condivisione",
|
||||
"save_status_saved": "Salvato",
|
||||
"save_status_saving": "Salvataggio in corso...",
|
||||
"save_status_unsaved": "Non salvato",
|
||||
"save_status_error": "Salvataggio non riuscito",
|
||||
"save_status_saving_tooltip": "Le modifiche sono state salvate.",
|
||||
"save_status_unsaved_tooltip": "Ci sono modifiche non salvate. Verranno salvate automaticamente tra un attimo.",
|
||||
"save_status_error_tooltip": "Si è verificato un errore durante il salvataggio della nota. Se possibile, prova a copiare il contenuto della nota altrove e a ricaricare l'applicazione."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"workspace_badge": "Area di lavoro",
|
||||
@@ -2243,5 +2254,18 @@
|
||||
"empty_button": "Nascondi il pannello",
|
||||
"toggle": "Attiva/disattiva pannello destro",
|
||||
"custom_widget_go_to_source": "Vai al codice sorgente"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} allegato",
|
||||
"attachments_many": "{{count}} allegati",
|
||||
"attachments_other": "{{count}} allegati",
|
||||
"layers_one": "{{count}} livello",
|
||||
"layers_many": "{{count}} livelli",
|
||||
"layers_other": "{{count}} livelli",
|
||||
"pages_one": "{{count}} pagina",
|
||||
"pages_many": "{{count}} pagine",
|
||||
"pages_other": "{{count}} pagine",
|
||||
"pages_alt": "Pagina {{pageNumber}}",
|
||||
"pages_loading": "Caricamento in corso..."
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/client/src/types-fancytree.d.ts
vendored
21
apps/client/src/types-fancytree.d.ts
vendored
@@ -69,7 +69,7 @@ declare namespace Fancytree {
|
||||
debug(msg: any): void;
|
||||
|
||||
/** Expand (or collapse) all parent nodes. */
|
||||
expandAll(flag?: boolean, options?: Object): void;
|
||||
expandAll(flag?: boolean, options?: object): void;
|
||||
|
||||
/** [ext-filter] Dimm or hide whole branches.
|
||||
* @returns {integer} count
|
||||
@@ -221,6 +221,7 @@ declare namespace Fancytree {
|
||||
branchId: string;
|
||||
isProtected: boolean;
|
||||
noteType: NoteType;
|
||||
subtreeHidden: boolean;
|
||||
}
|
||||
|
||||
interface FancytreeNewNode extends FancytreeNodeData {
|
||||
@@ -369,7 +370,7 @@ declare namespace Fancytree {
|
||||
* @param mode 'before', 'after', or 'child' (default='child')
|
||||
* @param init NodeData (or simple title string)
|
||||
*/
|
||||
editCreateNode(mode?: string, init?: Object): void;
|
||||
editCreateNode(mode?: string, init?: object): void;
|
||||
|
||||
/** [ext-edit] Stop inline editing.
|
||||
*
|
||||
@@ -526,7 +527,7 @@ declare namespace Fancytree {
|
||||
*
|
||||
* @param opts passed to `setExpanded()`. Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true}
|
||||
*/
|
||||
makeVisible(opts?: Object): JQueryPromise<any>;
|
||||
makeVisible(opts?: object): JQueryPromise<any>;
|
||||
|
||||
/** Move this node to targetNode.
|
||||
*
|
||||
@@ -589,25 +590,25 @@ declare namespace Fancytree {
|
||||
* @param effects animation options.
|
||||
* @param options {topNode: null, effects: ..., parent: ...} this node will remain visible in any case, even if `this` is outside the scroll pane.
|
||||
*/
|
||||
scrollIntoView(effects?: boolean, options?: Object): JQueryPromise<any>;
|
||||
scrollIntoView(effects?: boolean, options?: object): JQueryPromise<any>;
|
||||
|
||||
/**
|
||||
* @param effects animation options.
|
||||
* @param options {topNode: null, effects: ..., parent: ...} this node will remain visible in any case, even if `this` is outside the scroll pane.
|
||||
*/
|
||||
scrollIntoView(effects?: Object, options?: Object): JQueryPromise<any>;
|
||||
scrollIntoView(effects?: object, options?: object): JQueryPromise<any>;
|
||||
|
||||
/**
|
||||
* @param flag pass false to deactivate
|
||||
* @param opts additional options. Defaults to {noEvents: false}
|
||||
*/
|
||||
setActive(flag?: boolean, opts?: Object): JQueryPromise<any>;
|
||||
setActive(flag?: boolean, opts?: object): JQueryPromise<any>;
|
||||
|
||||
/**
|
||||
* @param flag pass false to collapse.
|
||||
* @param opts additional options. Defaults to {noAnimation:false, noEvents:false}
|
||||
*/
|
||||
setExpanded(flag?: boolean, opts?: Object): JQueryPromise<any>;
|
||||
setExpanded(flag?: boolean, opts?: object): JQueryPromise<any>;
|
||||
|
||||
/**
|
||||
* Set keyboard focus to this node.
|
||||
@@ -1109,7 +1110,7 @@ declare namespace Fancytree {
|
||||
/** class names added to the node markup (separate with space) */
|
||||
extraClasses?: string | undefined;
|
||||
/** all properties from will be copied to `node.data` */
|
||||
data?: Object | undefined;
|
||||
data?: object | undefined;
|
||||
|
||||
/** Will be added as title attribute of the node's icon span,thus enabling a tooltip. */
|
||||
iconTooltip?: string | undefined;
|
||||
@@ -1160,7 +1161,7 @@ declare namespace Fancytree {
|
||||
|
||||
escapeHtml(s: string): string;
|
||||
|
||||
getEventTarget(event: Event): Object;
|
||||
getEventTarget(event: Event): object;
|
||||
|
||||
getEventTargetType(event: Event): string;
|
||||
|
||||
@@ -1179,7 +1180,7 @@ declare namespace Fancytree {
|
||||
parseHtml($ul: JQuery): NodeData[];
|
||||
|
||||
/** Add Fancytree extension definition to the list of globally available extensions. */
|
||||
registerExtension(definition: Object): void;
|
||||
registerExtension(definition: object): void;
|
||||
|
||||
unescapeHtml(s: string): string;
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function NoteDetail() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||
class={`component note-detail ${isFullHeight ? "full-height" : ""}`}
|
||||
>
|
||||
{Object.entries(noteTypesToRender).map(([ itemType, Element ]) => {
|
||||
return <NoteDetailWrapper
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import "./UserAttributesList.css";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -31,7 +29,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -48,13 +46,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = `${attr.type === "label" ? `label` + ` ${ attr.def.labelType}` : "relation"}`;
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -63,7 +61,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
const value = attr.value;
|
||||
let value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -104,7 +102,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -44,6 +44,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
|
||||
const { pageNotes, ...pagination } = usePagination(note, noteIds);
|
||||
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
|
||||
return (
|
||||
<div class="note-list grid-view">
|
||||
@@ -52,7 +53,7 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
{pageNotes?.map(childNote => (
|
||||
<GridNoteCard note={childNote} parentNote={note} highlightedTokens={highlightedTokens} />
|
||||
<GridNoteCard note={childNote} parentNote={note} highlightedTokens={highlightedTokens} includeArchived={includeArchived} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -94,14 +95,16 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
|
||||
</h5>
|
||||
|
||||
{isExpanded && <>
|
||||
<NoteContent note={note} highlightedTokens={highlightedTokens} noChildrenList />
|
||||
<NoteContent note={note} highlightedTokens={highlightedTokens} noChildrenList includeArchivedNotes={includeArchived} />
|
||||
<NoteChildren note={note} parentNote={parentNote} highlightedTokens={highlightedTokens} currentLevel={currentLevel} expandDepth={expandDepth} includeArchived={includeArchived} />
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
|
||||
function GridNoteCard({ note, parentNote, highlightedTokens, includeArchived }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined, includeArchived: boolean }) {
|
||||
const titleRef = useRef<HTMLSpanElement>(null);
|
||||
const [ noteTitle, setNoteTitle ] = useState<string>();
|
||||
const notePath = getNotePath(parentNote, note);
|
||||
|
||||
return (
|
||||
@@ -120,6 +123,7 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa
|
||||
note={note}
|
||||
trim
|
||||
highlightedTokens={highlightedTokens}
|
||||
includeArchivedNotes={includeArchived}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -136,14 +140,22 @@ function NoteAttributes({ note }: { note: FNote }) {
|
||||
return <span className="note-list-attributes" ref={ref} />;
|
||||
}
|
||||
|
||||
function NoteContent({ note, trim, noChildrenList, highlightedTokens }: { note: FNote, trim?: boolean, noChildrenList?: boolean, highlightedTokens: string[] | null | undefined }) {
|
||||
function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
|
||||
note: FNote;
|
||||
trim?: boolean;
|
||||
noChildrenList?: boolean;
|
||||
highlightedTokens: string[] | null | undefined;
|
||||
includeArchivedNotes: boolean;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
content_renderer.getRenderedContent(note, {
|
||||
trim,
|
||||
noChildrenList
|
||||
noChildrenList,
|
||||
noIncludedNotes: true,
|
||||
includeArchivedNotes
|
||||
})
|
||||
.then(({ $renderedContent, type }) => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
import { JSX } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
|
||||
import froca from "../../../services/froca.js";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { JSX } from "preact";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
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";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@@ -79,7 +78,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>}
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
@@ -201,14 +200,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
|
||||
editorParams: {},
|
||||
) => HTMLElement | false) {
|
||||
return (cell, _, success, cancel, editorParams) => {
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />;
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function NoteFormatter({ cell }: FormatterOpts) {
|
||||
const noteId = cell.getValue();
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null);
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId || note?.noteId === noteId) return;
|
||||
@@ -232,5 +231,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>;
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { isDesktop, isMobile } from "../../services/utils";
|
||||
import CalendarWidget from "./CalendarWidget";
|
||||
import SpacerWidget from "./SpacerWidget";
|
||||
import BookmarkButtons from "./BookmarkButtons";
|
||||
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import HistoryNavigationButton from "./HistoryNavigation";
|
||||
import { AiChatButton, CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { onWheelHorizontalScroll } from "../widget_utils";
|
||||
import BookmarkButtons from "./BookmarkButtons";
|
||||
import CalendarWidget from "./CalendarWidget";
|
||||
import HistoryNavigationButton from "./HistoryNavigation";
|
||||
import { LaunchBarContext } from "./launch_bar_widgets";
|
||||
import { AiChatButton, CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
|
||||
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
|
||||
import SpacerWidget from "./SpacerWidget";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
|
||||
export default function LauncherContainer({ isHorizontalLayout }: { isHorizontalLayout: boolean }) {
|
||||
const childNotes = useLauncherChildNotes();
|
||||
@@ -34,18 +35,19 @@ export default function LauncherContainer({ isHorizontalLayout }: { isHorizontal
|
||||
}}>
|
||||
{childNotes?.map(childNote => {
|
||||
if (childNote.type !== "launcher") {
|
||||
throw new Error(`Note '${childNote.noteId}' '${childNote.title}' is not a launcher even though it's in the launcher subtree`);
|
||||
console.warn(`Note '${childNote.noteId}' '${childNote.title}' is not a launcher even though it's in the launcher subtree`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDesktop() && childNote.isLabelTruthy("desktopOnly")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return <Launcher key={childNote.noteId} note={childNote} isHorizontalLayout={isHorizontalLayout} />
|
||||
return <Launcher key={childNote.noteId} note={childNote} isHorizontalLayout={isHorizontalLayout} />;
|
||||
})}
|
||||
</LaunchBarContext.Provider>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Launcher({ note, isHorizontalLayout }: { note: FNote, isHorizontalLayout: boolean }) {
|
||||
@@ -72,7 +74,7 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
|
||||
const builtinWidget = note.getLabelValue("builtinWidget");
|
||||
switch (builtinWidget) {
|
||||
case "calendar":
|
||||
return <CalendarWidget launcherNote={note} />
|
||||
return <CalendarWidget launcherNote={note} />;
|
||||
case "spacer":
|
||||
// || has to be inside since 0 is a valid value
|
||||
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
|
||||
@@ -86,15 +88,15 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
|
||||
case "syncStatus":
|
||||
return <SyncStatus />;
|
||||
case "backInHistoryButton":
|
||||
return <HistoryNavigationButton launcherNote={note} command="backInNoteHistory" />
|
||||
return <HistoryNavigationButton launcherNote={note} command="backInNoteHistory" />;
|
||||
case "forwardInHistoryButton":
|
||||
return <HistoryNavigationButton launcherNote={note} command="forwardInNoteHistory" />
|
||||
return <HistoryNavigationButton launcherNote={note} command="forwardInNoteHistory" />;
|
||||
case "todayInJournal":
|
||||
return <TodayLauncher launcherNote={note} />
|
||||
return <TodayLauncher launcherNote={note} />;
|
||||
case "quickSearch":
|
||||
return <QuickSearchLauncherWidget />
|
||||
return <QuickSearchLauncherWidget />;
|
||||
case "aiChatLauncher":
|
||||
return <AiChatButton launcherNote={note} />
|
||||
return <AiChatButton launcherNote={note} />;
|
||||
default:
|
||||
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
|
||||
@@ -338,19 +338,19 @@ interface AttributesProps extends StatusBarContext {
|
||||
function AttributesButton({ note, attributesShown, setAttributesShown }: AttributesProps) {
|
||||
const [ count, setCount ] = useState(note.attributes.length);
|
||||
|
||||
const getAttributeCount = useCallback((note: FNote) => {
|
||||
const refreshCount = useCallback((note: FNote) => {
|
||||
return note.getAttributes().filter(a => !a.isAutoLink).length;
|
||||
}, []);
|
||||
|
||||
// React to note changes.
|
||||
useEffect(() => {
|
||||
setCount(getAttributeCount(note));
|
||||
}, [ note, getAttributeCount ]);
|
||||
setCount(refreshCount(note));
|
||||
}, [ note, refreshCount ]);
|
||||
|
||||
// React to changes in count.
|
||||
useTriliumEvent("entitiesReloaded", (({loadResults}) => {
|
||||
if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) {
|
||||
setCount(getAttributeCount(note));
|
||||
setCount(refreshCount(note));
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -82,6 +82,13 @@ function ViewOptions({ note, viewType }: { note: FNote, viewType: ViewTypeOption
|
||||
))}
|
||||
{properties.length > 0 && <FormDropdownDivider />}
|
||||
|
||||
<ViewProperty note={note} property={{
|
||||
type: "checkbox",
|
||||
icon: "bx bx-hide",
|
||||
label: t("book_properties.hide_child_notes"),
|
||||
bindToLabel: "subtreeHidden"
|
||||
} as CheckBoxProperty} />
|
||||
|
||||
<ViewProperty note={note} property={{
|
||||
type: "checkbox",
|
||||
icon: "bx bx-archive",
|
||||
|
||||
20
apps/client/src/widgets/note_tree.css
Normal file
20
apps/client/src/widgets/note_tree.css
Normal file
@@ -0,0 +1,20 @@
|
||||
#left-pane .tree-wrapper {
|
||||
.note-indicator-icon.subtree-hidden-badge {
|
||||
font-family: inherit !important;
|
||||
margin-inline: 0.5em;
|
||||
margin-top: 0.3em;
|
||||
background: var(--left-pane-item-action-button-background);
|
||||
color: var(--left-pane-item-action-button-color);
|
||||
padding: 0.1em 0.6em;
|
||||
border-radius: 0.5em;
|
||||
font-size: 0.7rem;
|
||||
font-weight: normal;
|
||||
float: right;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.spotlighted-node {
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import "jquery.fancytree/dist/modules/jquery.fancytree.dnd5.js";
|
||||
import "jquery.fancytree/dist/modules/jquery.fancytree.clones.js";
|
||||
import "jquery.fancytree/dist/modules/jquery.fancytree.filter.js";
|
||||
import "../stylesheets/tree.css";
|
||||
import "./note_tree.css";
|
||||
|
||||
import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js";
|
||||
import type { SetNoteOpts } from "../components/note_context.js";
|
||||
import type { TouchBarItem } from "../components/touch_bar.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { NoteType } from "../entities/fnote.js";
|
||||
import contextMenu from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
import branchService from "../services/branches.js";
|
||||
@@ -153,7 +153,7 @@ const TPL = /*html*/`
|
||||
const MAX_SEARCH_RESULTS_IN_TREE = 100;
|
||||
|
||||
// this has to be hanged on the actual elements to effectively intercept and stop click event
|
||||
const cancelClickPropagation: (e: JQuery.ClickEvent) => void = (e) => e.stopPropagation();
|
||||
const cancelClickPropagation: (e: Event) => void = (e) => e.stopPropagation();
|
||||
|
||||
// TODO: Fix once we remove Node.js API from public
|
||||
type Timeout = NodeJS.Timeout | string | number | undefined;
|
||||
@@ -190,6 +190,9 @@ export interface DragData {
|
||||
|
||||
export const TREE_CLIPBOARD_TYPE = "application/x-fancytree-node";
|
||||
|
||||
/** Entity changes below the given threshold will be processed without batching to avoid performance degradation. */
|
||||
const BATCH_UPDATE_THRESHOLD = 10;
|
||||
|
||||
export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
private $tree!: JQuery<HTMLElement>;
|
||||
private $treeActions!: JQuery<HTMLElement>;
|
||||
@@ -201,6 +204,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
private treeName: "main";
|
||||
private autoCollapseTimeoutId?: Timeout;
|
||||
private lastFilteredHoistedNotePath?: string | null;
|
||||
private spotlightedNotePath?: string | null;
|
||||
private spotlightedNode: Fancytree.FancytreeNode | null = null;
|
||||
private tree!: Fancytree.Fancytree;
|
||||
|
||||
constructor() {
|
||||
@@ -353,6 +358,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
this.$tree.fancytree({
|
||||
titlesTabbable: true,
|
||||
keyboard: true,
|
||||
toggleEffect: options.is("motionEnabled") ? undefined : false,
|
||||
extensions: ["dnd5", "clones", "filter"],
|
||||
source: treeData,
|
||||
scrollOfs: {
|
||||
@@ -552,7 +558,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
} else if (data.hitMode === "after") {
|
||||
branchService.moveAfterBranch(selectedBranchIds, node.data.branchId);
|
||||
} else if (data.hitMode === "over") {
|
||||
branchService.moveToParentNote(selectedBranchIds, node.data.branchId);
|
||||
branchService.moveToParentNote(selectedBranchIds, node.data.branchId, this.componentId);
|
||||
} else {
|
||||
throw new Error(`Unknown hitMode '${data.hitMode}'`);
|
||||
}
|
||||
@@ -598,102 +604,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
clones: {
|
||||
highlightActiveClones: true
|
||||
},
|
||||
async enhanceTitle (
|
||||
event: Event,
|
||||
data: {
|
||||
node: Fancytree.FancytreeNode;
|
||||
noteId: string;
|
||||
}
|
||||
) {
|
||||
const node = data.node;
|
||||
|
||||
if (!node.data.noteId) {
|
||||
// if there's "non-note" node, then don't enhance
|
||||
// this can happen for e.g. "Load error!" node
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await froca.getNote(node.data.noteId, true);
|
||||
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeNoteContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
const $span = $(node.span);
|
||||
|
||||
$span.find(".tree-item-button").remove();
|
||||
$span.find(".note-indicator-icon").remove();
|
||||
|
||||
const isHoistedNote = activeNoteContext && activeNoteContext.hoistedNoteId === note.noteId && note.noteId !== "root";
|
||||
|
||||
if (note.hasLabel("workspace") && !isHoistedNote) {
|
||||
const $enterWorkspaceButton = $(`<span class="tree-item-button tn-icon enter-workspace-button bx bx-door-open" title="${t("note_tree.hoist-this-note-workspace")}"></span>`).on(
|
||||
"click",
|
||||
cancelClickPropagation
|
||||
);
|
||||
|
||||
$span.append($enterWorkspaceButton);
|
||||
}
|
||||
|
||||
if (note.type === "search") {
|
||||
const $refreshSearchButton = $(`<span class="tree-item-button tn-icon refresh-search-button bx bx-refresh" title="${t("note_tree.refresh-saved-search-results")}"></span>`).on(
|
||||
"click",
|
||||
cancelClickPropagation
|
||||
);
|
||||
|
||||
$span.append($refreshSearchButton);
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server's notes.ts#getAndValidateParent
|
||||
if (!["search", "launcher"].includes(note.type)
|
||||
&& !note.isOptions()
|
||||
&& !note.isLaunchBarConfig()
|
||||
&& !note.noteId.startsWith("_help")
|
||||
) {
|
||||
const $createChildNoteButton = $(`<span class="tree-item-button tn-icon add-note-button bx bx-plus" title="${t("note_tree.create-child-note")}"></span>`).on(
|
||||
"click",
|
||||
cancelClickPropagation
|
||||
);
|
||||
|
||||
$span.append($createChildNoteButton);
|
||||
}
|
||||
|
||||
if (isHoistedNote) {
|
||||
const $unhoistButton = $(`<span class="tree-item-button tn-icon unhoist-button bx bx-door-open" title="${t("note_tree.unhoist")}"></span>`).on("click", cancelClickPropagation);
|
||||
|
||||
$span.append($unhoistButton);
|
||||
}
|
||||
|
||||
// Add clone indicator with tooltip if note has multiple parents
|
||||
const parentNotes = note.getParentNotes();
|
||||
const realParents = parentNotes.filter(
|
||||
(parent) => !["_share", "_lbBookmarks"].includes(parent.noteId) && parent.type !== "search"
|
||||
);
|
||||
|
||||
if (realParents.length > 1) {
|
||||
const parentTitles = realParents.map((p) => p.title).join(", ");
|
||||
const tooltipText = realParents.length === 2
|
||||
? t("note_tree.clone-indicator-tooltip-single", { parent: realParents[1].title })
|
||||
: t("note_tree.clone-indicator-tooltip", { count: realParents.length, parents: parentTitles });
|
||||
|
||||
const $cloneIndicator = $(`<span class="note-indicator-icon clone-indicator"></span>`);
|
||||
$cloneIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($cloneIndicator);
|
||||
}
|
||||
|
||||
// Add shared indicator with tooltip if note is shared
|
||||
if (note.isShared()) {
|
||||
const shareId = note.getOwnedLabelValue("shareAlias") || note.noteId;
|
||||
const shareUrl = `${location.origin}${location.pathname}share/${shareId}`;
|
||||
const tooltipText = t("note_tree.shared-indicator-tooltip-with-url", { url: shareUrl });
|
||||
|
||||
const $sharedIndicator = $(`<span class="note-indicator-icon shared-indicator"></span>`);
|
||||
$sharedIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($sharedIndicator);
|
||||
}
|
||||
},
|
||||
enhanceTitle: buildEnhanceTitle(),
|
||||
// this is done to automatically lazy load all expanded notes after tree load
|
||||
loadChildren: (event, data) => {
|
||||
data.node.visit((subNode) => {
|
||||
@@ -803,6 +714,23 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
childBranches = childBranches.slice(0, MAX_SEARCH_RESULTS_IN_TREE);
|
||||
}
|
||||
|
||||
if (parentNote.isLabelTruthy("subtreeHidden")) {
|
||||
// If we have a spotlighted note path, show only the child that leads to it
|
||||
if (this.spotlightedNotePath) {
|
||||
const spotlightPathSegments = this.spotlightedNotePath.split('/');
|
||||
const parentIndex = spotlightPathSegments.indexOf(parentNote.noteId);
|
||||
|
||||
if (parentIndex >= 0 && parentIndex < spotlightPathSegments.length - 1) {
|
||||
const nextNoteIdInPath = spotlightPathSegments[parentIndex + 1];
|
||||
childBranches = childBranches.filter(branch => branch.noteId === nextNoteIdInPath);
|
||||
} else {
|
||||
childBranches = [];
|
||||
}
|
||||
} else {
|
||||
childBranches = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const branch of childBranches) {
|
||||
if (hideArchivedNotes) {
|
||||
const note = branch.getNoteFromCache();
|
||||
@@ -874,6 +802,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
lazy: true,
|
||||
folder: isFolder,
|
||||
expanded: !!branch.isExpanded && note.type !== "search",
|
||||
subtreeHidden: note.isLabelTruthy("subtreeHidden"),
|
||||
key: utils.randomString(12) // this should prevent some "duplicate key" errors
|
||||
};
|
||||
|
||||
@@ -932,6 +861,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
extraClasses.push(...["tinted", colorClass]);
|
||||
}
|
||||
|
||||
if (this.spotlightedNotePath && this.spotlightedNotePath.endsWith(`/${note.noteId}`)) {
|
||||
extraClasses.push("spotlighted-node");
|
||||
}
|
||||
|
||||
return extraClasses.join(" ");
|
||||
}
|
||||
|
||||
@@ -1082,18 +1015,43 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
foundChildNode = this.findChildNode(parentNode, childNoteId);
|
||||
|
||||
if (!foundChildNode) {
|
||||
if (logErrors) {
|
||||
// besides real errors, this can be also caused by hiding of e.g. included images
|
||||
// these are real notes with real notePath, user can display them in a detail,
|
||||
// but they don't have a node in the tree
|
||||
const childNote = await froca.getNote(childNoteId);
|
||||
|
||||
const childNote = await froca.getNote(childNoteId);
|
||||
if (childNote?.type === "image") return;
|
||||
|
||||
if (!childNote || childNote.type !== "image") {
|
||||
ws.logError(
|
||||
`Can't find node for child node of noteId=${childNoteId} for parent of noteId=${parentNode.data.noteId} and hoistedNoteId=${hoistedNoteService.getHoistedNoteId()}, requested path is ${notePath}`
|
||||
);
|
||||
// The child note can be part of a note with #subtreeHidden, case in which we need to "spotlight" it.
|
||||
const parentNote = froca.getNoteFromCache(parentNode.data.noteId);
|
||||
if (parentNote?.isLabelTruthy("subtreeHidden")) {
|
||||
// Enable spotlight mode and reload the parent to show only the path to this note
|
||||
this.spotlightedNotePath = notePath;
|
||||
await parentNode.load(true);
|
||||
|
||||
// Try to find the child again after reload
|
||||
foundChildNode = this.findChildNode(parentNode, childNoteId);
|
||||
this.spotlightedNode = foundChildNode ?? null;
|
||||
|
||||
if (!foundChildNode) {
|
||||
if (logErrors || !childNote) {
|
||||
ws.logError(
|
||||
`Can't find node for child node of noteId=${childNoteId} for parent of noteId=${parentNode.data.noteId} and hoistedNoteId=${hoistedNoteService.getHoistedNoteId()}, requested path is ${notePath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
parentNode = foundChildNode;
|
||||
continue;
|
||||
}
|
||||
|
||||
// besides real errors, this can be also caused by hiding of e.g. included images
|
||||
// these are real notes with real notePath, user can display them in a detail,
|
||||
// but they don't have a node in the tree
|
||||
if (logErrors || !childNote) {
|
||||
ws.logError(
|
||||
`Can't find node for child node of noteId=${childNoteId} for parent of noteId=${parentNode.data.noteId} and hoistedNoteId=${hoistedNoteService.getHoistedNoteId()}, requested path is ${notePath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -1108,7 +1066,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
findChildNode(parentNode: Fancytree.FancytreeNode, childNoteId: string) {
|
||||
return parentNode.getChildren().find((childNode) => childNode.data.noteId === childNoteId);
|
||||
return parentNode.getChildren()?.find((childNode) => childNode.data.noteId === childNoteId);
|
||||
}
|
||||
|
||||
async expandToNote(notePath: string, logErrors = true) {
|
||||
@@ -1149,12 +1107,20 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
(!treeService.isNotePathInHiddenSubtree(this.noteContext.notePath) || (await hoistedNoteService.isHoistedInHiddenSubtree())) &&
|
||||
(await this.getNodeFromPath(this.noteContext.notePath));
|
||||
|
||||
if (this.spotlightedNode && newActiveNode !== this.spotlightedNode) {
|
||||
// Can get removed when switching to another note in a spotlighted subtree.
|
||||
if (this.spotlightedNode.parent) {
|
||||
this.spotlightedNode.remove();
|
||||
}
|
||||
this.spotlightedNode = null;
|
||||
this.spotlightedNotePath = null;
|
||||
}
|
||||
|
||||
if (newActiveNode !== oldActiveNode) {
|
||||
let oldActiveNodeFocused = false;
|
||||
|
||||
if (oldActiveNode) {
|
||||
oldActiveNodeFocused = oldActiveNode.hasFocus();
|
||||
|
||||
oldActiveNode.setActive(false);
|
||||
oldActiveNode.setFocus(false);
|
||||
}
|
||||
@@ -1257,10 +1223,18 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const { movedActiveNode, parentsOfAddedNodes } = await this.#processBranchRows(branchRows, refreshCtx);
|
||||
|
||||
for (const noteId of loadResults.getNoteIds()) {
|
||||
const contentReloaded = loadResults.isNoteContentReloaded(noteId);
|
||||
if (contentReloaded && !loadResults.isNoteReloaded(noteId, contentReloaded.componentId)) {
|
||||
// Only the note content was reloaded, not the note itself. This would cause a redundant update on every few seconds while editing a note.
|
||||
continue;
|
||||
}
|
||||
|
||||
refreshCtx.noteIdsToUpdate.add(noteId);
|
||||
}
|
||||
|
||||
await this.#executeTreeUpdates(refreshCtx, loadResults);
|
||||
if (refreshCtx.noteIdsToUpdate.size + refreshCtx.noteIdsToReload.size > 0) {
|
||||
await this.#executeTreeUpdates(refreshCtx, loadResults);
|
||||
}
|
||||
|
||||
await this.#setActiveNode(activeNotePath, activeNodeFocused, movedActiveNode, parentsOfAddedNodes);
|
||||
|
||||
@@ -1280,7 +1254,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
} else {
|
||||
refreshCtx.noteIdsToUpdate.add(attrRow.noteId);
|
||||
}
|
||||
} else if (attrRow.type === "label" && attrRow.name === "archived" && attrRow.noteId) {
|
||||
} else if (attrRow.type === "label" && (attrRow.name === "archived" || attrRow.name === "subtreeHidden") && attrRow.noteId) {
|
||||
const note = froca.getNoteFromCache(attrRow.noteId);
|
||||
|
||||
if (note) {
|
||||
@@ -1365,18 +1339,34 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
} else if (frocaBranch) {
|
||||
// make sure it's loaded
|
||||
// we're forcing lazy since it's not clear if the whole required subtree is in froca
|
||||
const newNode = this.prepareNode(frocaBranch, true);
|
||||
if (newNode) {
|
||||
parentNode.addChildren([newNode]);
|
||||
}
|
||||
if (!parentNode.data.subtreeHidden) {
|
||||
const newNode = this.prepareNode(frocaBranch, true);
|
||||
if (newNode) {
|
||||
parentNode.addChildren([newNode]);
|
||||
}
|
||||
|
||||
if (frocaBranch?.isExpanded && note && note.hasChildren()) {
|
||||
refreshCtx.noteIdsToReload.add(frocaBranch.noteId);
|
||||
}
|
||||
if (frocaBranch?.isExpanded && note && note.hasChildren()) {
|
||||
refreshCtx.noteIdsToReload.add(frocaBranch.noteId);
|
||||
}
|
||||
|
||||
this.sortChildren(parentNode);
|
||||
this.sortChildren(parentNode);
|
||||
} else if (branchRow.componentId === this.componentId) {
|
||||
// Display the toast and focus to parent note only if we know for sure that the operation comes from the tree.
|
||||
const parentNote = froca.getNoteFromCache(parentNode.data.noteId || "");
|
||||
toastService.showPersistent({
|
||||
id: `subtree-hidden-moved`,
|
||||
title: t("note_tree.subtree-hidden-moved-title", { title: parentNote?.title }),
|
||||
message: parentNote?.type === "book"
|
||||
? t("note_tree.subtree-hidden-moved-description-collection")
|
||||
: t("note_tree.subtree-hidden-moved-description-other"),
|
||||
icon: "bx bx-hide",
|
||||
timeout: 5_000,
|
||||
});
|
||||
parentNode.setActive(true);
|
||||
}
|
||||
|
||||
// this might be a first child which would force an icon change
|
||||
// also update the count if the subtree is hidden.
|
||||
if (branchRow.parentNoteId) {
|
||||
refreshCtx.noteIdsToUpdate.add(branchRow.parentNoteId);
|
||||
}
|
||||
@@ -1392,7 +1382,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
async #executeTreeUpdates(refreshCtx: RefreshContext, loadResults: LoadResults) {
|
||||
await this.batchUpdate(async () => {
|
||||
const performUpdates = async () => {
|
||||
for (const noteId of refreshCtx.noteIdsToReload) {
|
||||
for (const node of this.getNodesByNoteId(noteId)) {
|
||||
await node.load(true);
|
||||
@@ -1408,7 +1398,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (refreshCtx.noteIdsToReload.size + refreshCtx.noteIdsToUpdate.size >= BATCH_UPDATE_THRESHOLD) {
|
||||
/**
|
||||
* Batch updates are used for large number of updates to prevent multiple re-renders, however in the context of small updates (such as changing a note title)
|
||||
* it can cause up to 400ms of delay for ~8k notes which is not acceptable. Therefore we use batching only for larger number of updates.
|
||||
* Without batching, the updates would take a couple of milliseconds.
|
||||
* We still keep the batching for potential cases where there are many updates, for example in a sync.
|
||||
*/
|
||||
await this.batchUpdate(performUpdates);
|
||||
} else {
|
||||
await performUpdates();
|
||||
}
|
||||
|
||||
// for some reason, node update cannot be in the batchUpdate() block (node is not re-rendered)
|
||||
for (const noteId of refreshCtx.noteIdsToUpdate) {
|
||||
@@ -1672,7 +1674,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const toNode = node.getPrevSibling();
|
||||
|
||||
if (toNode !== null) {
|
||||
branchService.moveToParentNote([node.data.branchId], toNode.data.branchId);
|
||||
branchService.moveToParentNote([node.data.branchId], toNode.data.branchId, this.componentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1809,12 +1811,12 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
#moveLaunchers(selectedOrActiveBranchIds: string[], desktopParent: string, mobileParent: string) {
|
||||
const desktopLaunchersToMove = selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith("_lbMobile"));
|
||||
if (desktopLaunchersToMove) {
|
||||
branchService.moveToParentNote(desktopLaunchersToMove, `_lbRoot_${ desktopParent}`);
|
||||
branchService.moveToParentNote(desktopLaunchersToMove, `_lbRoot_${ desktopParent}`, this.componentId);
|
||||
}
|
||||
|
||||
const mobileLaunchersToMove = selectedOrActiveBranchIds.filter((branchId) => branchId.startsWith("_lbMobile"));
|
||||
if (mobileLaunchersToMove) {
|
||||
branchService.moveToParentNote(mobileLaunchersToMove, `_lbMobileRoot_${ mobileParent}`);
|
||||
branchService.moveToParentNote(mobileLaunchersToMove, `_lbMobileRoot_${mobileParent}`, this.componentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1882,3 +1884,112 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnhanceTitle() {
|
||||
const createChildTemplate = document.createElement("span");
|
||||
createChildTemplate.className = "tree-item-button tn-icon add-note-button bx bx-plus";
|
||||
createChildTemplate.title = t("note_tree.create-child-note");
|
||||
|
||||
return async function enhanceTitle(event: Event,
|
||||
data: {
|
||||
node: Fancytree.FancytreeNode;
|
||||
noteId: string;
|
||||
}) {
|
||||
const node = data.node;
|
||||
|
||||
if (!node.data.noteId) {
|
||||
// if there's "non-note" node, then don't enhance
|
||||
// this can happen for e.g. "Load error!" node
|
||||
return;
|
||||
}
|
||||
|
||||
const note = froca.getNoteFromCache(node.data.noteId);
|
||||
if (!note) return;
|
||||
|
||||
const activeNoteContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
const $span = $(node.span);
|
||||
|
||||
$span.find(".tree-item-button").remove();
|
||||
$span.find(".note-indicator-icon").remove();
|
||||
|
||||
const isHoistedNote = activeNoteContext && activeNoteContext.hoistedNoteId === note.noteId && note.noteId !== "root";
|
||||
|
||||
if (note.hasLabel("workspace") && !isHoistedNote) {
|
||||
const $enterWorkspaceButton = $(`<span class="tree-item-button tn-icon enter-workspace-button bx bx-door-open" title="${t("note_tree.hoist-this-note-workspace")}"></span>`).on(
|
||||
"click",
|
||||
cancelClickPropagation
|
||||
);
|
||||
|
||||
$span.append($enterWorkspaceButton);
|
||||
}
|
||||
|
||||
if (note.type === "search") {
|
||||
const $refreshSearchButton = $(`<span class="tree-item-button tn-icon refresh-search-button bx bx-refresh" title="${t("note_tree.refresh-saved-search-results")}"></span>`).on(
|
||||
"click",
|
||||
cancelClickPropagation
|
||||
);
|
||||
|
||||
$span.append($refreshSearchButton);
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with server's notes.ts#getAndValidateParent
|
||||
const isSubtreeHidden = note.isLabelTruthy("subtreeHidden");
|
||||
if (!["search", "launcher"].includes(note.type)
|
||||
&& !note.isOptions()
|
||||
&& !note.isLaunchBarConfig()
|
||||
&& !note.noteId.startsWith("_help")
|
||||
&& !isSubtreeHidden
|
||||
&& !node.extraClasses.includes("spotlighted-node")
|
||||
) {
|
||||
const createChildItem = createChildTemplate.cloneNode();
|
||||
createChildItem.addEventListener("click", cancelClickPropagation);
|
||||
node.span.append(createChildItem);
|
||||
}
|
||||
|
||||
if (isHoistedNote) {
|
||||
const $unhoistButton = $(`<span class="tree-item-button tn-icon unhoist-button bx bx-door-open" title="${t("note_tree.unhoist")}"></span>`).on("click", cancelClickPropagation);
|
||||
|
||||
$span.append($unhoistButton);
|
||||
}
|
||||
|
||||
// Add clone indicator with tooltip if note has multiple parents
|
||||
const parentNotes = note.getParentNotes();
|
||||
const realParents: FNote[] = [];
|
||||
for (const parent of parentNotes) {
|
||||
if (parent.noteId !== "_share" && parent.noteId !== "_lbBookmarks" && parent.type !== "search") {
|
||||
realParents.push(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if (realParents.length > 1) {
|
||||
const parentTitles = realParents.map((p) => p.title).join(", ");
|
||||
const tooltipText = realParents.length === 2
|
||||
? t("note_tree.clone-indicator-tooltip-single", { parent: realParents[1].title })
|
||||
: t("note_tree.clone-indicator-tooltip", { count: realParents.length, parents: parentTitles });
|
||||
|
||||
const $cloneIndicator = $(`<span class="note-indicator-icon clone-indicator"></span>`);
|
||||
$cloneIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($cloneIndicator);
|
||||
}
|
||||
|
||||
// Add shared indicator with tooltip if note is shared
|
||||
if (note.isShared()) {
|
||||
const shareId = note.getOwnedLabelValue("shareAlias") || note.noteId;
|
||||
const shareUrl = `${location.origin}${location.pathname}share/${shareId}`;
|
||||
const tooltipText = t("note_tree.shared-indicator-tooltip-with-url", { url: shareUrl });
|
||||
|
||||
const $sharedIndicator = $(`<span class="note-indicator-icon shared-indicator"></span>`);
|
||||
$sharedIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($sharedIndicator);
|
||||
}
|
||||
|
||||
// Add a badge with the number of items if it hides children.
|
||||
const count = note.getChildNoteIds().length;
|
||||
if (isSubtreeHidden && count > 0) {
|
||||
const $badge = $(`<span class="note-indicator-icon subtree-hidden-badge">${count}</span>`);
|
||||
$badge.attr("title", t("note_tree.subtree-hidden-tooltip", { count }));
|
||||
$span.find(".fancytree-title").append($badge);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -646,17 +646,13 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F
|
||||
|
||||
const setter = useCallback((value: boolean) => {
|
||||
if (note) {
|
||||
if (value) {
|
||||
attributes.setLabel(note.noteId, labelName, "");
|
||||
} else {
|
||||
attributes.removeOwnedLabelByName(note, labelName);
|
||||
}
|
||||
attributes.setBooleanWithInheritance(note, labelName, value);
|
||||
}
|
||||
}, [note]);
|
||||
}, [note, labelName]);
|
||||
|
||||
useDebugValue(labelName);
|
||||
|
||||
const labelValue = !!note?.hasLabel(labelName);
|
||||
const labelValue = !!note?.isLabelTruthy(labelName);
|
||||
return [ labelValue, setter ] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import "./TableOfContents.css";
|
||||
|
||||
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import math from "../../services/math";
|
||||
import { randomString } from "../../services/utils";
|
||||
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import RightPanelWidget from "./RightPanelWidget";
|
||||
|
||||
//#region Generic impl.
|
||||
@@ -80,6 +82,22 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
|
||||
}) {
|
||||
const [ collapsed, setCollapsed ] = useState(false);
|
||||
const isActive = heading.id === activeHeadingId;
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Render math equations after component mounts/updates
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
const mathElements = contentRef.current.querySelectorAll(".ck-math-tex");
|
||||
|
||||
for (const mathEl of mathElements ?? []) {
|
||||
try {
|
||||
math.render(mathEl.textContent || "", mathEl as HTMLElement);
|
||||
} catch (e) {
|
||||
console.warn("Failed to render math in TOC:", e);
|
||||
}
|
||||
}
|
||||
}, [heading.text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className={clsx(collapsed && "collapsed", isActive && "active")}>
|
||||
@@ -90,12 +108,14 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
<RawHtml
|
||||
containerRef={contentRef}
|
||||
className="item-content"
|
||||
onClick={() => scrollToHeading(heading)}
|
||||
>{heading.text}</span>
|
||||
html={heading.text}
|
||||
/>
|
||||
</li>
|
||||
{heading.children && (
|
||||
{heading.children.length > 0 && (
|
||||
<ol>
|
||||
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
|
||||
</ol>
|
||||
@@ -189,9 +209,23 @@ function extractTocFromTextEditor(editor: CKTextEditor) {
|
||||
if (type !== "elementStart" || !item.is('element') || !item.name.startsWith('heading')) continue;
|
||||
|
||||
const level = Number(item.name.replace( 'heading', '' ));
|
||||
const text = Array.from( item.getChildren() )
|
||||
.map( c => c.is( '$text' ) ? c.data : '' )
|
||||
.join( '' );
|
||||
|
||||
// Convert model element to view, then to DOM to get HTML
|
||||
const viewEl = editor.editing.mapper.toViewElement(item);
|
||||
let text = '';
|
||||
if (viewEl) {
|
||||
const domEl = editor.editing.view.domConverter.mapViewToDom(viewEl);
|
||||
if (domEl instanceof HTMLElement) {
|
||||
text = domEl.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to plain text if conversion fails
|
||||
if (!text) {
|
||||
text = Array.from( item.getChildren() )
|
||||
.map( c => c.is( '$text' ) ? c.data : '' )
|
||||
.join( '' );
|
||||
}
|
||||
|
||||
// Assign a unique ID
|
||||
let tocId = item.getAttribute(TOC_ID) as string | undefined;
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function SqlResults() {
|
||||
{t("sql_result.no_rows")}
|
||||
</Alert>
|
||||
) : (
|
||||
<div class="sql-console-result-container">
|
||||
<div className="sql-console-result-container selectable-text">
|
||||
{results?.map(rows => {
|
||||
// inserts, updates
|
||||
if (typeof rows === "object" && !Array.isArray(rows)) {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
.note-detail-split .note-detail-split-editor {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-editor .note-detail-code {
|
||||
@@ -30,6 +32,7 @@
|
||||
margin: 5px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-preview {
|
||||
|
||||
@@ -286,7 +286,7 @@ function useWatchdogCrashHandling() {
|
||||
const currentState = watchdog.state;
|
||||
logInfo(`CKEditor state changed to ${currentState}`);
|
||||
|
||||
if (currentState === "ready") {
|
||||
if (currentState === "ready" && hasCrashed.current) {
|
||||
hasCrashed.current = false;
|
||||
watchdog.editor?.focus();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// <reference types='vitest' />
|
||||
import preact from "@preact/preset-vite";
|
||||
import { join } from 'path';
|
||||
import webpackStatsPlugin from 'rollup-plugin-webpack-stats';
|
||||
import { defineConfig } from 'vite';
|
||||
import { join, resolve } from 'path';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import webpackStatsPlugin from 'rollup-plugin-webpack-stats';
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
|
||||
|
||||
@@ -70,7 +70,7 @@ export default defineConfig(() => ({
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
desktop: join(__dirname, "src", "desktop.html"),
|
||||
desktop: join(__dirname, "src", "desktop.ts"),
|
||||
mobile: join(__dirname, "src", "mobile.ts"),
|
||||
login: join(__dirname, "src", "login.ts"),
|
||||
setup: join(__dirname, "src", "setup.ts"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.101.1",
|
||||
"version": "0.101.3",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "src/main.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { expect,test } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
const NOTE_TITLE = "Trilium Integration Test DB";
|
||||
@@ -65,21 +66,21 @@ test("Tabs are restored in right order", async ({ page, context }) => {
|
||||
// Open three tabs.
|
||||
await app.closeAllTabs();
|
||||
await app.goToNoteInNewTab("Code notes");
|
||||
await expect(app.getActiveTab()).toContainText("Code notes");
|
||||
await app.addNewTab();
|
||||
await app.goToNoteInNewTab("Text notes");
|
||||
await expect(app.getActiveTab()).toContainText("Text notes");
|
||||
await app.addNewTab();
|
||||
await app.goToNoteInNewTab("Mermaid");
|
||||
await expect(app.getActiveTab()).toContainText("Mermaid");
|
||||
|
||||
// Select the mid one.
|
||||
await app.getTab(1).click();
|
||||
await expect(app.noteTreeActiveNote).toContainText("Text notes");
|
||||
await expect(app.getTab(0)).toContainText("Code notes");
|
||||
await expect(app.getTab(1)).toContainText("Text notes");
|
||||
await expect(app.getTab(2)).toContainText("Mermaid");
|
||||
|
||||
// Refresh the page and check the order.
|
||||
await app.goto( { preserveTabs: true });
|
||||
await expect(app.getTab(0)).toContainText("Code notes", { timeout: 15_000 });
|
||||
await expect(app.getTab(0)).toContainText("Code notes");
|
||||
await expect(app.getTab(1)).toContainText("Text notes");
|
||||
await expect(app.getTab(2)).toContainText("Mermaid");
|
||||
|
||||
@@ -128,8 +129,8 @@ test("New tab displays workspaces", async ({ page, context }) => {
|
||||
|
||||
const workspaceNotesEl = app.currentNoteSplitContent.locator(".workspace-notes");
|
||||
await expect(workspaceNotesEl).toBeVisible();
|
||||
expect(workspaceNotesEl).toContainText("Personal");
|
||||
expect(workspaceNotesEl).toContainText("Work");
|
||||
await expect(workspaceNotesEl).toContainText("Personal");
|
||||
await expect(workspaceNotesEl).toContainText("Work");
|
||||
await expect(workspaceNotesEl.locator(".bx.bxs-user")).toBeVisible();
|
||||
await expect(workspaceNotesEl.locator(".bx.bx-briefcase-alt")).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import test, { BrowserContext, expect, Page } from "@playwright/test";
|
||||
import test, { expect, Page } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const app = await setLayout({ page, context }, true);
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.setOption("rightPaneCollapsedItems", "[]");
|
||||
});
|
||||
test.afterEach(async ({ page, context }) => await setLayout({ page, context }, false));
|
||||
|
||||
test("Table of contents works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
@@ -73,13 +73,15 @@ test("Attachments listing works", async ({ page, context }) => {
|
||||
test("Download original PDF works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.goToNoteInNewTab("Dacia Logan.pdf");
|
||||
await app.goToNoteInNewTab("Layers test.pdf");
|
||||
const pdfHelper = new PdfHelper(app);
|
||||
await pdfHelper.toBeInitialized();
|
||||
|
||||
const downloadButton = app.currentNoteSplit.locator(".icon-action.bx.bx-download");
|
||||
await expect(downloadButton).toBeVisible();
|
||||
const [ download ] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
app.currentNoteSplit.locator(".icon-action.bx.bx-download").click()
|
||||
downloadButton.click()
|
||||
]);
|
||||
expect(download).toBeDefined();
|
||||
});
|
||||
@@ -105,13 +107,6 @@ test("Layers listing works", async ({ page, context }) => {
|
||||
await expect(layersList.locator(".pdf-layer-item")).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function setLayout({ page, context}: { page: Page; context: BrowserContext }, newLayout: boolean) {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.setOption("newLayout", newLayout ? "true" : "false");
|
||||
return app;
|
||||
}
|
||||
|
||||
class PdfHelper {
|
||||
private contentFrame: ReturnType<Page["frameLocator"]>;
|
||||
|
||||
@@ -125,5 +120,6 @@ class PdfHelper {
|
||||
|
||||
async toBeInitialized() {
|
||||
await expect(this.contentFrame.locator("#pageNumber")).toBeVisible();
|
||||
await expect(this.contentFrame.locator(".page")).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
test("Table of contents is displayed", async ({ page, context }) => {
|
||||
@@ -8,7 +9,7 @@ test("Table of contents is displayed", async ({ page, context }) => {
|
||||
await app.goToNoteInNewTab("Table of contents");
|
||||
|
||||
await expect(app.sidebar).toContainText("Table of Contents");
|
||||
const rootList = app.sidebar.locator(".toc-widget > span > ol");
|
||||
const rootList = app.sidebar.locator(".toc > ol");
|
||||
|
||||
// Heading 1.1
|
||||
// Heading 1.1
|
||||
@@ -42,7 +43,7 @@ test("Highlights list is displayed", async ({ page, context }) => {
|
||||
await app.closeAllTabs();
|
||||
await app.goToNoteInNewTab("Highlights list");
|
||||
|
||||
await expect(app.sidebar).toContainText("Highlights List");
|
||||
await expect(app.sidebar).toContainText("10 highlights");
|
||||
const rootList = app.sidebar.locator(".highlights-list ol");
|
||||
let index = 0;
|
||||
for (const highlightedEl of ["Bold 1", "Italic 1", "Underline 1", "Colored text 1", "Background text 1", "Bold 2", "Italic 2", "Underline 2", "Colored text 2", "Background text 2"]) {
|
||||
@@ -64,6 +65,8 @@ test("Displays math popup", async ({ page, context }) => {
|
||||
await expect(mathForm).toBeVisible();
|
||||
|
||||
const input = mathForm.locator(".ck-input").first();
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toBeEnabled();
|
||||
await input.click();
|
||||
await input.fill("e=mc^2");
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class App {
|
||||
this.noteTreeHoistedNote = this.noteTree.locator(".fancytree-node", { has: page.locator(".unhoist-button") });
|
||||
this.launcherBar = page.locator("#launcher-container");
|
||||
this.currentNoteSplit = page.locator(".note-split:not(.hidden-ext)");
|
||||
this.currentNoteSplitTitle = this.currentNoteSplit.locator(".note-title");
|
||||
this.currentNoteSplitTitle = this.currentNoteSplit.locator(".note-title").first();
|
||||
this.currentNoteSplitContent = this.currentNoteSplit.locator(".note-detail-printable.visible");
|
||||
this.sidebar = page.locator("#right-pane");
|
||||
}
|
||||
@@ -68,13 +68,15 @@ export default class App {
|
||||
|
||||
async goToNoteInNewTab(noteTitle: string) {
|
||||
const autocomplete = this.currentNoteSplit.locator(".note-autocomplete");
|
||||
await expect(autocomplete).toBeVisible();
|
||||
await autocomplete.fill(noteTitle);
|
||||
|
||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(noteTitle);
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: noteTitle })
|
||||
.nth(1) // Select the second one, as the first one is "Create a new note"
|
||||
.click();
|
||||
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
|
||||
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
|
||||
await expect(suggestionSelector).toContainText(noteTitle);
|
||||
suggestionSelector.click();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.101.1",
|
||||
"version": "0.101.3",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"main": "./src/main.ts",
|
||||
@@ -35,11 +35,11 @@
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@electron/remote": "2.1.3",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
@@ -49,14 +49,17 @@
|
||||
"@types/compression": "1.8.1",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/debounce": "1.2.4",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/express-http-proxy": "1.6.7",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -83,7 +86,8 @@
|
||||
"ejs": "3.1.10",
|
||||
"electron": "39.2.7",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.3",
|
||||
@@ -105,12 +109,15 @@
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.1",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.6.3",
|
||||
"openai": "6.15.0",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sax": "1.4.3",
|
||||
"serve-favicon": "2.5.1",
|
||||
"stream-throttle": "0.1.3",
|
||||
@@ -121,6 +128,7 @@
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turndown": "7.2.2",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.3.0",
|
||||
"ws": "8.18.3",
|
||||
"xml2js": "0.6.2",
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import anonymizationService from "./services/anonymization.js";
|
||||
import sqlInit from "./services/sql_init.js";
|
||||
await import("@triliumnext/core");
|
||||
await import("./becca/entity_constructor.js");
|
||||
|
||||
sqlInit.dbReady.then(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import("@triliumnext/core");
|
||||
|
||||
import { erase } from "@triliumnext/core";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import express from "express";
|
||||
import { auth } from "express-openid-connect";
|
||||
import helmet from "helmet";
|
||||
import { t } from "i18next";
|
||||
import path from "path";
|
||||
import favicon from "serve-favicon";
|
||||
|
||||
import cookieParser from "cookie-parser";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import config from "./services/config.js";
|
||||
import utils, { getResourceDir, isDev } from "./services/utils.js";
|
||||
import assets from "./routes/assets.js";
|
||||
import routes from "./routes/routes.js";
|
||||
import custom from "./routes/custom.js";
|
||||
import error_handlers from "./routes/error_handlers.js";
|
||||
import routes from "./routes/routes.js";
|
||||
import config from "./services/config.js";
|
||||
import log from "./services/log.js";
|
||||
import openID from "./services/open_id.js";
|
||||
import { RESOURCE_DIR } from "./services/resource_dir.js";
|
||||
import { startScheduledCleanup } from "./services/erase.js";
|
||||
import sql_init from "./services/sql_init.js";
|
||||
import utils, { getResourceDir, isDev } from "./services/utils.js";
|
||||
import { auth } from "express-openid-connect";
|
||||
import openID from "./services/open_id.js";
|
||||
import { t } from "i18next";
|
||||
import eventService from "./services/events.js";
|
||||
import log from "./services/log.js";
|
||||
import "./services/handlers.js";
|
||||
import "./becca/becca_loader.js";
|
||||
import { RESOURCE_DIR } from "./services/resource_dir.js";
|
||||
|
||||
export default async function buildApp() {
|
||||
const app = express();
|
||||
@@ -107,7 +107,7 @@ export default async function buildApp() {
|
||||
|
||||
await import("./services/scheduler.js");
|
||||
|
||||
erase.startScheduledCleanup();
|
||||
startScheduledCleanup();
|
||||
|
||||
if (utils.isElectron) {
|
||||
(await import("@electron/remote/main/index.js")).initialize();
|
||||
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/1_Hiding the subtree_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/1_Hiding the subtree_image.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
95
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Hiding the subtree.html
generated
vendored
Normal file
95
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Hiding the subtree.html
generated
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
<figure class="image image-style-align-right">
|
||||
<img style="aspect-ratio:328/45;" src="1_Hiding the subtree_image.png"
|
||||
width="328" height="45">
|
||||
<figcaption>An example of a collection with a relatively large number of children
|
||||
that are hidden from the tree.</figcaption>
|
||||
</figure>
|
||||
<p>The tree works well when the notes are structured in a hierarchy so that
|
||||
the number of items stays small. When a note has a large number of notes
|
||||
(in the order of thousands or tens of thousands), two problems arise:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e536c86d371061c12f76f7de2a0af67be">Navigating between notes becomes cumbersome and the tree itself gets cluttered
|
||||
with a large amount of notes.</li>
|
||||
<li data-list-item-id="ecc37d6c4d0430254e98615842b94429d">The large amount of notes can slow down the application considerably.</li>
|
||||
</ul>
|
||||
<p>Since v0.102.0, Trilium allows the tree to hide the child notes of particular
|
||||
notes. This works for both <a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a> and
|
||||
normal notes.</p>
|
||||
<h2>Interaction</h2>
|
||||
<p>When the subtree of a note is hidden, there are a few subtle changes:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="ec1ce3d2030f36e4847f3bbd9468d28e3">To indicate that the subtree is hidden, the note will not have an expand
|
||||
button and it will display the number of children to the right.</li>
|
||||
<li
|
||||
data-list-item-id="ea99d38ea6c8a816cf2ab7a7e73cfcac5">It's not possible to add a new note directly from the tree.
|
||||
<ul>
|
||||
<li data-list-item-id="ef0132a903a11e9f667b2b2f4c4fff17a">For <a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a>,
|
||||
it's best to use the built-in mechanism to create notes (for example by
|
||||
creating a new point on a geo-map, or by adding a new row in a table).</li>
|
||||
<li
|
||||
data-list-item-id="e7db44100046c8c79bf79841285aacd1f">For normal notes, it's still possible to create children via other means
|
||||
such as using the <a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/QEAPj01N5f7w/_help_hrZ1D00cLbal">Internal (reference) links</a> system.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="eb049f46cf91db6de113af1099a14944e">Notes can be dragged from outside the note, case in which they will be
|
||||
cloned into it.
|
||||
<ul>
|
||||
<li data-list-item-id="e96d9b7a0755e9c054bab5db4fc1aa25e">Instead of switching to the child notes that were copied, the parent note
|
||||
is highlighted instead.</li>
|
||||
<li data-list-item-id="ec667e3f94a0cfa3fa41ce38d3ed6ee95">A notification will indicate this behavior.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li data-list-item-id="eb64670dd7ace6764c18602b440f88049">Similarly, features such as cut/copy and then paste into the note will
|
||||
also work.</li>
|
||||
</ul>
|
||||
<h2>Spotlighting</h2>
|
||||
<figure class="image image-style-align-right">
|
||||
<img style="aspect-ratio:322/83;" src="Hiding the subtree_image.png"
|
||||
width="322" height="83">
|
||||
</figure>
|
||||
<p>Even if the subtree of a note is hidden, if a child note manages to become
|
||||
active, it will still appear inside the tree in a special state called <em>spotlighted</em>.</p>
|
||||
<p>During this state, the note remains under its normal hierarchy, so that
|
||||
its easy to tell its location. In addition, this means that:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e2490369eb3d99ca694dba23a3410abef">The note position is clearly visible when using the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_eIg8jdvaoNNd">Search</a>.</li>
|
||||
<li
|
||||
data-list-item-id="e041d3807f80dc77b022540b0551b8376">The note can still be operated on from the tree, such as adding a
|
||||
<a
|
||||
class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/BFs8mudNFgCS/IakOLONlIfGI/_help_TBwsyfadTA18">Branch prefix</a> or moving it outside the collection.</li>
|
||||
</ul>
|
||||
<p>The note appears in italics to indicate its temporary display. When switching
|
||||
to another note, the spotlighted note will disappear.</p>
|
||||
<aside class="admonition note">
|
||||
<p>Only one note can be highlighted at the time. When working with multiple
|
||||
notes such as dragging them into the collection, no note will be spotlighted.
|
||||
This is intentional to avoid displaying a partial state of the subtree.</p>
|
||||
</aside>
|
||||
<h2>Working with collections</h2>
|
||||
<p>By default, some of the <a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a> will
|
||||
automatically hide their child notes, for example the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/GTwFsgaA0lCt/_help_CtBQqbwXDx1w">Kanban Board</a> or
|
||||
the <a class="reference-link" href="#root/pOsGYCXsbNQG/GTwFsgaA0lCt/_help_2FvYrpmOXm29">Table</a>.</p>
|
||||
<p>The reasoning behind this is that collections are generally opaque to
|
||||
the rest of the notes and they can generate a large amount of sub-notes
|
||||
since they intentionally lack structure (in order to allow easy swapping
|
||||
between views).</p>
|
||||
<p>Some types of collections have the child notes intentionally shown, for
|
||||
example the legacy ones (Grid and List), but also the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/GTwFsgaA0lCt/_help_zP3PMqaG71Ct">Presentation</a> which
|
||||
requires the tree structure in order to organize and edit the slides.</p>
|
||||
<p>To toggle this behavior:</p>
|
||||
<ul>
|
||||
<li data-list-item-id="e6d8c8c98802d70f13df626ea1f062122">In the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>,
|
||||
press the Options button underneath the title and uncheck <em>Hide child notes in tree</em>.</li>
|
||||
<li
|
||||
data-list-item-id="e2398432e127c54239d679a6b13d8390b">Right click the collection note in the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a> and
|
||||
select <em>Advanced</em> → <em>Show subtree</em>.</li>
|
||||
</ul>
|
||||
<h2>Working with normal notes</h2>
|
||||
<p>It's possible to hide the subtree for normal notes as well, not just collections.
|
||||
To do so, right click the note in the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a> and
|
||||
select <em>Advanced</em> → <em>Hide subtree.</em>
|
||||
</p>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Hiding the subtree_image.png
generated
vendored
Normal file
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Hiding the subtree_image.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
2
apps/server/src/assets/doc_notes/en/User Guide/User Guide/FAQ.html
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/User Guide/FAQ.html
generated
vendored
@@ -47,7 +47,7 @@
|
||||
href="#root/_help_tAassRL4RSQL">data directory</a>in the <code spellcheck="false">TRILIUM_DATA_DIR</code> environment
|
||||
variable and separate port on <code spellcheck="false">TRILIUM_PORT</code> environment
|
||||
variable. How to do that depends on the platform, in Unix-based systems
|
||||
you can achieve that by running command such as this:</p><pre><code class="language-text-x-trilium-auto">TRILIUM_DATA_DIR=/home/me/path/to/data/dir TRILIUM_PORT=12345 trilium </code></pre>
|
||||
you can achieve that by running command such as this:</p><pre><code class="language-text-x-sh">TRILIUM_DATA_DIR=/home/me/path/to/data/dir TRILIUM_PORT=12345 trilium </code></pre>
|
||||
<p>You can save this command into a <code spellcheck="false">.sh</code> script
|
||||
file or make an alias. Do this similarly for a second instance with different
|
||||
data directory and port.</p>
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"creating-and-moving-notes": "नोट्स बनाना और स्थानांतरित करना",
|
||||
"move-note-up": "नोट को ऊपर ले जाएं",
|
||||
"move-note-down": "नोट को नीचे ले जाएं",
|
||||
"note-clipboard": "नोट क्लिपबोर्ड"
|
||||
"note-clipboard": "नोट क्लिपबोर्ड",
|
||||
"duplicate-subtree": "डुप्लिकेट सबट्री",
|
||||
"open-new-tab": "नया टैब खोलें",
|
||||
"second-tab": "लिस्ट में दूसरी टैब एक्टिवेट करें",
|
||||
"third-tab": "लिस्ट में तीसरी टैब एक्टिवेट करें",
|
||||
"fourth-tab": "लिस्ट में चौथी टैब एक्टिवेट करें",
|
||||
"sixth-tab": "लिस्ट में छठी टैब एक्टिवेट करें",
|
||||
"seventh-tab": "लिस्ट में सातवीं टैब एक्टिवेट करें",
|
||||
"eight-tab": "लिस्ट में आठवीं टैब एक्टिवेट करें",
|
||||
"ninth-tab": "लिस्ट में नौवीं टैब एक्टिवेट करें",
|
||||
"last-tab": "लिस्ट में आखिरी टैब एक्टिवेट करें",
|
||||
"show-sql-console": "\"SQL कंसोल\" पेज खोलें",
|
||||
"show-backend-log": "\"बैकेंड लॉग\" पेज खोलें"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
<style id="trilium-icon-packs">
|
||||
<%- iconPackCss %>
|
||||
</style>
|
||||
<script src="<%= appPath %>/runtime.js" crossorigin type="module"></script>
|
||||
</head>
|
||||
<body
|
||||
id="trilium-app"
|
||||
class="desktop heading-style-<%= headingStyle %> layout-<%= layoutOrientation %> platform-<%= platform %> <%= isElectron ? 'electron' : '' %> <%= hasNativeTitleBar ? 'native-titlebar' : '' %> <%= hasBackgroundEffects ? 'background-effects' : '' %>"
|
||||
lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>"
|
||||
>
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<%- include("./partials/windowGlobal.ejs", locals) %>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
|
||||
|
||||
<link href="<%= assetPath %>/stylesheets/ckeditor-theme.css" rel="stylesheet">
|
||||
<link href="api/fonts" rel="stylesheet">
|
||||
<link href="<%= assetPath %>/stylesheets/theme-light.css" rel="stylesheet">
|
||||
|
||||
<% if (themeCssUrl) { %>
|
||||
<link href="<%= themeCssUrl %>" rel="stylesheet">
|
||||
<% } %>
|
||||
|
||||
<% if (themeUseNextAsBase === "next") { %>
|
||||
<link href="<%= assetPath %>/stylesheets/theme-next.css" rel="stylesheet">
|
||||
<% } else if (themeUseNextAsBase === "next-dark") { %>
|
||||
<link href="<%= assetPath %>/stylesheets/theme-next-dark.css" rel="stylesheet">
|
||||
<% } else if (themeUseNextAsBase === "next-light") { %>
|
||||
<link href="<%= assetPath %>/stylesheets/theme-next-light.css" rel="stylesheet">
|
||||
<% } %>
|
||||
|
||||
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
|
||||
|
||||
<script src="<%= appPath %>/desktop.js" crossorigin type="module"></script>
|
||||
|
||||
|
||||
25
apps/server/src/assets/views/partials/windowGlobal.ejs
Normal file
25
apps/server/src/assets/views/partials/windowGlobal.ejs
Normal file
@@ -0,0 +1,25 @@
|
||||
<script type="text/javascript">
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
|
||||
window.glob = {
|
||||
device: "<%= device %>",
|
||||
baseApiUrl: "<%= baseApiUrl %>",
|
||||
activeDialog: null,
|
||||
maxEntityChangeIdAtLoad: <%= maxEntityChangeIdAtLoad %>,
|
||||
maxEntityChangeSyncIdAtLoad: <%= maxEntityChangeSyncIdAtLoad %>,
|
||||
instanceName: '<%= instanceName %>',
|
||||
csrfToken: '<%= csrfToken %>',
|
||||
isDev: <%= isDev %>,
|
||||
appCssNoteIds: <%- JSON.stringify(appCssNoteIds) %>,
|
||||
isMainWindow: <%= isMainWindow %>,
|
||||
isProtectedSessionAvailable: <%= isProtectedSessionAvailable %>,
|
||||
triliumVersion: "<%= triliumVersion %>",
|
||||
assetPath: "<%= assetPath %>",
|
||||
appPath: "<%= appPath %>",
|
||||
platform: "<%= platform %>",
|
||||
hasNativeTitleBar: <%= hasNativeTitleBar %>,
|
||||
TRILIUM_SAFE_MODE: <%= !!process.env.TRILIUM_SAFE_MODE %>,
|
||||
isRtl: <%= !!currentLocale.rtl %>,
|
||||
iconRegistry: <%- JSON.stringify(iconRegistry) %>
|
||||
};
|
||||
</script>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NotFoundError } from "../errors.js";
|
||||
import sql from "../services/sql.js";
|
||||
import NoteSet from "../services/search/note_set.js";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import type BOption from "./entities/boption.js";
|
||||
import type BNote from "./entities/bnote.js";
|
||||
import type BEtapiToken from "./entities/betapi_token.js";
|
||||
@@ -10,8 +12,6 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons";
|
||||
import BBlob from "./entities/bblob.js";
|
||||
import BRecentNote from "./entities/brecent_note.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import { getSql } from "../services/sql/index.js";
|
||||
import NoteSet from "../services/search/note_set.js";
|
||||
|
||||
/**
|
||||
* Becca is a backend cache of all notes, branches, and attributes.
|
||||
@@ -151,7 +151,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getRevision(revisionId: string): BRevision | null {
|
||||
const row = getSql().getRow<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]);
|
||||
const row = sql.getRow<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]);
|
||||
return row ? new BRevision(row) : null;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export default class Becca {
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return getSql().getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
return sql.getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
getAttachmentOrThrow(attachmentId: string): BAttachment {
|
||||
@@ -182,7 +182,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getAttachments(attachmentIds: string[]): BAttachment[] {
|
||||
return getSql().getManyRows<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row));
|
||||
return sql.getManyRows<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getBlob(entity: { blobId?: string }): BBlob | null {
|
||||
@@ -190,7 +190,7 @@ export default class Becca {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = getSql().getRow<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]);
|
||||
const row = sql.getRow<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]);
|
||||
return row ? new BBlob(row) : null;
|
||||
}
|
||||
|
||||
@@ -227,12 +227,12 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {
|
||||
const rows = getSql().getRows<BRecentNote>(query, params);
|
||||
const rows = sql.getRows<BRecentNote>(query, params);
|
||||
return rows.map((row) => new BRecentNote(row));
|
||||
}
|
||||
|
||||
getRevisionsFromQuery(query: string, params: string[] = []): BRevision[] {
|
||||
const rows = getSql().getRows<RevisionRow>(query, params);
|
||||
const rows = sql.getRows<RevisionRow>(query, params);
|
||||
return rows.map((row) => new BRevision(row));
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
import { becca } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import Becca from "./becca-interface.js";
|
||||
|
||||
const becca = new Becca();
|
||||
|
||||
export default becca;
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import eventService from "../services/events";
|
||||
"use strict";
|
||||
|
||||
import entityConstructor from "../becca/entity_constructor.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
import ws from "../services/ws.js";
|
||||
import sql from "../services/sql.js";
|
||||
import eventService from "../services/events.js";
|
||||
import becca from "./becca.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import BAttribute from "./entities/battribute.js";
|
||||
import BBranch from "./entities/bbranch.js";
|
||||
import BEtapiToken from "./entities/betapi_token.js";
|
||||
import log from "../services/log.js";
|
||||
import BNote from "./entities/bnote.js";
|
||||
import BBranch from "./entities/bbranch.js";
|
||||
import BAttribute from "./entities/battribute.js";
|
||||
import BOption from "./entities/boption.js";
|
||||
import { getSql } from "../services/sql";
|
||||
import { getContext } from "../services/context.js";
|
||||
import BEtapiToken from "./entities/betapi_token.js";
|
||||
import cls from "../services/cls.js";
|
||||
import entityConstructor from "../becca/entity_constructor.js";
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import ws from "../services/ws.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
|
||||
export const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
// We have to import async since options init requires keyboard actions which require translations.
|
||||
const options_init = (await import("../services/options_init.js")).default;
|
||||
|
||||
dbReady.then(() => {
|
||||
getContext().init(() => {
|
||||
cls.init(() => {
|
||||
load();
|
||||
|
||||
options_init.initStartupOptions();
|
||||
@@ -35,7 +36,6 @@ function load() {
|
||||
becca.reset();
|
||||
|
||||
// we know this is slow and the total becca load time is logged
|
||||
const sql = getSql();
|
||||
sql.disableSlowQueryLogging(() => {
|
||||
// using a raw query and passing arrays to avoid allocating new objects,
|
||||
// this is worth it for the becca load since it happens every run and blocks the app until finished
|
||||
@@ -72,7 +72,7 @@ function load() {
|
||||
|
||||
becca.loaded = true;
|
||||
|
||||
getLog().info(`Becca (note cache) load took ${Date.now() - start}ms`);
|
||||
log.info(`Becca (note cache) load took ${Date.now() - start}ms`);
|
||||
}
|
||||
|
||||
function reload(reason: string) {
|
||||
@@ -284,7 +284,7 @@ eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
|
||||
try {
|
||||
becca.decryptProtectedNotes();
|
||||
} catch (e: any) {
|
||||
getLog().error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
|
||||
log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
import becca from "./becca.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import { getHoistedNoteId } from "../services/context.js";
|
||||
import cls from "../services/cls.js";
|
||||
import log from "../services/log.js";
|
||||
|
||||
function isNotePathArchived(notePath: string[]) {
|
||||
const noteId = notePath[notePath.length - 1];
|
||||
@@ -29,7 +29,7 @@ function getNoteTitle(childNoteId: string, parentNoteId?: string) {
|
||||
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
|
||||
|
||||
if (!childNote) {
|
||||
getLog().info(`Cannot find note '${childNoteId}'`);
|
||||
log.info(`Cannot find note '${childNoteId}'`);
|
||||
return "[error fetching title]";
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function getNoteTitleAndIcon(childNoteId: string, parentNoteId?: string) {
|
||||
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
|
||||
|
||||
if (!childNote) {
|
||||
getLog().info(`Cannot find note '${childNoteId}'`);
|
||||
log.info(`Cannot find note '${childNoteId}'`);
|
||||
return {
|
||||
title: "[error fetching title]"
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) {
|
||||
let hoistedNotePassed = false;
|
||||
|
||||
// this is a notePath from outside of hoisted subtree, so the full title path needs to be returned
|
||||
const hoistedNoteId = getHoistedNoteId();
|
||||
const hoistedNoteId = cls.getHoistedNoteId();
|
||||
const outsideOfHoistedSubtree = !notePathArray.includes(hoistedNoteId);
|
||||
|
||||
for (const noteId of notePathArray) {
|
||||
@@ -1,16 +1,16 @@
|
||||
import eventService from "../../services/events";
|
||||
"use strict";
|
||||
|
||||
import blobService from "../../services/blob.js";
|
||||
import * as cls from "../../services/context";
|
||||
import dateUtils from "../../services/utils/date";
|
||||
import utils from "../../services/utils.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import log from "../../services/log.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import type { default as Becca, ConstructorData } from "../becca-interface.js";
|
||||
import becca from "../becca.js";
|
||||
import type { ConstructorData,default as Becca } from "../becca-interface.js";
|
||||
import { getSql } from "../../services/sql";
|
||||
import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "../../services/utils/binary";
|
||||
import { hash, hashedBlobId, newEntityId, randomString } from "../../services/utils";
|
||||
|
||||
interface ContentOpts {
|
||||
forceSave?: boolean;
|
||||
@@ -36,7 +36,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
protected beforeSaving(opts?: {}) {
|
||||
const constructorData = this.constructor as unknown as ConstructorData<T>;
|
||||
if (!(this as any)[constructorData.primaryKeyName]) {
|
||||
(this as any)[constructorData.primaryKeyName] = newEntityId();
|
||||
(this as any)[constructorData.primaryKeyName] = utils.newEntityId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
contentToHash += "|deleted";
|
||||
}
|
||||
|
||||
return hash(contentToHash).substr(0, 10);
|
||||
return utils.hash(contentToHash).substr(0, 10);
|
||||
}
|
||||
|
||||
protected getPojoToSave() {
|
||||
@@ -111,7 +111,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
const pojo = this.getPojoToSave();
|
||||
|
||||
const sql = getSql();
|
||||
sql.transactional(() => {
|
||||
sql.upsert(entityName, primaryKeyName, pojo);
|
||||
|
||||
@@ -138,7 +137,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected _setContent(content: string | Uint8Array, opts: ContentOpts = {}) {
|
||||
protected _setContent(content: string | Buffer, opts: ContentOpts = {}) {
|
||||
// client code asks to save entity even if blobId didn't change (something else was changed)
|
||||
opts.forceSave = !!opts.forceSave;
|
||||
opts.forceFrontendReload = !!opts.forceFrontendReload;
|
||||
@@ -149,9 +148,9 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
|
||||
if (this.hasStringContent()) {
|
||||
content = unwrapStringOrBuffer(content);
|
||||
content = content.toString();
|
||||
} else {
|
||||
content = wrapStringOrBuffer(content);
|
||||
content = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
}
|
||||
|
||||
const unencryptedContentForHashCalculation = this.getUnencryptedContentForHashCalculation(content);
|
||||
@@ -168,7 +167,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
getSql().transactional(() => {
|
||||
sql.transactional(() => {
|
||||
const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts);
|
||||
const oldBlobId = this.blobId;
|
||||
|
||||
@@ -184,7 +183,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
|
||||
private deleteBlobIfNotUsed(oldBlobId: string) {
|
||||
const sql = getSql();
|
||||
if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) {
|
||||
return;
|
||||
}
|
||||
@@ -203,29 +201,24 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [oldBlobId]);
|
||||
}
|
||||
|
||||
private getUnencryptedContentForHashCalculation(unencryptedContent: Uint8Array | string) {
|
||||
private getUnencryptedContentForHashCalculation(unencryptedContent: Buffer | string) {
|
||||
if (this.isProtected) {
|
||||
// a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content
|
||||
const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_";
|
||||
if (typeof unencryptedContent === "string") {
|
||||
return `${encryptedPrefixSuffix}${unencryptedContent}`;
|
||||
} else {
|
||||
return concat2(encodeUtf8(encryptedPrefixSuffix), unencryptedContent)
|
||||
}
|
||||
return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`;
|
||||
} else {
|
||||
return unencryptedContent;
|
||||
}
|
||||
return unencryptedContent;
|
||||
|
||||
}
|
||||
|
||||
private saveBlob(content: string | Uint8Array, unencryptedContentForHashCalculation: string | Uint8Array, opts: ContentOpts = {}) {
|
||||
private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, opts: ContentOpts = {}) {
|
||||
/*
|
||||
* We're using the unencrypted blob for the hash calculation, because otherwise the random IV would
|
||||
* cause every content blob to be unique which would balloon the database size (esp. with revisioning).
|
||||
* This has minor security implications (it's easy to infer that given content is shared between different
|
||||
* notes/attachments), but the trade-off comes out clearly positive.
|
||||
*/
|
||||
const newBlobId = hashedBlobId(unencryptedContentForHashCalculation);
|
||||
const sql = getSql();
|
||||
const newBlobId = utils.hashedBlobId(unencryptedContentForHashCalculation);
|
||||
const blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]);
|
||||
|
||||
if (!blobNeedsInsert) {
|
||||
@@ -234,7 +227,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
const pojo = {
|
||||
blobId: newBlobId,
|
||||
content,
|
||||
content: content,
|
||||
dateModified: dateUtils.localNowDateTime(),
|
||||
utcDateModified: dateUtils.utcNowDateTime()
|
||||
};
|
||||
@@ -248,13 +241,13 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
entityChangesService.putEntityChange({
|
||||
entityName: "blobs",
|
||||
entityId: newBlobId,
|
||||
hash,
|
||||
hash: hash,
|
||||
isErased: false,
|
||||
utcDateChanged: pojo.utcDateModified,
|
||||
isSynced: true,
|
||||
// overriding componentId will cause the frontend to think the change is coming from a different component
|
||||
// and thus reload
|
||||
componentId: opts.forceFrontendReload ? randomString(10) : null
|
||||
componentId: opts.forceFrontendReload ? utils.randomString(10) : null
|
||||
});
|
||||
|
||||
eventService.emit(eventService.ENTITY_CHANGED, {
|
||||
@@ -265,16 +258,15 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
return newBlobId;
|
||||
}
|
||||
|
||||
protected _getContent(): string | Uint8Array {
|
||||
const sql = getSql();
|
||||
const row = sql.getRow<{ content: string | Uint8Array }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
|
||||
protected _getContent(): string | Buffer {
|
||||
const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
|
||||
|
||||
if (!row) {
|
||||
const constructorData = this.constructor as unknown as ConstructorData<T>;
|
||||
throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`);
|
||||
}
|
||||
|
||||
return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Uint8Array;
|
||||
return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +281,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
const sql = getSql();
|
||||
sql.execute(
|
||||
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
|
||||
WHERE ${constructorData.primaryKeyName} = ?`,
|
||||
@@ -302,7 +293,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
sql.execute(/*sql*/`UPDATE ${entityName} SET dateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, [this.dateModified, entityId]);
|
||||
}
|
||||
|
||||
getLog().info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.putEntityChange(true);
|
||||
|
||||
@@ -316,14 +307,13 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
const sql = getSql();
|
||||
sql.execute(
|
||||
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
|
||||
WHERE ${constructorData.primaryKeyName} = ?`,
|
||||
[this.utcDateModified, entityId]
|
||||
);
|
||||
|
||||
getLog().info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.putEntityChange(true);
|
||||
|
||||
@@ -1,2 +1,260 @@
|
||||
import { BAttachment } from "@triliumnext/core";
|
||||
|
||||
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type BBranch from "./bbranch.js";
|
||||
import type BNote from "./bnote.js";
|
||||
|
||||
const attachmentRoleToNoteTypeMapping = {
|
||||
image: "image",
|
||||
file: "file"
|
||||
};
|
||||
|
||||
interface ContentOpts {
|
||||
// TODO: Found in bnote.ts, to check if it's actually used and not a typo.
|
||||
forceSave?: boolean;
|
||||
|
||||
/** will also save this BAttachment entity */
|
||||
forceFullSave?: boolean;
|
||||
/** override frontend heuristics on when to reload, instruct to reload */
|
||||
forceFrontendReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment represent data related/attached to the note. Conceptually similar to attributes, but intended for
|
||||
* larger amounts of data and generally not accessible to the user.
|
||||
*/
|
||||
class BAttachment extends AbstractBeccaEntity<BAttachment> {
|
||||
static get entityName() {
|
||||
return "attachments";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "attachmentId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["attachmentId", "ownerId", "role", "mime", "title", "blobId", "utcDateScheduledForErasureSince"];
|
||||
}
|
||||
|
||||
noteId?: number;
|
||||
attachmentId?: string;
|
||||
/** either noteId or revisionId to which this attachment belongs */
|
||||
ownerId!: string;
|
||||
role!: string;
|
||||
mime!: string;
|
||||
title!: string;
|
||||
type?: keyof typeof attachmentRoleToNoteTypeMapping;
|
||||
position?: number;
|
||||
utcDateScheduledForErasureSince?: string | null;
|
||||
/** optionally added to the entity */
|
||||
contentLength?: number;
|
||||
isDecrypted?: boolean;
|
||||
|
||||
constructor(row: AttachmentRow) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.decrypt();
|
||||
}
|
||||
|
||||
updateFromRow(row: AttachmentRow): void {
|
||||
if (!row.ownerId?.trim()) {
|
||||
throw new Error("'ownerId' must be given to initialize a Attachment entity");
|
||||
} else if (!row.role?.trim()) {
|
||||
throw new Error("'role' must be given to initialize a Attachment entity");
|
||||
} else if (!row.mime?.trim()) {
|
||||
throw new Error("'mime' must be given to initialize a Attachment entity");
|
||||
} else if (!row.title?.trim()) {
|
||||
throw new Error("'title' must be given to initialize a Attachment entity");
|
||||
}
|
||||
|
||||
this.attachmentId = row.attachmentId;
|
||||
this.ownerId = row.ownerId;
|
||||
this.role = row.role;
|
||||
this.mime = row.mime;
|
||||
this.title = row.title;
|
||||
this.position = row.position;
|
||||
this.blobId = row.blobId;
|
||||
this.isProtected = !!row.isProtected;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
|
||||
this.contentLength = row.contentLength;
|
||||
}
|
||||
|
||||
copy(): BAttachment {
|
||||
return new BAttachment({
|
||||
ownerId: this.ownerId,
|
||||
role: this.role,
|
||||
mime: this.mime,
|
||||
title: this.title,
|
||||
blobId: this.blobId,
|
||||
isProtected: this.isProtected
|
||||
});
|
||||
}
|
||||
|
||||
getNote(): BNote {
|
||||
return this.becca.notes[this.ownerId];
|
||||
}
|
||||
|
||||
/** @returns true if the note has string content (not binary) */
|
||||
override hasStringContent(): boolean {
|
||||
return utils.isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items
|
||||
}
|
||||
|
||||
isContentAvailable() {
|
||||
return (
|
||||
!this.attachmentId || // new attachment which was not encrypted yet
|
||||
!this.isProtected ||
|
||||
protectedSessionService.isProtectedSessionAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
getTitleOrProtected() {
|
||||
return this.isContentAvailable() ? this.title : "[protected]";
|
||||
}
|
||||
|
||||
decrypt() {
|
||||
if (!this.isProtected || !this.attachmentId) {
|
||||
this.isDecrypted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
try {
|
||||
this.title = protectedSessionService.decryptString(this.title) || "";
|
||||
this.isDecrypted = true;
|
||||
} catch (e: any) {
|
||||
log.error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getContent(): Buffer {
|
||||
return this._getContent() as Buffer;
|
||||
}
|
||||
|
||||
setContent(content: string | Buffer, opts?: ContentOpts) {
|
||||
this._setContent(content, opts);
|
||||
}
|
||||
|
||||
convertToNote(): { note: BNote; branch: BBranch } {
|
||||
// TODO: can this ever be "search"?
|
||||
if ((this.type as string) === "search") {
|
||||
throw new Error(`Note of type search cannot have child notes`);
|
||||
}
|
||||
|
||||
if (!this.getNote()) {
|
||||
throw new Error("Cannot find note of this attachment. It is possible that this is note revision's attachment. " + "Converting note revision's attachments to note is not (yet) supported.");
|
||||
}
|
||||
|
||||
if (!(this.role in attachmentRoleToNoteTypeMapping)) {
|
||||
throw new Error(`Mapping from attachment role '${this.role}' to note's type is not defined`);
|
||||
}
|
||||
|
||||
if (!this.isContentAvailable()) {
|
||||
// isProtected is the same for attachment
|
||||
throw new Error(`Cannot convert protected attachment outside of protected session`);
|
||||
}
|
||||
|
||||
const { note, branch } = noteService.createNewNote({
|
||||
parentNoteId: this.ownerId,
|
||||
title: this.title,
|
||||
type: (attachmentRoleToNoteTypeMapping as any)[this.role],
|
||||
mime: this.mime,
|
||||
content: this.getContent(),
|
||||
isProtected: this.isProtected
|
||||
});
|
||||
|
||||
this.markAsDeleted();
|
||||
|
||||
const parentNote = this.getNote();
|
||||
|
||||
if (this.role === "image" && parentNote.type === "text") {
|
||||
const origContent = parentNote.getContent();
|
||||
|
||||
if (typeof origContent !== "string") {
|
||||
throw new Error(`Note with ID '${note.noteId} has a text type but non-string content.`);
|
||||
}
|
||||
|
||||
const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`;
|
||||
const newNoteUrl = `api/images/${note.noteId}/`;
|
||||
|
||||
const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl);
|
||||
|
||||
if (fixedContent !== origContent) {
|
||||
parentNote.setContent(fixedContent);
|
||||
}
|
||||
|
||||
noteService.asyncPostProcessContent(note, fixedContent);
|
||||
}
|
||||
|
||||
return { note, branch };
|
||||
}
|
||||
|
||||
getFileName() {
|
||||
const type = this.role === "image" ? "image" : "file";
|
||||
|
||||
return utils.formatDownloadTitle(this.title, type, this.mime);
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
if (this.position === undefined || this.position === null) {
|
||||
this.position =
|
||||
10 +
|
||||
sql.getValue<number>(
|
||||
/*sql*/`SELECT COALESCE(MAX(position), 0)
|
||||
FROM attachments
|
||||
WHERE ownerId = ?`,
|
||||
[this.noteId]
|
||||
);
|
||||
}
|
||||
|
||||
this.dateModified = dateUtils.localNowDateTime();
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
attachmentId: this.attachmentId,
|
||||
ownerId: this.ownerId,
|
||||
role: this.role,
|
||||
mime: this.mime,
|
||||
title: this.title || undefined,
|
||||
position: this.position,
|
||||
blobId: this.blobId,
|
||||
isProtected: !!this.isProtected,
|
||||
isDeleted: false,
|
||||
dateModified: this.dateModified,
|
||||
utcDateModified: this.utcDateModified,
|
||||
utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince,
|
||||
contentLength: this.contentLength
|
||||
};
|
||||
}
|
||||
|
||||
override getPojoToSave() {
|
||||
const pojo = this.getPojo();
|
||||
delete pojo.contentLength;
|
||||
|
||||
if (pojo.isProtected) {
|
||||
if (this.isDecrypted) {
|
||||
pojo.title = protectedSessionService.encrypt(pojo.title || "") || undefined;
|
||||
} else {
|
||||
// updating protected note outside of protected session means we will keep original ciphertexts
|
||||
delete pojo.title;
|
||||
}
|
||||
}
|
||||
|
||||
return pojo;
|
||||
}
|
||||
}
|
||||
|
||||
export default BAttachment;
|
||||
|
||||
@@ -1,2 +1,227 @@
|
||||
import { BAttribute } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import BNote from "./bnote.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
|
||||
import type { AttributeRow, AttributeType } from "@triliumnext/commons";
|
||||
|
||||
interface SavingOpts {
|
||||
skipValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute is an abstract concept which has two real uses - label (key - value pair)
|
||||
* and relation (representing named relationship between source and target note)
|
||||
*/
|
||||
class BAttribute extends AbstractBeccaEntity<BAttribute> {
|
||||
static get entityName() {
|
||||
return "attributes";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "attributeId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["attributeId", "noteId", "type", "name", "value", "isInheritable"];
|
||||
}
|
||||
|
||||
attributeId!: string;
|
||||
noteId!: string;
|
||||
type!: AttributeType;
|
||||
name!: string;
|
||||
position!: number;
|
||||
value!: string;
|
||||
isInheritable!: boolean;
|
||||
|
||||
constructor(row?: AttributeRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: AttributeRow) {
|
||||
this.update([row.attributeId, row.noteId, row.type, row.name, row.value, row.isInheritable, row.position, row.utcDateModified]);
|
||||
}
|
||||
|
||||
update([attributeId, noteId, type, name, value, isInheritable, position, utcDateModified]: any) {
|
||||
this.attributeId = attributeId;
|
||||
this.noteId = noteId;
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.position = position;
|
||||
this.value = value || "";
|
||||
this.isInheritable = !!isInheritable;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.attributeId) {
|
||||
this.becca.attributes[this.attributeId] = this;
|
||||
}
|
||||
|
||||
if (!(this.noteId in this.becca.notes)) {
|
||||
// entities can come out of order in sync, create skeleton which will be filled later
|
||||
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
|
||||
}
|
||||
|
||||
this.becca.notes[this.noteId].ownedAttributes.push(this);
|
||||
|
||||
const key = `${this.type}-${this.name.toLowerCase()}`;
|
||||
this.becca.attributeIndex[key] = this.becca.attributeIndex[key] || [];
|
||||
this.becca.attributeIndex[key].push(this);
|
||||
|
||||
const targetNote = this.targetNote;
|
||||
|
||||
if (targetNote) {
|
||||
targetNote.targetRelations.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
validate() {
|
||||
if (!["label", "relation"].includes(this.type)) {
|
||||
throw new Error(`Invalid attribute type '${this.type}' in attribute '${this.attributeId}' of note '${this.noteId}'`);
|
||||
}
|
||||
|
||||
if (!this.name?.trim()) {
|
||||
throw new Error(`Invalid empty name in attribute '${this.attributeId}' of note '${this.noteId}'`);
|
||||
}
|
||||
|
||||
if (this.type === "relation" && !(this.value in this.becca.notes)) {
|
||||
throw new Error(`Cannot save relation '${this.name}' of note '${this.noteId}' since it targets not existing note '${this.value}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
get isAffectingSubtree() {
|
||||
return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
|
||||
}
|
||||
|
||||
get targetNoteId() {
|
||||
// alias
|
||||
return this.type === "relation" ? this.value : undefined;
|
||||
}
|
||||
|
||||
isAutoLink() {
|
||||
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
get targetNote() {
|
||||
if (this.type === "relation") {
|
||||
return this.becca.notes[this.value];
|
||||
}
|
||||
}
|
||||
|
||||
getNote() {
|
||||
const note = this.becca.getNote(this.noteId);
|
||||
|
||||
if (!note) {
|
||||
throw new Error(`Note '${this.noteId}' of attribute '${this.attributeId}', type '${this.type}', name '${this.name}' does not exist.`);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
getTargetNote() {
|
||||
if (this.type !== "relation") {
|
||||
throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
|
||||
}
|
||||
|
||||
if (!this.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.becca.getNote(this.value);
|
||||
}
|
||||
|
||||
isDefinition() {
|
||||
return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
|
||||
}
|
||||
|
||||
getDefinition() {
|
||||
return promotedAttributeDefinitionParser.parse(this.value);
|
||||
}
|
||||
|
||||
getDefinedName() {
|
||||
if (this.type === "label" && this.name.startsWith("label:")) {
|
||||
return this.name.substr(6);
|
||||
} else if (this.type === "label" && this.name.startsWith("relation:")) {
|
||||
return this.name.substr(9);
|
||||
} else {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return !(this.attributeId in this.becca.attributes);
|
||||
}
|
||||
|
||||
override beforeSaving(opts: SavingOpts = {}) {
|
||||
if (!opts.skipValidation) {
|
||||
this.validate();
|
||||
}
|
||||
|
||||
this.name = sanitizeAttributeName(this.name);
|
||||
|
||||
if (!this.value) {
|
||||
// null value isn't allowed
|
||||
this.value = "";
|
||||
}
|
||||
|
||||
if (this.position === undefined || this.position === null) {
|
||||
const maxExistingPosition = this.getNote()
|
||||
.getAttributes()
|
||||
.reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
|
||||
|
||||
this.position = maxExistingPosition + 10;
|
||||
}
|
||||
|
||||
if (!this.isInheritable) {
|
||||
this.isInheritable = false;
|
||||
}
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
this.becca.attributes[this.attributeId] = this;
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
attributeId: this.attributeId,
|
||||
noteId: this.noteId,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
position: this.position,
|
||||
value: this.value,
|
||||
isInheritable: this.isInheritable,
|
||||
utcDateModified: this.utcDateModified,
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
createClone(type: AttributeType, name: string, value: string, isInheritable?: boolean) {
|
||||
return new BAttribute({
|
||||
noteId: this.noteId,
|
||||
type: type,
|
||||
name: name,
|
||||
value: value,
|
||||
position: this.position,
|
||||
isInheritable: isInheritable,
|
||||
utcDateModified: this.utcDateModified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default BAttribute;
|
||||
|
||||
@@ -13,7 +13,7 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
return ["blobId", "content"];
|
||||
}
|
||||
|
||||
content!: string | Uint8Array;
|
||||
content!: string | Buffer;
|
||||
contentLength!: number;
|
||||
|
||||
constructor(row: BlobRow) {
|
||||
@@ -1,2 +1,288 @@
|
||||
import { BBranch } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import BNote from "./bnote.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import log from "../../services/log.js";
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
import handlers from "../../services/handlers.js";
|
||||
|
||||
/**
|
||||
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
|
||||
* parents.
|
||||
*
|
||||
* Note that you should not rely on the branch's identity, since it can change easily with a note's move.
|
||||
* Always check noteId instead.
|
||||
*/
|
||||
class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
static get entityName() {
|
||||
return "branches";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "branchId";
|
||||
}
|
||||
// notePosition is not part of hash because it would produce a lot of updates in case of reordering
|
||||
static get hashedProperties() {
|
||||
return ["branchId", "noteId", "parentNoteId", "prefix"];
|
||||
}
|
||||
|
||||
branchId?: string;
|
||||
noteId!: string;
|
||||
parentNoteId!: string;
|
||||
prefix!: string | null;
|
||||
notePosition!: number;
|
||||
isExpanded!: boolean;
|
||||
|
||||
constructor(row?: BranchRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: BranchRow) {
|
||||
this.update([row.branchId, row.noteId, row.parentNoteId, row.prefix, row.notePosition, row.isExpanded, row.utcDateModified]);
|
||||
}
|
||||
|
||||
update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]: any) {
|
||||
this.branchId = branchId;
|
||||
this.noteId = noteId;
|
||||
this.parentNoteId = parentNoteId;
|
||||
this.prefix = prefix;
|
||||
this.notePosition = notePosition;
|
||||
this.isExpanded = !!isExpanded;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.branchId) {
|
||||
this.becca.branches[this.branchId] = this;
|
||||
}
|
||||
|
||||
this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
|
||||
|
||||
const childNote = this.childNote;
|
||||
|
||||
if (!childNote.parentBranches.includes(this)) {
|
||||
childNote.parentBranches.push(this);
|
||||
}
|
||||
|
||||
if (this.noteId === "root") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNote = this.parentNote;
|
||||
if (parentNote) {
|
||||
if (!childNote.parents.includes(parentNote)) {
|
||||
childNote.parents.push(parentNote);
|
||||
}
|
||||
|
||||
if (!parentNote.children.includes(childNote)) {
|
||||
parentNote.children.push(childNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get childNote(): BNote {
|
||||
if (!(this.noteId in this.becca.notes)) {
|
||||
// entities can come out of order in sync/import, create skeleton which will be filled later
|
||||
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
|
||||
}
|
||||
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
getNote(): BNote {
|
||||
return this.childNote;
|
||||
}
|
||||
|
||||
/** @returns root branch will have undefined parent, all other branches have to have a parent note */
|
||||
get parentNote(): BNote | undefined {
|
||||
if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== "none") {
|
||||
// entities can come out of order in sync/import, create skeleton which will be filled later
|
||||
this.becca.addNote(this.parentNoteId, new BNote({ noteId: this.parentNoteId }));
|
||||
}
|
||||
|
||||
return this.becca.notes[this.parentNoteId];
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return this.branchId == undefined || !(this.branchId in this.becca.branches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch is weak when its existence should not hinder deletion of its note.
|
||||
* As a result, note with only weak branches should be immediately deleted.
|
||||
* An example is shared or bookmarked clones - they are created automatically and exist for technical reasons,
|
||||
* not as user-intended actions. From user perspective, they don't count as real clones and for the purpose
|
||||
* of deletion should not act as a clone.
|
||||
*/
|
||||
get isWeak() {
|
||||
return ["_share", "_lbBookmarks"].includes(this.parentNoteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch. If this is a last note's branch, delete the note as well.
|
||||
*
|
||||
* @param deleteId - optional delete identified
|
||||
*
|
||||
* @returns true if note has been deleted, false otherwise
|
||||
*/
|
||||
deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
|
||||
if (!deleteId) {
|
||||
deleteId = utils.randomString(10);
|
||||
}
|
||||
|
||||
if (!taskContext) {
|
||||
taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
|
||||
}
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
const note = this.getNote();
|
||||
|
||||
if (!taskContext.noteDeletionHandlerTriggered) {
|
||||
const parentBranches = note.getParentBranches();
|
||||
|
||||
if (parentBranches.length === 1 && parentBranches[0] === this) {
|
||||
// needs to be run before branches and attributes are deleted and thus attached relations disappear
|
||||
handlers.runAttachedRelations(note, "runOnNoteDeletion", note);
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.noteId === "root" || this.noteId === cls.getHoistedNoteId()) && !this.isWeak) {
|
||||
throw new Error("Can't delete root or hoisted branch/note");
|
||||
}
|
||||
|
||||
this.markAsDeleted(deleteId);
|
||||
|
||||
const notDeletedBranches = note.getStrongParentBranches();
|
||||
|
||||
if (notDeletedBranches.length === 0) {
|
||||
for (const weakBranch of note.getParentBranches()) {
|
||||
weakBranch.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const childBranch of note.getChildBranches()) {
|
||||
if (childBranch) {
|
||||
childBranch.deleteBranch(deleteId, taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
// first delete children and then parent - this will show up better in recent changes
|
||||
|
||||
log.info(`Deleting note '${note.noteId}'`);
|
||||
|
||||
this.becca.notes[note.noteId].isBeingDeleted = true;
|
||||
|
||||
for (const attribute of note.getOwnedAttributes().slice()) {
|
||||
attribute.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const relation of note.getTargetRelations()) {
|
||||
relation.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const attachment of note.getAttachments()) {
|
||||
attachment.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
note.markAsDeleted(deleteId);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
if (!this.noteId || !this.parentNoteId) {
|
||||
throw new Error(`noteId and parentNoteId are mandatory properties for Branch`);
|
||||
}
|
||||
|
||||
this.branchId = `${this.parentNoteId}_${this.noteId}`;
|
||||
|
||||
if (this.notePosition === undefined || this.notePosition === null) {
|
||||
let maxNotePos = 0;
|
||||
|
||||
if (this.parentNote) {
|
||||
for (const childBranch of this.parentNote.getChildBranches()) {
|
||||
if (!childBranch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
maxNotePos < childBranch.notePosition &&
|
||||
childBranch.noteId !== "_hidden" // hidden has a very large notePosition to always stay last
|
||||
) {
|
||||
maxNotePos = childBranch.notePosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.notePosition = maxNotePos + 10;
|
||||
}
|
||||
|
||||
if (!this.isExpanded) {
|
||||
this.isExpanded = false;
|
||||
}
|
||||
|
||||
if (!this.prefix?.trim()) {
|
||||
this.prefix = null;
|
||||
}
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
this.becca.branches[this.branchId] = this;
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
branchId: this.branchId,
|
||||
noteId: this.noteId,
|
||||
parentNoteId: this.parentNoteId,
|
||||
prefix: this.prefix,
|
||||
notePosition: this.notePosition,
|
||||
isExpanded: this.isExpanded,
|
||||
isDeleted: false,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
|
||||
createClone(parentNoteId: string, notePosition?: number) {
|
||||
const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
|
||||
|
||||
if (existingBranch) {
|
||||
if (notePosition) {
|
||||
existingBranch.notePosition = notePosition;
|
||||
}
|
||||
return existingBranch;
|
||||
} else {
|
||||
return new BBranch({
|
||||
noteId: this.noteId,
|
||||
parentNoteId: parentNoteId,
|
||||
notePosition: notePosition || null,
|
||||
prefix: this.prefix,
|
||||
isExpanded: this.isExpanded
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1,2 +1,89 @@
|
||||
import { BEtapiToken } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import type { EtapiTokenRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
|
||||
/**
|
||||
* EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
|
||||
* Used by:
|
||||
* - Trilium Sender
|
||||
* - ETAPI clients
|
||||
*
|
||||
* The format user is presented with is "<etapiTokenId>_<tokenHash>". This is also called "authToken" to distinguish it
|
||||
* from tokenHash and token.
|
||||
*/
|
||||
class BEtapiToken extends AbstractBeccaEntity<BEtapiToken> {
|
||||
static get entityName() {
|
||||
return "etapi_tokens";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "etapiTokenId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"];
|
||||
}
|
||||
|
||||
etapiTokenId?: string;
|
||||
name!: string;
|
||||
tokenHash!: string;
|
||||
private _isDeleted?: boolean;
|
||||
|
||||
constructor(row?: EtapiTokenRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return !!this._isDeleted;
|
||||
}
|
||||
|
||||
updateFromRow(row: EtapiTokenRow) {
|
||||
this.etapiTokenId = row.etapiTokenId;
|
||||
this.name = row.name;
|
||||
this.tokenHash = row.tokenHash;
|
||||
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
|
||||
this.utcDateModified = row.utcDateModified || this.utcDateCreated;
|
||||
this._isDeleted = !!row.isDeleted;
|
||||
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
etapiTokenId: this.etapiTokenId,
|
||||
name: this.name,
|
||||
tokenHash: this.tokenHash,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified,
|
||||
isDeleted: this.isDeleted
|
||||
};
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BEtapiToken;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,56 @@
|
||||
import { BOption } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type { OptionRow } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* Option represents a name-value pair, either directly configurable by the user or some system property.
|
||||
*/
|
||||
class BOption extends AbstractBeccaEntity<BOption> {
|
||||
static get entityName() {
|
||||
return "options";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "name";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["name", "value"];
|
||||
}
|
||||
|
||||
name!: string;
|
||||
value!: string;
|
||||
|
||||
constructor(row?: OptionRow) {
|
||||
super();
|
||||
|
||||
if (row) {
|
||||
this.updateFromRow(row);
|
||||
}
|
||||
this.becca.options[this.name] = this;
|
||||
}
|
||||
|
||||
updateFromRow(row: OptionRow) {
|
||||
this.name = row.name;
|
||||
this.value = row.value;
|
||||
this.isSynced = !!row.isSynced;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
name: this.name,
|
||||
value: this.value,
|
||||
isSynced: this.isSynced,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BOption;
|
||||
|
||||
@@ -1,2 +1,46 @@
|
||||
import { BRecentNote } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import type { RecentNoteRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
|
||||
/**
|
||||
* RecentNote represents recently visited note.
|
||||
*/
|
||||
class BRecentNote extends AbstractBeccaEntity<BRecentNote> {
|
||||
static get entityName() {
|
||||
return "recent_notes";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "noteId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["noteId", "notePath"];
|
||||
}
|
||||
|
||||
noteId!: string;
|
||||
notePath!: string;
|
||||
|
||||
constructor(row: RecentNoteRow) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
}
|
||||
|
||||
updateFromRow(row: RecentNoteRow): void {
|
||||
this.noteId = row.noteId;
|
||||
this.notePath = row.notePath;
|
||||
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
noteId: this.noteId,
|
||||
notePath: this.notePath,
|
||||
utcDateCreated: this.utcDateCreated
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BRecentNote;
|
||||
|
||||
@@ -1,2 +1,225 @@
|
||||
import { BRevision } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import becca from "../becca.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import BAttachment from "./battachment.js";
|
||||
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
|
||||
import eraseService from "../../services/erase.js";
|
||||
|
||||
interface ContentOpts {
|
||||
/** will also save this BRevision entity */
|
||||
forceSave?: boolean;
|
||||
}
|
||||
|
||||
interface GetByIdOpts {
|
||||
includeContentLength?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revision represents a snapshot of note's title and content at some point in the past.
|
||||
* It's used for seamless note versioning.
|
||||
*/
|
||||
class BRevision extends AbstractBeccaEntity<BRevision> {
|
||||
static get entityName() {
|
||||
return "revisions";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "revisionId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
|
||||
}
|
||||
|
||||
revisionId?: string;
|
||||
noteId!: string;
|
||||
type!: NoteType;
|
||||
mime!: string;
|
||||
title!: string;
|
||||
dateLastEdited?: string;
|
||||
utcDateLastEdited?: string;
|
||||
contentLength?: number;
|
||||
content?: string | Buffer;
|
||||
|
||||
constructor(row: RevisionRow, titleDecrypted = false) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
if (this.isProtected && !titleDecrypted) {
|
||||
const decryptedTitle = protectedSessionService.isProtectedSessionAvailable() ? protectedSessionService.decryptString(this.title) : null;
|
||||
this.title = decryptedTitle || "[protected]";
|
||||
}
|
||||
}
|
||||
|
||||
updateFromRow(row: RevisionRow) {
|
||||
this.revisionId = row.revisionId;
|
||||
this.noteId = row.noteId;
|
||||
this.type = row.type;
|
||||
this.mime = row.mime;
|
||||
this.isProtected = !!row.isProtected;
|
||||
this.title = row.title;
|
||||
this.blobId = row.blobId;
|
||||
this.dateLastEdited = row.dateLastEdited;
|
||||
this.dateCreated = row.dateCreated;
|
||||
this.utcDateLastEdited = row.utcDateLastEdited;
|
||||
this.utcDateCreated = row.utcDateCreated;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
this.contentLength = row.contentLength;
|
||||
}
|
||||
|
||||
getNote() {
|
||||
return becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
/** @returns true if the note has string content (not binary) */
|
||||
override hasStringContent(): boolean {
|
||||
return utils.isStringNote(this.type, this.mime);
|
||||
}
|
||||
|
||||
isContentAvailable() {
|
||||
return (
|
||||
!this.revisionId || // new note which was not encrypted yet
|
||||
!this.isProtected ||
|
||||
protectedSessionService.isProtectedSessionAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
|
||||
* part of Revision entity with its own sync. The reason behind this hybrid design is that
|
||||
* content can be quite large, and it's not necessary to load it / fill memory for any note access even
|
||||
* if we don't need a content, especially for bulk operations like search.
|
||||
*
|
||||
* This is the same approach as is used for Note's content.
|
||||
*/
|
||||
getContent(): string | Buffer {
|
||||
return this._getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error in case of invalid JSON */
|
||||
getJsonContent(): {} | null {
|
||||
const content = this.getContent();
|
||||
|
||||
if (!content || typeof content !== "string" || !content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/** @returns valid object or null if the content cannot be parsed as JSON */
|
||||
getJsonContentSafely(): {} | null {
|
||||
try {
|
||||
return this.getJsonContent();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setContent(content: string | Buffer, opts: ContentOpts = {}) {
|
||||
this._setContent(content, opts);
|
||||
}
|
||||
|
||||
getAttachments(): BAttachment[] {
|
||||
return sql
|
||||
.getRows<AttachmentRow>(
|
||||
`
|
||||
SELECT attachments.*
|
||||
FROM attachments
|
||||
WHERE ownerId = ?
|
||||
AND isDeleted = 0`,
|
||||
[this.revisionId]
|
||||
)
|
||||
.map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null {
|
||||
opts.includeContentLength = !!opts.includeContentLength;
|
||||
|
||||
const query = opts.includeContentLength
|
||||
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
|
||||
: /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return sql.getRows<AttachmentRow>(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
getAttachmentsByRole(role: string): BAttachment[] {
|
||||
return sql
|
||||
.getRows<AttachmentRow>(
|
||||
`
|
||||
SELECT attachments.*
|
||||
FROM attachments
|
||||
WHERE ownerId = ?
|
||||
AND role = ?
|
||||
AND isDeleted = 0
|
||||
ORDER BY position`,
|
||||
[this.revisionId, role]
|
||||
)
|
||||
.map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getAttachmentByTitle(title: string): BAttachment {
|
||||
// cannot use SQL to filter by title since it can be encrypted
|
||||
return this.getAttachments().filter((attachment) => attachment.title === title)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revisions are not soft-deletable, they are immediately hard-deleted (erased).
|
||||
*/
|
||||
eraseRevision() {
|
||||
if (this.revisionId) {
|
||||
eraseService.eraseRevisions([this.revisionId]);
|
||||
}
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
revisionId: this.revisionId,
|
||||
noteId: this.noteId,
|
||||
type: this.type,
|
||||
mime: this.mime,
|
||||
isProtected: this.isProtected,
|
||||
title: this.title,
|
||||
blobId: this.blobId,
|
||||
dateLastEdited: this.dateLastEdited,
|
||||
dateCreated: this.dateCreated,
|
||||
utcDateLastEdited: this.utcDateLastEdited,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified,
|
||||
content: this.content, // used when retrieving full note revision to frontend
|
||||
contentLength: this.contentLength
|
||||
} satisfies RevisionPojo;
|
||||
}
|
||||
|
||||
override getPojoToSave() {
|
||||
const pojo = this.getPojo();
|
||||
delete pojo.content; // not getting persisted
|
||||
delete pojo.contentLength; // not getting persisted
|
||||
|
||||
if (pojo.isProtected) {
|
||||
if (protectedSessionService.isProtectedSessionAvailable()) {
|
||||
pojo.title = protectedSessionService.encrypt(this.title) ?? "";
|
||||
} else {
|
||||
// updating protected note outside of protected session means we will keep original ciphertexts
|
||||
pojo.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
return pojo;
|
||||
}
|
||||
}
|
||||
|
||||
export default BRevision;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import becca from "./becca.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import log from "../services/log.js";
|
||||
import beccaService from "./becca_service.js";
|
||||
import dateUtils from "../services/utils/date";
|
||||
import dateUtils from "../services/date_utils.js";
|
||||
import { parse } from "node-html-parser";
|
||||
import type BNote from "./entities/bnote.js";
|
||||
import { SimilarNote } from "@triliumnext/commons";
|
||||
@@ -359,7 +359,7 @@ async function findSimilarNotes(noteId: string): Promise<SimilarNote[] | undefin
|
||||
let factor = 1;
|
||||
|
||||
if (!value.startsWith) {
|
||||
getLog().info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`);
|
||||
log.info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`);
|
||||
continue;
|
||||
} else if (value.startsWith("http")) {
|
||||
value = filterUrlValue(value);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
import clsHooked from "cls-hooked";
|
||||
|
||||
export const namespace = clsHooked.createNamespace("trilium");
|
||||
|
||||
export default class ClsHookedExecutionContext implements ExecutionContext {
|
||||
|
||||
get<T = any>(key: string): T | undefined {
|
||||
return namespace.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
namespace.set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
clsHooked.reset();
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
return namespace.runAndReturn(callback);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { CryptoProvider } from "@triliumnext/core";
|
||||
import crypto from "crypto";
|
||||
import { generator } from "rand-token";
|
||||
|
||||
const randtoken = generator({ source: "crypto" });
|
||||
|
||||
export default class NodejsCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array {
|
||||
return crypto.createHash(algorithm).update(content).digest();
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } {
|
||||
return crypto.createCipheriv(algorithm, key, iv);
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array) {
|
||||
return crypto.createDecipheriv(algorithm, key, iv);
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
return crypto.randomBytes(size);
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
return randtoken.generate(length);
|
||||
}
|
||||
|
||||
}
|
||||
12
apps/server/src/errors/forbidden_error.ts
Normal file
12
apps/server/src/errors/forbidden_error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import HttpError from "./http_error.js";
|
||||
|
||||
class ForbiddenError extends HttpError {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ForbiddenError;
|
||||
13
apps/server/src/errors/http_error.ts
Normal file
13
apps/server/src/errors/http_error.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
class HttpError extends Error {
|
||||
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default HttpError;
|
||||
12
apps/server/src/errors/not_found_error.ts
Normal file
12
apps/server/src/errors/not_found_error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import HttpError from "./http_error.js";
|
||||
|
||||
class NotFoundError extends HttpError {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message, 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default NotFoundError;
|
||||
9
apps/server/src/errors/open_id_error.ts
Normal file
9
apps/server/src/errors/open_id_error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
class OpenIdError {
|
||||
message: string;
|
||||
|
||||
constructor(message: string) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenIdError;
|
||||
12
apps/server/src/errors/validation_error.ts
Normal file
12
apps/server/src/errors/validation_error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import HttpError from "./http_error.js";
|
||||
|
||||
class ValidationError extends HttpError {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message, 400)
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ValidationError;
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import { namespace } from "../cls_provider.js";
|
||||
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
|
||||
import cls from "../services/cls.js";
|
||||
import config from "../services/config.js";
|
||||
import etapiTokenService from "../services/etapi_tokens.js";
|
||||
import log from "../services/log.js";
|
||||
import sql from "../services/sql.js";
|
||||
import log from "../services/log.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import etapiTokenService from "../services/etapi_tokens.js";
|
||||
import config from "../services/config.js";
|
||||
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
|
||||
const GENERIC_CODE = "GENERIC";
|
||||
|
||||
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
||||
@@ -37,8 +35,8 @@ function sendError(res: Response, statusCode: number, code: string, message: str
|
||||
.send(
|
||||
JSON.stringify({
|
||||
status: statusCode,
|
||||
code,
|
||||
message
|
||||
code: code,
|
||||
message: message
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -53,8 +51,8 @@ function checkEtapiAuth(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
function processRequest(req: Request, res: Response, routeHandler: ApiRequestHandler, next: NextFunction, method: string, path: string) {
|
||||
try {
|
||||
namespace.bindEmitter(req);
|
||||
namespace.bindEmitter(res);
|
||||
cls.namespace.bindEmitter(req);
|
||||
cls.namespace.bindEmitter(res);
|
||||
|
||||
cls.init(() => {
|
||||
cls.set("componentId", "etapi");
|
||||
@@ -88,9 +86,9 @@ function getAndCheckNote(noteId: string) {
|
||||
|
||||
if (note) {
|
||||
return note;
|
||||
}
|
||||
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
|
||||
|
||||
} else {
|
||||
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAndCheckAttachment(attachmentId: string) {
|
||||
@@ -98,9 +96,9 @@ function getAndCheckAttachment(attachmentId: string) {
|
||||
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
}
|
||||
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
|
||||
|
||||
} else {
|
||||
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAndCheckBranch(branchId: string) {
|
||||
@@ -108,9 +106,9 @@ function getAndCheckBranch(branchId: string) {
|
||||
|
||||
if (branch) {
|
||||
return branch;
|
||||
}
|
||||
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
|
||||
|
||||
} else {
|
||||
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAndCheckAttribute(attributeId: string) {
|
||||
@@ -118,9 +116,9 @@ function getAndCheckAttribute(attributeId: string) {
|
||||
|
||||
if (attribute) {
|
||||
return attribute;
|
||||
}
|
||||
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
|
||||
|
||||
} else {
|
||||
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { NoteParams } from "@triliumnext/core";
|
||||
import type { Request, Router } from "express";
|
||||
import type { ParsedQs } from "qs";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import zipExportService from "../services/export/zip.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
import zipImportService from "../services/import/zip.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import utils from "../services/utils.js";
|
||||
import eu from "./etapi_utils.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import mappers from "./mappers.js";
|
||||
import noteService from "../services/notes.js";
|
||||
import TaskContext from "../services/task_context.js";
|
||||
import v from "./validators.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import zipExportService from "../services/export/zip.js";
|
||||
import zipImportService from "../services/import/zip.js";
|
||||
import type { Request, Router } from "express";
|
||||
import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
|
||||
@@ -3,49 +3,9 @@
|
||||
* are loaded later and will result in an empty string.
|
||||
*/
|
||||
|
||||
import { initializeCore } from "@triliumnext/core";
|
||||
|
||||
import ClsHookedExecutionContext from "./cls_provider.js";
|
||||
import NodejsCryptoProvider from "./crypto_provider.js";
|
||||
import BetterSqlite3Provider from "./sql_provider.js";
|
||||
import { initializeTranslations } from "./services/i18n.js";
|
||||
|
||||
async function startApplication() {
|
||||
const config = (await import("./services/config.js")).default;
|
||||
const { DOCUMENT_PATH } = (await import("./services/data_dir.js")).default;
|
||||
|
||||
const dbProvider = new BetterSqlite3Provider();
|
||||
dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly);
|
||||
|
||||
initializeCore({
|
||||
dbConfig: {
|
||||
provider: dbProvider,
|
||||
isReadOnly: config.General.readOnly,
|
||||
async onTransactionCommit() {
|
||||
const ws = (await import("./services/ws.js")).default;
|
||||
ws.sendTransactionEntityChangesToAllClients();
|
||||
},
|
||||
async onTransactionRollback() {
|
||||
const cls = (await import("./services/cls.js")).default;
|
||||
const becca_loader = (await import("@triliumnext/core")).becca_loader;
|
||||
const entity_changes = (await import("./services/entity_changes.js")).default;
|
||||
const log = (await import("./services/log")).default;
|
||||
|
||||
const entityChangeIds = cls.getAndClearEntityChangeIds();
|
||||
|
||||
if (entityChangeIds.length > 0) {
|
||||
log.info("Transaction rollback dirtied the becca, forcing reload.");
|
||||
|
||||
becca_loader.load();
|
||||
}
|
||||
|
||||
// the maxEntityChangeId has been incremented during failed transaction, need to recalculate
|
||||
entity_changes.recalculateMaxEntityChangeId();
|
||||
}
|
||||
},
|
||||
crypto: new NodejsCryptoProvider(),
|
||||
executionContext: new ClsHookedExecutionContext()
|
||||
});
|
||||
const { initializeTranslations } = (await import("./services/i18n.js"));
|
||||
await initializeTranslations();
|
||||
const startTriliumServer = (await import("./www.js")).default;
|
||||
await startTriliumServer();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { becca_loader } from "@triliumnext/core";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import becca_loader from "../becca/becca_loader.js";
|
||||
import cls from "../services/cls.js";
|
||||
import log from "../services/log.js";
|
||||
import sql from "../services/sql.js";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { becca_loader } from "@triliumnext/core";
|
||||
|
||||
import becca from "../becca/becca";
|
||||
import becca_loader from "../becca/becca_loader";
|
||||
import cls from "../services/cls.js";
|
||||
import hidden_subtree from "../services/hidden_subtree";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||
import { blob as blobService, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import type { Request } from "express";
|
||||
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||
|
||||
function getAttachmentBlob(req: Request) {
|
||||
const preview = req.query.preview === "true";
|
||||
@@ -34,7 +34,7 @@ function getAllAttachments(req: Request) {
|
||||
function saveAttachment(req: Request) {
|
||||
const { noteId } = req.params;
|
||||
const { attachmentId, role, mime, title, content } = req.body;
|
||||
const matchByQuery = req.query.matchBy;
|
||||
const matchByQuery = req.query.matchBy
|
||||
const isValidMatchBy = (typeof matchByQuery === "string") && (matchByQuery === "attachmentId" || matchByQuery === "title");
|
||||
const matchBy = isValidMatchBy ? matchByQuery : undefined;
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import BAttribute from "../../becca/entities/battribute.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import log from "../../services/log.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import BAttribute from "../../becca/entities/battribute.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||
|
||||
function getEffectiveNoteAttributes(req: Request) {
|
||||
const note = becca.getNote(req.params.noteId);
|
||||
@@ -46,7 +47,7 @@ function updateNoteAttribute(req: Request) {
|
||||
}
|
||||
|
||||
attribute = new BAttribute({
|
||||
noteId,
|
||||
noteId: noteId,
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
isInheritable: body.isInheritable
|
||||
@@ -207,7 +208,7 @@ function createRelation(req: Request) {
|
||||
if (!attribute) {
|
||||
attribute = new BAttribute({
|
||||
noteId: sourceNoteId,
|
||||
name,
|
||||
name: name,
|
||||
type: "relation",
|
||||
value: targetNoteId
|
||||
}).save();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { becca_service, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import log from "../../services/log.js";
|
||||
import searchService from "../../services/search/services/search.js";
|
||||
@@ -66,8 +67,8 @@ function getRecentNotes(activeNoteId: string) {
|
||||
return recentNotes.map((rn) => {
|
||||
const notePathArray = rn.notePath.split("/");
|
||||
|
||||
const { title, icon } = becca_service.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]);
|
||||
const notePathTitle = becca_service.getNoteTitleForPath(notePathArray);
|
||||
const { title, icon } = beccaService.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]);
|
||||
const notePathTitle = beccaService.getNoteTitleForPath(notePathArray);
|
||||
|
||||
return {
|
||||
notePath: rn.notePath,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { erase as eraseService, events as eventService, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import log from "../../services/log.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
/**
|
||||
* Code in this file deals with moving and cloning branches. The relationship between note and parent note is unique
|
||||
@@ -253,7 +256,7 @@ function deleteBranch(req: Request) {
|
||||
}
|
||||
|
||||
return {
|
||||
noteDeleted
|
||||
noteDeleted: noteDeleted
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,7 +272,7 @@ function setPrefix(req: Request) {
|
||||
|
||||
function setPrefixBatch(req: Request) {
|
||||
const { branchIds, prefix } = req.body;
|
||||
|
||||
|
||||
if (!Array.isArray(branchIds)) {
|
||||
throw new ValidationError("branchIds must be an array");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { sanitize, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
import { parse } from "node-html-parser";
|
||||
import path from "path";
|
||||
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import cloneService from "../../services/cloning.js";
|
||||
import dateNoteService from "../../services/date_notes.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import htmlSanitizer from "../../services/html_sanitizer.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
@@ -31,13 +32,13 @@ async function addClipping(req: Request) {
|
||||
|
||||
const clipperInbox = await getClipperInboxNote();
|
||||
|
||||
const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl);
|
||||
const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl);
|
||||
let clippingNote = findClippingNote(clipperInbox, pageUrl, clipType);
|
||||
|
||||
if (!clippingNote) {
|
||||
clippingNote = noteService.createNewNote({
|
||||
parentNoteId: clipperInbox.noteId,
|
||||
title,
|
||||
title: title,
|
||||
content: "",
|
||||
type: "text"
|
||||
}).note;
|
||||
@@ -98,8 +99,8 @@ async function getClipperInboxNote() {
|
||||
async function createNote(req: Request) {
|
||||
const { content, images, labels } = req.body;
|
||||
|
||||
const clipType = sanitize.sanitizeHtml(req.body.clipType);
|
||||
const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl);
|
||||
const clipType = htmlSanitizer.sanitize(req.body.clipType);
|
||||
const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl);
|
||||
|
||||
const trimmedTitle = (typeof req.body.title === "string") ? req.body.title.trim() : "";
|
||||
const title = trimmedTitle || `Clipped note from ${pageUrl}`;
|
||||
@@ -125,7 +126,7 @@ async function createNote(req: Request) {
|
||||
|
||||
if (labels) {
|
||||
for (const labelName in labels) {
|
||||
const labelValue = sanitize.sanitizeHtml(labels[labelName]);
|
||||
const labelValue = htmlSanitizer.sanitize(labels[labelName]);
|
||||
note.setLabel(labelName, labelValue);
|
||||
}
|
||||
}
|
||||
@@ -146,7 +147,7 @@ async function createNote(req: Request) {
|
||||
}
|
||||
|
||||
export function processContent(images: Image[], note: BNote, content: string) {
|
||||
let rewrittenContent = sanitize.sanitizeHtml(content);
|
||||
let rewrittenContent = htmlSanitizer.sanitize(content);
|
||||
|
||||
if (images) {
|
||||
for (const { src, dataUrl, imageId } of images) {
|
||||
@@ -197,11 +198,11 @@ function openNote(req: Request) {
|
||||
return {
|
||||
result: "ok"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
result: "open-in-browser"
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: "open-in-browser"
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function handshake() {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||
import { becca_loader, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import anonymizationService from "../../services/anonymization.js";
|
||||
import backupService from "../../services/backup.js";
|
||||
import consistencyChecksService from "../../services/consistency_checks.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import log from "../../services/log.js";
|
||||
import backupService from "../../services/backup.js";
|
||||
import anonymizationService from "../../services/anonymization.js";
|
||||
import consistencyChecksService from "../../services/consistency_checks.js";
|
||||
import type { Request } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import sql_init from "../../services/sql_init.js";
|
||||
import becca_loader from "../../becca/becca_loader.js";
|
||||
import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||
|
||||
function getExistingBackups() {
|
||||
return backupService.getExistingBackups();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NotFoundError, ValidationError } from "@triliumnext/core";
|
||||
import type { Request, Response } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import opmlExportService from "../../services/export/opml.js";
|
||||
import singleExportService from "../../services/export/single.js";
|
||||
import zipExportService from "../../services/export/zip.js";
|
||||
import log from "../../services/log.js";
|
||||
import singleExportService from "../../services/export/single.js";
|
||||
import opmlExportService from "../../services/export/opml.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import log from "../../services/log.js";
|
||||
import NotFoundError from "../../errors/not_found_error.js";
|
||||
import type { Request, Response } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
function exportBranch(req: Request, res: Response) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
|
||||
|
||||
import chokidar from "chokidar";
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs";
|
||||
@@ -8,6 +9,7 @@ import tmp from "tmp";
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BAttachment from "../../becca/entities/battachment.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import dataDirs from "../../services/data_dir.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
@@ -121,7 +123,7 @@ function attachmentContentProvider(req: Request) {
|
||||
return streamContent(attachment.getContent(), attachment.getFileName(), attachment.mime);
|
||||
}
|
||||
|
||||
async function streamContent(content: string | Uint8Array, fileName: string, mimeType: string) {
|
||||
async function streamContent(content: string | Buffer, fileName: string, mimeType: string) {
|
||||
if (typeof content === "string") {
|
||||
content = Buffer.from(content, "utf8");
|
||||
}
|
||||
@@ -168,7 +170,7 @@ function saveAttachmentToTmpDir(req: Request) {
|
||||
|
||||
const createdTemporaryFiles = new Set<string>();
|
||||
|
||||
function saveToTmpDir(fileName: string, content: string | Uint8Array, entityType: string, entityId: string) {
|
||||
function saveToTmpDir(fileName: string, content: string | Buffer, entityType: string, entityId: string) {
|
||||
const tmpObj = tmp.fileSync({
|
||||
postfix: fileName,
|
||||
tmpdir: dataDirs.TMP_DIR
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import fs from "fs";
|
||||
|
||||
import imageService from "../../services/image.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import fs from "fs";
|
||||
import type { Request, Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import { RESOURCE_DIR } from "../../services/resource_dir.js";
|
||||
|
||||
function returnImageFromNote(req: Request, res: Response) {
|
||||
@@ -43,7 +42,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
}
|
||||
|
||||
export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
|
||||
let svg: string | Uint8Array = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
|
||||
let svg: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
|
||||
if (attachment) {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { becca_loader,ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
import path from "path";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import enexImportService from "../../services/import/enex.js";
|
||||
import opmlImportService from "../../services/import/opml.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import zipImportService from "../../services/import/zip.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import path from "path";
|
||||
import becca from "../../becca/becca.js";
|
||||
import beccaLoader from "../../becca/becca_loader.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
async function importNotesToBranch(req: Request) {
|
||||
@@ -86,7 +88,7 @@ async function importNotesToBranch(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId,
|
||||
parentNoteId: parentNoteId,
|
||||
importedNoteId: note?.noteId
|
||||
}),
|
||||
1000
|
||||
@@ -94,7 +96,7 @@ async function importNotesToBranch(req: Request) {
|
||||
}
|
||||
|
||||
// import has deactivated note events so becca is not updated, instead we force it to reload
|
||||
becca_loader.load();
|
||||
beccaLoader.load();
|
||||
|
||||
return note.getPojo();
|
||||
}
|
||||
@@ -136,7 +138,7 @@ function importAttachmentsToNote(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId
|
||||
parentNoteId: parentNoteId
|
||||
}),
|
||||
1000
|
||||
);
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { events as eventService, getInstanceId } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import passwordEncryptionService from "../../services/encryption/password_encryption.js";
|
||||
import recoveryCodeService from "../../services/encryption/recovery_codes";
|
||||
import etapiTokenService from "../../services/etapi_tokens.js";
|
||||
import options from "../../services/options.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import totp from "../../services/totp";
|
||||
import utils from "../../services/utils.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import instanceId from "../../services/instance_id.js";
|
||||
import passwordEncryptionService from "../../services/encryption/password_encryption.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import etapiTokenService from "../../services/etapi_tokens.js";
|
||||
import type { Request } from "express";
|
||||
import totp from "../../services/totp";
|
||||
import recoveryCodeService from "../../services/encryption/recovery_codes";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -113,7 +115,7 @@ function loginSync(req: Request) {
|
||||
req.session.loggedIn = true;
|
||||
|
||||
return {
|
||||
instanceId: getInstanceId(),
|
||||
instanceId: instanceId,
|
||||
maxEntityChangeId: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
|
||||
import { blob as blobService, erase as eraseService, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import type { Request } from "express";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import config from "../../services/config.js";
|
||||
import { changeLanguage, getLocales } from "../../services/i18n.js";
|
||||
import log from "../../services/log.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user