mirror of
https://github.com/zadam/trilium.git
synced 2026-01-07 16:02:13 +01:00
Compare commits
61 Commits
lightweigh
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f3968cc0 | ||
|
|
7f2cc885fe | ||
|
|
19a365a370 | ||
|
|
9a50da328e | ||
|
|
181e36a7c1 | ||
|
|
178508d245 | ||
|
|
d132d084cf | ||
|
|
494b55d685 | ||
|
|
51513d3779 | ||
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ upload
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
scripts/translation/.language*.json
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"mind-elixir": "5.5.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.1",
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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}
|
||||
/>;
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ChangePasswordResponse } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import passwordService from "../../services/encryption/password.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
import { ChangePasswordResponse } from "@triliumnext/commons";
|
||||
|
||||
function changePassword(req: Request): ChangePasswordResponse {
|
||||
if (passwordService.isPasswordSet()) {
|
||||
return passwordService.changePassword(req.body.current_password, req.body.new_password);
|
||||
} else {
|
||||
return passwordService.setPassword(req.body.new_password);
|
||||
}
|
||||
return passwordService.setPassword(req.body.new_password);
|
||||
|
||||
}
|
||||
|
||||
function resetPassword(req: Request) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons";
|
||||
import { becca_service, binary_utils, blob as blobService, erase as eraseService, NotePojo } from "@triliumnext/core";
|
||||
import type { Request, Response } from "express";
|
||||
import path from "path";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import path from "path";
|
||||
import becca from "../../becca/becca.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import type { Request, Response } from "express";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { NotePojo } from "../../becca/becca-interface.js";
|
||||
import { EditedNotesResponse, RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons";
|
||||
|
||||
interface NotePath {
|
||||
noteId: string;
|
||||
@@ -52,7 +56,7 @@ function getRevision(req: Request) {
|
||||
revision.content = revision.getContent();
|
||||
|
||||
if (revision.content && revision.type === "image") {
|
||||
revision.content = binary_utils.encodeBase64(revision.content);
|
||||
revision.content = revision.content.toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +166,7 @@ function getEditedNotesOnDate(req: Request) {
|
||||
)
|
||||
ORDER BY isDeleted
|
||||
LIMIT 50`,
|
||||
{ date: `${req.params.date}%` }
|
||||
{ date: `${req.params.date}%` }
|
||||
);
|
||||
|
||||
let notes = becca.getNotes(noteIds, true);
|
||||
@@ -187,7 +191,7 @@ function getNotePathData(note: BNote): NotePath | undefined {
|
||||
const retPath = note.getBestNotePath();
|
||||
|
||||
if (retPath) {
|
||||
const noteTitle = becca_service.getNoteTitleForPath(retPath);
|
||||
const noteTitle = beccaService.getNoteTitleForPath(retPath);
|
||||
|
||||
let branchId;
|
||||
|
||||
@@ -200,7 +204,7 @@ function getNotePathData(note: BNote): NotePath | undefined {
|
||||
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
branchId,
|
||||
branchId: branchId,
|
||||
title: noteTitle,
|
||||
notePath: retPath,
|
||||
path: retPath.join("/")
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { becca_service,ValidationError } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import SearchContext from "../../services/search/search_context.js";
|
||||
import searchService, { EMPTY_RESULT, type SearchNoteResult } from "../../services/search/services/search.js";
|
||||
import bulkActionService from "../../services/bulk_actions.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import SearchContext from "../../services/search/search_context.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type SearchResult from "../../services/search/search_result.js";
|
||||
import searchService, { EMPTY_RESULT, type SearchNoteResult } from "../../services/search/services/search.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
|
||||
function searchFromNote(req: Request): SearchNoteResult {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
@@ -69,7 +72,7 @@ function quickSearch(req: Request) {
|
||||
|
||||
// Map to API format
|
||||
const searchResults = trimmed.map((result) => {
|
||||
const { title, icon } = becca_service.getNoteTitleAndIcon(result.noteId);
|
||||
const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId);
|
||||
return {
|
||||
notePath: result.notePath,
|
||||
noteTitle: title,
|
||||
@@ -79,7 +82,7 @@ function quickSearch(req: Request) {
|
||||
highlightedContentSnippet: result.highlightedContentSnippet,
|
||||
attributeSnippet: result.attributeSnippet,
|
||||
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
|
||||
icon
|
||||
icon: icon
|
||||
};
|
||||
});
|
||||
|
||||
@@ -87,7 +90,7 @@ function quickSearch(req: Request) {
|
||||
|
||||
return {
|
||||
searchResultNoteIds: resultNoteIds,
|
||||
searchResults,
|
||||
searchResults: searchResults,
|
||||
error: searchContext.getError()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { utils } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
import imageType from "image-type";
|
||||
|
||||
import imageService from "../../services/image.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
|
||||
import specialNotesService from "../../services/special_notes.js";
|
||||
|
||||
async function uploadImage(req: Request) {
|
||||
@@ -43,14 +43,14 @@ async function uploadImage(req: Request) {
|
||||
const labels = JSON.parse(labelsStr);
|
||||
|
||||
for (const { name, value } of labels) {
|
||||
note.setLabel(utils.sanitizeAttributeName(name), value);
|
||||
note.setLabel(sanitizeAttributeName(name), value);
|
||||
}
|
||||
}
|
||||
|
||||
note.setLabel("sentFromSender");
|
||||
|
||||
return {
|
||||
noteId
|
||||
noteId: noteId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async function saveNote(req: Request) {
|
||||
|
||||
if (req.body.labels) {
|
||||
for (const { name, value } of req.body.labels) {
|
||||
note.setLabel(utils.sanitizeAttributeName(name), value);
|
||||
note.setLabel(sanitizeAttributeName(name), value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { SimilarNoteResponse } from "@triliumnext/commons";
|
||||
import { similarity } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import similarityService from "../../becca/similarity.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import { SimilarNoteResponse } from "@triliumnext/commons";
|
||||
|
||||
async function getSimilarNotes(req: Request) {
|
||||
const noteId = req.params.noteId;
|
||||
|
||||
const _note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
return (await similarity.findSimilarNotes(noteId) satisfies SimilarNoteResponse);
|
||||
return (await similarityService.findSimilarNotes(noteId) satisfies SimilarNoteResponse);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import type { Request } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
interface Table {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { type EntityChange,SyncTestResponse } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
import { t } from "i18next";
|
||||
"use strict";
|
||||
|
||||
import consistencyChecksService from "../../services/consistency_checks.js";
|
||||
import contentHashService from "../../services/content_hash.js";
|
||||
import syncService from "../../services/sync.js";
|
||||
import syncUpdateService from "../../services/sync_update.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import log from "../../services/log.js";
|
||||
import optionService from "../../services/options.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import syncService from "../../services/sync.js";
|
||||
import optionService from "../../services/options.js";
|
||||
import contentHashService from "../../services/content_hash.js";
|
||||
import log from "../../services/log.js";
|
||||
import syncOptions from "../../services/sync_options.js";
|
||||
import syncUpdateService from "../../services/sync_update.js";
|
||||
import utils, { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import ws from "../../services/ws.js";
|
||||
import type { Request } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import consistencyChecksService from "../../services/consistency_checks.js";
|
||||
import { t } from "i18next";
|
||||
import { SyncTestResponse, type EntityChange } from "@triliumnext/commons";
|
||||
|
||||
async function testSync(): Promise<SyncTestResponse> {
|
||||
try {
|
||||
@@ -286,10 +287,10 @@ function update(req: Request) {
|
||||
|
||||
if (pageIndex !== pageCount - 1) {
|
||||
return;
|
||||
}
|
||||
body = JSON.parse(partialRequests[requestId].payload);
|
||||
delete partialRequests[requestId];
|
||||
|
||||
} else {
|
||||
body = JSON.parse(partialRequests[requestId].payload);
|
||||
delete partialRequests[requestId];
|
||||
}
|
||||
}
|
||||
|
||||
const { entities, instanceId } = body;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
|
||||
import { NotFoundError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
"use strict";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import log from "../../services/log.js";
|
||||
import NotFoundError from "../../errors/not_found_error.js";
|
||||
import type { Request } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
|
||||
|
||||
function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
|
||||
const noteIds = new Set(_noteIds);
|
||||
|
||||
@@ -25,20 +25,14 @@ async function register(app: express.Application) {
|
||||
const clientDir = path.join(srcRoot, "../client");
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
appType: "custom",
|
||||
cacheDir: path.join(srcRoot, "../../.cache/vite"),
|
||||
base: `/${assetUrlFragment}/`,
|
||||
root: clientDir,
|
||||
css: { devSourcemap: true }
|
||||
});
|
||||
app.use(`/${assetUrlFragment}/`, vite.middlewares);
|
||||
app.get(`/`, (req, res, next) => {
|
||||
// We force the page to not be cached since on mobile the CSRF token can be
|
||||
// broken when closing the browser and coming back in to the page.
|
||||
// The page is restored from cache, but the API call fail.
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
req.url = `/${assetUrlFragment}/src/desktop.html`;
|
||||
app.use(`/${assetUrlFragment}/`, (req, res, next) => {
|
||||
req.url = `/${assetUrlFragment}${req.url}`;
|
||||
vite.middlewares(req, res, next);
|
||||
});
|
||||
app.use(`/node_modules/@excalidraw/excalidraw/dist/prod`, persistentCacheStatic(path.join(srcRoot, "../../node_modules/@excalidraw/excalidraw/dist/prod")));
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import { namespace } from "../cls_provider.js";
|
||||
import cls from "../services/cls.js";
|
||||
import log from "../services/log.js";
|
||||
import scriptService from "../services/script.js";
|
||||
import sql from "../services/sql.js";
|
||||
import { normalizeCustomHandlerPattern,safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import fileService from "./api/files.js";
|
||||
import scriptService from "../services/script.js";
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
|
||||
|
||||
function handleRequest(req: Request, res: Response) {
|
||||
|
||||
@@ -29,7 +27,7 @@ function handleRequest(req: Request, res: Response) {
|
||||
// splitPath.map(segment => encodeURIComponent(segment)).join("/")
|
||||
// might be safer
|
||||
|
||||
const path = splitPath.join("/");
|
||||
const path = splitPath.join("/")
|
||||
|
||||
const attributeIds = sql.getColumn<string>("SELECT attributeId FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name IN ('customRequestHandler', 'customResourceProvider')");
|
||||
|
||||
@@ -98,8 +96,8 @@ function register(router: Router) {
|
||||
// explicitly no CSRF middleware since it's meant to allow integration from external services
|
||||
|
||||
router.all("/custom/*path", (req: Request, res: Response, _next) => {
|
||||
namespace.bindEmitter(req);
|
||||
namespace.bindEmitter(res);
|
||||
cls.namespace.bindEmitter(req);
|
||||
cls.namespace.bindEmitter(res);
|
||||
|
||||
cls.init(() => handleRequest(req, res));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ForbiddenError, HttpError, NotFoundError } from "@triliumnext/core";
|
||||
import type { Application, NextFunction, Request, Response } from "express";
|
||||
|
||||
import log from "../services/log.js";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import ForbiddenError from "../errors/forbidden_error.js";
|
||||
import HttpError from "../errors/http_error.js";
|
||||
|
||||
function register(app: Application) {
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ import sql from "../services/sql.js";
|
||||
import { isDev, isElectron, isWindows11 } from "../services/utils.js";
|
||||
import { generateToken as generateCsrfToken } from "./csrf_protection.js";
|
||||
|
||||
|
||||
type View = "desktop" | "mobile" | "print";
|
||||
|
||||
export function bootstrap(req: Request, res: Response) {
|
||||
function index(req: Request, res: Response) {
|
||||
const view = getView(req);
|
||||
const options = optionService.getOptionMap();
|
||||
|
||||
//'overwrite' set to false (default) => the existing token will be re-used and validated
|
||||
@@ -26,14 +26,17 @@ export function bootstrap(req: Request, res: Response) {
|
||||
const csrfToken = generateCsrfToken(req, res, false, false);
|
||||
log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`);
|
||||
|
||||
const view = getView(req);
|
||||
// We force the page to not be cached since on mobile the CSRF token can be
|
||||
// broken when closing the browser and coming back in to the page.
|
||||
// The page is restored from cache, but the API call fail.
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
const theme = options.theme;
|
||||
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
|
||||
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
|
||||
const iconPacks = getIconPacks();
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
res.send({
|
||||
res.render(view, {
|
||||
device: view,
|
||||
csrfToken,
|
||||
themeCssUrl: getThemeCssUrl(theme, themeNote),
|
||||
@@ -44,10 +47,9 @@ export function bootstrap(req: Request, res: Response) {
|
||||
isElectron,
|
||||
hasNativeTitleBar: isElectron && nativeTitleBarVisible,
|
||||
hasBackgroundEffects: isElectron && isWindows11 && !nativeTitleBarVisible && options.backgroundEffects === "true",
|
||||
// TODO: These font size don't actually seem to be used.
|
||||
mainFontSize: parseInt(options.mainFontSize, 10),
|
||||
treeFontSize: parseInt(options.treeFontSize, 10),
|
||||
detailFontSize: parseInt(options.detailFontSize, 10),
|
||||
mainFontSize: parseInt(options.mainFontSize),
|
||||
treeFontSize: parseInt(options.treeFontSize),
|
||||
detailFontSize: parseInt(options.detailFontSize),
|
||||
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
|
||||
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
|
||||
instanceName: config.General ? config.General.instanceName : null,
|
||||
@@ -59,16 +61,14 @@ export function bootstrap(req: Request, res: Response) {
|
||||
assetPath,
|
||||
appPath,
|
||||
baseApiUrl: 'api/',
|
||||
currentLocale,
|
||||
isRtl: !!currentLocale.rtl,
|
||||
currentLocale: getCurrentLocale(),
|
||||
iconPackCss: iconPacks
|
||||
.map(p => generateCss(p, p.builtin
|
||||
? `${assetPath}/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
: `api/attachments/download/${p.fontAttachmentId}`))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconRegistry: generateIconRegistry(iconPacks),
|
||||
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE
|
||||
iconRegistry: generateIconRegistry(iconPacks)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,3 +133,7 @@ function getThemeCssUrl(theme: string, themeNote: BNote | null) {
|
||||
function getAppCssNoteIds() {
|
||||
return attributeService.getNotesWithLabel("appCss").map((note) => note.noteId);
|
||||
}
|
||||
|
||||
export default {
|
||||
index
|
||||
};
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import crypto from "crypto";
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import appPath from "../services/app_path.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import myScryptService from "../services/encryption/my_scrypt.js";
|
||||
import openIDEncryption from '../services/encryption/open_id_encryption.js';
|
||||
import passwordService from "../services/encryption/password.js";
|
||||
import recoveryCodeService from '../services/encryption/recovery_codes.js';
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
import log from "../services/log.js";
|
||||
import openID from '../services/open_id.js';
|
||||
import optionService from "../services/options.js";
|
||||
import totp from '../services/totp.js';
|
||||
import utils from "../services/utils.js";
|
||||
import optionService from "../services/options.js";
|
||||
import myScryptService from "../services/encryption/my_scrypt.js";
|
||||
import log from "../services/log.js";
|
||||
import passwordService from "../services/encryption/password.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import ValidationError from "../errors/validation_error.js";
|
||||
import type { Request, Response } from 'express';
|
||||
import totp from '../services/totp.js';
|
||||
import recoveryCodeService from '../services/encryption/recovery_codes.js';
|
||||
import openID from '../services/open_id.js';
|
||||
import openIDEncryption from '../services/encryption/open_id_encryption.js';
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
|
||||
function loginPage(req: Request, res: Response) {
|
||||
// Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed.
|
||||
@@ -24,9 +23,9 @@ function loginPage(req: Request, res: Response) {
|
||||
ssoEnabled: openID.isOpenIDEnabled(),
|
||||
ssoIssuerName: openID.getSSOIssuerName(),
|
||||
ssoIssuerIcon: openID.getSSOIssuerIcon(),
|
||||
assetPath,
|
||||
assetPath: assetPath,
|
||||
assetPathFragment: assetUrlFragment,
|
||||
appPath,
|
||||
appPath: appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
});
|
||||
}
|
||||
@@ -182,9 +181,9 @@ function sendLoginError(req: Request, res: Response, errorType: 'password' | 'to
|
||||
wrongTotp: errorType === 'totp',
|
||||
totpEnabled: totp.isTotpEnabled(),
|
||||
ssoEnabled: openID.isOpenIDEnabled(),
|
||||
assetPath,
|
||||
assetPath: assetPath,
|
||||
assetPathFragment: assetUrlFragment,
|
||||
appPath,
|
||||
appPath: appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import multer from "multer";
|
||||
|
||||
import { namespace } from "../cls_provider.js";
|
||||
import auth from "../services/auth.js";
|
||||
import cls from "../services/cls.js";
|
||||
import entityChangesService from "../services/entity_changes.js";
|
||||
import log from "../services/log.js";
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import entityChangesService from "../services/entity_changes.js";
|
||||
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import ValidationError from "../errors/validation_error.js";
|
||||
import auth from "../services/auth.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||
export const router = express.Router();
|
||||
@@ -67,9 +67,9 @@ export function apiResultHandler(req: express.Request, res: express.Response, re
|
||||
return send(res, statusCode, response);
|
||||
} else if (result === undefined) {
|
||||
return send(res, 204, "");
|
||||
} else {
|
||||
return send(res, 200, result);
|
||||
}
|
||||
return send(res, 200, result);
|
||||
|
||||
}
|
||||
|
||||
function send(res: express.Response, statusCode: number, response: unknown) {
|
||||
@@ -81,14 +81,14 @@ function send(res: express.Response, statusCode: number, response: unknown) {
|
||||
res.status(statusCode).send(response);
|
||||
|
||||
return response.length;
|
||||
} else {
|
||||
const json = JSON.stringify(response);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(statusCode).send(json);
|
||||
|
||||
return json.length;
|
||||
}
|
||||
const json = JSON.stringify(response);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(statusCode).send(json);
|
||||
|
||||
return json.length;
|
||||
|
||||
}
|
||||
|
||||
export function apiRoute(method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
|
||||
@@ -112,8 +112,8 @@ function internalRoute(method: HttpMethod, path: string, middleware: express.Han
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
namespace.bindEmitter(req);
|
||||
namespace.bindEmitter(res);
|
||||
cls.namespace.bindEmitter(req);
|
||||
cls.namespace.bindEmitter(res);
|
||||
|
||||
const result = cls.init(() => {
|
||||
cls.set("componentId", req.headers["trilium-component-id"]);
|
||||
@@ -193,7 +193,7 @@ export function createUploadMiddleware(): RequestHandler {
|
||||
const uploadMiddleware = createUploadMiddleware();
|
||||
|
||||
export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
uploadMiddleware(req, res, (err) => {
|
||||
uploadMiddleware(req, res, function (err) {
|
||||
if (err?.code === "LIMIT_FILE_SIZE") {
|
||||
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
|
||||
} else {
|
||||
|
||||
@@ -1,73 +1,76 @@
|
||||
import { createPartialContentHandler } from "@triliumnext/express-partial-content";
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import express from "express";
|
||||
|
||||
import auth from "../services/auth.js";
|
||||
import openID from '../services/open_id.js';
|
||||
import totp from './api/totp.js';
|
||||
import recoveryCodes from './api/recovery_codes.js';
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { createPartialContentHandler } from "@triliumnext/express-partial-content";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
// page routes
|
||||
import setupRoute from "./setup.js";
|
||||
import loginRoute from "./login.js";
|
||||
import indexRoute from "./index.js";
|
||||
|
||||
// API routes
|
||||
import treeApiRoute from "./api/tree.js";
|
||||
import notesApiRoute from "./api/notes.js";
|
||||
import branchesApiRoute from "./api/branches.js";
|
||||
import attachmentsApiRoute from "./api/attachments.js";
|
||||
import autocompleteApiRoute from "./api/autocomplete.js";
|
||||
import cloningApiRoute from "./api/cloning.js";
|
||||
import revisionsApiRoute from "./api/revisions.js";
|
||||
import recentChangesApiRoute from "./api/recent_changes.js";
|
||||
import optionsApiRoute from "./api/options.js";
|
||||
import passwordApiRoute from "./api/password.js";
|
||||
import syncApiRoute from "./api/sync.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import recentNotesRoute from "./api/recent_notes.js";
|
||||
import appInfoRoute from "./api/app_info.js";
|
||||
import exportRoute from "./api/export.js";
|
||||
import importRoute from "./api/import.js";
|
||||
import setupApiRoute from "./api/setup.js";
|
||||
import sqlRoute from "./api/sql.js";
|
||||
import databaseRoute from "./api/database.js";
|
||||
import imageRoute from "./api/image.js";
|
||||
import attributesRoute from "./api/attributes.js";
|
||||
import scriptRoute from "./api/script.js";
|
||||
import senderRoute from "./api/sender.js";
|
||||
import filesRoute from "./api/files.js";
|
||||
import searchRoute from "./api/search.js";
|
||||
import bulkActionRoute from "./api/bulk_action.js";
|
||||
import specialNotesRoute from "./api/special_notes.js";
|
||||
import noteMapRoute from "./api/note_map.js";
|
||||
import clipperRoute from "./api/clipper.js";
|
||||
import similarNotesRoute from "./api/similar_notes.js";
|
||||
import keysRoute from "./api/keys.js";
|
||||
import backendLogRoute from "./api/backend_log.js";
|
||||
import statsRoute from "./api/stats.js";
|
||||
import fontsRoute from "./api/fonts.js";
|
||||
import etapiTokensApiRoutes from "./api/etapi_tokens.js";
|
||||
import relationMapApiRoute from "./api/relation-map.js";
|
||||
import otherRoute from "./api/other.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import shareRoutes from "../share/routes.js";
|
||||
import ollamaRoute from "./api/ollama.js";
|
||||
import openaiRoute from "./api/openai.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
import etapiAppInfoRoutes from "../etapi/app_info.js";
|
||||
import etapiAttachmentRoutes from "../etapi/attachments.js";
|
||||
import etapiAttributeRoutes from "../etapi/attributes.js";
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
import etapiBackupRoute from "../etapi/backup.js";
|
||||
import etapiBranchRoutes from "../etapi/branches.js";
|
||||
import etapiMetricsRoute from "../etapi/metrics.js";
|
||||
import etapiNoteRoutes from "../etapi/notes.js";
|
||||
import etapiSpecRoute from "../etapi/spec.js";
|
||||
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
||||
import auth from "../services/auth.js";
|
||||
import openID from '../services/open_id.js';
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import shareRoutes from "../share/routes.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import appInfoRoute from "./api/app_info.js";
|
||||
import attachmentsApiRoute from "./api/attachments.js";
|
||||
import attributesRoute from "./api/attributes.js";
|
||||
import autocompleteApiRoute from "./api/autocomplete.js";
|
||||
import backendLogRoute from "./api/backend_log.js";
|
||||
import branchesApiRoute from "./api/branches.js";
|
||||
import bulkActionRoute from "./api/bulk_action.js";
|
||||
import clipperRoute from "./api/clipper.js";
|
||||
import cloningApiRoute from "./api/cloning.js";
|
||||
import databaseRoute from "./api/database.js";
|
||||
import etapiTokensApiRoutes from "./api/etapi_tokens.js";
|
||||
import exportRoute from "./api/export.js";
|
||||
import filesRoute from "./api/files.js";
|
||||
import fontsRoute from "./api/fonts.js";
|
||||
import imageRoute from "./api/image.js";
|
||||
import importRoute from "./api/import.js";
|
||||
import keysRoute from "./api/keys.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import noteMapRoute from "./api/note_map.js";
|
||||
import notesApiRoute from "./api/notes.js";
|
||||
import ollamaRoute from "./api/ollama.js";
|
||||
import openaiRoute from "./api/openai.js";
|
||||
import optionsApiRoute from "./api/options.js";
|
||||
import otherRoute from "./api/other.js";
|
||||
import passwordApiRoute from "./api/password.js";
|
||||
import recentChangesApiRoute from "./api/recent_changes.js";
|
||||
import recentNotesRoute from "./api/recent_notes.js";
|
||||
import recoveryCodes from './api/recovery_codes.js';
|
||||
import relationMapApiRoute from "./api/relation-map.js";
|
||||
import revisionsApiRoute from "./api/revisions.js";
|
||||
import scriptRoute from "./api/script.js";
|
||||
import searchRoute from "./api/search.js";
|
||||
import senderRoute from "./api/sender.js";
|
||||
import setupApiRoute from "./api/setup.js";
|
||||
import similarNotesRoute from "./api/similar_notes.js";
|
||||
import specialNotesRoute from "./api/special_notes.js";
|
||||
import sqlRoute from "./api/sql.js";
|
||||
import statsRoute from "./api/stats.js";
|
||||
import syncApiRoute from "./api/sync.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
import totp from './api/totp.js';
|
||||
// API routes
|
||||
import treeApiRoute from "./api/tree.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import * as indexRoute from "./index.js";
|
||||
import loginRoute from "./login.js";
|
||||
import etapiSpecRoute from "../etapi/spec.js";
|
||||
import etapiBackupRoute from "../etapi/backup.js";
|
||||
import etapiMetricsRoute from "../etapi/metrics.js";
|
||||
import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
|
||||
// page routes
|
||||
import setupRoute from "./setup.js";
|
||||
|
||||
const GET = "get",
|
||||
PST = "post",
|
||||
@@ -76,6 +79,7 @@ const GET = "get",
|
||||
DEL = "delete";
|
||||
|
||||
function register(app: express.Application) {
|
||||
route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index);
|
||||
route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
|
||||
route(GET, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPasswordPage);
|
||||
|
||||
@@ -85,7 +89,6 @@ function register(app: express.Application) {
|
||||
skipSuccessfulRequests: true // successful auth to rate-limited ETAPI routes isn't counted. However, successful auth to /login is still counted!
|
||||
});
|
||||
|
||||
route(GET, "/bootstrap", [ auth.checkAuth ], indexRoute.bootstrap);
|
||||
route(PST, "/login", [loginRateLimiter], loginRoute.login);
|
||||
route(PST, "/logout", [csrfMiddleware, auth.checkAuth], loginRoute.logout);
|
||||
route(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword);
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
import { app_info as coreAppInfo } from "@triliumnext/core";
|
||||
import path from "path";
|
||||
|
||||
import build from "./build.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
const APP_DB_VERSION = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
export default {
|
||||
...coreAppInfo,
|
||||
appVersion: packageJson.version,
|
||||
dbVersion: APP_DB_VERSION,
|
||||
nodeVersion: process.version,
|
||||
syncVersion: SYNC_VERSION,
|
||||
buildDate: build.buildDate,
|
||||
buildRevision: build.buildRevision,
|
||||
dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR),
|
||||
clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION,
|
||||
utcDateTime: new Date().toISOString()
|
||||
} satisfies AppInfo;
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons";
|
||||
import { type AbstractBeccaEntity, Becca, NoteParams } from "@triliumnext/core";
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import xml2js from "xml2js";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import type BAttachment from "../becca/entities/battachment.js";
|
||||
import type BAttribute from "../becca/entities/battribute.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import type BEtapiToken from "../becca/entities/betapi_token.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type BOption from "../becca/entities/boption.js";
|
||||
import type BRevision from "../becca/entities/brevision.js";
|
||||
import appInfo from "./app_info.js";
|
||||
import attributeService from "./attributes.js";
|
||||
import type { ApiParams } from "./backend_script_api_interface.js";
|
||||
import backupService from "./backup.js";
|
||||
import branchService from "./branches.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import config from "./config.js";
|
||||
import dateNoteService from "./date_notes.js";
|
||||
import exportService from "./export/zip.js";
|
||||
import log from "./log.js";
|
||||
import noteService from "./notes.js";
|
||||
import optionsService from "./options.js";
|
||||
import SearchContext from "./search/search_context.js";
|
||||
import sql from "./sql.js";
|
||||
import { randomString, escapeHtml, unescapeHtml } from "./utils.js";
|
||||
import attributeService from "./attributes.js";
|
||||
import dateNoteService from "./date_notes.js";
|
||||
import treeService from "./tree.js";
|
||||
import config from "./config.js";
|
||||
import axios from "axios";
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
import xml2js from "xml2js";
|
||||
import * as cheerio from "cheerio";
|
||||
import cloningService from "./cloning.js";
|
||||
import appInfo from "./app_info.js";
|
||||
import searchService from "./search/services/search.js";
|
||||
import SearchContext from "./search/search_context.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import ws from "./ws.js";
|
||||
import SpacedUpdate from "./spaced_update.js";
|
||||
import specialNotesService from "./special_notes.js";
|
||||
import sql from "./sql.js";
|
||||
import branchService from "./branches.js";
|
||||
import exportService from "./export/zip.js";
|
||||
import syncMutex from "./sync_mutex.js";
|
||||
import treeService from "./tree.js";
|
||||
import { escapeHtml, randomString, unescapeHtml } from "./utils.js";
|
||||
import ws from "./ws.js";
|
||||
import backupService from "./backup.js";
|
||||
import optionsService from "./options.js";
|
||||
import { formatLogMessage } from "@triliumnext/commons";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import type BAttribute from "../becca/entities/battribute.js";
|
||||
import type BAttachment from "../becca/entities/battachment.js";
|
||||
import type BRevision from "../becca/entities/brevision.js";
|
||||
import type BEtapiToken from "../becca/entities/betapi_token.js";
|
||||
import type BOption from "../becca/entities/boption.js";
|
||||
import type { AttributeRow } from "@triliumnext/commons";
|
||||
import type Becca from "../becca/becca-interface.js";
|
||||
import type { NoteParams } from "./note-interface.js";
|
||||
import type { ApiParams } from "./backend_script_api_interface.js";
|
||||
|
||||
/**
|
||||
* A whole number
|
||||
@@ -503,7 +506,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
|
||||
throw new Error(`Unable to find parent note with ID ${parentNote}.`);
|
||||
}
|
||||
|
||||
const extraOptions: NoteParams = {
|
||||
let extraOptions: NoteParams = {
|
||||
..._extraOptions,
|
||||
content: "",
|
||||
type: "text",
|
||||
@@ -617,13 +620,13 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
|
||||
}
|
||||
|
||||
const parentNoteId = opts.isVisible ? "_lbVisibleLaunchers" : "_lbAvailableLaunchers";
|
||||
const noteId = `al_${ opts.id}`;
|
||||
const noteId = "al_" + opts.id;
|
||||
|
||||
const launcherNote =
|
||||
becca.getNote(noteId) ||
|
||||
specialNotesService.createLauncher({
|
||||
noteId,
|
||||
parentNoteId,
|
||||
noteId: noteId,
|
||||
parentNoteId: parentNoteId,
|
||||
launcherType: opts.type
|
||||
}).note;
|
||||
|
||||
@@ -677,7 +680,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
|
||||
|
||||
ws.sendMessageToAllClients({
|
||||
type: "execute-script",
|
||||
script,
|
||||
script: script,
|
||||
params: prepareParams(params),
|
||||
startNoteId: this.startNote?.noteId,
|
||||
currentNoteId: this.currentNote.noteId,
|
||||
@@ -693,9 +696,9 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
|
||||
return params.map((p) => {
|
||||
if (typeof p === "function") {
|
||||
return `!@#Function: ${p.toString()}`;
|
||||
} else {
|
||||
return p;
|
||||
}
|
||||
return p;
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AbstractBeccaEntity } from "@triliumnext/core";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
|
||||
export interface ApiParams {
|
||||
|
||||
5
apps/server/src/services/blob-interface.ts
Normal file
5
apps/server/src/services/blob-interface.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Blob {
|
||||
blobId: string;
|
||||
content: string | Buffer;
|
||||
utcDateModified: string;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BlobRow } from "@triliumnext/commons";
|
||||
import becca from "../becca/becca.js";
|
||||
import { NotFoundError } from "../errors";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import { decodeUtf8 } from "./utils/binary.js";
|
||||
import { hash } from "./utils/index.js";
|
||||
import { hash } from "./utils.js";
|
||||
import type { Blob } from "./blob-interface.js";
|
||||
|
||||
function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) {
|
||||
// TODO: Unused opts.
|
||||
@@ -22,37 +21,36 @@ function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boo
|
||||
if (!entity.hasStringContent()) {
|
||||
pojo.content = null;
|
||||
} else {
|
||||
pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Uint8Array;
|
||||
pojo.content = processContent(pojo.content, !!entity.isProtected, true);
|
||||
}
|
||||
|
||||
return pojo;
|
||||
}
|
||||
|
||||
function processContent(content: Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) {
|
||||
function processContent(content: Buffer | string | null, isProtected: boolean, isStringContent: boolean) {
|
||||
if (isProtected) {
|
||||
if (protectedSessionService.isProtectedSessionAvailable()) {
|
||||
content = content === null ? null : protectedSessionService.decrypt(content as Uint8Array);
|
||||
content = content === null ? null : protectedSessionService.decrypt(content);
|
||||
} else {
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (isStringContent) {
|
||||
if (content === null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return decodeUtf8(content as Uint8Array);
|
||||
}
|
||||
// see https://github.com/zadam/trilium/issues/3523
|
||||
// IIRC a zero-sized buffer can be returned as null from the database
|
||||
if (content === null) {
|
||||
// this will force de/encryption
|
||||
content = new Uint8Array(0);
|
||||
}
|
||||
return content === null ? "" : content.toString("utf-8");
|
||||
} else {
|
||||
// see https://github.com/zadam/trilium/issues/3523
|
||||
// IIRC a zero-sized buffer can be returned as null from the database
|
||||
if (content === null) {
|
||||
// this will force de/encryption
|
||||
content = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
return content;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateContentHash({ blobId, content }: Pick<BlobRow, "blobId" | "content">) {
|
||||
function calculateContentHash({ blobId, content }: Blob) {
|
||||
return hash(`${blobId}|${content.toString()}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
|
||||
import { erase as eraseService } from "@triliumnext/core";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import branchService from "./branches.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import log from "./log.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import branchService from "./branches.js";
|
||||
import { randomString } from "./utils.js";
|
||||
import eraseService from "./erase.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
|
||||
|
||||
type ActionHandler<T> = (action: T, note: BNote) => void;
|
||||
|
||||
|
||||
@@ -1,2 +1,190 @@
|
||||
import { cloning } from "@triliumnext/core";
|
||||
export default cloning;
|
||||
"use strict";
|
||||
|
||||
import sql from "./sql.js";
|
||||
import eventChangesService from "./entity_changes.js";
|
||||
import treeService from "./tree.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import log from "./log.js";
|
||||
import { CloneResponse } from "@triliumnext/commons";
|
||||
|
||||
function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse {
|
||||
if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) {
|
||||
return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." };
|
||||
}
|
||||
|
||||
const parentNote = becca.getNote(parentNoteId);
|
||||
if (!parentNote) {
|
||||
return { success: false, message: "Note cannot be cloned because the parent note could not be found." };
|
||||
}
|
||||
|
||||
if (parentNote.type === "search") {
|
||||
return {
|
||||
success: false,
|
||||
message: "Can't clone into a search note"
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(parentNoteId, noteId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
const branch = new BBranch({
|
||||
noteId: noteId,
|
||||
parentNoteId: parentNoteId,
|
||||
prefix: prefix,
|
||||
isExpanded: false
|
||||
}).save();
|
||||
|
||||
log.info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branchId: branch.branchId,
|
||||
notePath: `${parentNote.getBestNotePathString()}/${noteId}`
|
||||
};
|
||||
}
|
||||
|
||||
function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) {
|
||||
const parentBranch = becca.getBranch(parentBranchId);
|
||||
|
||||
if (!parentBranch) {
|
||||
return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` };
|
||||
}
|
||||
|
||||
const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix);
|
||||
|
||||
parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user
|
||||
parentBranch.save();
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
return { branch: null, success: false, message: `Note '${noteId}' is deleted.` };
|
||||
} else if (!(parentNoteId in becca.notes)) {
|
||||
return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` };
|
||||
}
|
||||
|
||||
const parentNote = becca.getNote(parentNoteId);
|
||||
|
||||
if (!parentNote) {
|
||||
return { branch: null, success: false, message: "Can't find parent note." };
|
||||
}
|
||||
if (parentNote.type === "search") {
|
||||
return { branch: null, success: false, message: "Can't clone into a search note" };
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(parentNoteId, noteId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
const branch = new BBranch({
|
||||
noteId: noteId,
|
||||
parentNoteId: parentNoteId,
|
||||
prefix: prefix,
|
||||
isExpanded: false
|
||||
}).save();
|
||||
|
||||
log.info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`);
|
||||
|
||||
return { branch: branch, success: true };
|
||||
}
|
||||
|
||||
function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) {
|
||||
const branchId = sql.getValue<string>(/*sql*/`SELECT branchId FROM branches WHERE noteId = ? AND parentNoteId = ? AND isDeleted = 0`, [noteId, parentNoteId]);
|
||||
const branch = becca.getBranch(branchId);
|
||||
|
||||
if (branch) {
|
||||
if (!branch.isWeak && branch.getNote().getStrongParentBranches().length <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot remove branch '${branch.branchId}' between child '${noteId}' and parent '${parentNoteId}' because this would delete the note as well.`
|
||||
};
|
||||
}
|
||||
|
||||
branch.deleteBranch();
|
||||
|
||||
log.info(`Ensured note '${noteId}' is NOT in parent note '${parentNoteId}'`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNoteInParent(present: boolean, noteId: string, parentNoteId: string, prefix?: string) {
|
||||
if (present) {
|
||||
return ensureNoteIsPresentInParent(noteId, parentNoteId, prefix);
|
||||
} else {
|
||||
return ensureNoteIsAbsentFromParent(noteId, parentNoteId);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneNoteAfter(noteId: string, afterBranchId: string) {
|
||||
if (["_hidden", "root"].includes(noteId)) {
|
||||
return { success: false, message: `Cloning the note '${noteId}' is forbidden.` };
|
||||
}
|
||||
|
||||
const afterBranch = becca.getBranch(afterBranchId);
|
||||
|
||||
if (!afterBranch) {
|
||||
return { success: false, message: `Branch '${afterBranchId}' does not exist.` };
|
||||
}
|
||||
|
||||
if (afterBranch.noteId === "_hidden") {
|
||||
return { success: false, message: "Cannot clone after the hidden branch." };
|
||||
}
|
||||
|
||||
const afterNote = becca.getBranch(afterBranchId);
|
||||
|
||||
if (!(noteId in becca.notes)) {
|
||||
return { success: false, message: `Note to be cloned '${noteId}' is deleted or does not exist.` };
|
||||
} else if (!afterNote || !(afterNote.parentNoteId in becca.notes)) {
|
||||
return { success: false, message: `After note '${afterNote?.parentNoteId}' is deleted or does not exist.` };
|
||||
}
|
||||
|
||||
const parentNote = becca.getNote(afterNote.parentNoteId);
|
||||
|
||||
if (!parentNote || parentNote.type === "search") {
|
||||
return {
|
||||
success: false,
|
||||
message: "Can't clone into a search note"
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(afterNote.parentNoteId, noteId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// we don't change utcDateModified, so other changes are prioritized in case of conflict
|
||||
// also we would have to sync all those modified branches otherwise hash checks would fail
|
||||
sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]);
|
||||
|
||||
eventChangesService.putNoteReorderingEntityChange(afterNote.parentNoteId);
|
||||
|
||||
const branch = new BBranch({
|
||||
noteId: noteId,
|
||||
parentNoteId: afterNote.parentNoteId,
|
||||
notePosition: afterNote.notePosition + 10,
|
||||
isExpanded: false
|
||||
}).save();
|
||||
|
||||
log.info(`Cloned note '${noteId}' into parent note '${afterNote.parentNoteId}' after note '${afterNote.noteId}', branch '${afterBranchId}'`);
|
||||
|
||||
return { success: true, branchId: branch.branchId };
|
||||
}
|
||||
|
||||
export default {
|
||||
cloneNoteToBranch,
|
||||
cloneNoteToParentNote,
|
||||
ensureNoteIsPresentInParent,
|
||||
ensureNoteIsAbsentFromParent,
|
||||
toggleNoteInParent,
|
||||
cloneNoteAfter
|
||||
};
|
||||
|
||||
@@ -1,79 +1,109 @@
|
||||
import clsHooked from "cls-hooked";
|
||||
import type { EntityChange } from "@triliumnext/commons";
|
||||
import { cls } from "@triliumnext/core";
|
||||
const namespace = clsHooked.createNamespace("trilium");
|
||||
|
||||
function init<T>(callback: () => T) {
|
||||
return cls.getContext().init(callback);
|
||||
type Callback = (...args: any[]) => any;
|
||||
|
||||
function init(callback: Callback) {
|
||||
return namespace.runAndReturn(callback);
|
||||
}
|
||||
|
||||
function wrap(callback: Callback) {
|
||||
return () => {
|
||||
try {
|
||||
init(callback);
|
||||
} catch (e: any) {
|
||||
console.log(`Error occurred: ${e.message}: ${e.stack}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function get(key: string) {
|
||||
return namespace.get(key);
|
||||
}
|
||||
|
||||
function set(key: string, value: any) {
|
||||
namespace.set(key, value);
|
||||
}
|
||||
|
||||
function getHoistedNoteId() {
|
||||
return cls.getHoistedNoteId();
|
||||
return namespace.get("hoistedNoteId") || "root";
|
||||
}
|
||||
|
||||
function getComponentId() {
|
||||
return cls.getComponentId();
|
||||
return namespace.get("componentId");
|
||||
}
|
||||
|
||||
function getLocalNowDateTime() {
|
||||
return namespace.get("localNowDateTime");
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
function disableEntityEvents() {
|
||||
cls.disableEntityEvents();
|
||||
namespace.set("disableEntityEvents", true);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
function enableEntityEvents() {
|
||||
cls.enableEntityEvents();
|
||||
namespace.set("disableEntityEvents", false);
|
||||
}
|
||||
|
||||
function isEntityEventsDisabled() {
|
||||
return cls.isEntityEventsDisabled();
|
||||
return !!namespace.get("disableEntityEvents");
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
function setMigrationRunning(running: boolean) {
|
||||
cls.setMigrationRunning(running);
|
||||
namespace.set("migrationRunning", !!running);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
function isMigrationRunning() {
|
||||
return cls.isMigrationRunning();
|
||||
return !!namespace.get("migrationRunning");
|
||||
}
|
||||
|
||||
function disableSlowQueryLogging(disable: boolean) {
|
||||
namespace.set("disableSlowQueryLogging", disable);
|
||||
}
|
||||
|
||||
function isSlowQueryLoggingDisabled() {
|
||||
return !!namespace.get("disableSlowQueryLogging");
|
||||
}
|
||||
|
||||
function getAndClearEntityChangeIds() {
|
||||
const entityChangeIds = cls.getContext().get("entityChangeIds") || [];
|
||||
const entityChangeIds = namespace.get("entityChangeIds") || [];
|
||||
|
||||
cls.getContext().set("entityChangeIds", []);
|
||||
namespace.set("entityChangeIds", []);
|
||||
|
||||
return entityChangeIds;
|
||||
}
|
||||
|
||||
function putEntityChange(entityChange: EntityChange) {
|
||||
cls.putEntityChange(entityChange);
|
||||
}
|
||||
if (namespace.get("ignoreEntityChangeIds")) {
|
||||
return;
|
||||
}
|
||||
|
||||
function ignoreEntityChangeIds() {
|
||||
cls.getContext().set("ignoreEntityChangeIds", true);
|
||||
}
|
||||
const entityChangeIds = namespace.get("entityChangeIds") || [];
|
||||
|
||||
function get(key: string) {
|
||||
return cls.getContext().get(key);
|
||||
}
|
||||
// store only ID since the record can be modified (e.g., in erase)
|
||||
entityChangeIds.push(entityChange.id);
|
||||
|
||||
function set(key: string, value: unknown) {
|
||||
cls.getContext().set(key, value);
|
||||
namespace.set("entityChangeIds", entityChangeIds);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
cls.getContext().reset();
|
||||
clsHooked.reset();
|
||||
}
|
||||
|
||||
export const wrap = cls.wrap;
|
||||
function ignoreEntityChangeIds() {
|
||||
namespace.set("ignoreEntityChangeIds", true);
|
||||
}
|
||||
|
||||
export default {
|
||||
init,
|
||||
wrap,
|
||||
get,
|
||||
set,
|
||||
namespace,
|
||||
getHoistedNoteId,
|
||||
getComponentId,
|
||||
getLocalNowDateTime,
|
||||
disableEntityEvents,
|
||||
enableEntityEvents,
|
||||
isEntityEventsDisabled,
|
||||
@@ -81,6 +111,8 @@ export default {
|
||||
getAndClearEntityChangeIds,
|
||||
putEntityChange,
|
||||
ignoreEntityChangeIds,
|
||||
disableSlowQueryLogging,
|
||||
isSlowQueryLoggingDisabled,
|
||||
setMigrationRunning,
|
||||
isMigrationRunning
|
||||
};
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
import type { EntityChange } from "@triliumnext/commons";
|
||||
import { becca_loader, erase as eraseService, utils } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import noteTypesService from "../services/note_types.js";
|
||||
import { hashedBlobId, randomString } from "../services/utils.js";
|
||||
import cls from "./cls.js";
|
||||
import entityChangesService from "./entity_changes.js";
|
||||
import log from "./log.js";
|
||||
import optionsService from "./options.js";
|
||||
import sql from "./sql.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import syncMutexService from "./sync_mutex.js";
|
||||
import log from "./log.js";
|
||||
import ws from "./ws.js";
|
||||
import syncMutexService from "./sync_mutex.js";
|
||||
import cls from "./cls.js";
|
||||
import entityChangesService from "./entity_changes.js";
|
||||
import optionsService from "./options.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import { hash as getHash, hashedBlobId, randomString } from "../services/utils.js";
|
||||
import eraseService from "../services/erase.js";
|
||||
import sanitizeAttributeName from "./sanitize_attribute_name.js";
|
||||
import noteTypesService from "../services/note_types.js";
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
import type { EntityChange } from "@triliumnext/commons";
|
||||
import becca_loader from "../becca/becca_loader.js";
|
||||
const noteTypes = noteTypesService.getNoteTypeNames();
|
||||
|
||||
class ConsistencyChecks {
|
||||
@@ -81,11 +84,11 @@ class ConsistencyChecks {
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`);
|
||||
|
||||
this.unrecoveredConsistencyErrors = true;
|
||||
}
|
||||
logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`);
|
||||
|
||||
this.unrecoveredConsistencyErrors = true;
|
||||
|
||||
} else {
|
||||
const newPath = path.slice();
|
||||
newPath.push(noteId);
|
||||
@@ -183,7 +186,7 @@ class ConsistencyChecks {
|
||||
if (note.getParentBranches().length === 0) {
|
||||
const newBranch = new BBranch({
|
||||
parentNoteId: "root",
|
||||
noteId,
|
||||
noteId: noteId,
|
||||
prefix: "recovered"
|
||||
}).save();
|
||||
|
||||
@@ -346,7 +349,7 @@ class ConsistencyChecks {
|
||||
if (this.autoFix) {
|
||||
const branch = new BBranch({
|
||||
parentNoteId: "root",
|
||||
noteId,
|
||||
noteId: noteId,
|
||||
prefix: "recovered"
|
||||
}).save();
|
||||
|
||||
@@ -482,18 +485,18 @@ class ConsistencyChecks {
|
||||
if (!blobAlreadyExists) {
|
||||
// manually creating row since this can also affect deleted notes
|
||||
sql.upsert("blobs", "blobId", {
|
||||
noteId,
|
||||
noteId: noteId,
|
||||
content: blankContent,
|
||||
utcDateModified: fakeDate,
|
||||
dateModified: fakeDate
|
||||
});
|
||||
|
||||
const hash = utils.hash(randomString(10));
|
||||
const hash = getHash(randomString(10));
|
||||
|
||||
entityChangesService.putEntityChange({
|
||||
entityName: "blobs",
|
||||
entityId: blobId,
|
||||
hash,
|
||||
hash: hash,
|
||||
isErased: false,
|
||||
utcDateChanged: fakeDate,
|
||||
isSynced: true
|
||||
@@ -802,7 +805,7 @@ class ConsistencyChecks {
|
||||
const attrNames = sql.getColumn<string>(/*sql*/`SELECT DISTINCT name FROM attributes`);
|
||||
|
||||
for (const origName of attrNames) {
|
||||
const fixedName = utils.sanitizeAttributeName(origName);
|
||||
const fixedName = sanitizeAttributeName(origName);
|
||||
|
||||
if (fixedName !== origName) {
|
||||
if (this.autoFix) {
|
||||
@@ -908,7 +911,7 @@ class ConsistencyChecks {
|
||||
|
||||
ws.sendMessageToAllClients({ type: "consistency-checks-failed" });
|
||||
} else {
|
||||
log.info(`All consistency checks passed ${ this.fixedIssues ? "after some fixes" : "with no errors detected" } (took ${elapsedTimeMs}ms)`);
|
||||
log.info(`All consistency checks passed ` + (this.fixedIssues ? "after some fixes" : "with no errors detected") + ` (took ${elapsedTimeMs}ms)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { erase as eraseService,utils } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import log from "./log.js";
|
||||
import sql from "./sql.js";
|
||||
import { hash } from "./utils.js";
|
||||
import log from "./log.js";
|
||||
import eraseService from "./erase.js";
|
||||
|
||||
type SectorHash = Record<string, string>;
|
||||
|
||||
@@ -46,7 +48,7 @@ function getEntityHashes() {
|
||||
|
||||
for (const entityHashMap of Object.values(hashMap)) {
|
||||
for (const key in entityHashMap) {
|
||||
entityHashMap[key] = utils.hash(entityHashMap[key]);
|
||||
entityHashMap[key] = hash(entityHashMap[key]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,107 @@
|
||||
import { date_utils } from "@triliumnext/core";
|
||||
export default date_utils;
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
import cls from "./cls.js";
|
||||
|
||||
const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ";
|
||||
const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ";
|
||||
|
||||
function utcNowDateTime() {
|
||||
return utcDateTimeStr(new Date());
|
||||
}
|
||||
|
||||
// CLS date time is important in web deployments - server often runs in different time zone than user is located in,
|
||||
// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain
|
||||
// "trilium-local-now-datetime" header which is then stored in CLS
|
||||
function localNowDateTime() {
|
||||
return cls.getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT);
|
||||
}
|
||||
|
||||
function localNowDate() {
|
||||
const clsDateTime = cls.getLocalNowDateTime();
|
||||
|
||||
if (clsDateTime) {
|
||||
return clsDateTime.substr(0, 10);
|
||||
} else {
|
||||
const date = new Date();
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
}
|
||||
|
||||
function pad(num: number) {
|
||||
return num <= 9 ? `0${num}` : `${num}`;
|
||||
}
|
||||
|
||||
function utcDateStr(date: Date) {
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function utcDateTimeStr(date: Date) {
|
||||
return date.toISOString().replace("T", " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr().
|
||||
* also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time
|
||||
*/
|
||||
function parseDateTime(str: string) {
|
||||
try {
|
||||
return new Date(Date.parse(str));
|
||||
} catch (e: any) {
|
||||
throw new Error(`Can't parse date from '${str}': ${e.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocalDate(str: string) {
|
||||
const datePart = str.substr(0, 10);
|
||||
|
||||
// not specifying the timezone and specifying the time means Date.parse() will use the local timezone
|
||||
return parseDateTime(`${datePart} 12:00:00.000`);
|
||||
}
|
||||
|
||||
function getDateTimeForFile() {
|
||||
return new Date().toISOString().substr(0, 19).replace(/:/g, "");
|
||||
}
|
||||
|
||||
function validateLocalDateTime(str: string | null | undefined) {
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) {
|
||||
return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`;
|
||||
}
|
||||
|
||||
if (!dayjs(str, LOCAL_DATETIME_FORMAT)) {
|
||||
return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`;
|
||||
}
|
||||
}
|
||||
|
||||
function validateUtcDateTime(str: string | undefined) {
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) {
|
||||
return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`;
|
||||
}
|
||||
|
||||
if (!dayjs(str, UTC_DATETIME_FORMAT)) {
|
||||
return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
LOCAL_DATETIME_FORMAT,
|
||||
UTC_DATETIME_FORMAT,
|
||||
utcNowDateTime,
|
||||
localNowDateTime,
|
||||
localNowDate,
|
||||
|
||||
utcDateStr,
|
||||
utcDateTimeStr,
|
||||
parseDateTime,
|
||||
parseLocalDate,
|
||||
getDateTimeForFile,
|
||||
validateLocalDateTime,
|
||||
validateUtcDateTime
|
||||
};
|
||||
|
||||
114
apps/server/src/services/encryption/data_encryption.ts
Normal file
114
apps/server/src/services/encryption/data_encryption.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import crypto from "crypto";
|
||||
import log from "../log.js";
|
||||
|
||||
function arraysIdentical(a: any[] | Buffer, b: any[] | Buffer) {
|
||||
let i = a.length;
|
||||
if (i !== b.length) return false;
|
||||
while (i--) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shaArray(content: crypto.BinaryLike) {
|
||||
// we use this as a simple checksum and don't rely on its security, so SHA-1 is good enough
|
||||
return crypto.createHash("sha1").update(content).digest();
|
||||
}
|
||||
|
||||
function pad(data: Buffer): Buffer {
|
||||
if (data.length > 16) {
|
||||
data = data.slice(0, 16);
|
||||
} else if (data.length < 16) {
|
||||
const zeros = Array(16 - data.length).fill(0);
|
||||
|
||||
data = Buffer.concat([data, Buffer.from(zeros)]);
|
||||
}
|
||||
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
function encrypt(key: Buffer, plainText: Buffer | string) {
|
||||
if (!key) {
|
||||
throw new Error("No data key!");
|
||||
}
|
||||
|
||||
const plainTextBuffer = Buffer.isBuffer(plainText) ? plainText : Buffer.from(plainText);
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-128-cbc", pad(key), pad(iv));
|
||||
|
||||
const digest = shaArray(plainTextBuffer).slice(0, 4);
|
||||
|
||||
const digestWithPayload = Buffer.concat([digest, plainTextBuffer]);
|
||||
|
||||
const encryptedData = Buffer.concat([cipher.update(digestWithPayload), cipher.final()]);
|
||||
|
||||
const encryptedDataWithIv = Buffer.concat([iv, encryptedData]);
|
||||
|
||||
return encryptedDataWithIv.toString("base64");
|
||||
}
|
||||
|
||||
function decrypt(key: Buffer, cipherText: string | Buffer): Buffer | false | null {
|
||||
if (cipherText === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return Buffer.from("[protected]");
|
||||
}
|
||||
|
||||
try {
|
||||
const cipherTextBufferWithIv = Buffer.from(cipherText.toString(), "base64");
|
||||
|
||||
// old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017
|
||||
const ivLength = cipherTextBufferWithIv.length % 16 === 0 ? 16 : 13;
|
||||
|
||||
const iv = cipherTextBufferWithIv.slice(0, ivLength);
|
||||
|
||||
const cipherTextBuffer = cipherTextBufferWithIv.slice(ivLength);
|
||||
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", pad(key), pad(iv));
|
||||
|
||||
const decryptedBytes = Buffer.concat([decipher.update(cipherTextBuffer), decipher.final()]);
|
||||
|
||||
const digest = decryptedBytes.slice(0, 4);
|
||||
const payload = decryptedBytes.slice(4);
|
||||
|
||||
const computedDigest = shaArray(payload).slice(0, 4);
|
||||
|
||||
if (!arraysIdentical(digest, computedDigest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (e: any) {
|
||||
// recovery from https://github.com/zadam/trilium/issues/510
|
||||
if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) {
|
||||
log.info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead");
|
||||
|
||||
return (Buffer.isBuffer(cipherText) ? cipherText : Buffer.from(cipherText));
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decryptString(dataKey: Buffer, cipherText: string) {
|
||||
const buffer = decrypt(dataKey, cipherText);
|
||||
|
||||
if (buffer === null) {
|
||||
return null;
|
||||
} else if (buffer === false) {
|
||||
log.error(`Could not decrypt string. Buffer: ${buffer}`);
|
||||
|
||||
throw new Error("Could not decrypt string.");
|
||||
}
|
||||
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
|
||||
export default {
|
||||
encrypt,
|
||||
decrypt,
|
||||
decryptString
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { data_encryption, OpenIdError } from "@triliumnext/core";
|
||||
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
import utils, { constantTimeCompare } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.js";
|
||||
import sql from "../sql.js";
|
||||
import sqlInit from "../sql_init.js";
|
||||
import utils, { constantTimeCompare } from "../utils.js";
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
import OpenIdError from "../../errors/open_id_error.js";
|
||||
|
||||
function saveUser(subjectIdentifier: string, name: string, email: string) {
|
||||
if (isUserSaved()) return false;
|
||||
@@ -16,7 +16,7 @@ function saveUser(subjectIdentifier: string, name: string, email: string) {
|
||||
verificationSalt
|
||||
);
|
||||
if (!verificationHash) {
|
||||
throw new OpenIdError("Verification hash undefined!");
|
||||
throw new OpenIdError("Verification hash undefined!")
|
||||
}
|
||||
|
||||
const userIDEncryptedDataKey = setDataKey(
|
||||
@@ -35,10 +35,10 @@ function saveUser(subjectIdentifier: string, name: string, email: string) {
|
||||
userIDVerificationHash: utils.toBase64(verificationHash),
|
||||
salt: verificationSalt,
|
||||
derivedKey: derivedKeySalt,
|
||||
userIDEncryptedDataKey,
|
||||
userIDEncryptedDataKey: userIDEncryptedDataKey,
|
||||
isSetup: "true",
|
||||
username: name,
|
||||
email
|
||||
email: email
|
||||
};
|
||||
|
||||
sql.upsert("user_data", "tmpID", data);
|
||||
@@ -53,7 +53,7 @@ function isSubjectIdentifierSaved() {
|
||||
|
||||
function isUserSaved() {
|
||||
const isSaved = sql.getValue<string>("SELECT isSetup FROM user_data;");
|
||||
return isSaved === "true";
|
||||
return isSaved === "true" ? true : false;
|
||||
}
|
||||
|
||||
function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) {
|
||||
@@ -102,7 +102,7 @@ function setDataKey(
|
||||
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
|
||||
return undefined;
|
||||
}
|
||||
const newEncryptedDataKey = data_encryption.encrypt(
|
||||
const newEncryptedDataKey = dataEncryptionService.encrypt(
|
||||
subjectIdentifierDerivedKey,
|
||||
plainTextDataKey
|
||||
);
|
||||
@@ -127,7 +127,7 @@ function getDataKey(subjectIdentifier: string) {
|
||||
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
|
||||
return undefined;
|
||||
}
|
||||
const decryptedDataKey = data_encryption.decrypt(
|
||||
const decryptedDataKey = dataEncryptionService.decrypt(
|
||||
subjectIdentifierDerivedKey,
|
||||
encryptedDataKey.toString()
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { data_encryption } from "@triliumnext/core";
|
||||
|
||||
import optionService from "../options.js";
|
||||
import { constantTimeCompare,toBase64 } from "../utils.js";
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
import { toBase64, constantTimeCompare } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.js";
|
||||
|
||||
function verifyPassword(password: string) {
|
||||
const givenPasswordHash = toBase64(myScryptService.getVerificationHash(password));
|
||||
@@ -16,10 +15,10 @@ function verifyPassword(password: string) {
|
||||
return constantTimeCompare(givenPasswordHash, dbPasswordHash);
|
||||
}
|
||||
|
||||
function setDataKey(password: string, plainTextDataKey: string | Buffer | Uint8Array) {
|
||||
function setDataKey(password: string, plainTextDataKey: string | Buffer) {
|
||||
const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password);
|
||||
|
||||
const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey);
|
||||
const newEncryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, plainTextDataKey);
|
||||
|
||||
optionService.setOption("encryptedDataKey", newEncryptedDataKey);
|
||||
}
|
||||
@@ -29,7 +28,7 @@ function getDataKey(password: string) {
|
||||
|
||||
const encryptedDataKey = optionService.getOption("encryptedDataKey");
|
||||
|
||||
const decryptedDataKey = data_encryption.decrypt(passwordDerivedKey, encryptedDataKey);
|
||||
const decryptedDataKey = dataEncryptionService.decrypt(passwordDerivedKey, encryptedDataKey);
|
||||
|
||||
return decryptedDataKey;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
import { data_encryption } from "@triliumnext/core";
|
||||
|
||||
import optionService from "../options.js";
|
||||
import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.js";
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
const TOTP_OPTIONS: Record<string, OptionNames> = {
|
||||
SALT: "totpEncryptionSalt",
|
||||
@@ -33,7 +32,7 @@ function setTotpSecret(secret: string) {
|
||||
const verificationHash = toBase64(myScryptService.getVerificationHash(secret));
|
||||
optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash);
|
||||
|
||||
const encryptedSecret = data_encryption.encrypt(
|
||||
const encryptedSecret = dataEncryptionService.encrypt(
|
||||
Buffer.from(encryptionSalt),
|
||||
secret
|
||||
);
|
||||
@@ -49,7 +48,7 @@ function getTotpSecret(): string | null {
|
||||
}
|
||||
|
||||
try {
|
||||
const decryptedSecret = data_encryption.decrypt(
|
||||
const decryptedSecret = dataEncryptionService.decrypt(
|
||||
Buffer.from(encryptionSalt),
|
||||
encryptedSecret
|
||||
);
|
||||
|
||||
@@ -1,2 +1,208 @@
|
||||
import { entity_changes } from "@triliumnext/core";
|
||||
export default entity_changes;
|
||||
import sql from "./sql.js";
|
||||
import dateUtils from "./date_utils.js";
|
||||
import log from "./log.js";
|
||||
import cls from "./cls.js";
|
||||
import { randomString } from "./utils.js";
|
||||
import instanceId from "./instance_id.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import blobService from "../services/blob.js";
|
||||
import type { EntityChange } from "@triliumnext/commons";
|
||||
import type { Blob } from "./blob-interface.js";
|
||||
import eventService from "./events.js";
|
||||
|
||||
let maxEntityChangeId = 0;
|
||||
|
||||
function putEntityChangeWithInstanceId(origEntityChange: EntityChange, instanceId: string) {
|
||||
const ec = { ...origEntityChange, instanceId };
|
||||
|
||||
putEntityChange(ec);
|
||||
}
|
||||
|
||||
function putEntityChangeWithForcedChange(origEntityChange: EntityChange) {
|
||||
const ec = { ...origEntityChange, changeId: null };
|
||||
|
||||
putEntityChange(ec);
|
||||
}
|
||||
|
||||
function putEntityChange(origEntityChange: EntityChange) {
|
||||
const ec = { ...origEntityChange };
|
||||
|
||||
delete ec.id;
|
||||
|
||||
if (!ec.changeId) {
|
||||
ec.changeId = randomString(12);
|
||||
}
|
||||
|
||||
ec.componentId = ec.componentId || cls.getComponentId() || "NA"; // NA = not available
|
||||
ec.instanceId = ec.instanceId || instanceId;
|
||||
ec.isSynced = ec.isSynced ? 1 : 0;
|
||||
ec.isErased = ec.isErased ? 1 : 0;
|
||||
ec.id = sql.replace("entity_changes", ec);
|
||||
|
||||
if (ec.id) {
|
||||
maxEntityChangeId = Math.max(maxEntityChangeId, ec.id);
|
||||
}
|
||||
|
||||
cls.putEntityChange(ec);
|
||||
}
|
||||
|
||||
function putNoteReorderingEntityChange(parentNoteId: string, componentId?: string) {
|
||||
putEntityChange({
|
||||
entityName: "note_reordering",
|
||||
entityId: parentNoteId,
|
||||
hash: "N/A",
|
||||
isErased: false,
|
||||
utcDateChanged: dateUtils.utcNowDateTime(),
|
||||
isSynced: true,
|
||||
componentId,
|
||||
instanceId
|
||||
});
|
||||
|
||||
eventService.emit(eventService.ENTITY_CHANGED, {
|
||||
entityName: "note_reordering",
|
||||
entity: sql.getMap(/*sql*/`SELECT branchId, notePosition FROM branches WHERE isDeleted = 0 AND parentNoteId = ?`, [parentNoteId])
|
||||
});
|
||||
}
|
||||
|
||||
function putEntityChangeForOtherInstances(ec: EntityChange) {
|
||||
putEntityChange({
|
||||
...ec,
|
||||
changeId: null,
|
||||
instanceId: null
|
||||
});
|
||||
}
|
||||
|
||||
function addEntityChangesForSector(entityName: string, sector: string) {
|
||||
const entityChanges = sql.getRows<EntityChange>(/*sql*/`SELECT * FROM entity_changes WHERE entityName = ? AND SUBSTR(entityId, 1, 1) = ?`, [entityName, sector]);
|
||||
|
||||
let entitiesInserted = entityChanges.length;
|
||||
|
||||
sql.transactional(() => {
|
||||
if (entityName === "blobs") {
|
||||
entitiesInserted += addEntityChangesForDependingEntity(sector, "notes", "noteId");
|
||||
entitiesInserted += addEntityChangesForDependingEntity(sector, "attachments", "attachmentId");
|
||||
entitiesInserted += addEntityChangesForDependingEntity(sector, "revisions", "revisionId");
|
||||
}
|
||||
|
||||
for (const ec of entityChanges) {
|
||||
putEntityChangeWithForcedChange(ec);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Added sector ${sector} of '${entityName}' (${entitiesInserted} entities) to the sync queue.`);
|
||||
}
|
||||
|
||||
function addEntityChangesForDependingEntity(sector: string, tableName: string, primaryKeyColumn: string) {
|
||||
// problem in blobs might be caused by problem in entity referencing the blob
|
||||
const dependingEntityChanges = sql.getRows<EntityChange>(
|
||||
`
|
||||
SELECT dep_change.*
|
||||
FROM entity_changes orig_sector
|
||||
JOIN ${tableName} ON ${tableName}.blobId = orig_sector.entityId
|
||||
JOIN entity_changes dep_change ON dep_change.entityName = '${tableName}' AND dep_change.entityId = ${tableName}.${primaryKeyColumn}
|
||||
WHERE orig_sector.entityName = 'blobs' AND SUBSTR(orig_sector.entityId, 1, 1) = ?`,
|
||||
[sector]
|
||||
);
|
||||
|
||||
for (const ec of dependingEntityChanges) {
|
||||
putEntityChangeWithForcedChange(ec);
|
||||
}
|
||||
|
||||
return dependingEntityChanges.length;
|
||||
}
|
||||
|
||||
function cleanupEntityChangesForMissingEntities(entityName: string, entityPrimaryKey: string) {
|
||||
sql.execute(`
|
||||
DELETE
|
||||
FROM entity_changes
|
||||
WHERE
|
||||
isErased = 0
|
||||
AND entityName = '${entityName}'
|
||||
AND entityId NOT IN (SELECT ${entityPrimaryKey} FROM ${entityName})`);
|
||||
}
|
||||
|
||||
function fillEntityChanges(entityName: string, entityPrimaryKey: string, condition = "") {
|
||||
cleanupEntityChangesForMissingEntities(entityName, entityPrimaryKey);
|
||||
|
||||
sql.transactional(() => {
|
||||
const entityIds = sql.getColumn<string>(/*sql*/`SELECT ${entityPrimaryKey} FROM ${entityName} ${condition}`);
|
||||
|
||||
let createdCount = 0;
|
||||
|
||||
for (const entityId of entityIds) {
|
||||
const existingRows = sql.getValue("SELECT COUNT(1) FROM entity_changes WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
|
||||
|
||||
if (existingRows !== 0) {
|
||||
// we don't want to replace existing entities (which would effectively cause full resync)
|
||||
continue;
|
||||
}
|
||||
|
||||
createdCount++;
|
||||
|
||||
const ec: Partial<EntityChange> = {
|
||||
entityName,
|
||||
entityId,
|
||||
isErased: false
|
||||
};
|
||||
|
||||
if (entityName === "blobs") {
|
||||
const blob = sql.getRow<Blob>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]);
|
||||
ec.hash = blobService.calculateContentHash(blob);
|
||||
ec.utcDateChanged = blob.utcDateModified;
|
||||
ec.isSynced = true; // blobs are always synced
|
||||
} else {
|
||||
const entity = becca.getEntity(entityName, entityId);
|
||||
|
||||
if (entity) {
|
||||
ec.hash = entity.generateHash();
|
||||
ec.utcDateChanged = entity.getUtcDateChanged() || dateUtils.utcNowDateTime();
|
||||
ec.isSynced = entityName !== "options" || !!entity.isSynced;
|
||||
} else {
|
||||
// entity might be null (not present in becca) when it's deleted
|
||||
// this will produce different hash value than when entity is being deleted since then
|
||||
// all normal hashed attributes are being used. Sync should recover from that, though.
|
||||
ec.hash = "deleted";
|
||||
ec.utcDateChanged = dateUtils.utcNowDateTime();
|
||||
ec.isSynced = true; // deletable (the ones with isDeleted) entities are synced
|
||||
}
|
||||
}
|
||||
|
||||
putEntityChange(ec as EntityChange);
|
||||
}
|
||||
|
||||
if (createdCount > 0) {
|
||||
log.info(`Created ${createdCount} missing entity changes for entity '${entityName}'.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fillAllEntityChanges() {
|
||||
sql.transactional(() => {
|
||||
sql.execute("DELETE FROM entity_changes WHERE isErased = 0");
|
||||
|
||||
fillEntityChanges("notes", "noteId");
|
||||
fillEntityChanges("branches", "branchId");
|
||||
fillEntityChanges("revisions", "revisionId");
|
||||
fillEntityChanges("attachments", "attachmentId");
|
||||
fillEntityChanges("blobs", "blobId");
|
||||
fillEntityChanges("attributes", "attributeId");
|
||||
fillEntityChanges("etapi_tokens", "etapiTokenId");
|
||||
fillEntityChanges("options", "name", "WHERE isSynced = 1");
|
||||
});
|
||||
}
|
||||
|
||||
function recalculateMaxEntityChangeId() {
|
||||
maxEntityChangeId = sql.getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes");
|
||||
}
|
||||
|
||||
export default {
|
||||
putNoteReorderingEntityChange,
|
||||
putEntityChangeForOtherInstances,
|
||||
putEntityChangeWithForcedChange,
|
||||
putEntityChange,
|
||||
putEntityChangeWithInstanceId,
|
||||
fillAllEntityChanges,
|
||||
addEntityChangesForSector,
|
||||
getMaxEntityChangeId: () => maxEntityChangeId,
|
||||
recalculateMaxEntityChangeId
|
||||
};
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { getLog } from "./log.js";
|
||||
import sql from "./sql.js";
|
||||
import log from "./log.js";
|
||||
import entityChangesService from "./entity_changes.js";
|
||||
import optionService from "./options.js";
|
||||
import dateUtils from "./utils/date.js";
|
||||
import * as sqlInit from "./sql_init.js";
|
||||
import * as cls from "./context.js";
|
||||
import dateUtils from "./date_utils.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import cls from "./cls.js";
|
||||
import type { EntityChange } from "@triliumnext/commons";
|
||||
import { getSql } from "./sql/index.js";
|
||||
|
||||
function eraseNotes(noteIdsToErase: string[]) {
|
||||
if (noteIdsToErase.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = getSql();
|
||||
sql.executeMany(/*sql*/`DELETE FROM notes WHERE noteId IN (???)`, noteIdsToErase);
|
||||
setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'notes' AND entityId IN (???)`, noteIdsToErase));
|
||||
|
||||
@@ -29,7 +28,8 @@ function eraseNotes(noteIdsToErase: string[]) {
|
||||
|
||||
eraseRevisions(revisionIdsToErase);
|
||||
|
||||
getLog().info(`Erased notes: ${JSON.stringify(noteIdsToErase)}`);
|
||||
|
||||
log.info(`Erased notes: ${JSON.stringify(noteIdsToErase)}`);
|
||||
}
|
||||
|
||||
function setEntityChangesAsErased(entityChanges: EntityChange[]) {
|
||||
@@ -48,12 +48,11 @@ function eraseBranches(branchIdsToErase: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = getSql();
|
||||
sql.executeMany(/*sql*/`DELETE FROM branches WHERE branchId IN (???)`, branchIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'branches' AND entityId IN (???)`, branchIdsToErase));
|
||||
|
||||
getLog().info(`Erased branches: ${JSON.stringify(branchIdsToErase)}`);
|
||||
log.info(`Erased branches: ${JSON.stringify(branchIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseAttributes(attributeIdsToErase: string[]) {
|
||||
@@ -61,12 +60,11 @@ function eraseAttributes(attributeIdsToErase: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = getSql();
|
||||
sql.executeMany(/*sql*/`DELETE FROM attributes WHERE attributeId IN (???)`, attributeIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'attributes' AND entityId IN (???)`, attributeIdsToErase));
|
||||
|
||||
getLog().info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`);
|
||||
log.info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseAttachments(attachmentIdsToErase: string[]) {
|
||||
@@ -74,12 +72,11 @@ function eraseAttachments(attachmentIdsToErase: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = getSql();
|
||||
sql.executeMany(/*sql*/`DELETE FROM attachments WHERE attachmentId IN (???)`, attachmentIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'attachments' AND entityId IN (???)`, attachmentIdsToErase));
|
||||
|
||||
getLog().info(`Erased attachments: ${JSON.stringify(attachmentIdsToErase)}`);
|
||||
log.info(`Erased attachments: ${JSON.stringify(attachmentIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseRevisions(revisionIdsToErase: string[]) {
|
||||
@@ -87,16 +84,14 @@ function eraseRevisions(revisionIdsToErase: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = getSql();
|
||||
sql.executeMany(/*sql*/`DELETE FROM revisions WHERE revisionId IN (???)`, revisionIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'revisions' AND entityId IN (???)`, revisionIdsToErase));
|
||||
|
||||
getLog().info(`Removed revisions: ${JSON.stringify(revisionIdsToErase)}`);
|
||||
log.info(`Removed revisions: ${JSON.stringify(revisionIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseUnusedBlobs() {
|
||||
const sql = getSql();
|
||||
const unusedBlobIds = sql.getColumn(`
|
||||
SELECT blobs.blobId
|
||||
FROM blobs
|
||||
@@ -116,11 +111,10 @@ function eraseUnusedBlobs() {
|
||||
// this is because technically every keystroke can create a new blob and there would be just too many
|
||||
sql.executeMany(/*sql*/`DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId IN (???)`, unusedBlobIds);
|
||||
|
||||
getLog().info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`);
|
||||
log.info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`);
|
||||
}
|
||||
|
||||
function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds: number | null = null) {
|
||||
const sql = getSql();
|
||||
// this is important also so that the erased entity changes are sent to the connected clients
|
||||
sql.transactional(() => {
|
||||
if (eraseEntitiesAfterTimeInSeconds === null) {
|
||||
@@ -146,7 +140,6 @@ function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds: number | null = n
|
||||
}
|
||||
|
||||
function eraseNotesWithDeleteId(deleteId: string) {
|
||||
const sql = getSql();
|
||||
const noteIdsToErase = sql.getColumn<string>("SELECT noteId FROM notes WHERE isDeleted = 1 AND deleteId = ?", [deleteId]);
|
||||
eraseNotes(noteIdsToErase);
|
||||
|
||||
@@ -177,12 +170,13 @@ function eraseScheduledAttachments(eraseUnusedAttachmentsAfterSeconds: number |
|
||||
}
|
||||
|
||||
const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - eraseUnusedAttachmentsAfterSeconds * 1000));
|
||||
const attachmentIdsToErase = getSql().getColumn<string>("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]);
|
||||
const attachmentIdsToErase = sql.getColumn<string>("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]);
|
||||
|
||||
eraseAttachments(attachmentIdsToErase);
|
||||
}
|
||||
|
||||
function startScheduledCleanup() {
|
||||
|
||||
export function startScheduledCleanup() {
|
||||
sqlInit.dbReady.then(() => {
|
||||
// first cleanup kickoff 5 minutes after startup
|
||||
setTimeout(
|
||||
@@ -211,6 +205,5 @@ export default {
|
||||
eraseNotesWithDeleteId,
|
||||
eraseUnusedBlobs,
|
||||
eraseAttachments,
|
||||
eraseRevisions,
|
||||
startScheduledCleanup
|
||||
eraseRevisions
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import log, { getLog } from "./log.js";
|
||||
import log from "./log.js";
|
||||
|
||||
const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED";
|
||||
const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION";
|
||||
@@ -52,7 +52,7 @@ function emit(eventType: string, data?: any) {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (e: any) {
|
||||
getLog().error(`Listener threw error: ${e.message}, stack: ${e.stack}`);
|
||||
log.error(`Listener threw error: ${e.message}, stack: ${e.stack}`);
|
||||
// we won't stop execution because of listener
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
import type { Response } from "express";
|
||||
import html from "html";
|
||||
import mimeTypes from "mime-types";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
import { escapeHtml,getContentDisposition } from "../utils.js";
|
||||
import html from "html";
|
||||
import { getContentDisposition, escapeHtml } from "../utils.js";
|
||||
import mdService from "./markdown.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||
@@ -35,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
export function mapByNoteType(note: BNote, content: string | Uint8Array, format: ExportFormat) {
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||
let payload, extension, mime;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import archiver from "archiver";
|
||||
import type { Response } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sanitize from "sanitize-filename";
|
||||
"use strict";
|
||||
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import becca from "../../becca/becca.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
import becca from "../../becca/becca.js";
|
||||
import archiver from "archiver";
|
||||
import log from "../log.js";
|
||||
import TaskContext from "../task_context.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import TaskContext from "../task_context.js";
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import HtmlExportProvider from "./zip/html.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import MarkdownExportProvider from "./zip/markdown.js";
|
||||
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown", "share"].includes(format)) {
|
||||
@@ -72,11 +73,11 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
} while (newName in existingFileNames);
|
||||
|
||||
return `${index}_${fileName}`;
|
||||
} else {
|
||||
existingFileNames[lcFileName] = 1;
|
||||
|
||||
return fileName;
|
||||
}
|
||||
existingFileNames[lcFileName] = 1;
|
||||
|
||||
return fileName;
|
||||
|
||||
}
|
||||
|
||||
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
@@ -88,15 +89,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
// Crop fileName to avoid its length exceeding 30 and prevent cutting into the extension.
|
||||
if (fileName.length > 30) {
|
||||
// We use regex to match the extension to preserve multiple dots in extensions (e.g. .tar.gz).
|
||||
const match = fileName.match(/(\.[a-zA-Z0-9_.!#-]+)$/);
|
||||
const ext = match ? match[0] : "";
|
||||
let match = fileName.match(/(\.[a-zA-Z0-9_.!#-]+)$/);
|
||||
let ext = match ? match[0] : "";
|
||||
// Crop the extension if extension length exceeds 30
|
||||
const croppedExt = ext.slice(-30);
|
||||
// Crop the file name section and append the cropped extension
|
||||
fileName = fileName.slice(0, 30 - croppedExt.length) + croppedExt;
|
||||
}
|
||||
|
||||
const existingExtension = path.extname(fileName).toLowerCase();
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||
|
||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||
@@ -139,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const meta: NoteMeta = {
|
||||
isClone: true,
|
||||
noteId: note.noteId,
|
||||
notePath,
|
||||
notePath: notePath,
|
||||
title: note.getTitleOrProtected(),
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName,
|
||||
@@ -197,16 +198,16 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
meta.attachments = attachments
|
||||
.toSorted((a, b) => ((a.attachmentId ?? "").localeCompare(b.attachmentId ?? "", "en") ?? 1))
|
||||
.map((attachment) => {
|
||||
const attMeta: AttachmentMeta = {
|
||||
attachmentId: attachment.attachmentId,
|
||||
title: attachment.title,
|
||||
role: attachment.role,
|
||||
mime: attachment.mime,
|
||||
position: attachment.position,
|
||||
dataFileName: getDataFileName(null, attachment.mime, `${baseFileName }_${ attachment.title}`, existingFileNames)
|
||||
};
|
||||
return attMeta;
|
||||
});
|
||||
const attMeta: AttachmentMeta = {
|
||||
attachmentId: attachment.attachmentId,
|
||||
title: attachment.title,
|
||||
role: attachment.role,
|
||||
mime: attachment.mime,
|
||||
position: attachment.position,
|
||||
dataFileName: getDataFileName(null, attachment.mime, baseFileName + "_" + attachment.title, existingFileNames)
|
||||
};
|
||||
return attMeta;
|
||||
});
|
||||
|
||||
if (childBranches.length > 0) {
|
||||
meta.dirFileName = getUniqueFilename(existingFileNames, baseFileName);
|
||||
@@ -318,7 +319,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note?: BNote): string | Uint8Array {
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||
if (isText) {
|
||||
content = content.toString();
|
||||
@@ -339,11 +340,11 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
if (noteMeta.isClone) {
|
||||
const targetUrl = getNoteTargetUrl(noteMeta.noteId, noteMeta);
|
||||
|
||||
let content: string | Uint8Array = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
|
||||
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||
|
||||
archive.append(content as Buffer, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -359,7 +360,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||
|
||||
archive.append(content as string | Buffer, {
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + noteMeta.dataFileName,
|
||||
date: dateUtils.parseDateTime(note.utcDateModified)
|
||||
});
|
||||
@@ -375,7 +376,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const attachment = note.getAttachmentById(attachmentMeta.attachmentId);
|
||||
const content = attachment.getContent();
|
||||
|
||||
archive.append(content as Buffer, {
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + attachmentMeta.dataFileName,
|
||||
date: dateUtils.parseDateTime(note.utcDateModified)
|
||||
});
|
||||
@@ -420,9 +421,9 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
} else if (attr.value === "root" || attr.value?.startsWith("_")) {
|
||||
// relations to "named" noteIds can be preserved
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export abstract class ZipExportProvider {
|
||||
}
|
||||
|
||||
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||
abstract prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array;
|
||||
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||
abstract afterDone(rootMeta: NoteMeta): void;
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class HtmlExportProvider extends ZipExportProvider {
|
||||
metaFile.files.push(this.cssMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array {
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import NoteMeta from "../../meta/note_meta";
|
||||
import NoteMeta from "../../meta/note_meta"
|
||||
import { ZipExportProvider } from "./abstract_provider.js"
|
||||
import mdService from "../markdown.js";
|
||||
import { ZipExportProvider } from "./abstract_provider.js";
|
||||
|
||||
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||
|
||||
prepareMeta() { }
|
||||
|
||||
prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array {
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
content = this.rewriteFn(content, noteMeta);
|
||||
content = mdService.toMarkdown(content);
|
||||
|
||||
@@ -61,7 +61,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array {
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
const note = this.branch.getNote();
|
||||
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||
this.archive.append(fullHtml as Buffer, { name: this.indexMeta.dataFileName });
|
||||
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||
@@ -166,7 +166,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
// Inject the custom fonts.
|
||||
for (const iconPack of this.iconPacks) {
|
||||
const extension = MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime];
|
||||
let fontData: Uint8Array | undefined;
|
||||
let fontData: Buffer | undefined;
|
||||
if (iconPack.builtin) {
|
||||
fontData = readFileSync(join(getClientDir(), "fonts", `${iconPack.fontAttachmentId}.${extension}`));
|
||||
} else {
|
||||
@@ -178,7 +178,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
continue;
|
||||
};
|
||||
const fontFileName = `assets/icon-pack-${iconPack.prefix.toLowerCase()}.${extension}`;
|
||||
this.archive.append(fontData as Buffer, { name: fontFileName });
|
||||
this.archive.append(fontData, { name: fontFileName });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,251 @@
|
||||
import { handlers } from "@triliumnext/core";
|
||||
export default handlers;
|
||||
import eventService from "./events.js";
|
||||
import scriptService from "./script.js";
|
||||
import treeService from "./tree.js";
|
||||
import noteService from "./notes.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import hiddenSubtreeService from "./hidden_subtree.js";
|
||||
import oneTimeTimer from "./one_time_timer.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
|
||||
|
||||
type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void;
|
||||
|
||||
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the same script note can get here with multiple ways, but execute only once
|
||||
const notesToRun = new Set(
|
||||
note
|
||||
.getRelations(relationName)
|
||||
.map((relation) => relation.getTargetNote())
|
||||
.filter((note) => !!note) as BNote[]
|
||||
);
|
||||
|
||||
for (const noteToRun of notesToRun) {
|
||||
scriptService.executeNoteNoException(noteToRun, { originEntity });
|
||||
}
|
||||
}
|
||||
|
||||
eventService.subscribe(eventService.NOTE_TITLE_CHANGED, (note) => {
|
||||
runAttachedRelations(note, "runOnNoteTitleChange", note);
|
||||
|
||||
if (!note.isRoot()) {
|
||||
const noteFromCache = becca.notes[note.noteId];
|
||||
|
||||
if (!noteFromCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const parentNote of noteFromCache.parents) {
|
||||
if (parentNote.hasLabel("sorted")) {
|
||||
treeService.sortNotesIfNeeded(parentNote.noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED], ({ entityName, entity }) => {
|
||||
if (entityName === "attributes") {
|
||||
runAttachedRelations(entity.getNote(), "runOnAttributeChange", entity);
|
||||
|
||||
if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) {
|
||||
handleSortedAttribute(entity);
|
||||
} else if (entity.type === "label") {
|
||||
handleMaybeSortingLabel(entity);
|
||||
}
|
||||
} else if (entityName === "notes") {
|
||||
// ENTITY_DELETED won't trigger anything since all branches/attributes are already deleted at this point
|
||||
runAttachedRelations(entity, "runOnNoteChange", entity);
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => {
|
||||
if (entityName === "branches") {
|
||||
const parentNote = becca.getNote(entity.parentNoteId);
|
||||
|
||||
if (parentNote?.hasLabel("sorted")) {
|
||||
treeService.sortNotesIfNeeded(parentNote.noteId);
|
||||
}
|
||||
|
||||
const childNote = becca.getNote(entity.noteId);
|
||||
|
||||
if (childNote) {
|
||||
runAttachedRelations(childNote, "runOnBranchChange", entity);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.NOTE_CONTENT_CHANGE, ({ entity }) => {
|
||||
runAttachedRelations(entity, "runOnNoteContentChange", entity);
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) => {
|
||||
if (entityName === "attributes") {
|
||||
runAttachedRelations(entity.getNote(), "runOnAttributeCreation", entity);
|
||||
|
||||
if (entity.type === "relation" && entity.name === "template") {
|
||||
const note = becca.getNote(entity.noteId);
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateNote = becca.getNote(entity.value);
|
||||
|
||||
if (!templateNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = note.getContent();
|
||||
|
||||
if (
|
||||
["text", "code", "mermaid", "canvas", "relationMap", "mindMap", "webView"].includes(note.type) &&
|
||||
typeof content === "string" &&
|
||||
// if the note has already content we're not going to overwrite it with template's one
|
||||
(!content || content.trim().length === 0) &&
|
||||
templateNote.hasStringContent()
|
||||
) {
|
||||
const templateNoteContent = templateNote.getContent();
|
||||
|
||||
if (templateNoteContent) {
|
||||
note.setContent(templateNoteContent);
|
||||
}
|
||||
|
||||
note.type = templateNote.type;
|
||||
note.mime = templateNote.mime;
|
||||
note.save();
|
||||
}
|
||||
|
||||
// we'll copy the children notes only if there's none so far
|
||||
// this protects against e.g. multiple assignment of template relation resulting in having multiple copies of the subtree
|
||||
if (note.getChildNotes().length === 0 && !note.isDescendantOfNote(templateNote.noteId)) {
|
||||
noteService.duplicateSubtreeWithoutRoot(templateNote.noteId, note.noteId);
|
||||
}
|
||||
} else if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) {
|
||||
handleSortedAttribute(entity);
|
||||
} else if (entity.type === "label") {
|
||||
handleMaybeSortingLabel(entity);
|
||||
}
|
||||
} else if (entityName === "branches") {
|
||||
runAttachedRelations(entity.getNote(), "runOnBranchCreation", entity);
|
||||
|
||||
if (entity.parentNote?.hasLabel("sorted")) {
|
||||
treeService.sortNotesIfNeeded(entity.parentNoteId);
|
||||
}
|
||||
} else if (entityName === "notes") {
|
||||
runAttachedRelations(entity, "runOnNoteCreation", entity);
|
||||
}
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.CHILD_NOTE_CREATED, ({ parentNote, childNote }) => {
|
||||
runAttachedRelations(parentNote, "runOnChildNoteCreation", childNote);
|
||||
});
|
||||
|
||||
function processInverseRelations(entityName: string, entity: BAttribute, handler: Handler) {
|
||||
if (entityName === "attributes" && entity.type === "relation") {
|
||||
const note = entity.getNote();
|
||||
const relDefinitions = note.getLabels(`relation:${entity.name}`);
|
||||
|
||||
for (const relDefinition of relDefinitions) {
|
||||
const definition = relDefinition.getDefinition();
|
||||
|
||||
if (definition.inverseRelation && definition.inverseRelation.trim()) {
|
||||
const targetNote = entity.getTargetNote();
|
||||
|
||||
if (targetNote) {
|
||||
handler(definition, note, targetNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortedAttribute(entity: BAttribute) {
|
||||
treeService.sortNotesIfNeeded(entity.noteId);
|
||||
|
||||
if (entity.isInheritable) {
|
||||
const note = becca.notes[entity.noteId];
|
||||
|
||||
if (note) {
|
||||
for (const noteId of note.getSubtreeNoteIds()) {
|
||||
treeService.sortNotesIfNeeded(noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMaybeSortingLabel(entity: BAttribute) {
|
||||
// check if this label is used for sorting, if yes force re-sort
|
||||
const note = becca.notes[entity.noteId];
|
||||
|
||||
// this will not work on deleted notes, but in that case we don't really need to re-sort
|
||||
if (note) {
|
||||
for (const parentNote of note.getParentNotes()) {
|
||||
const sorted = parentNote.getLabelValue("sorted");
|
||||
if (sorted === null) {
|
||||
// checking specifically for null since that means the label doesn't exist
|
||||
// empty valued "sorted" is still valid
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sorted.includes(entity.name) || // hacky check if this label is used in the sort
|
||||
entity.name === "top" ||
|
||||
entity.name === "bottom"
|
||||
) {
|
||||
treeService.sortNotesIfNeeded(parentNote.noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => {
|
||||
processInverseRelations(entityName, entity, (definition, note, targetNote) => {
|
||||
// we need to make sure that also target's inverse attribute exists and if not, then create it
|
||||
// inverse attribute has to target our note as well
|
||||
const hasInverseAttribute = targetNote.getRelations(definition.inverseRelation).some((attr) => attr.value === note.noteId);
|
||||
|
||||
if (!hasInverseAttribute) {
|
||||
new BAttribute({
|
||||
noteId: targetNote.noteId,
|
||||
type: "relation",
|
||||
name: definition.inverseRelation || "",
|
||||
value: note.noteId,
|
||||
isInheritable: entity.isInheritable
|
||||
}).save();
|
||||
|
||||
// becca will not be updated before we'll check from the other side which would create infinite relation creation (#2269)
|
||||
targetNote.invalidateThisCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_DELETED, ({ entityName, entity }) => {
|
||||
processInverseRelations(entityName, entity, (definition: DefinitionObject, note: BNote, targetNote: BNote) => {
|
||||
// if one inverse attribute is deleted, then the other should be deleted as well
|
||||
const relations = targetNote.getOwnedRelations(definition.inverseRelation);
|
||||
|
||||
for (const relation of relations) {
|
||||
if (relation.value === note.noteId) {
|
||||
relation.markAsDeleted();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (entityName === "branches") {
|
||||
runAttachedRelations(entity.getNote(), "runOnBranchDeletion", entity);
|
||||
}
|
||||
|
||||
if (entityName === "notes" && entity.noteId.startsWith("_")) {
|
||||
// "named" note has been deleted, we will probably need to rebuild the hidden subtree
|
||||
// scheduling so that bulk deletes won't trigger so many checks
|
||||
oneTimeTimer.scheduleExecution("hidden-subtree-check", 1000, () => hiddenSubtreeService.checkHiddenSubtree());
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
runAttachedRelations
|
||||
};
|
||||
|
||||
@@ -1,2 +1,498 @@
|
||||
import { hidden_subtree } from "@triliumnext/core";
|
||||
export default hidden_subtree;
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import type { HiddenSubtreeItem } from "@triliumnext/commons";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import noteService from "./notes.js";
|
||||
import log from "./log.js";
|
||||
import migrationService from "./migration.js";
|
||||
import { t } from "i18next";
|
||||
import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
|
||||
import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
|
||||
import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
|
||||
|
||||
export const LBTPL_ROOT = "_lbTplRoot";
|
||||
export const LBTPL_BASE = "_lbTplBase";
|
||||
export const LBTPL_HEADER = "_lbTplHeader";
|
||||
export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote";
|
||||
export const LBTPL_WIDGET = "_lbTplLauncherWidget";
|
||||
export const LBTPL_COMMAND = "_lbTplLauncherCommand";
|
||||
export const LBTPL_SCRIPT = "_lbTplLauncherScript";
|
||||
export const LBTPL_SPACER = "_lbTplSpacer";
|
||||
export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget";
|
||||
|
||||
/*
|
||||
* Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always
|
||||
* produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce
|
||||
* duplicate subtrees. This way, all instances will generate the same structure with the same IDs.
|
||||
*/
|
||||
|
||||
let hiddenSubtreeDefinition: HiddenSubtreeItem;
|
||||
|
||||
function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem {
|
||||
const launchbarConfig = buildLaunchBarConfig();
|
||||
|
||||
return {
|
||||
id: "_hidden",
|
||||
title: t("hidden-subtree.root-title"),
|
||||
type: "doc",
|
||||
icon: "bx bx-hide",
|
||||
// we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation
|
||||
// over tree when it's in the middle
|
||||
notePosition: 999_999_999,
|
||||
enforceAttributes: true,
|
||||
attributes: [
|
||||
{ type: "label", name: "docName", value: "hidden" }
|
||||
],
|
||||
children: [
|
||||
{
|
||||
id: "_search",
|
||||
title: t("hidden-subtree.search-history-title"),
|
||||
type: "doc"
|
||||
},
|
||||
{
|
||||
id: "_globalNoteMap",
|
||||
title: t("hidden-subtree.note-map-title"),
|
||||
type: "noteMap",
|
||||
attributes: [
|
||||
{ type: "label", name: "mapRootNoteId", value: "hoisted" },
|
||||
{ type: "label", name: "keepCurrentHoisting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_sqlConsole",
|
||||
title: t("hidden-subtree.sql-console-history-title"),
|
||||
type: "doc",
|
||||
icon: "bx-data"
|
||||
},
|
||||
{
|
||||
id: "_share",
|
||||
title: t("hidden-subtree.shared-notes-title"),
|
||||
type: "doc",
|
||||
attributes: [{ type: "label", name: "docName", value: "share" }]
|
||||
},
|
||||
{
|
||||
id: "_bulkAction",
|
||||
title: t("hidden-subtree.bulk-action-title"),
|
||||
type: "doc"
|
||||
},
|
||||
{
|
||||
id: "_backendLog",
|
||||
title: t("hidden-subtree.backend-log-title"),
|
||||
type: "contentWidget",
|
||||
icon: "bx-terminal",
|
||||
attributes: [
|
||||
{ type: "label", name: "keepCurrentHoisting" },
|
||||
{ type: "label", name: "fullContentWidth" }
|
||||
]
|
||||
},
|
||||
{
|
||||
// place for user scripts hidden stuff (scripts should not create notes directly under hidden root)
|
||||
id: "_userHidden",
|
||||
title: t("hidden-subtree.user-hidden-title"),
|
||||
type: "doc",
|
||||
attributes: [{ type: "label", name: "docName", value: "user_hidden" }]
|
||||
},
|
||||
{
|
||||
id: LBTPL_ROOT,
|
||||
title: t("hidden-subtree.launch-bar-templates-title"),
|
||||
type: "doc",
|
||||
children: [
|
||||
{
|
||||
id: LBTPL_BASE,
|
||||
title: t("hidden-subtree.base-abstract-launcher-title"),
|
||||
type: "doc"
|
||||
},
|
||||
{
|
||||
id: LBTPL_COMMAND,
|
||||
title: t("hidden-subtree.command-launcher-title"),
|
||||
type: "doc",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_BASE },
|
||||
{ type: "label", name: "launcherType", value: "command" },
|
||||
{ type: "label", name: "docName", value: "launchbar_command_launcher" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: LBTPL_NOTE_LAUNCHER,
|
||||
title: t("hidden-subtree.note-launcher-title"),
|
||||
type: "doc",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_BASE },
|
||||
{ type: "label", name: "launcherType", value: "note" },
|
||||
{ type: "label", name: "relation:target", value: "promoted" },
|
||||
{ type: "label", name: "relation:hoistedNote", value: "promoted" },
|
||||
{ type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
|
||||
{ type: "label", name: "docName", value: "launchbar_note_launcher" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: LBTPL_SCRIPT,
|
||||
title: t("hidden-subtree.script-launcher-title"),
|
||||
type: "doc",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_BASE },
|
||||
{ type: "label", name: "launcherType", value: "script" },
|
||||
{ type: "label", name: "relation:script", value: "promoted" },
|
||||
{ type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
|
||||
{ type: "label", name: "docName", value: "launchbar_script_launcher" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: LBTPL_WIDGET,
|
||||
title: t("hidden-subtree.built-in-widget-title"),
|
||||
type: "doc",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_BASE },
|
||||
{ type: "label", name: "launcherType", value: "builtinWidget" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: LBTPL_SPACER,
|
||||
title: t("hidden-subtree.spacer-title"),
|
||||
type: "doc",
|
||||
icon: "bx-move-vertical",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_WIDGET },
|
||||
{ type: "label", name: "builtinWidget", value: "spacer" },
|
||||
{ type: "label", name: "label:baseSize", value: "promoted,number" },
|
||||
{ type: "label", name: "label:growthFactor", value: "promoted,number" },
|
||||
{ type: "label", name: "docName", value: "launchbar_spacer" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: LBTPL_CUSTOM_WIDGET,
|
||||
title: t("hidden-subtree.custom-widget-title"),
|
||||
type: "doc",
|
||||
attributes: [
|
||||
{ type: "relation", name: "template", value: LBTPL_BASE },
|
||||
{ type: "label", name: "launcherType", value: "customWidget" },
|
||||
{ type: "label", name: "relation:widget", value: "promoted" },
|
||||
{ type: "label", name: "docName", value: "launchbar_widget_launcher" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_lbRoot",
|
||||
title: t("hidden-subtree.launch-bar-title"),
|
||||
type: "doc",
|
||||
icon: "bx-sidebar",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: [
|
||||
{
|
||||
id: "_lbAvailableLaunchers",
|
||||
title: t("hidden-subtree.available-launchers-title"),
|
||||
type: "doc",
|
||||
icon: "bx-hide",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: launchbarConfig.desktopAvailableLaunchers
|
||||
},
|
||||
{
|
||||
id: "_lbVisibleLaunchers",
|
||||
title: t("hidden-subtree.visible-launchers-title"),
|
||||
type: "doc",
|
||||
icon: "bx-show",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: launchbarConfig.desktopVisibleLaunchers
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_lbMobileRoot",
|
||||
title: "Mobile Launch Bar",
|
||||
type: "doc",
|
||||
icon: "bx-mobile",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: [
|
||||
{
|
||||
id: "_lbMobileAvailableLaunchers",
|
||||
title: t("hidden-subtree.available-launchers-title"),
|
||||
type: "doc",
|
||||
icon: "bx-hide",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: launchbarConfig.mobileAvailableLaunchers
|
||||
},
|
||||
{
|
||||
id: "_lbMobileVisibleLaunchers",
|
||||
title: t("hidden-subtree.visible-launchers-title"),
|
||||
type: "doc",
|
||||
icon: "bx-show",
|
||||
isExpanded: true,
|
||||
attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
|
||||
children: launchbarConfig.mobileVisibleLaunchers
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_options",
|
||||
title: t("hidden-subtree.options-title"),
|
||||
type: "book",
|
||||
icon: "bx-cog",
|
||||
children: [
|
||||
{ id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" },
|
||||
{ id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" },
|
||||
{ id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" },
|
||||
{ id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" },
|
||||
{ id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" },
|
||||
{ id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" },
|
||||
{ id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" },
|
||||
{ id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' },
|
||||
{ id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
|
||||
{ id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
|
||||
{ id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
|
||||
{ id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" },
|
||||
{ id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
|
||||
{ id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },
|
||||
{ id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_help",
|
||||
title: t("hidden-subtree.user-guide"),
|
||||
type: "book",
|
||||
icon: "bx-help-circle",
|
||||
children: helpSubtree,
|
||||
isExpanded: true
|
||||
},
|
||||
buildHiddenSubtreeTemplates()
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckHiddenExtraOpts {
|
||||
restoreNames?: boolean;
|
||||
}
|
||||
|
||||
function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) {
|
||||
if (!force && !migrationService.isDbUpToDate()) {
|
||||
// on-delete hook might get triggered during some future migration and cause havoc
|
||||
log.info("Will not check hidden subtree until migration is finished.");
|
||||
return;
|
||||
}
|
||||
|
||||
const helpSubtree = getHelpHiddenSubtreeData();
|
||||
if (!hiddenSubtreeDefinition || force) {
|
||||
hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree);
|
||||
}
|
||||
|
||||
checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts);
|
||||
|
||||
try {
|
||||
cleanUpHelp(helpSubtree);
|
||||
} catch (e) {
|
||||
// Non-critical operation should something go wrong.
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all expected parent IDs for a given note ID from the hidden subtree definition
|
||||
*/
|
||||
function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] {
|
||||
const expectedParents: string[] = [];
|
||||
|
||||
function traverse(item: HiddenSubtreeItem, parentId: string) {
|
||||
if (item.id === noteId) {
|
||||
expectedParents.push(parentId);
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
traverse(child, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start traversal from root
|
||||
if (subtree.id === noteId) {
|
||||
expectedParents.push("root");
|
||||
}
|
||||
|
||||
if (subtree.children) {
|
||||
for (const child of subtree.children) {
|
||||
traverse(child, subtree.id);
|
||||
}
|
||||
}
|
||||
|
||||
return expectedParents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a note ID is within the hidden subtree structure
|
||||
*/
|
||||
function isWithinHiddenSubtree(noteId: string): boolean {
|
||||
// Consider a note to be within hidden subtree if it starts with underscore
|
||||
// This is the convention used for hidden subtree notes
|
||||
return noteId.startsWith("_") || noteId === "root";
|
||||
}
|
||||
|
||||
function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) {
|
||||
if (!item.id || !item.type || !item.title) {
|
||||
throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`);
|
||||
}
|
||||
|
||||
if (item.id.charAt(0) !== "_") {
|
||||
throw new Error(`ID has to start with underscore, given '${item.id}'`);
|
||||
}
|
||||
|
||||
let note = becca.notes[item.id];
|
||||
let branch;
|
||||
|
||||
if (!note) {
|
||||
// Missing item, add it.
|
||||
({ note, branch } = noteService.createNewNote({
|
||||
noteId: item.id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
parentNoteId: parentNoteId,
|
||||
content: item.content ?? "",
|
||||
ignoreForbiddenParents: true
|
||||
}));
|
||||
} else {
|
||||
// Existing item, check if it's in the right state.
|
||||
branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId);
|
||||
|
||||
if (item.content && note.getContent() !== item.content) {
|
||||
log.info(`Updating content of ${item.id}.`);
|
||||
note.setContent(item.content);
|
||||
}
|
||||
|
||||
// Clean up any branches that shouldn't exist according to the meta definition
|
||||
// For hidden subtree notes, we want to ensure they only exist in their designated locations
|
||||
if (item.enforceBranches || item.id.startsWith("_help")) {
|
||||
// If the note exists but doesn't have a branch in the expected parent,
|
||||
// create the missing branch to ensure it's in the correct location
|
||||
if (!branch) {
|
||||
log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`);
|
||||
branch = new BBranch({
|
||||
noteId: item.id,
|
||||
parentNoteId: parentNoteId,
|
||||
notePosition: item.notePosition !== undefined ? item.notePosition : undefined,
|
||||
isExpanded: item.isExpanded !== undefined ? item.isExpanded : false
|
||||
}).save();
|
||||
}
|
||||
|
||||
// Remove any branches that are not in the expected parent.
|
||||
const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
|
||||
const currentBranches = note.getParentBranches();
|
||||
|
||||
for (const currentBranch of currentBranches) {
|
||||
// Only delete branches that are not in the expected locations
|
||||
// and are within the hidden subtree structure (avoid touching user-created clones)
|
||||
if (!expectedParents.includes(currentBranch.parentNoteId) &&
|
||||
isWithinHiddenSubtree(currentBranch.parentNoteId)) {
|
||||
log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`);
|
||||
currentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = [...(item.attributes || [])];
|
||||
|
||||
if (item.icon) {
|
||||
attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` });
|
||||
}
|
||||
|
||||
if (item.type === "launcher") {
|
||||
if (item.command) {
|
||||
attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND });
|
||||
attrs.push({ type: "label", name: "command", value: item.command });
|
||||
} else if (item.builtinWidget) {
|
||||
if (item.builtinWidget === "spacer") {
|
||||
attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER });
|
||||
attrs.push({ type: "label", name: "baseSize", value: item.baseSize });
|
||||
attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor });
|
||||
} else {
|
||||
attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET });
|
||||
}
|
||||
|
||||
attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget });
|
||||
} else if (item.targetNoteId) {
|
||||
attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER });
|
||||
attrs.push({ type: "relation", name: "target", value: item.targetNoteId });
|
||||
} else {
|
||||
throw new Error(`No action defined for launcher ${JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template");
|
||||
if (shouldRestoreNames && note.title !== item.title) {
|
||||
note.title = item.title;
|
||||
note.save();
|
||||
}
|
||||
|
||||
if (note.type !== item.type) {
|
||||
// enforce a correct note type
|
||||
note.type = item.type;
|
||||
note.save();
|
||||
}
|
||||
|
||||
if (branch) {
|
||||
// in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between
|
||||
// visible and available will change branch since the branch's parent-child relationship is immutable
|
||||
if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) {
|
||||
branch.notePosition = item.notePosition;
|
||||
branch.save();
|
||||
}
|
||||
|
||||
if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) {
|
||||
branch.isExpanded = item.isExpanded;
|
||||
branch.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce attribute structure if needed.
|
||||
if (item.enforceAttributes) {
|
||||
for (const attribute of note.getAttributes()) {
|
||||
// Remove unwanted attributes.
|
||||
const attrDef = attrs.find(a => a.name === attribute.name);
|
||||
if (!attrDef) {
|
||||
attribute.markAsDeleted();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure value is consistent.
|
||||
if (attribute.value !== attrDef.value) {
|
||||
note.setAttributeValueById(attribute.attributeId, attrDef.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of attrs) {
|
||||
const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name;
|
||||
|
||||
const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId);
|
||||
|
||||
if (!existingAttribute) {
|
||||
new BAttribute({
|
||||
attributeId: attrId,
|
||||
noteId: note.noteId,
|
||||
type: attr.type,
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
isInheritable: attr.isInheritable
|
||||
}).save();
|
||||
} else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) {
|
||||
if (existingAttribute.value !== attr.value) {
|
||||
log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`);
|
||||
existingAttribute.value = attr.value ?? "";
|
||||
existingAttribute.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of item.children || []) {
|
||||
checkHiddenSubtreeRecursively(item.id, child, extraOpts);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
checkHiddenSubtree
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() {
|
||||
id: "_lbBackInHistory",
|
||||
...sharedLaunchers.backInHistory
|
||||
},
|
||||
{
|
||||
{
|
||||
id: "_lbForwardInHistory",
|
||||
...sharedLaunchers.forwardInHistory
|
||||
},
|
||||
@@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() {
|
||||
command: "commandPalette",
|
||||
icon: "bx bx-chevron-right-square"
|
||||
},
|
||||
{
|
||||
{
|
||||
id: "_lbBackendLog",
|
||||
title: t("hidden-subtree.backend-log-title"),
|
||||
type: "launcher",
|
||||
targetNoteId: "_backendLog",
|
||||
icon: "bx bx-detail"
|
||||
icon: "bx bx-detail"
|
||||
},
|
||||
{
|
||||
id: "_zenMode",
|
||||
@@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() {
|
||||
baseSize: "50",
|
||||
growthFactor: "0"
|
||||
},
|
||||
{
|
||||
{
|
||||
id: "_lbBookmarks",
|
||||
title: t("hidden-subtree.bookmarks-title"),
|
||||
type: "launcher",
|
||||
@@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() {
|
||||
id: "_lbToday",
|
||||
...sharedLaunchers.openToday
|
||||
},
|
||||
{
|
||||
{
|
||||
id: "_lbSpacer2",
|
||||
title: t("hidden-subtree.spacer-title"),
|
||||
type: "launcher",
|
||||
@@ -214,4 +214,4 @@ export default function buildLaunchBarConfig() {
|
||||
mobileAvailableLaunchers,
|
||||
mobileVisibleLaunchers
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user