Compare commits
245 Commits
totp
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ba18ddd0 | ||
|
|
a6a2635836 | ||
|
|
5f8a0aee13 | ||
|
|
ba663e6162 | ||
|
|
14925266cf | ||
|
|
702e29bd8c | ||
|
|
27ac3e58c5 | ||
|
|
86e268c06d | ||
|
|
6e4b231319 | ||
|
|
041eff6cbd | ||
|
|
1a3471a516 | ||
|
|
57c8727bb1 | ||
|
|
bd451d0738 | ||
|
|
ced062842d | ||
|
|
a1bf7bfa08 | ||
|
|
2a67c93c20 | ||
|
|
b51bfdfb33 | ||
|
|
9aa84877ee | ||
|
|
9e99670b19 | ||
|
|
744b93dd98 | ||
|
|
5abb77242c | ||
|
|
4ab3b0dd2b | ||
|
|
a6a1594265 | ||
|
|
b06cdd442d | ||
|
|
cf0f5ba4c4 | ||
|
|
caa428c1a2 | ||
|
|
517c721664 | ||
|
|
a8cdaa69f7 | ||
|
|
53d221ef34 | ||
|
|
5450fde472 | ||
|
|
808446cef5 | ||
|
|
921c663199 | ||
|
|
1b8a75b615 | ||
|
|
f78ced5bc3 | ||
|
|
81bf5f4f3b | ||
|
|
aaed368670 | ||
|
|
5e8de14721 | ||
|
|
634ab5b5c0 | ||
|
|
906889a035 | ||
|
|
ab9d50b905 | ||
|
|
e61b7c7cfc | ||
|
|
1c628fba4c | ||
|
|
f8b4c6cb15 | ||
|
|
3edd8f6c5a | ||
|
|
7777f72893 | ||
|
|
9af85b767b | ||
|
|
73260b91eb | ||
|
|
2858f63873 | ||
|
|
15ca328727 | ||
|
|
5b3fbecc0f | ||
|
|
365d0f0aac | ||
|
|
e86d84c463 | ||
|
|
6b974c2ac7 | ||
|
|
d2afcbb98d | ||
|
|
68a122fcf5 | ||
|
|
92f0144b48 | ||
|
|
a5a345728c | ||
|
|
23890e64e9 | ||
|
|
3de712aca4 | ||
|
|
cb5b4d870f | ||
|
|
f81aef2de5 | ||
|
|
06aed16ea1 | ||
|
|
aa2d8af15c | ||
|
|
dc7b91433b | ||
|
|
72951386b1 | ||
|
|
db8df01d82 | ||
|
|
98713ed111 | ||
|
|
3e88fecb15 | ||
|
|
fe4255f2fc | ||
|
|
c046a57654 | ||
|
|
d8fc0d45a8 | ||
|
|
567b96cfb4 | ||
|
|
d25849d280 | ||
|
|
d4d73995db | ||
|
|
f4657b5da9 | ||
|
|
614f43cb8a | ||
|
|
ca2fbf8dba | ||
|
|
a421513442 | ||
|
|
a9599c471a | ||
|
|
415bcac641 | ||
|
|
9527017314 | ||
|
|
1d3d7c77f8 | ||
|
|
e868615fd5 | ||
|
|
80493a52be | ||
|
|
3fed2ba42e | ||
|
|
82592ada54 | ||
|
|
5528701744 | ||
|
|
0ca665fb85 | ||
|
|
7eb452ed8b | ||
|
|
d81dec94a9 | ||
|
|
6631a4a806 | ||
|
|
12f817c896 | ||
|
|
87229600d2 | ||
|
|
471a46a030 | ||
|
|
41220eebd5 | ||
|
|
755872277b | ||
|
|
2cb54d7021 | ||
|
|
5a16bafbbf | ||
|
|
fc6e9d89d9 | ||
|
|
8af35da279 | ||
|
|
7107fec1a4 | ||
|
|
4bb662c5fb | ||
|
|
89297b92f8 | ||
|
|
e019271e74 | ||
|
|
f6d61eefcc | ||
|
|
fabc07be42 | ||
|
|
bccfa7956c | ||
|
|
42a05f411b | ||
|
|
7ba7b98f5f | ||
|
|
2132c2ab38 | ||
|
|
2ce4d512e7 | ||
|
|
1258d32820 | ||
|
|
db763ba229 | ||
|
|
951fdaec70 | ||
|
|
4303f3687e | ||
|
|
540b0e0b83 | ||
|
|
08a0326cb0 | ||
|
|
8b0a45e4fd | ||
|
|
0e0ad2ed73 | ||
|
|
4c73f31aca | ||
|
|
6b2ae8fd12 | ||
|
|
88d84fae1e | ||
|
|
cdc46faaad | ||
|
|
24dbc79961 | ||
|
|
8cb58dcc45 | ||
|
|
fe70b8aee6 | ||
|
|
00f66cfb49 | ||
|
|
3a4b080765 | ||
|
|
41269ef987 | ||
|
|
e521c6a386 | ||
|
|
1c35a557c1 | ||
|
|
99eb8389c5 | ||
|
|
c5e560ef5b | ||
|
|
a7d7a078b1 | ||
|
|
a06fa5222f | ||
|
|
8d3e40a28a | ||
|
|
8e32f99790 | ||
|
|
57bce62e48 | ||
|
|
1c873394d5 | ||
|
|
d652f67364 | ||
|
|
5e54d098c5 | ||
|
|
ec95303c31 | ||
|
|
07aafe7e89 | ||
|
|
dc7acbb70e | ||
|
|
0dcb8b3ff8 | ||
|
|
e4ddff01ca | ||
|
|
015c1161d4 | ||
|
|
ca0c6076c5 | ||
|
|
80a02f88be | ||
|
|
430833bedb | ||
|
|
dc80d83964 | ||
|
|
5f7ade45f4 | ||
|
|
8b36a7ab1e | ||
|
|
fd18276693 | ||
|
|
0becfc16ba | ||
|
|
d480d1f6ba | ||
|
|
f5c9a71ba0 | ||
|
|
c177a8a464 | ||
|
|
c826564c9e | ||
|
|
ccb13fa6b9 | ||
|
|
69e374138f | ||
|
|
3156b2cb59 | ||
|
|
d6217ffed4 | ||
|
|
fc90c6af9d | ||
|
|
a1118419ec | ||
|
|
8599785ee8 | ||
|
|
99ba192a44 | ||
|
|
b86d3587ac | ||
|
|
b2a0baf56a | ||
|
|
22f37817e5 | ||
|
|
6b4fe03625 | ||
|
|
f44b47ec23 | ||
|
|
8d667e838a | ||
|
|
f32385de2e | ||
|
|
90796fc4fa | ||
|
|
4960c49cb2 | ||
|
|
b112e8b56b | ||
|
|
83095130f6 | ||
|
|
d005c0ef2d | ||
|
|
c135578626 | ||
|
|
9a6e20029e | ||
|
|
39bd4ccea1 | ||
|
|
aac4774326 | ||
|
|
ea7aac2030 | ||
|
|
e7f98f08d0 | ||
|
|
8ac9daa5d3 | ||
|
|
0b506c6327 | ||
|
|
d2b62540ec | ||
|
|
64418c7fec | ||
|
|
8c1a58e64f | ||
|
|
b27fd31c1f | ||
|
|
f18a531924 | ||
|
|
3cabb4b661 | ||
|
|
5c88b1c6b8 | ||
|
|
c2adc43780 | ||
|
|
7eaa5352ba | ||
|
|
17e3e3187b | ||
|
|
2ad7cd3a49 | ||
|
|
39aa8d61c2 | ||
|
|
1a3ea977b7 | ||
|
|
4cd8f9a1e6 | ||
|
|
87ce6d1231 | ||
|
|
8fdbeacf77 | ||
|
|
f4f775a1c9 | ||
|
|
fe1154cb2d | ||
|
|
638f479ff3 | ||
|
|
70436bdb04 | ||
|
|
575ecaae07 | ||
|
|
d277e6db94 | ||
|
|
25efcd12d0 | ||
|
|
10129321be | ||
|
|
72710a8f6b | ||
|
|
6a7c5c04d8 | ||
|
|
7f32fe5ef7 | ||
|
|
5d89591dea | ||
|
|
a88bf5a87b | ||
|
|
bbe5d3506e | ||
|
|
c2993d4e7d | ||
|
|
17ba479182 | ||
|
|
a465014bbe | ||
|
|
5dfe253ef6 | ||
|
|
ae7ca6021f | ||
|
|
c389697acd | ||
|
|
c13c3e0f4a | ||
|
|
82c042d045 | ||
|
|
9145ba1690 | ||
|
|
d60653ee17 | ||
|
|
dae8613b4e | ||
|
|
2f8e2c40be | ||
|
|
d85225a0dc | ||
|
|
0cb66df2b2 | ||
|
|
92e0578cb6 | ||
|
|
2eee06786e | ||
|
|
19053dcb3b | ||
|
|
e10c30c59f | ||
|
|
4595a3a5dd | ||
|
|
1b70101123 | ||
|
|
700e99e854 | ||
|
|
0206e8247b | ||
|
|
5476fe3df9 | ||
|
|
d9a4581d37 | ||
|
|
d3337eab9c | ||
|
|
8128a8192a | ||
|
|
65514a6fd7 | ||
|
|
93a7f8c711 |
4
.github/workflows/dev.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
|
||||
2
.github/workflows/main-docker.yml
vendored
@@ -229,7 +229,7 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@v0.4
|
||||
uses: imjasonh/setup-crane@v0.5
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.32.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.20.2",
|
||||
"archiver": "7.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -25,19 +25,25 @@
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.16.1",
|
||||
"@univerjs/preset-sheets-core": "0.16.1",
|
||||
"@univerjs/preset-sheets-data-validation": "0.16.1",
|
||||
"@univerjs/preset-sheets-filter": "0.16.1",
|
||||
"@univerjs/preset-sheets-find-replace": "0.16.1",
|
||||
"@univerjs/preset-sheets-note": "0.16.1",
|
||||
"@univerjs/preset-sheets-sort": "0.16.1",
|
||||
"@univerjs/presets": "0.16.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"@zumer/snapdom": "2.1.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -47,28 +53,28 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.17",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.33",
|
||||
"katex": "0.16.38",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.9.1",
|
||||
"mind-elixir": "5.9.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.5",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.5.6",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "5.2.1",
|
||||
"rrule": "2.8.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"tabulator-tables": "6.4.0",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -82,8 +88,8 @@
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.3",
|
||||
"lightningcss": "1.31.1",
|
||||
"happy-dom": "20.8.4",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
}
|
||||
|
||||
@@ -381,6 +381,10 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
// Collections must always display a note list, even if no children.
|
||||
if (note.type === "book") {
|
||||
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
await renderText(entity, $renderedContent, options);
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
|
||||
@@ -110,7 +110,12 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
|
||||
}
|
||||
}
|
||||
|
||||
if (ec.componentId) {
|
||||
// Only register as a content change if the protection status didn't change.
|
||||
// When isProtected changes, the blobId change is a side effect of re-encryption,
|
||||
// not a content edit. Registering it as content would cause the tree's content-only
|
||||
// filter to incorrectly skip the note update (since both changes share the same
|
||||
// componentId).
|
||||
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
|
||||
loadResults.addNoteContent(note.noteId, ec.componentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import { AttributeRow } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
import froca from "./froca.js";
|
||||
import treeService from "./tree.js";
|
||||
import toastService from "./toast.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import treeService from "./tree.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
export interface CreateNoteOpts {
|
||||
isProtected?: boolean;
|
||||
@@ -24,6 +26,8 @@ export interface CreateNoteOpts {
|
||||
target?: string;
|
||||
targetBranchId?: string;
|
||||
textEditor?: CKTextEditor;
|
||||
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
|
||||
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
@@ -37,7 +41,7 @@ interface DuplicateResponse {
|
||||
note: FNote;
|
||||
}
|
||||
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
|
||||
options = Object.assign(
|
||||
{
|
||||
activate: true,
|
||||
@@ -77,8 +81,9 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
|
||||
isProtected: options.isProtected,
|
||||
type: options.type,
|
||||
mime: options.mime,
|
||||
templateNoteId: options.templateNoteId
|
||||
});
|
||||
templateNoteId: options.templateNoteId,
|
||||
attributes: options.attributes
|
||||
}, componentId);
|
||||
|
||||
if (options.saveSelection) {
|
||||
// we remove the selection only after it was saved to server to make sure we don't lose anything
|
||||
@@ -140,9 +145,8 @@ function parseSelectedHtml(selectedHtml: string) {
|
||||
const content = selectedHtml.replace(dom[0].outerHTML, "");
|
||||
|
||||
return [title, content];
|
||||
} else {
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
|
||||
async function duplicateSubtree(noteId: string, parentNotePath: string) {
|
||||
|
||||
@@ -89,7 +89,7 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
@@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: "PUT",
|
||||
type: method,
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import "jquery";
|
||||
|
||||
import ko from "knockout";
|
||||
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
@@ -18,8 +16,6 @@ class SetupModel {
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
totpToken: ko.Observable<string | undefined>;
|
||||
totpEnabled: ko.Observable<boolean>;
|
||||
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
@@ -31,8 +27,6 @@ class SetupModel {
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
this.totpToken = ko.observable();
|
||||
this.totpEnabled = ko.observable(false);
|
||||
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
@@ -46,7 +40,7 @@ class SetupModel {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
async selectSetupType() {
|
||||
selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
@@ -58,24 +52,6 @@ class SetupModel {
|
||||
}
|
||||
}
|
||||
|
||||
async checkTotpStatus() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
if (!syncServerHost) {
|
||||
this.totpEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await $.post("api/setup/check-server-totp", {
|
||||
syncServerHost
|
||||
});
|
||||
this.totpEnabled(!!resp.totpEnabled);
|
||||
} catch {
|
||||
// If we can't reach the server, don't show TOTP field yet
|
||||
this.totpEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
@@ -96,22 +72,11 @@ class SetupModel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check TOTP status before submitting (in case it wasn't checked yet)
|
||||
await this.checkTotpStatus();
|
||||
|
||||
const totpToken = this.totpToken();
|
||||
|
||||
if (this.totpEnabled() && !totpToken) {
|
||||
showAlert("TOTP token can't be empty when two-factor authentication is enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost,
|
||||
syncProxy,
|
||||
password,
|
||||
totpToken
|
||||
syncServerHost: syncServerHost,
|
||||
syncProxy: syncProxy,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
|
||||
@@ -803,12 +803,13 @@
|
||||
"web-view": "عرض الويب",
|
||||
"mind-map": "خريطة ذهنية",
|
||||
"geo-map": "خريطة جغرافية",
|
||||
"task-list": "قائمة المهام"
|
||||
"task-list": "قائمة المهام",
|
||||
"spreadsheet": "جدول البيانات"
|
||||
},
|
||||
"shared_switch": {
|
||||
"shared": "مشترك",
|
||||
"toggle-on-title": "مشاركة الملاحظة",
|
||||
"toggle-off-title": "الغاء مشاركة الملاحظة"
|
||||
"toggle-off-title": "إلغاء مشاركة الملاحظة"
|
||||
},
|
||||
"template_switch": {
|
||||
"template": "قالب"
|
||||
@@ -1286,8 +1287,10 @@
|
||||
"search-for": "بحث ل \"{{term}}\""
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-off": "ازالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة"
|
||||
"toggle-off": "إزالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة",
|
||||
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
|
||||
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
|
||||
},
|
||||
"open-help-page": "فتح صفحة المساعدة",
|
||||
"empty": {
|
||||
|
||||
@@ -1535,7 +1535,8 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天"
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
|
||||
@@ -1488,20 +1488,21 @@
|
||||
"mermaid-diagram": "Mermaid Diagramm",
|
||||
"canvas": "Leinwand",
|
||||
"web-view": "Webansicht",
|
||||
"mind-map": "Mind Map",
|
||||
"mind-map": "Mindmap",
|
||||
"file": "Datei",
|
||||
"image": "Bild",
|
||||
"launcher": "Starter",
|
||||
"doc": "Dokument",
|
||||
"widget": "Widget",
|
||||
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"geo-map": "Geo-Karte",
|
||||
"beta-feature": "Beta",
|
||||
"book": "Sammlung",
|
||||
"ai-chat": "KI Chat",
|
||||
"ai-chat": "KI-Chat",
|
||||
"task-list": "Aufgabenliste",
|
||||
"new-feature": "Neu",
|
||||
"collections": "Sammlungen"
|
||||
"collections": "Sammlungen",
|
||||
"spreadsheet": "Tabelle"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Notiz schützen",
|
||||
|
||||
@@ -1036,6 +1036,25 @@
|
||||
"file_preview_not_available": "File preview is not available for this file format.",
|
||||
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
|
||||
},
|
||||
"media": {
|
||||
"play": "Play (Space)",
|
||||
"pause": "Pause (Space)",
|
||||
"back-10s": "Back 10s (Left arrow key)",
|
||||
"forward-30s": "Forward 30s",
|
||||
"mute": "Mute (M)",
|
||||
"unmute": "Unmute (M)",
|
||||
"playback-speed": "Playback speed",
|
||||
"loop": "Loop",
|
||||
"disable-loop": "Disable loop",
|
||||
"rotate": "Rotate",
|
||||
"picture-in-picture": "Picture-in-picture",
|
||||
"exit-picture-in-picture": "Exit picture-in-picture",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"exit-fullscreen": "Exit fullscreen",
|
||||
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
|
||||
"zoom-to-fit": "Zoom to fill",
|
||||
"zoom-reset": "Reset zoom to fill"
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Showing protected note requires entering your password:",
|
||||
"start_session_button": "Start protected session",
|
||||
|
||||
@@ -1548,7 +1548,8 @@
|
||||
"task-list": "Lista de tareas",
|
||||
"book": "Colección",
|
||||
"new-feature": "Nuevo",
|
||||
"collections": "Colecciones"
|
||||
"collections": "Colecciones",
|
||||
"spreadsheet": "Hoja de cálculo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteger la nota",
|
||||
@@ -1650,7 +1651,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
|
||||
"search_now": "Buscar ahora"
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurar barra de lanzamiento"
|
||||
@@ -2196,5 +2198,12 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Para saber más"
|
||||
},
|
||||
"media": {
|
||||
"play": "Reproducir (Espacio)",
|
||||
"pause": "Pausa (Espacio)",
|
||||
"back-10s": "Retroceder 10s (tecla de flecha izquierda)",
|
||||
"forward-30s": "Adelantar 30s",
|
||||
"mute": "Silenciar (M)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,7 +1571,8 @@
|
||||
"ai-chat": "Comhrá AI",
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin"
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -1717,7 +1717,8 @@
|
||||
"task-list": "Elenco delle attività",
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA"
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
|
||||
@@ -600,7 +600,8 @@
|
||||
"task-list": "タスクリスト",
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット"
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
@@ -2167,5 +2168,24 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "さらに詳しく"
|
||||
},
|
||||
"media": {
|
||||
"play": "再生 (スペース)",
|
||||
"pause": "一時停止 (スペース)",
|
||||
"back-10s": "10 秒戻る (左矢印キー)",
|
||||
"forward-30s": "30 秒進む",
|
||||
"mute": "ミュート (M)",
|
||||
"unmute": "ミュート解除 (M)",
|
||||
"playback-speed": "再生速度",
|
||||
"loop": "ループ",
|
||||
"disable-loop": "ループを解除",
|
||||
"rotate": "回転",
|
||||
"picture-in-picture": "ピクチャーインピクチャー",
|
||||
"exit-picture-in-picture": "ピクチャーインピクチャーを終了",
|
||||
"fullscreen": "全画面表示 (F)",
|
||||
"exit-fullscreen": "全画面表示を終了",
|
||||
"unsupported-format": "このファイル形式ではメディアプレビューはご利用いただけません:\n{{mime}}",
|
||||
"zoom-to-fit": "ズームして全体を表示",
|
||||
"zoom-reset": "ズーム設定をリセット"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1780,7 +1780,8 @@
|
||||
"ai-chat": "Czat AI",
|
||||
"task-list": "Lista zadań",
|
||||
"new-feature": "Nowość",
|
||||
"collections": "Kolekcje"
|
||||
"collections": "Kolekcje",
|
||||
"spreadsheet": "Arkusz"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Chroń notatkę",
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
"collapseExpand": "свернуть/развернуть узел",
|
||||
"notSet": "не установлено",
|
||||
"goBackForwards": "назад / вперед в истории",
|
||||
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
|
||||
"showJumpToNoteDialog": "Перейти к <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Перейти к\" окно</a>",
|
||||
"scrollToActiveNote": "прокрутка к активной заметке",
|
||||
"jumpToParentNote": "переход к родительской заметке",
|
||||
"collapseWholeTree": "свернуть все дерево заметок",
|
||||
@@ -471,7 +471,7 @@
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -594,7 +594,8 @@
|
||||
"display-week-numbers": "Отображать номера недель",
|
||||
"hide-weekends": "Скрыть выходные",
|
||||
"raster": "Растр",
|
||||
"show-scale": "Показать масштаб"
|
||||
"show-scale": "Показать масштаб",
|
||||
"show-labels": "Показать названия маркеров"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"note_completion_enabled": "Включить автодополнение",
|
||||
@@ -782,7 +783,13 @@
|
||||
"shared-indicator-tooltip": "Эта заметка опубликована",
|
||||
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
|
||||
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
|
||||
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
|
||||
"subtree-hidden-moved-title": "Добавлено в {{title}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
|
||||
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
|
||||
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
|
||||
},
|
||||
"quick-search": {
|
||||
"no-results": "Результаты не найдены",
|
||||
@@ -826,7 +833,9 @@
|
||||
"mind-map": "Mind Map",
|
||||
"geo-map": "Географическая карта",
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1153,7 +1162,8 @@
|
||||
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
|
||||
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
|
||||
"actions_executed": "Действия выполнены.",
|
||||
"view_options": "Просмотреть опции:"
|
||||
"view_options": "Просмотреть опции:",
|
||||
"option": "опция"
|
||||
},
|
||||
"ancestor": {
|
||||
"depth_label": "глубина",
|
||||
@@ -1403,7 +1413,8 @@
|
||||
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
|
||||
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
|
||||
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
|
||||
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
|
||||
},
|
||||
"sync_2": {
|
||||
"timeout_unit": "миллисекунд",
|
||||
@@ -1713,7 +1724,8 @@
|
||||
"delete_this_note": "Удалить эту заметку",
|
||||
"insert_child_note": "Вставить дочернюю заметку",
|
||||
"note_revisions": "История изменений",
|
||||
"content_language_switcher": "Язык содержимого: {{language}}"
|
||||
"content_language_switcher": "Язык содержимого: {{language}}",
|
||||
"backlinks": "Ссылки"
|
||||
},
|
||||
"svg_export_button": {
|
||||
"button_title": "Экспортировать диаграмму как SVG"
|
||||
@@ -1790,7 +1802,8 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
|
||||
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
|
||||
"search_not_executed": "Поиск ещё не выполнен.",
|
||||
"search_now": "Искать сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"search_placeholder": "поиск заметки по ее названию",
|
||||
@@ -1988,10 +2001,12 @@
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
|
||||
"print_report_error_title": "Не удалось напечатать",
|
||||
"print_report_stack_trace": "Трассировка стека"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
|
||||
"drag_locked_title": "Защищено от изменения",
|
||||
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
|
||||
},
|
||||
@@ -2007,7 +2022,9 @@
|
||||
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} заметок"
|
||||
"total_notes": "{{count}} заметок",
|
||||
"prev_page": "Предыдущая страница",
|
||||
"next_page": "Следующая страница"
|
||||
},
|
||||
"status_bar": {
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
@@ -2137,5 +2154,49 @@
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Доступно для {{platform}}"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
|
||||
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
|
||||
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
|
||||
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
|
||||
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
|
||||
"disabled_button_enable": "Включить заметки для рендера"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
|
||||
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
|
||||
"create_button": "Создать веб-просмотр",
|
||||
"invalid_url_title": "Неверный адрес",
|
||||
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
|
||||
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
|
||||
"disabled_button_enable": "Включить просмотр веб-страниц"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Набор иконок",
|
||||
"type_backend_script": "Бэкенд скрипт",
|
||||
"type_frontend_script": "Фронтенд скрипт",
|
||||
"type_widget": "Виджет",
|
||||
"type_app_css": "Пользовательский CSS",
|
||||
"type_render_note": "Заметка для рендера",
|
||||
"type_web_view": "Просмотр веб-страницы",
|
||||
"type_app_theme": "Пользовательская тема",
|
||||
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
|
||||
"menu_docs": "Открытая документация",
|
||||
"menu_execute_now": "Выполнить скрипт сейчас",
|
||||
"menu_run": "Выполнять автоматически",
|
||||
"menu_run_disabled": "Вручную",
|
||||
"menu_run_backend_startup": "При запуске бэкенда",
|
||||
"menu_run_hourly": "Ежечасно",
|
||||
"menu_run_daily": "Ежедневно",
|
||||
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
|
||||
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
|
||||
"menu_change_to_widget": "Изменить виджет",
|
||||
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
|
||||
"menu_theme_base": "Базовая тема"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Узнать больше"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
"title": "Om Trilium Notes",
|
||||
"homepage": "Hemsida:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:"
|
||||
"db_version": "DB version:",
|
||||
"sync_version": "Sync version:",
|
||||
"build_date": "Bygg datum:",
|
||||
"build_revision": "Bygg version:",
|
||||
"data_directory": "Data sökväg:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritiskt fel",
|
||||
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Misslyckades att starta widget",
|
||||
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
|
||||
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Misslyckades att starta ett anpassat skript",
|
||||
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Misslyckades att hämta widget-listan från servern"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Misslyckades att renderera en anpassad React-widget"
|
||||
},
|
||||
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
|
||||
"open-script-note": "Öppna skriptanteckning",
|
||||
"scripting-error": "Fel i anpassat skript: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Infoga länk",
|
||||
"help_on_links": "Hjälp om länkar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1496,7 +1496,8 @@
|
||||
"task-list": "任務列表",
|
||||
"new-feature": "新增",
|
||||
"collections": "集合",
|
||||
"ai-chat": "AI 聊天"
|
||||
"ai-chat": "AI 聊天",
|
||||
"spreadsheet": "試算表"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保護筆記",
|
||||
|
||||
@@ -40,6 +40,21 @@ export default function NoteDetail() {
|
||||
const widgetRequestId = useRef(0);
|
||||
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
|
||||
|
||||
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
|
||||
// Special contexts (ntxId starting with "_", e.g. popup editor) are always considered active.
|
||||
const isSpecialContext = ntxId?.startsWith("_") ?? false;
|
||||
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => isSpecialContext || (noteContext?.isActive() ?? false));
|
||||
useEffect(() => {
|
||||
if (!hasTabBeenActive && noteContext?.isActive()) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
}, [ noteContext, hasTabBeenActive ]);
|
||||
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId === ntxId && !hasTabBeenActive) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
});
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
@@ -49,7 +64,7 @@ export default function NoteDetail() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!type) return;
|
||||
if (!type || !hasTabBeenActive) return;
|
||||
const requestId = ++widgetRequestId.current;
|
||||
|
||||
if (!noteTypesToRender[type]) {
|
||||
@@ -68,7 +83,7 @@ export default function NoteDetail() {
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type, noteTypesToRender ]);
|
||||
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
@@ -247,9 +262,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setCachedProps(props);
|
||||
} else {
|
||||
// Do nothing, keep the old props.
|
||||
}
|
||||
// When not visible, keep the old props to avoid re-rendering in the background.
|
||||
}, [ props, isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
@@ -260,7 +274,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
height: isFullHeight ? "100%" : ""
|
||||
}}
|
||||
>
|
||||
{ <Element {...cachedProps} /> }
|
||||
<Element {...cachedProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
import { BoardViewData } from ".";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
@@ -9,6 +9,7 @@ import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import note_create from "../../../services/note_create";
|
||||
import server from "../../../services/server";
|
||||
import { BoardViewData } from ".";
|
||||
import { ColumnMap } from "./data";
|
||||
|
||||
export default class BoardApi {
|
||||
@@ -35,13 +36,11 @@ export default class BoardApi {
|
||||
|
||||
async createNewItem(column: string, title: string) {
|
||||
try {
|
||||
// Get the parent note path
|
||||
const parentNotePath = this.parentNote.noteId;
|
||||
|
||||
// Create a new note as a child of the parent note
|
||||
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
|
||||
const { note: newNote, branch: newBranch } = await note_create.createNote(this.parentNote.noteId, {
|
||||
activate: false,
|
||||
title
|
||||
title,
|
||||
isProtected: this.parentNote.isProtected
|
||||
});
|
||||
|
||||
if (newNote && newBranch) {
|
||||
@@ -87,7 +86,7 @@ export default class BoardApi {
|
||||
|
||||
const action: BulkAction = this.isRelationMode
|
||||
? { name: "deleteRelation", relationName: this.statusAttribute }
|
||||
: { name: "deleteLabel", labelName: this.statusAttribute }
|
||||
: { name: "deleteLabel", labelName: this.statusAttribute };
|
||||
await executeBulkActions(noteIds, [ action ]);
|
||||
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
|
||||
this.saveConfig(this.viewConfig);
|
||||
@@ -99,7 +98,7 @@ export default class BoardApi {
|
||||
// Change the value in the notes.
|
||||
const action: BulkAction = this.isRelationMode
|
||||
? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue }
|
||||
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue }
|
||||
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue };
|
||||
await executeBulkActions(noteIds, [ action ]);
|
||||
|
||||
// Rename the column in the persisted data.
|
||||
@@ -137,9 +136,9 @@ export default class BoardApi {
|
||||
}
|
||||
|
||||
async insertRowAtPosition(
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after") {
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after") {
|
||||
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
|
||||
activate: false,
|
||||
targetBranchId: relativeToBranchId,
|
||||
@@ -179,9 +178,8 @@ export default class BoardApi {
|
||||
if (!note) return;
|
||||
if (this.isRelationMode) {
|
||||
return attributes.removeOwnedRelationByName(note, this.statusAttribute);
|
||||
} else {
|
||||
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
|
||||
}
|
||||
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
|
||||
}
|
||||
|
||||
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
margin-inline: var(--content-margin-inline);
|
||||
padding-block: 4px;
|
||||
padding: 4px var(--content-margin-inline);
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -42,7 +41,11 @@ body.mobile .board-view-container {
|
||||
body.mobile .board-view-container .board-column {
|
||||
width: 75vw;
|
||||
max-width: 300px;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
body.mobile .board-view-container .board-column,
|
||||
body.mobile .board-view-container .board-add-column {
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.board-view-container .board-column.drag-over {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
|
||||
import { AttributeRow } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import server from "../../../services/server";
|
||||
import note_create from "../../../services/note_create";
|
||||
|
||||
interface NewEventOpts {
|
||||
title: string;
|
||||
@@ -51,11 +51,13 @@ export async function newEvent(parentNote: FNote, { title, startDate, endDate, s
|
||||
}
|
||||
|
||||
// Create the note.
|
||||
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
await note_create.createNote(parentNote.noteId, {
|
||||
title,
|
||||
isProtected: parentNote.isProtected,
|
||||
content: "",
|
||||
type: "text",
|
||||
attributes
|
||||
attributes,
|
||||
activate: false
|
||||
}, componentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
|
||||
import { LOCATION_ATTRIBUTE } from ".";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { prompt } from "../../../services/dialog";
|
||||
import server from "../../../services/server";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
import note_create from "../../../services/note_create";
|
||||
import { LOCATION_ATTRIBUTE } from ".";
|
||||
|
||||
const CHILD_NOTE_ICON = "bx bx-pin";
|
||||
|
||||
@@ -13,16 +14,20 @@ export async function moveMarker(noteId: string, latLng: LatLng | null) {
|
||||
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
|
||||
}
|
||||
|
||||
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
|
||||
export async function createNewNote(parentNote: FNote, e: LeafletMouseEvent) {
|
||||
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
|
||||
|
||||
if (title?.trim()) {
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
|
||||
await note_create.createNote(parentNote.noteId, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text"
|
||||
type: "text",
|
||||
activate: false,
|
||||
isProtected: parentNote.isProtected,
|
||||
attributes: [
|
||||
{ type: "label", name: LOCATION_ATTRIBUTE, value: [e.latlng.lat, e.latlng.lng].join(",") },
|
||||
{ type: "label", name: "iconClass", value: CHILD_NOTE_ICON }
|
||||
]
|
||||
});
|
||||
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
|
||||
moveMarker(note.noteId, e.latlng);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
|
||||
|
||||
import appContext, { type CommandMappings } from "../../../components/app_context.js";
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
|
||||
import linkContextMenu from "../../../menus/link_context_menu.js";
|
||||
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import { createNewNote } from "./api.js";
|
||||
import linkContextMenu from "../../../menus/link_context_menu.js";
|
||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import link from "../../../services/link.js";
|
||||
import { createNewNote } from "./api.js";
|
||||
|
||||
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
let items: MenuItem<keyof CommandMappings>[] = [
|
||||
@@ -44,7 +46,7 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is
|
||||
});
|
||||
}
|
||||
|
||||
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
export function openMapContextMenu(note: FNote, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
let items: MenuItem<keyof CommandMappings>[] = [
|
||||
...buildGeoLocationItem(e)
|
||||
];
|
||||
@@ -55,10 +57,10 @@ export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEdita
|
||||
{ kind: "separator" },
|
||||
{
|
||||
title: t("geo-map-context.add-note"),
|
||||
handler: () => createNewNote(noteId, e),
|
||||
handler: () => createNewNote(note, e),
|
||||
uiIcon: "bx bx-plus"
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
|
||||
@@ -93,14 +93,14 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
const onClick = useCallback(async (e: LeafletMouseEvent) => {
|
||||
if (state === State.NewNote) {
|
||||
toast.closePersistent("geo-new-note");
|
||||
await createNewNote(note.noteId, e);
|
||||
await createNewNote(note, e);
|
||||
setState(State.Normal);
|
||||
}
|
||||
}, [ state ]);
|
||||
}, [ note, state ]);
|
||||
|
||||
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
|
||||
openMapContextMenu(note.noteId, e, !isReadOnly);
|
||||
}, [ note.noteId, isReadOnly ]);
|
||||
openMapContextMenu(note, e, !isReadOnly);
|
||||
}, [ note, isReadOnly ]);
|
||||
|
||||
// Dragging
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -18,14 +18,14 @@ import useRowTableEditing from "./row_editing";
|
||||
import { TableData } from "./rows";
|
||||
import Tabulator from "./tabulator";
|
||||
|
||||
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
|
||||
export default function TableView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
|
||||
const tabulatorRef = useRef<VanillaTabulator>(null);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
|
||||
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
|
||||
const persistenceProps = usePersistence(viewConfig, saveConfig);
|
||||
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath);
|
||||
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, note);
|
||||
const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note);
|
||||
const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition);
|
||||
const dataTreeProps = useMemo<Options>(() => {
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
|
||||
import { CommandListenerData } from "../../../components/app_context";
|
||||
import note_create, { CreateNoteOpts } from "../../../services/note_create";
|
||||
import { useLegacyImperativeHandlers } from "../../react/hooks";
|
||||
import { RefObject } from "preact";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import froca from "../../../services/froca";
|
||||
import server from "../../../services/server";
|
||||
import branches from "../../../services/branches";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
|
||||
|
||||
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
|
||||
import { CommandListenerData } from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import branches from "../../../services/branches";
|
||||
import froca from "../../../services/froca";
|
||||
import note_create, { CreateNoteOpts } from "../../../services/note_create";
|
||||
import server from "../../../services/server";
|
||||
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
|
||||
import { useLegacyImperativeHandlers } from "../../react/hooks";
|
||||
|
||||
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote): Partial<EventCallBackMethods> {
|
||||
// Adding new rows
|
||||
useLegacyImperativeHandlers({
|
||||
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
|
||||
const notePath = customNotePath ?? parentNotePath;
|
||||
const notePath = customNotePath ?? parentNote.noteId;
|
||||
if (notePath) {
|
||||
const opts: CreateNoteOpts = {
|
||||
activate: false,
|
||||
isProtected: parentNote.isProtected,
|
||||
...customOpts
|
||||
}
|
||||
};
|
||||
note_create.createNote(notePath, opts).then(({ branch }) => {
|
||||
if (branch) {
|
||||
setTimeout(() => {
|
||||
@@ -26,7 +29,7 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
|
||||
focusOnBranch(api.current, branch?.branchId);
|
||||
}, 100);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -91,14 +94,14 @@ function focusOnBranch(api: Tabulator, branchId: string) {
|
||||
}
|
||||
|
||||
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
|
||||
for (let row of rows) {
|
||||
for (const row of rows) {
|
||||
const item = row.getIndex() as string;
|
||||
|
||||
if (item === branchId) {
|
||||
return row;
|
||||
}
|
||||
|
||||
let found = findRowDataById(row.getTreeChildren(), branchId);
|
||||
const found = findRowDataById(row.getTreeChildren(), branchId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
|
||||
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
|
||||
case "canvas":
|
||||
case "mindMap":
|
||||
case "mermaid": {
|
||||
case "mermaid":
|
||||
case "spreadsheet": {
|
||||
const encodedTitle = encodeURIComponent(revisionItem.title);
|
||||
return <img
|
||||
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
animation: fadeOut 250ms ease-in 5s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body#trilium-app.motion-disabled &.saved {
|
||||
animation: fadeOut 0s 5s forwards !important;
|
||||
}
|
||||
}
|
||||
&.active-content-badge { --color: var(--badge-active-content-background-color); }
|
||||
&.active-content-badge.disabled {
|
||||
|
||||
@@ -143,7 +143,7 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
isFullHeight: true
|
||||
},
|
||||
spreadsheet: {
|
||||
view: () => import("./type_widgets/Spreadsheet"),
|
||||
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
|
||||
className: "note-detail-spreadsheet",
|
||||
printable: true,
|
||||
isFullHeight: true
|
||||
|
||||
@@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
|
||||
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
color: var(--muted-text-color);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 4em;
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface SavedData {
|
||||
mime: string;
|
||||
content: string;
|
||||
position: number;
|
||||
encoding?: "base64";
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
const noteType = useNoteProperty(note, "type") ?? "";
|
||||
const [viewType] = useNoteLabel(note, "viewType");
|
||||
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
|
||||
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
|
||||
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
|
||||
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
|
||||
const isContentAvailable = note.isContentAvailable();
|
||||
|
||||
@@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N
|
||||
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
|
||||
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
|
||||
&& note.isContentAvailable() && isDefaultViewMode;
|
||||
|
||||
return isEnabled && <NoteAction
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TableOfContents.css";
|
||||
|
||||
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
|
||||
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -170,11 +170,14 @@ function EditableTextTableOfContents() {
|
||||
|
||||
const affectsHeadings = changes.some( change => {
|
||||
return (
|
||||
change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel')
|
||||
change.type === 'insert' || change.type === 'remove' ||
|
||||
(change.type === 'attribute' && attributeChangeAffectsHeading(change, textEditor))
|
||||
);
|
||||
});
|
||||
if (affectsHeadings) {
|
||||
setHeadings(extractTocFromTextEditor(textEditor));
|
||||
requestAnimationFrame(() => {
|
||||
setHeadings(extractTocFromTextEditor(textEditor));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.note-detail-file > .pdf-preview,
|
||||
.note-detail-file > .video-preview {
|
||||
.note-detail-file > .pdf-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-grow: 100;
|
||||
@@ -38,4 +37,4 @@
|
||||
right: 15px;
|
||||
width: calc(100% - 30px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import "./File.css";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import { getUrlForDownload } from "../../services/open";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteBlob } from "../react/hooks";
|
||||
import AudioPreview from "./file/Audio";
|
||||
import PdfPreview from "./file/Pdf";
|
||||
import VideoPreview from "./file/Video";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
const TEXT_MAX_NUM_CHARS = 5000;
|
||||
@@ -42,27 +42,6 @@ function TextPreview({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VideoPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<video
|
||||
class="video-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
datatype={note?.mime}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioPreview({ note }: { note: FNote }) {
|
||||
return (
|
||||
<audio
|
||||
class="audio-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPreview() {
|
||||
return (
|
||||
<Alert className="file-preview-not-available" type="info">
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import "@univerjs/preset-sheets-core/lib/index.css";
|
||||
import "./Spreadsheet.css";
|
||||
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
||||
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
|
||||
import { CommandType, createUniver, FUniver, IDisposable, IWorkbookData, LocaleType, mergeLocales } from '@univerjs/presets';
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { useColorScheme, useEditorSpacedUpdate } from "../react/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: Parameters<FUniver["createWorkbook"]>[0];
|
||||
}
|
||||
|
||||
export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<FUniver>();
|
||||
|
||||
useInitializeSpreadsheet(containerRef, apiRef);
|
||||
useDarkMode(apiRef);
|
||||
usePersistence(note, noteContext, apiRef);
|
||||
|
||||
return <div ref={containerRef} className="spreadsheet" />;
|
||||
}
|
||||
|
||||
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { univerAPI } = createUniver({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(
|
||||
UniverPresetSheetsCoreEnUS
|
||||
),
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container: containerRef.current,
|
||||
})
|
||||
]
|
||||
});
|
||||
apiRef.current = univerAPI;
|
||||
return () => univerAPI.dispose();
|
||||
}, [ apiRef, containerRef ]);
|
||||
}
|
||||
|
||||
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// React to dark mode.
|
||||
useEffect(() => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
univerAPI.toggleDarkMode(colorScheme === 'dark');
|
||||
}, [ colorScheme, apiRef ]);
|
||||
}
|
||||
|
||||
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>) {
|
||||
const changeListener = useRef<IDisposable>(null);
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
noteType: "spreadsheet",
|
||||
note,
|
||||
noteContext,
|
||||
getData() {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return undefined;
|
||||
const content = {
|
||||
version: 1,
|
||||
workbook: workbook.save()
|
||||
};
|
||||
return {
|
||||
content: JSON.stringify(content)
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
|
||||
// Dispose the existing workbook.
|
||||
const existingWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (existingWorkbook) {
|
||||
univerAPI.disposeUnit(existingWorkbook.getId());
|
||||
}
|
||||
|
||||
let workbookData: Partial<IWorkbookData> = {};
|
||||
if (newContent) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(newContent) as unknown;
|
||||
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
|
||||
const persistedData = parsedContent as PersistedData;
|
||||
workbookData = persistedData.workbook;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse spreadsheet content", e);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = univerAPI.createWorkbook(workbookData);
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
}
|
||||
changeListener.current = workbook.onCommandExecuted(command => {
|
||||
if (command.type !== CommandType.MUTATION) return;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
changeListener.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { getUrlForDownload } from "../../../services/open";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoItems from "../../react/NoItems";
|
||||
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||
|
||||
export default function AudioPreview({ note }: { note: FNote }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const togglePlayback = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, []);
|
||||
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
|
||||
|
||||
useEffect(() => setError(false), [note.noteId]);
|
||||
const onError = useCallback(() => setError(true), []);
|
||||
|
||||
if (error) {
|
||||
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<audio
|
||||
class="audio-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
ref={audioRef}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={onError}
|
||||
/>
|
||||
<div className="audio-preview-icon-wrapper">
|
||||
<Icon icon="bx bx-music" className="audio-preview-icon" />
|
||||
</div>
|
||||
<div className="media-preview-controls">
|
||||
<SeekBar mediaRef={audioRef} />
|
||||
|
||||
<div class="media-buttons-row">
|
||||
<div className="left">
|
||||
<PlaybackSpeed mediaRef={audioRef} />
|
||||
</div>
|
||||
|
||||
<div className="center">
|
||||
<div className="spacer" />
|
||||
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||
<LoopButton mediaRef={audioRef} />
|
||||
</div>
|
||||
|
||||
<div className="right">
|
||||
<VolumeControl mediaRef={audioRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
|
||||
return useCallback((e: KeyboardEvent) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayback();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
audio.muted = !audio.muted;
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
audio.volume = Math.min(1, audio.volume + 0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
audio.volume = Math.max(0, audio.volume - 0.05);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
audio.currentTime = 0;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
audio.currentTime = audio.duration;
|
||||
break;
|
||||
}
|
||||
}, [ audioRef, togglePlayback ]);
|
||||
}
|
||||
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.media-preview-controls {
|
||||
padding: 1.25em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
.media-buttons-row {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: var(--icon-button-size, 32px);
|
||||
height: var(--icon-button-size, 32px);
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
--icon-button-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-seekbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
.media-time {
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-trackbar {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.media-volume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
|
||||
.media-volume-slider {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.speed-dropdown {
|
||||
position: relative;
|
||||
|
||||
.tn-icon {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
|
||||
.media-speed-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(15%);
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-preview-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.audio-preview-icon-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import "./MediaPlayer.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Dropdown from "../../react/Dropdown";
|
||||
import Icon from "../../react/Icon";
|
||||
|
||||
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(media.currentTime);
|
||||
const onDurationChange = () => setDuration(media.duration);
|
||||
|
||||
media.addEventListener("timeupdate", onTimeUpdate);
|
||||
media.addEventListener("durationchange", onDurationChange);
|
||||
return () => {
|
||||
media.removeEventListener("timeupdate", onTimeUpdate);
|
||||
media.removeEventListener("durationchange", onDurationChange);
|
||||
};
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const onSeek = (e: Event) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="media-seekbar-row">
|
||||
<span class="media-time">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="media-trackbar"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onInput={onSeek}
|
||||
/>
|
||||
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PlayPauseButton({ playing, togglePlayback }: {
|
||||
playing: boolean,
|
||||
togglePlayback: () => void
|
||||
}) {
|
||||
return (
|
||||
<ActionButton
|
||||
className="play-button"
|
||||
icon={playing ? "bx bx-pause" : "bx bx-play"}
|
||||
text={playing ? t("media.pause") : t("media.play")}
|
||||
onClick={togglePlayback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
|
||||
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
|
||||
|
||||
// Sync state when the media element changes volume externally.
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
setVolume(media.volume);
|
||||
setMuted(media.muted);
|
||||
|
||||
const onVolumeChange = () => {
|
||||
setVolume(media.volume);
|
||||
setMuted(media.muted);
|
||||
};
|
||||
media.addEventListener("volumechange", onVolumeChange);
|
||||
return () => media.removeEventListener("volumechange", onVolumeChange);
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const onVolumeChange = (e: Event) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
media.volume = val;
|
||||
setVolume(val);
|
||||
if (val > 0 && media.muted) {
|
||||
media.muted = false;
|
||||
setMuted(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.muted = !media.muted;
|
||||
setMuted(media.muted);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="media-volume-row">
|
||||
<ActionButton
|
||||
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
|
||||
text={muted ? t("media.unmute") : t("media.mute")}
|
||||
onClick={toggleMute}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
class="media-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onInput={onVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
|
||||
const skip = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton icon={icon} text={text} onClick={skip} />
|
||||
);
|
||||
}
|
||||
|
||||
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
setLoop(media.loop);
|
||||
|
||||
const observer = new MutationObserver(() => setLoop(media.loop));
|
||||
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
|
||||
return () => observer.disconnect();
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const toggle = () => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.loop = !media.loop;
|
||||
setLoop(media.loop);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={loop ? "active" : ""}
|
||||
icon="bx bx-repeat"
|
||||
text={loop ? t("media.disable-loop") : t("media.loop")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
|
||||
|
||||
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
|
||||
|
||||
useEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
|
||||
setSpeed(media.playbackRate);
|
||||
|
||||
const onRateChange = () => setSpeed(media.playbackRate);
|
||||
media.addEventListener("ratechange", onRateChange);
|
||||
return () => media.removeEventListener("ratechange", onRateChange);
|
||||
}, [ mediaRef ]);
|
||||
|
||||
const selectSpeed = (rate: number) => {
|
||||
const media = mediaRef.current;
|
||||
if (!media) return;
|
||||
media.playbackRate = rate;
|
||||
setSpeed(rate);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
iconAction
|
||||
hideToggleArrow
|
||||
buttonClassName="speed-dropdown"
|
||||
text={<>
|
||||
<Icon icon="bx bx-tachometer" />
|
||||
<span class="media-speed-label">{speed}x</span>
|
||||
</>}
|
||||
title={t("media.playback-speed")}
|
||||
>
|
||||
{PLAYBACK_SPEEDS.map((rate) => (
|
||||
<li key={rate}>
|
||||
<button
|
||||
class={`dropdown-item ${rate === speed ? "active" : ""}`}
|
||||
onClick={() => selectSpeed(rate)}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
<PdfViewer
|
||||
iframeRef={iframeRef}
|
||||
tabIndex={300}
|
||||
pdfUrl={`../../api/notes/${note.noteId}/open`}
|
||||
pdfUrl={new URL(`${window.glob.baseApiUrl}notes/${note.noteId}/open`, window.location.href).pathname}
|
||||
onLoad={() => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (win) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HTMLAttributes, RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef } from "preact/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
|
||||
|
||||
interface FontDefinition {
|
||||
name: string;
|
||||
@@ -10,11 +11,11 @@ interface FontDefinition {
|
||||
|
||||
const FONTS: FontDefinition[] = [
|
||||
{name: "Inter", url: Inter},
|
||||
]
|
||||
];
|
||||
|
||||
interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabIndex"> {
|
||||
iframeRef?: RefObject<HTMLIFrameElement>;
|
||||
/** Note: URLs are relative to /pdfjs/web. */
|
||||
/** Note: URLs are relative to /pdfjs/web, ideally use absolute paths (but without domain name) to avoid issues with some proxies. */
|
||||
pdfUrl: string;
|
||||
onLoad?(): void;
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
|
||||
ref={iframeRef}
|
||||
class="pdf-preview"
|
||||
style={{width: "100%", height: "100%"}}
|
||||
src={`pdfjs/web/viewer.html?file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
|
||||
onLoad={() => {
|
||||
injectStyles();
|
||||
onLoad?.();
|
||||
@@ -63,7 +64,7 @@ function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
const fontStyles = doc.createElement("style");
|
||||
fontStyles.textContent = FONTS.map(injectFont).join("\n");
|
||||
doc.head.appendChild(fontStyles);
|
||||
|
||||
|
||||
}, [ iframeRef ]);
|
||||
|
||||
// React to changes.
|
||||
@@ -107,4 +108,4 @@ function injectFont(font: FontDefinition) {
|
||||
src: url('${font.url}');
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
35
apps/client/src/widgets/type_widgets/file/Video.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.note-detail-file > .video-preview-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: black;
|
||||
|
||||
.video-preview {
|
||||
background-color: black;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.controls-hidden {
|
||||
cursor: pointer;
|
||||
|
||||
.media-preview-controls {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview-controls {
|
||||
--icon-button-hover-color: white;
|
||||
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ease;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
298
apps/client/src/widgets/type_widgets/file/Video.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import "./Video.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { getUrlForDownload } from "../../../services/open";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import NoItems from "../../react/NoItems";
|
||||
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||
|
||||
const AUTO_HIDE_DELAY = 3000;
|
||||
|
||||
export default function VideoPreview({ note }: { note: FNote }) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const { visible: controlsVisible, onMouseMove, flash: flashControls } = useAutoHideControls(videoRef, playing);
|
||||
|
||||
useEffect(() => setError(false), [note.noteId]);
|
||||
const onError = useCallback(() => setError(true), []);
|
||||
|
||||
const togglePlayback = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onVideoClick = useCallback((e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
|
||||
togglePlayback();
|
||||
}, [togglePlayback]);
|
||||
|
||||
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
|
||||
|
||||
if (error) {
|
||||
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
class="video-preview"
|
||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||
datatype={note?.mime}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<div className="media-preview-controls">
|
||||
<SeekBar mediaRef={videoRef} />
|
||||
<div class="media-buttons-row">
|
||||
<div className="left">
|
||||
<PlaybackSpeed mediaRef={videoRef} />
|
||||
<RotateButton videoRef={videoRef} />
|
||||
</div>
|
||||
<div className="center">
|
||||
<div className="spacer" />
|
||||
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||
<LoopButton mediaRef={videoRef} />
|
||||
</div>
|
||||
<div className="right">
|
||||
<VolumeControl mediaRef={videoRef} />
|
||||
<ZoomToFitButton videoRef={videoRef} />
|
||||
<PictureInPictureButton videoRef={videoRef} />
|
||||
<FullscreenButton targetRef={wrapperRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
|
||||
return useCallback((e: KeyboardEvent) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayback();
|
||||
flashControls();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - (e.ctrlKey ? 60 : 10));
|
||||
flashControls();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + (e.ctrlKey ? 60 : 10));
|
||||
flashControls();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
e.preventDefault();
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
wrapperRef.current?.requestFullscreen();
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
video.muted = !video.muted;
|
||||
flashControls();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
video.volume = Math.min(1, video.volume + 0.05);
|
||||
flashControls();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
video.volume = Math.max(0, video.volume - 0.05);
|
||||
flashControls();
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
video.currentTime = 0;
|
||||
flashControls();
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
video.currentTime = video.duration;
|
||||
flashControls();
|
||||
break;
|
||||
}
|
||||
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
|
||||
}
|
||||
|
||||
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
|
||||
}
|
||||
}, [ videoRef]);
|
||||
|
||||
const onMouseMove = useCallback(() => {
|
||||
setVisible(true);
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
// Hide immediately when playback starts, show when paused.
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
setVisible(false);
|
||||
} else {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
setVisible(true);
|
||||
}
|
||||
return () => clearTimeout(hideTimerRef.current);
|
||||
}, [playing, scheduleHide]);
|
||||
|
||||
return { visible, onMouseMove, flash: onMouseMove };
|
||||
}
|
||||
|
||||
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
const rotate = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const next = (rotation + 90) % 360;
|
||||
setRotation(next);
|
||||
|
||||
const isSideways = next === 90 || next === 270;
|
||||
if (isSideways) {
|
||||
// Scale down so the rotated video fits within its container.
|
||||
const container = video.parentElement;
|
||||
if (container) {
|
||||
const ratio = container.clientWidth / container.clientHeight;
|
||||
video.style.transform = `rotate(${next}deg) scale(${1 / ratio})`;
|
||||
} else {
|
||||
video.style.transform = `rotate(${next}deg)`;
|
||||
}
|
||||
} else {
|
||||
video.style.transform = next === 0 ? "" : `rotate(${next}deg)`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
icon="bx bx-rotate-right"
|
||||
text={t("media.rotate")}
|
||||
onClick={rotate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [fitted, setFitted] = useState(false);
|
||||
|
||||
const toggle = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const next = !fitted;
|
||||
video.style.objectFit = next ? "cover" : "";
|
||||
setFitted(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={fitted ? "active" : ""}
|
||||
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
|
||||
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||
const [active, setActive] = useState(false);
|
||||
// The standard PiP API is only supported in Chromium-based browsers.
|
||||
// Firefox uses its own proprietary PiP implementation.
|
||||
const supported = "requestPictureInPicture" in HTMLVideoElement.prototype;
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !supported) return;
|
||||
|
||||
const onEnter = () => setActive(true);
|
||||
const onLeave = () => setActive(false);
|
||||
|
||||
video.addEventListener("enterpictureinpicture", onEnter);
|
||||
video.addEventListener("leavepictureinpicture", onLeave);
|
||||
return () => {
|
||||
video.removeEventListener("enterpictureinpicture", onEnter);
|
||||
video.removeEventListener("leavepictureinpicture", onLeave);
|
||||
};
|
||||
}, [ videoRef, supported ]);
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
const toggle = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture();
|
||||
} else {
|
||||
video.requestPictureInPicture();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
icon={active ? "bx bx-exit" : "bx bx-window-open"}
|
||||
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
|
||||
onClick={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> }) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const target = targetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
target.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
|
||||
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
153
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import "./Spreadsheet.css";
|
||||
import "@univerjs/preset-sheets-core/lib/index.css";
|
||||
import "@univerjs/preset-sheets-sort/lib/index.css";
|
||||
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
|
||||
import "@univerjs/preset-sheets-find-replace/lib/index.css";
|
||||
import "@univerjs/preset-sheets-note/lib/index.css";
|
||||
import "@univerjs/preset-sheets-filter/lib/index.css";
|
||||
import "@univerjs/preset-sheets-data-validation/lib/index.css";
|
||||
|
||||
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
|
||||
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
|
||||
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
|
||||
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
|
||||
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
|
||||
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
|
||||
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
|
||||
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
|
||||
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
|
||||
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
|
||||
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
|
||||
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
|
||||
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
|
||||
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import usePersistence from "./persistence";
|
||||
|
||||
export default function Spreadsheet(props: TypeWidgetProps) {
|
||||
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
|
||||
|
||||
// Use readOnly as key to force full remount (and data reload) when it changes.
|
||||
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
|
||||
}
|
||||
|
||||
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const apiRef = useRef<FUniver>();
|
||||
|
||||
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
|
||||
useDarkMode(apiRef);
|
||||
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
|
||||
useSearchIntegration(apiRef);
|
||||
useFixRadixPortals();
|
||||
|
||||
// Focus the spreadsheet when the note is focused.
|
||||
useTriliumEvent("focusOnDetail", () => {
|
||||
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
|
||||
if (focusable instanceof HTMLElement) {
|
||||
focusable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={containerRef} className="spreadsheet" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Univer's design system uses Radix UI primitives whose DismissableLayer detects
|
||||
* "outside" clicks/focus via document-level pointerdown/focusin listeners combined
|
||||
* with a React capture-phase flag. In React, portal events bubble through the
|
||||
* component tree so onPointerDownCapture fires on the DismissableLayer, setting an
|
||||
* internal flag that suppresses the "outside" detection. With preact/compat, portal
|
||||
* events don't bubble through the React tree, so the flag never gets set and Radix
|
||||
* immediately dismisses popups.
|
||||
*
|
||||
* Radix dispatches cancelable custom events ("dismissableLayer.pointerDownOutside"
|
||||
* and "dismissableLayer.focusOutside") on the original event target before calling
|
||||
* onDismiss. The dismiss is skipped if defaultPrevented is true. This hook intercepts
|
||||
* those custom events in the capture phase and prevents default when the target is
|
||||
* inside a Radix portal, restoring the expected behavior.
|
||||
*/
|
||||
function useFixRadixPortals() {
|
||||
useEffect(() => {
|
||||
function preventDismiss(e: Event) {
|
||||
if (e.target instanceof HTMLElement && e.target.closest("[id^='radix-']")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true);
|
||||
document.addEventListener("dismissableLayer.focusOutside", preventDismiss, true);
|
||||
return () => {
|
||||
document.removeEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true);
|
||||
document.removeEventListener("dismissableLayer.focusOutside", preventDismiss, true);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { univerAPI } = createUniver({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(
|
||||
sheetsCoreEnUS,
|
||||
sheetsFindReplaceEnUS,
|
||||
sheetsNoteEnUS,
|
||||
UniverPresetSheetsFilterEnUS,
|
||||
UniverPresetSheetsSortEnUS,
|
||||
UniverPresetSheetsDataValidationEnUS,
|
||||
UniverPresetSheetsConditionalFormattingEnUS,
|
||||
),
|
||||
},
|
||||
presets: [
|
||||
UniverSheetsCorePreset({
|
||||
container: containerRef.current,
|
||||
toolbar: !readOnly,
|
||||
contextMenu: !readOnly,
|
||||
formulaBar: !readOnly,
|
||||
footer: readOnly ? false : undefined,
|
||||
menu: {
|
||||
"sheet.contextMenu.permission": { hidden: true },
|
||||
"sheet-permission.operation.openPanel": { hidden: true },
|
||||
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
|
||||
},
|
||||
}),
|
||||
UniverSheetsFindReplacePreset(),
|
||||
UniverSheetsNotePreset(),
|
||||
UniverSheetsFilterPreset(),
|
||||
UniverSheetsSortPreset(),
|
||||
UniverSheetsDataValidationPreset(),
|
||||
UniverSheetsConditionalFormattingPreset()
|
||||
]
|
||||
});
|
||||
apiRef.current = univerAPI;
|
||||
return () => univerAPI.dispose();
|
||||
}, [ apiRef, containerRef, readOnly ]);
|
||||
}
|
||||
|
||||
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
// React to dark mode.
|
||||
useEffect(() => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
univerAPI.toggleDarkMode(colorScheme === 'dark');
|
||||
}, [ colorScheme, apiRef ]);
|
||||
}
|
||||
|
||||
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
|
||||
useTriliumEvent("findInText", () => {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
// Open find/replace panel and populate the search term.
|
||||
univerAPI.executeCommand("ui.operation.open-find-dialog");
|
||||
});
|
||||
}
|
||||
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
|
||||
import { MutableRef, useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../../../components/note_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: Parameters<FUniver["createWorkbook"]>[0];
|
||||
}
|
||||
|
||||
interface SpreadsheetViewState {
|
||||
activeSheetId?: string;
|
||||
cursorRow?: number;
|
||||
cursorCol?: number;
|
||||
scrollRow?: number;
|
||||
scrollCol?: number;
|
||||
}
|
||||
|
||||
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
|
||||
const changeListener = useRef<IDisposable>(null);
|
||||
const pendingContent = useRef<string | null>(null);
|
||||
|
||||
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
|
||||
const state: SpreadsheetViewState = {};
|
||||
try {
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return state;
|
||||
|
||||
const activeSheet = workbook.getActiveSheet();
|
||||
state.activeSheetId = activeSheet?.getSheetId();
|
||||
|
||||
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
|
||||
if (currentCell) {
|
||||
state.cursorRow = currentCell.actualRow;
|
||||
state.cursorCol = currentCell.actualColumn;
|
||||
}
|
||||
|
||||
const scrollState = activeSheet?.getScrollState?.();
|
||||
if (scrollState) {
|
||||
state.scrollRow = scrollState.sheetViewStartRow;
|
||||
state.scrollCol = scrollState.sheetViewStartColumn;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when reading state from a workbook being disposed.
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
|
||||
try {
|
||||
if (state.activeSheetId) {
|
||||
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
|
||||
if (targetSheet) {
|
||||
workbook.setActiveSheet(targetSheet);
|
||||
}
|
||||
}
|
||||
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
|
||||
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
|
||||
}
|
||||
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
|
||||
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when restoring state (e.g. sheet no longer exists).
|
||||
}
|
||||
}
|
||||
|
||||
function applyContent(univerAPI: FUniver, newContent: string) {
|
||||
const viewState = saveViewState(univerAPI);
|
||||
|
||||
// Dispose the existing workbook.
|
||||
const existingWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (existingWorkbook) {
|
||||
univerAPI.disposeUnit(existingWorkbook.getId());
|
||||
}
|
||||
|
||||
let workbookData: Partial<IWorkbookData> = {};
|
||||
if (newContent) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(newContent) as unknown;
|
||||
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
|
||||
const persistedData = parsedContent as PersistedData;
|
||||
workbookData = persistedData.workbook;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse spreadsheet content", e);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = univerAPI.createWorkbook(workbookData);
|
||||
if (readOnly) {
|
||||
workbook.disableSelection();
|
||||
const permission = workbook.getPermission();
|
||||
permission.setWorkbookEditPermission(workbook.getId(), false);
|
||||
permission.setPermissionDialogVisible(false);
|
||||
}
|
||||
|
||||
restoreViewState(workbook, viewState);
|
||||
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
}
|
||||
changeListener.current = workbook.onCommandExecuted(command => {
|
||||
if (command.type !== CommandType.MUTATION) return;
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
function isContainerVisible() {
|
||||
const el = containerRef.current;
|
||||
if (!el) return false;
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0;
|
||||
}
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
noteType: "spreadsheet",
|
||||
note,
|
||||
noteContext,
|
||||
async getData() {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
const workbook = univerAPI.getActiveWorkbook();
|
||||
if (!workbook) return undefined;
|
||||
const content = {
|
||||
version: 1,
|
||||
workbook: workbook.save()
|
||||
};
|
||||
|
||||
const attachments: SavedData["attachments"] = [];
|
||||
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
|
||||
if (canvasEl) {
|
||||
const dataUrl = canvasEl.toDataURL("image/png");
|
||||
const base64 = dataUrl.split(",")[1];
|
||||
attachments.push({
|
||||
role: "image",
|
||||
title: "spreadsheet-export.png",
|
||||
mime: "image/png",
|
||||
content: base64,
|
||||
position: 0,
|
||||
encoding: "base64"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(content),
|
||||
attachments
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return undefined;
|
||||
|
||||
// Defer content application if the container is hidden (zero size),
|
||||
// since the spreadsheet library cannot calculate layout in that state.
|
||||
if (!isContainerVisible()) {
|
||||
pendingContent.current = newContent;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, newContent);
|
||||
},
|
||||
});
|
||||
|
||||
// Apply pending content once the container becomes visible (non-zero size).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pendingContent.current === null || !isContainerVisible()) return;
|
||||
|
||||
const univerAPI = apiRef.current;
|
||||
if (!univerAPI) return;
|
||||
|
||||
const content = pendingContent.current;
|
||||
pendingContent.current = null;
|
||||
applyContent(univerAPI, content);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
|
||||
}, [ containerRef ]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeListener.current) {
|
||||
changeListener.current.dispose();
|
||||
changeListener.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"colors": "1.4.0",
|
||||
"diff": "8.0.3",
|
||||
"sqlite": "5.1.1",
|
||||
"sqlite3": "5.1.7"
|
||||
"sqlite3": "6.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/compare.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"private": true,
|
||||
"main": "src/main.ts",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.8.0",
|
||||
"electron": "40.8.1",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/edit-docs",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"private": true,
|
||||
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
|
||||
"dependencies": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.8.0",
|
||||
"electron": "40.8.1",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Nginx Proxy Manager (for testing reverse proxy setups)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start Trilium on the host (default port 8080):
|
||||
```bash
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
2. Start Nginx Proxy Manager:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
|
||||
- Email: `admin@example.com`
|
||||
- Password: `changeme`
|
||||
(You'll be asked to change these on first login.)
|
||||
|
||||
4. Add a proxy host:
|
||||
- **Domain Names**: `localhost`
|
||||
- **Scheme**: `http`
|
||||
- **Forward Hostname / IP**: `host.docker.internal`
|
||||
- **Forward Port**: `8080`
|
||||
- Enable **Websockets Support** (required for Trilium sync)
|
||||
|
||||
5. Access Trilium through NPM at **http://localhost:8090**.
|
||||
|
||||
## With a subpath
|
||||
|
||||
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
|
||||
|
||||
```nginx
|
||||
location /trilium/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://host.docker.internal:8080/;
|
||||
proxy_cookie_path / /trilium/;
|
||||
proxy_read_timeout 90;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
nginx-proxy-manager:
|
||||
image: "jc21/nginx-proxy-manager:latest"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Public HTTP port
|
||||
- "8090:80"
|
||||
# Admin panel
|
||||
- "8081:81"
|
||||
volumes:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
# Use host network mode so NPM can reach Trilium on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
npm_letsencrypt:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"main": "./src/main.ts",
|
||||
@@ -55,9 +55,9 @@
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -82,14 +82,14 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "40.8.0",
|
||||
"electron": "40.8.1",
|
||||
"electron-debug": "4.1.0",
|
||||
"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.4",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"express-rate-limit": "8.3.1",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
@@ -98,7 +98,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.17",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -128,6 +128,6 @@
|
||||
"vite": "7.3.1",
|
||||
"ws": "8.19.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.0"
|
||||
"yauzl": "3.2.1"
|
||||
}
|
||||
}
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
12
apps/server/src/assets/doc_notes/en/User Guide/User Guide/AI.html
generated
vendored
@@ -4,21 +4,17 @@
|
||||
maintaining and supporting it long-term proved to be unsustainable.</p>
|
||||
<p>When upgrading to v0.102.0, your Chat notes will be preserved, but instead
|
||||
of the dedicated chat window they will be turned to a normal <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a> note,
|
||||
revealing the underlying JSON of the conversation.</p>
|
||||
href="#root/_help_6f9hih2hXXZk">Code</a> note, revealing the underlying
|
||||
JSON of the conversation.</p>
|
||||
<h2>Alternative solutions (MCP)</h2>
|
||||
<p>Given the recent advancements of the AI scene, MCP has grown to be more
|
||||
powerful and facilitates easier integrations with various application.</p>
|
||||
<p>As such, there are third-party solutions that integrate an MCP server
|
||||
that can be used with Trilium:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p><a href="https://github.com/tan-yong-sheng/triliumnext-mcp">tan-yong-sheng/triliumnext-mcp</a>
|
||||
</p>
|
||||
<li><a href="https://github.com/tan-yong-sheng/triliumnext-mcp">tan-yong-sheng/triliumnext-mcp</a>
|
||||
</li>
|
||||
<li>
|
||||
<p><a href="https://github.com/perfectra1n/triliumnext-mcp">perfectra1n/triliumnext-mcp</a>
|
||||
</p>
|
||||
<li><a href="https://github.com/perfectra1n/triliumnext-mcp">perfectra1n/triliumnext-mcp</a>
|
||||
</li>
|
||||
</ul>
|
||||
<aside class="admonition important">
|
||||
|
||||
44
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
generated
vendored
@@ -209,7 +209,7 @@
|
||||
<tr>
|
||||
<td><code spellcheck="false">#calendar:color</code>
|
||||
</td>
|
||||
<td><strong>❌️ Removed since v0.100.0. Use</strong> <code spellcheck="false">**#color**</code> <strong>instead.</strong>
|
||||
<td><strong>❌️ Removed since v0.100.0. Use</strong> <code spellcheck="false">**#color**</code> <strong>instead.</strong>
|
||||
<br>
|
||||
<br>Similar to <code spellcheck="false">#color</code>, but applies the color
|
||||
only for the event in the calendar and not for other places such as the
|
||||
@@ -233,15 +233,15 @@
|
||||
<td><code spellcheck="false">#calendar:displayedAttributes</code>
|
||||
</td>
|
||||
<td>Allows displaying the value of one or more attributes in the calendar
|
||||
like this:
|
||||
like this:
|
||||
<br>
|
||||
<br>
|
||||
<img src="7_Calendar_image.png">
|
||||
<img src="7_Calendar_image.png">
|
||||
<br>
|
||||
<br><code spellcheck="false">#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>
|
||||
<br><code spellcheck="false">#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>
|
||||
<br>
|
||||
<br>It can also be used with relations, case in which it will display the
|
||||
title of the target note:
|
||||
title of the target note:
|
||||
<br>
|
||||
<br><code spellcheck="false">~assignee=@My assignee #calendar:displayedAttributes="assignee"</code>
|
||||
</td>
|
||||
@@ -294,44 +294,27 @@
|
||||
<p>When not used in a Journal, the calendar is recursive. That is, it will
|
||||
look for events not just in its child notes but also in the children of
|
||||
these child notes.</p>
|
||||
<p> </p>
|
||||
<h2>Recurrence</h2>
|
||||
<p>The built in calendar view also supports repeating tasks. If a child note
|
||||
of the calendar has a #recurrence label with a valid recurrence, that event
|
||||
will repeat on the calendar according to the recurrence string. </p>
|
||||
<p>For example, to make a note repeat on the calendar:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Every Day - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=1"</code>
|
||||
</p>
|
||||
<li>Every Day - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=1"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every 3 days - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=3"</code>
|
||||
</p>
|
||||
<li>Every 3 days - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=3"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every week - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=1"</code>
|
||||
</p>
|
||||
<li>Every week - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=1"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every 2 weeks on Monday, Wednesday and Friday - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"</code>
|
||||
</p>
|
||||
<li>Every 2 weeks on Monday, Wednesday and Friday - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every 3 months - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=3"</code>
|
||||
</p>
|
||||
<li>Every 3 months - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=3"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every 2 months on the First Sunday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"</code>
|
||||
</p>
|
||||
<li>Every 2 months on the First Sunday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"</code>
|
||||
</li>
|
||||
<li>
|
||||
<p>Every month on the Last Friday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"</code>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>And so on.</p>
|
||||
<li>Every month on the Last Friday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"</code>
|
||||
</li>
|
||||
<li>And so on.</li>
|
||||
</ul>
|
||||
<p>For other examples of valid <code spellcheck="false">RRULE</code> strings
|
||||
see <a href="https://icalendar.org/rrule-tool.html">https://icalendar.org/rrule-tool.html</a>
|
||||
@@ -352,7 +335,6 @@
|
||||
note ID and title of the note with the erroneous recurrence message. This
|
||||
note will not be added to the calendar</p>
|
||||
</aside>
|
||||
<p> </p>
|
||||
<h2>Use-cases</h2>
|
||||
<h3>Using with the Journal / calendar</h3>
|
||||
<p>It is possible to integrate the calendar view into the Journal with day
|
||||
|
||||
190
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types.html
generated
vendored
@@ -9,7 +9,8 @@
|
||||
note where to place the new one and select:</p>
|
||||
<ul>
|
||||
<li><em>Insert note after</em>, to put the new note underneath the one selected.</li>
|
||||
<li><em>Insert child note</em>, to insert the note as a child of the selected
|
||||
<li
|
||||
><em>Insert child note</em>, to insert the note as a child of the selected
|
||||
note.</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -20,7 +21,8 @@
|
||||
<li>When adding a <a href="#root/_help_QEAPj01N5f7w">link</a> in a <a class="reference-link"
|
||||
href="#root/_help_iPIMuisry3hd">Text</a> note, type the desired title of
|
||||
the new note and press Enter. Afterwards the type of the note will be asked.</li>
|
||||
<li>Similarly, when creating a new tab, type the desired title and press Enter.</li>
|
||||
<li
|
||||
>Similarly, when creating a new tab, type the desired title and press Enter.</li>
|
||||
</ul>
|
||||
<h2>Changing the type of a note</h2>
|
||||
<p>It is possible to change the type of a note after it has been created
|
||||
@@ -30,94 +32,96 @@
|
||||
edit the <a href="#root/_help_4FahAwuGTAwC">source of a note</a>.</p>
|
||||
<h2>Supported note types</h2>
|
||||
<p>The following note types are supported by Trilium:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Note Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
|
||||
</td>
|
||||
<td>The default note type, which allows for rich text formatting, images,
|
||||
admonitions and right-to-left support.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
|
||||
</td>
|
||||
<td>Uses a mono-space font and can be used to store larger chunks of code
|
||||
or plain text than a text note, and has better syntax highlighting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
|
||||
</td>
|
||||
<td>Stores the information about a search (the search text, criteria, etc.)
|
||||
for later use. Can be used for quick filtering of a large amount of notes,
|
||||
for example. The search can easily be triggered.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
|
||||
</td>
|
||||
<td>Allows easy creation of notes and relations between them. Can be used
|
||||
for mainly relational data such as a family tree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
|
||||
</td>
|
||||
<td>Displays the relationships between the notes, whether via relations or
|
||||
their hierarchical structure.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
|
||||
</td>
|
||||
<td>Used in <a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
|
||||
it displays the HTML content of another note. This allows displaying any
|
||||
kind of content, provided there is a script behind it to generate it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
|
||||
</td>
|
||||
<td>Displays the children of the note either as a grid, a list, or for a more
|
||||
specialized case: a calendar.
|
||||
<br>
|
||||
<br>Generally useful for easy reading of short notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
|
||||
</td>
|
||||
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
|
||||
Requires a bit of technical knowledge since the diagrams are written in
|
||||
a specialized format.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
|
||||
</td>
|
||||
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
|
||||
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
|
||||
</td>
|
||||
<td>Displays the content of an external web page, similar to a browser.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
|
||||
</td>
|
||||
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a>
|
||||
</td>
|
||||
<td>Displays the children of the note as a geographical map, one use-case
|
||||
would be to plan vacations. It even has basic support for tracks. Notes
|
||||
can also be created from it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
|
||||
</td>
|
||||
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Note Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
|
||||
</td>
|
||||
<td>The default note type, which allows for rich text formatting, images,
|
||||
admonitions and right-to-left support.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
|
||||
</td>
|
||||
<td>Uses a mono-space font and can be used to store larger chunks of code
|
||||
or plain text than a text note, and has better syntax highlighting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
|
||||
</td>
|
||||
<td>Stores the information about a search (the search text, criteria, etc.)
|
||||
for later use. Can be used for quick filtering of a large amount of notes,
|
||||
for example. The search can easily be triggered.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
|
||||
</td>
|
||||
<td>Allows easy creation of notes and relations between them. Can be used
|
||||
for mainly relational data such as a family tree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
|
||||
</td>
|
||||
<td>Displays the relationships between the notes, whether via relations or
|
||||
their hierarchical structure.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
|
||||
</td>
|
||||
<td>Used in <a class="reference-link" href="#root/_help_CdNpE2pqjmI6">Scripting</a>,
|
||||
it displays the HTML content of another note. This allows displaying any
|
||||
kind of content, provided there is a script behind it to generate it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
|
||||
</td>
|
||||
<td>Displays the children of the note either as a grid, a list, or for a more
|
||||
specialized case: a calendar.
|
||||
<br>
|
||||
<br>Generally useful for easy reading of short notes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
|
||||
</td>
|
||||
<td>Displays diagrams such as bar charts, flow charts, state diagrams, etc.
|
||||
Requires a bit of technical knowledge since the diagrams are written in
|
||||
a specialized format.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
|
||||
</td>
|
||||
<td>Allows easy drawing of sketches, diagrams, handwritten content. Uses the
|
||||
same technology behind <a href="https://excalidraw.com">excalidraw.com</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
|
||||
</td>
|
||||
<td>Displays the content of an external web page, similar to a browser.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
|
||||
</td>
|
||||
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
|
||||
</td>
|
||||
<td>Displays the children of the note as a geographical map, one use-case
|
||||
would be to plan vacations. It even has basic support for tracks. Notes
|
||||
can also be created from it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
|
||||
</td>
|
||||
<td>Represents an uploaded file such as PDFs, images, video or audio files.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/1_File_image.png
generated
vendored
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/2_File_image.png
generated
vendored
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 612 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/3_File_image.png
generated
vendored
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 10 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/4_File_image.png
generated
vendored
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 15 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/5_File_image.png
generated
vendored
|
Before Width: | Height: | Size: 15 KiB |
27
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File.html
generated
vendored
@@ -13,7 +13,7 @@
|
||||
<p>See <a class="reference-link" href="#root/_help_XJGJrpu7F9sh">PDFs</a>.</p>
|
||||
<h3>Images</h3>
|
||||
<figure class="image image-style-align-center image_resized" style="width:50%;">
|
||||
<img style="aspect-ratio:879/766;" src="3_File_image.png"
|
||||
<img style="aspect-ratio:879/766;" src="2_File_image.png"
|
||||
width="879" height="766">
|
||||
</figure>
|
||||
<p>Interaction:</p>
|
||||
@@ -30,25 +30,10 @@
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Videos</h3>
|
||||
<figure class="image image-style-align-center image_resized" style="width:50%;">
|
||||
<img style="aspect-ratio:854/700;" src="File_image.png"
|
||||
width="854" height="700">
|
||||
</figure>
|
||||
<p>Video files can be added in as well. The file is streamed directly, so
|
||||
when accessing the note from a server it doesn't have to download the entire
|
||||
video to start playing it.</p>
|
||||
<aside class="admonition caution">
|
||||
<p>Although Trilium offers support for videos, it is generally not meant
|
||||
to be used with very large files. Uploading large videos will cause the
|
||||
<a
|
||||
class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a> to balloon as well as the any <a class="reference-link"
|
||||
href="#root/_help_ODY7qQn5m2FT">Backup</a> of it. In addition to that, there
|
||||
might be slowdowns when first uploading the files. Otherwise, a large database
|
||||
should not impact the general performance of Trilium significantly.</p>
|
||||
</aside>
|
||||
<p>See <a class="reference-link" href="#root/_help_AjqEeiDUOzj4">Videos</a>.</p>
|
||||
<h3>Audio</h3>
|
||||
<figure class="image image-style-align-center image_resized" style="width:50%;">
|
||||
<img style="aspect-ratio:850/243;" src="2_File_image.png"
|
||||
<img style="aspect-ratio:850/243;" src="1_File_image.png"
|
||||
width="850" height="243">
|
||||
</figure>
|
||||
<p>Adding a supported audio file will reveal a basic audio player that can
|
||||
@@ -64,7 +49,7 @@
|
||||
</ul>
|
||||
<h3>Text files</h3>
|
||||
<figure class="image image-style-align-center image_resized" style="width:50%;">
|
||||
<img style="aspect-ratio:926/347;" src="1_File_image.png"
|
||||
<img style="aspect-ratio:926/347;" src="File_image.png"
|
||||
width="926" height="347">
|
||||
</figure>
|
||||
<p>Files that are identified as containing text will show a preview of their
|
||||
@@ -83,7 +68,7 @@
|
||||
application.</p>
|
||||
<h3>Unknown file types</h3>
|
||||
<figure class="image image-style-align-center image_resized" style="width:50%;">
|
||||
<img style="aspect-ratio:532/240;" src="4_File_image.png"
|
||||
<img style="aspect-ratio:532/240;" src="3_File_image.png"
|
||||
width="532" height="240">
|
||||
</figure>
|
||||
<p>If the file could not be identified as any of the supported file types
|
||||
@@ -110,7 +95,7 @@
|
||||
<p>Files are also displayed in the <a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a> based
|
||||
on their type:</p>
|
||||
<img class="image_resized" style="aspect-ratio:853/315;width:50%;"
|
||||
src="5_File_image.png" width="853" height="315">
|
||||
src="4_File_image.png" width="853" height="315">
|
||||
</li>
|
||||
<li>
|
||||
<p>Non-image files can be embedded into text notes as read-only widgets via
|
||||
|
||||
131
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File/Videos.html
generated
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
<figure class="image image-style-align-right image_resized" style="width:61.8%;">
|
||||
<img style="aspect-ratio:953/587;" src="Videos_image.png"
|
||||
width="953" height="587">
|
||||
</figure>
|
||||
<p>Starting with v0.103.0, Trilium has a custom video player which offers
|
||||
more features than the built-in video player.</p>
|
||||
<p>Versions prior to v0.103.0 also support videos, but using the built-in
|
||||
player.</p>
|
||||
<p>The file is streamed directly, so when accessing the note from a server
|
||||
it doesn't have to download the entire video to start playing it.</p>
|
||||
<h2>Note on large video files</h2>
|
||||
<p>Although Trilium offers support for videos, it is generally not meant
|
||||
to be used with very large files. Uploading large videos will cause the
|
||||
<a
|
||||
class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a> to balloon as well as the any <a class="reference-link"
|
||||
href="#root/_help_ODY7qQn5m2FT">Backup</a> of it. In addition to that, there
|
||||
might be slowdowns when first uploading the files. Otherwise, a large database
|
||||
should not impact the general performance of Trilium significantly.</p>
|
||||
<h2>Supported formats</h2>
|
||||
<p>Trilium uses the built-in video decoding mechanism of the browser (or
|
||||
Electron/Chromium when running on the desktop). Starting with v0.103.0,
|
||||
a message will be displayed instead when a video format is not supported.</p>
|
||||
<h2>Interactions</h2>
|
||||
<p>To play/pause the video, simply click anywhere on the video.</p>
|
||||
<p>The controls at the bottom will hide automatically after playing, simply
|
||||
move the mouse to show them again.</p>
|
||||
<p>The bottom bar has the following features:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>A track bar to seek across the video.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>On the left of the track bar, the current time is indicated.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>On the right of the track bar, the remaining time is indicated.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>On the left side there are buttons to:</p>
|
||||
<ul>
|
||||
<li>Adjust the playback speed (e.g. 0.5x, 1x).</li>
|
||||
<li>Rotate the video by 90 degrees.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>In the center:</p>
|
||||
<ul>
|
||||
<li>Go back by 10s</li>
|
||||
<li>Play/pause</li>
|
||||
<li>Go forward by 30s</li>
|
||||
<li>Loop, which when enabled will restart the video once it reaches the end.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>On the right side:</p>
|
||||
<ul>
|
||||
<li>Mute button</li>
|
||||
<li>Volume adjustment</li>
|
||||
<li>Full screen</li>
|
||||
<li>Zoom to fill, which will crop the video so that it fills the entire window.</li>
|
||||
<li>Picture-in-picture (if the browser supports it).</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>The following keyboard shortcuts are supported by the video player:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><kbd>Space</kbd>
|
||||
</td>
|
||||
<td>Play/pause</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Left arrow key</kbd>
|
||||
</td>
|
||||
<td>Go back by 10s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Right arrow key</kbd>
|
||||
</td>
|
||||
<td>Go forward by 10s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Ctrl</kbd> + <kbd>Left arrow key</kbd>
|
||||
</td>
|
||||
<td>Go back by 1 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Ctrl</kbd> + <kbd>Right arrow key</kbd>
|
||||
</td>
|
||||
<td>Go right by 1 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>F</kbd>
|
||||
</td>
|
||||
<td>Toggle full-screen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>M</kbd>
|
||||
</td>
|
||||
<td>Mute/unmute</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Home</kbd>
|
||||
</td>
|
||||
<td>Go to the beginning of the video</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>End</kbd>
|
||||
</td>
|
||||
<td>Go to the end of the video</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Up</kbd>
|
||||
</td>
|
||||
<td>Increase volume by 5%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>Down</kbd>
|
||||
</td>
|
||||
<td>Decrease volume by 5%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File/Videos_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 842 KiB |
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/File_image.png
generated
vendored
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 15 KiB |
@@ -38,30 +38,34 @@
|
||||
<img src="1_Mermaid Diagrams_image.png">
|
||||
</li>
|
||||
<li>The preview can be moved around by holding the left mouse button and dragging.</li>
|
||||
<li>Zooming can also be done by using the scroll wheel.</li>
|
||||
<li>The zoom and position on the preview will remain fixed as the diagram
|
||||
changes, to be able to work more easily with large diagrams.</li>
|
||||
</ul>
|
||||
<li
|
||||
>Zooming can also be done by using the scroll wheel.</li>
|
||||
<li>The zoom and position on the preview will remain fixed as the diagram
|
||||
changes, to be able to work more easily with large diagrams.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>The size of the source/preview panes can be adjusted by hovering over
|
||||
the border between them and dragging it with the mouse.</li>
|
||||
<li>In the <a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a> area:
|
||||
<ul>
|
||||
<li>The source/preview can be laid out left-right or bottom-top via the <em>Move editing pane to the left / bottom</em> option.</li>
|
||||
<li>Press <em>Lock editing</em> to automatically mark the note as read-only.
|
||||
<li
|
||||
>Press <em>Lock editing</em> to automatically mark the note as read-only.
|
||||
In this mode, the code pane is hidden and the diagram is displayed full-size.
|
||||
Similarly, press <em>Unlock editing</em> to mark a read-only note as editable.</li>
|
||||
<li>Press the <em>Copy image reference to the clipboard</em> to be able to insert
|
||||
the image representation of the diagram into a text note. See <a class="reference-link"
|
||||
href="#root/_help_0Ofbk1aSuVRu">Image references</a> for more information.</li>
|
||||
<li>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
|
||||
of the diagram. Can be used to present the diagram without degrading when
|
||||
zooming.</li>
|
||||
<li
|
||||
>Press the <em>Copy image reference to the clipboard</em> to be able to insert
|
||||
the image representation of the diagram into a text note. See <a class="reference-link"
|
||||
href="#root/_help_0Ofbk1aSuVRu">Image references</a> for more information.</li>
|
||||
<li
|
||||
>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
|
||||
of the diagram. Can be used to present the diagram without degrading when
|
||||
zooming.</li>
|
||||
<li>Press the <em>Export diagram as PNG</em> to download a normal image (at
|
||||
1x scale, raster) of the diagram. Can be used to send the diagram in more
|
||||
traditional channels such as e-mail.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h2>Errors in the diagram</h2>
|
||||
<p>If there is an error in the source code, the error will be displayed in
|
||||
|
||||
111
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Spreadsheets.html
generated
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
<figure class="image">
|
||||
<img style="aspect-ratio:1102/573;" src="Spreadsheets_image.png"
|
||||
width="1102" height="573">
|
||||
</figure>
|
||||
<aside class="admonition important">
|
||||
<p>Spreadsheets are a new type of note introduced in v0.103.0 and are currently
|
||||
considered experimental/beta. As such, expect major changes to occur to
|
||||
this note type.</p>
|
||||
</aside>
|
||||
<p>Spreadsheets provide a familiar experience to Microsoft Excel or LibreOffice
|
||||
Calc, with support for formulas, data validation and text formatting.</p>
|
||||
<h2>Spreadsheets vs. collections</h2>
|
||||
<p>There is a slight overlap between spreadsheets and the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/GTwFsgaA0lCt/_help_2FvYrpmOXm29">Table</a> collection.
|
||||
In general the table collection is useful to track meta-information about
|
||||
notes (for example a collection of people and their birthdays), whereas
|
||||
spreadsheets are quite useful for calculations since they support formulas.</p>
|
||||
<p>Spreadsheets also benefit from a wider range of features such as data
|
||||
validation, formatting and can work on a relatively large dataset.</p>
|
||||
<h2>Important statement regarding data format</h2>
|
||||
<p>For Trilium as a knowledge database, it is important that data is stored
|
||||
in a format that is easy to convert to something else. For example,
|
||||
<a
|
||||
class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a> notes can be exported to either HTML or Markdown, making
|
||||
it relatively easy to migrate to another software or simply to stand the
|
||||
test of time.</p>
|
||||
<p>For spreadsheets, Trilium uses a technology called <a href="https://docs.univer.ai/">Univer Sheets</a>,
|
||||
developed by DreamNum Co., Ltd. Although this software library is quite
|
||||
powerful and has a good track record (starting with Luckysheet from 2020,
|
||||
becoming Univer somewhere in 2023), it uses its own JSON format to store
|
||||
the sheets.</p>
|
||||
<p>As such, if Univer were to become unmaintained or incompatible for some
|
||||
reason, your data might become vendor locked-in.</p>
|
||||
<p>With that in mind, spreadsheets can be really useful for quick calculations,
|
||||
but it's important not to have critical information on it that you might
|
||||
not want to need in a few years time.</p>
|
||||
<h2>Regarding data export</h2>
|
||||
<p>Currently, in Trilium there is no way to export the spreadsheets to CSV
|
||||
or Excel formats. We might manage to add support for it at some point,
|
||||
but currently this is not the case.</p>
|
||||
<h2>Supported features</h2>
|
||||
<p>The spreadsheet has support for the following features:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Filtering</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Sorting</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Data validation</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Conditional formatting</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Notes / annotations</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Find / replace</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>We might consider adding <a href="https://docs.univer.ai/guides/sheets/features/filter">other features</a> from
|
||||
Univer at some point. If there is a particular feature that can be added
|
||||
easily, it can be discussed over <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">GitHub Issues</a>.</p>
|
||||
<h2>Features not supported yet</h2>
|
||||
<h3>Regarding Pro features</h3>
|
||||
<p>Univer spreadsheets also feature a <a href="https://univer.ai/pro">Pro plan</a> which
|
||||
adds quite a lot of functionality such as charts, printing, pivot tables,
|
||||
export, etc.</p>
|
||||
<p>As the Pro plan needs a license, Trilium does not support any of the premium
|
||||
features. Theoretically, pro features can be used in trial mode with some
|
||||
limitations, we might explore this direction at some point.</p>
|
||||
<h3>Planned features</h3>
|
||||
<p>There are a few features that are already planned but are not supported
|
||||
yet:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Trilium-specific formulas (e.g. to obtain the title of a note).</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>User-defined formulas</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Cross-workbook calculation</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>If you would like us to work on these features, consider <a href="https://triliumnotes.org/en/support-us">supporting us</a>.</p>
|
||||
<h2>Known limitations</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<p>It is possible to share a spreadsheet, case in which a best-effort HTML
|
||||
rendering of the spreadsheet is done.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>For more advanced use cases, this will most likely not work as intended.
|
||||
Feel free to <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report issues</a>,
|
||||
but keep in mind that we might not be able to have a complete feature parity
|
||||
with all the features of Univer.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>There is currently no export functionality, as stated previously.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>There is no dedicated mobile support. Mobile support is currently experimental
|
||||
in Univer and when it becomes stable, we could potentially integrate it
|
||||
into Trilium as well.</p>
|
||||
</li>
|
||||
</ul>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Spreadsheets_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 117 KiB |
@@ -155,8 +155,6 @@
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"totp-token": "TOTP 验证码",
|
||||
"totp-token-placeholder": "请输入 TOTP 验证码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
|
||||
@@ -252,8 +252,6 @@
|
||||
"proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Password",
|
||||
"totp-token": "TOTP Token",
|
||||
"totp-token-placeholder": "Enter your TOTP code",
|
||||
"back": "Back",
|
||||
"finish-setup": "Finish setup"
|
||||
},
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"go-to-next-note-title": "К следующей заметке",
|
||||
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
|
||||
"zen-mode": "Режим \"Дзен\"",
|
||||
"command-palette": "Открыть панель команд"
|
||||
"command-palette": "Открыть панель команд",
|
||||
"tab-switcher-title": "Переключатель вкладок"
|
||||
},
|
||||
"tray": {
|
||||
"bookmarks": "Закладки",
|
||||
@@ -313,7 +314,7 @@
|
||||
"title": "Настройка",
|
||||
"heading": "Настройка Trilium",
|
||||
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
|
||||
"sync-from-desktop": "У меня уже есть приложение ПК, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
|
||||
"init-in-progress": "Идет инициализация документа",
|
||||
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
|
||||
@@ -397,8 +398,8 @@
|
||||
"clipped-from": "Эта заметка изначально была вырезана из {{- url}}"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронизация с приложения ПК",
|
||||
"description": "Эту настройку необходимо инициировать из приложения для ПК:",
|
||||
"heading": "Синхронизация с настольной версией",
|
||||
"description": "Это настройку нужно выполнить с помощью настольной версии:",
|
||||
"step1": "Откройте приложение Trilium Notes на ПК.",
|
||||
"step2": "В меню Trilium выберите «Параметры».",
|
||||
"step3": "Нажмите на категорию «Синхронизация».",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "Gå till föregående anteckning i historiken",
|
||||
"forward-in-note-history": "Gå till nästa anteckning i historiken",
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
|
||||
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog",
|
||||
"open-command-palette": "Öppna kommandomenyn",
|
||||
"quick-search": "Öppna snabbsökning",
|
||||
"search-in-subtree": "Sök anteckningar nedåt i anteckningshierarkin",
|
||||
"expand-subtree": "Expandera hierarkin under denna anteckning",
|
||||
"collapse-tree": "Stänger anteckningshierarkin",
|
||||
"collapse-subtree": "Stänger hierarkin under aktuell anteckning",
|
||||
"sort-child-notes": "Sortera underordnade anteckningar",
|
||||
"creating-and-moving-notes": "Skapa och flytta anteckningar",
|
||||
"create-note-after": "Skapa ny anteckning efter aktiv anteckning",
|
||||
"create-note-into": "Skapa ny anteckning underordnad aktiv anteckning",
|
||||
"create-note-into-inbox": "Skapa en anteckning i inboxen (om angiven) eller som daganteckning",
|
||||
"delete-note": "Radera anteckning",
|
||||
"move-note-up": "Flytta anteckning uppåt",
|
||||
"move-note-down": "Flytta anteckning nedåt",
|
||||
"scroll-to-active-note": "Bläddra i anteckningshierarkin till aktiv anteckning",
|
||||
"move-note-up-in-hierarchy": "Flytta anteckning uppåt i hierarkin",
|
||||
"move-note-down-in-hierarchy": "Flytta anteckning neråt i hierarkin",
|
||||
"edit-note-title": "Hoppa från träd till anteckning och redigera titel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,6 @@
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"totp-token": "TOTP 驗證碼",
|
||||
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sync-server-host"><%= t("setup_sync-from-server.server-host") %></label>
|
||||
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost, event: { blur: checkTotpStatus }" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sync-proxy"><%= t("setup_sync-from-server.proxy-server") %></label>
|
||||
@@ -141,10 +141,6 @@
|
||||
<label for="password"><%= t("setup_sync-from-server.password") %></label>
|
||||
<input type="password" id="password" class="form-control" data-bind="value: password" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;" data-bind="visible: totpEnabled">
|
||||
<label for="totpToken"><%= t("setup_sync-from-server.totp-token") %></label>
|
||||
<input type="text" id="totpToken" class="form-control" data-bind="value: totpToken" placeholder="<%= t("setup_sync-from-server.totp-token-placeholder") %>" autocomplete="one-time-code">
|
||||
</div>
|
||||
|
||||
<button type="button" data-bind="click: back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>
|
||||
|
||||
|
||||
1
apps/server/src/express.d.ts
vendored
@@ -8,7 +8,6 @@ export declare module "express-serve-static-core" {
|
||||
|
||||
authorization?: string;
|
||||
"trilium-cred"?: string;
|
||||
"trilium-totp"?: string;
|
||||
"x-csrf-token"?: string;
|
||||
|
||||
"trilium-component-id"?: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
if (!image) {
|
||||
res.set("Content-Type", "image/png");
|
||||
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
|
||||
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
renderSvgAttachment(image, res, "mermaid-export.svg");
|
||||
} else if (image.type === "mindMap") {
|
||||
renderSvgAttachment(image, res, "mindmap-export.svg");
|
||||
} else if (image.type === "spreadsheet") {
|
||||
renderPngAttachment(image, res, "spreadsheet-export.png");
|
||||
} else {
|
||||
res.set("Content-Type", image.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
@@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
|
||||
if (attachment) {
|
||||
res.set("Content-Type", "image/png");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(attachment.getContent());
|
||||
} else {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
|
||||
const attachment = becca.getAttachment(req.params.attachmentId);
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import log from "../../services/log.js";
|
||||
import setupService from "../../services/setup.js";
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import totp from "../../services/totp.js";
|
||||
import setupService from "../../services/setup.js";
|
||||
import log from "../../services/log.js";
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
function getStatus() {
|
||||
return {
|
||||
isInitialized: sqlInit.isDbInitialized(),
|
||||
schemaExists: sqlInit.schemaExists(),
|
||||
syncVersion: appInfo.syncVersion,
|
||||
totpEnabled: totp.isTotpEnabled()
|
||||
syncVersion: appInfo.syncVersion
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,9 +19,9 @@ async function setupNewDocument() {
|
||||
}
|
||||
|
||||
function setupSyncFromServer(req: Request) {
|
||||
const { syncServerHost, syncProxy, password, totpToken } = req.body;
|
||||
const { syncServerHost, syncProxy, password } = req.body;
|
||||
|
||||
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken);
|
||||
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
|
||||
}
|
||||
|
||||
function saveSyncSeed(req: Request) {
|
||||
@@ -85,26 +82,10 @@ function getSyncSeed() {
|
||||
};
|
||||
}
|
||||
|
||||
async function checkServerTotpStatus(req: Request) {
|
||||
const { syncServerHost } = req.body;
|
||||
|
||||
if (!syncServerHost) {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await setupService.checkRemoteTotpStatus(syncServerHost);
|
||||
return { totpEnabled: !!resp.totpEnabled };
|
||||
} catch {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getStatus,
|
||||
setupNewDocument,
|
||||
setupSyncFromServer,
|
||||
getSyncSeed,
|
||||
saveSyncSeed,
|
||||
checkServerTotpStatus
|
||||
saveSyncSeed
|
||||
};
|
||||
|
||||
@@ -244,7 +244,6 @@ function register(app: express.Application) {
|
||||
asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler);
|
||||
route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler);
|
||||
asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler);
|
||||
asyncRoute(PST, "/api/setup/check-server-totp", [auth.checkAppNotInitialized], setupApiRoute.checkServerTotpStatus, apiResultHandler);
|
||||
|
||||
apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete);
|
||||
apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount);
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { OptionRow } from "@triliumnext/commons";
|
||||
export interface SetupStatusResponse {
|
||||
syncVersion: number;
|
||||
schemaExists: boolean;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,10 +9,6 @@ import options from "./options";
|
||||
|
||||
let app: Application;
|
||||
|
||||
function encodeCred(password: string): string {
|
||||
return Buffer.from(`dummy:${password}`).toString("base64");
|
||||
}
|
||||
|
||||
describe("Auth", () => {
|
||||
beforeAll(async () => {
|
||||
const buildApp = (await (import("../../src/app.js"))).default;
|
||||
@@ -76,49 +72,4 @@ describe("Auth", () => {
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Setup status endpoint", () => {
|
||||
it("returns totpEnabled: true when TOTP is enabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "true");
|
||||
options.setOption("mfaMethod", "totp");
|
||||
options.setOption("totpVerificationHash", "hi");
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/status")
|
||||
.expect(200);
|
||||
expect(response.body.totpEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns totpEnabled: false when TOTP is disabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "false");
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/status")
|
||||
.expect(200);
|
||||
expect(response.body.totpEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCredentials TOTP enforcement", () => {
|
||||
beforeAll(() => {
|
||||
config.General.noAuthentication = false;
|
||||
refreshAuth();
|
||||
});
|
||||
|
||||
it("does not require TOTP token when TOTP is disabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "false");
|
||||
});
|
||||
// Will still fail with 401 due to wrong password, but NOT because of missing TOTP
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/sync-seed")
|
||||
.set("trilium-cred", encodeCred("wrongpassword"))
|
||||
.expect(401);
|
||||
// The error should be about password, not TOTP
|
||||
expect(response.text).toContain("Incorrect password");
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import attributes from "./attributes.js";
|
||||
import config from "./config.js";
|
||||
import passwordService from "./encryption/password.js";
|
||||
import passwordEncryptionService from "./encryption/password_encryption.js";
|
||||
import recoveryCodeService from "./encryption/recovery_codes.js";
|
||||
import etapiTokenService from "./etapi_tokens.js";
|
||||
import log from "./log.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import { isElectron } from "./utils.js";
|
||||
import passwordEncryptionService from "./encryption/password_encryption.js";
|
||||
import config from "./config.js";
|
||||
import passwordService from "./encryption/password.js";
|
||||
import totp from "./totp.js";
|
||||
import openID from "./open_id.js";
|
||||
import options from "./options.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import totp from "./totp.js";
|
||||
import { isElectron } from "./utils.js";
|
||||
import attributes from "./attributes.js";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
let noAuthentication = false;
|
||||
refreshAuth();
|
||||
@@ -163,28 +161,9 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {
|
||||
if (!passwordEncryptionService.verifyPassword(password)) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password");
|
||||
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
|
||||
return;
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
// Verify TOTP if enabled
|
||||
if (totp.isTotpEnabled()) {
|
||||
const totpHeader = req.headers["trilium-totp"];
|
||||
const totpToken = Array.isArray(totpHeader) ? totpHeader[0] : totpHeader;
|
||||
if (typeof totpToken !== "string" || !totpToken) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required");
|
||||
log.info(`WARNING: Missing or invalid TOTP token from ${req.ip}, rejecting.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept TOTP code or recovery code
|
||||
if (!totp.validateTOTP(totpToken) && !recoveryCodeService.verifyRecoveryCode(totpToken)) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect TOTP token");
|
||||
log.info(`WARNING: Wrong TOTP token from ${req.ip}, rejecting.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
import optionService from "../options.js";
|
||||
import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.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",
|
||||
|
||||
@@ -10449,6 +10449,12 @@
|
||||
"terms": [
|
||||
"virus-block"
|
||||
]
|
||||
},
|
||||
"bx-empty": {
|
||||
"glyph": "",
|
||||
"terms": [
|
||||
"empty"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
|
||||
if (attachments?.length > 0) {
|
||||
const existingAttachmentsByTitle = toMap(note.getAttachments(), "title");
|
||||
|
||||
for (const { attachmentId, role, mime, title, position, content } of attachments) {
|
||||
for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) {
|
||||
const decodedContent = encoding === "base64" && typeof content === "string"
|
||||
? Buffer.from(content, "base64")
|
||||
: content;
|
||||
|
||||
const existingAttachment = existingAttachmentsByTitle.get(title);
|
||||
if (attachmentId || !existingAttachment) {
|
||||
note.saveAttachment({ attachmentId, role, mime, title, content, position });
|
||||
note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position });
|
||||
} else {
|
||||
existingAttachment.role = role;
|
||||
existingAttachment.mime = mime;
|
||||
existingAttachment.position = position;
|
||||
if (content) {
|
||||
existingAttachment.setContent(content, { forceSave: true });
|
||||
if (decodedContent) {
|
||||
existingAttachment.setContent(decodedContent, { forceSave: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ async function exec<T>(opts: ExecOpts): Promise<T> {
|
||||
|
||||
if (opts.auth) {
|
||||
headers["trilium-cred"] = Buffer.from(`dummy:${opts.auth.password}`).toString("base64");
|
||||
if (opts.auth.totpToken) {
|
||||
headers["trilium-totp"] = opts.auth.totpToken;
|
||||
}
|
||||
}
|
||||
|
||||
const request = (await client).request({
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface ExecOpts {
|
||||
cookieJar?: CookieJar;
|
||||
auth?: {
|
||||
password?: string;
|
||||
totpToken?: string;
|
||||
};
|
||||
timeout: number;
|
||||
body?: string | {};
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { processMindmapContent } from "./note_content_fulltext.js";
|
||||
import { describe, expect,it } from "vitest";
|
||||
|
||||
import NoteContentFulltextExp from "./note_content_fulltext.js";
|
||||
|
||||
describe("processMindmapContent", () => {
|
||||
it("supports empty JSON", () => {
|
||||
expect(processMindmapContent("{}")).toEqual("");
|
||||
});
|
||||
|
||||
it("supports blank text / invalid JSON", () => {
|
||||
expect(processMindmapContent("")).toEqual("");
|
||||
expect(processMindmapContent(`{ "node": " }`)).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fuzzy Search Operators", () => {
|
||||
it("~= operator works with typos", () => {
|
||||
// Test that the ~= operator can handle common typos
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
import type { NoteRow } from "@triliumnext/commons";
|
||||
import type SearchContext from "../search_context.js";
|
||||
|
||||
import Expression from "./expression.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import log from "../../log.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
import log from "../../log.js";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import striptags from "striptags";
|
||||
import { normalize } from "../../utils.js";
|
||||
import sql from "../../sql.js";
|
||||
import {
|
||||
normalizeSearchText,
|
||||
calculateOptimizedEditDistance,
|
||||
validateFuzzySearchTokens,
|
||||
validateAndPreprocessContent,
|
||||
import NoteSet from "../note_set.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
import {
|
||||
FUZZY_SEARCH_CONFIG,
|
||||
fuzzyMatchWord,
|
||||
FUZZY_SEARCH_CONFIG
|
||||
} from "../utils/text_utils.js";
|
||||
normalizeSearchText,
|
||||
validateAndPreprocessContent,
|
||||
validateFuzzySearchTokens} from "../utils/text_utils.js";
|
||||
import Expression from "./expression.js";
|
||||
import preprocessContent from "./note_content_fulltext_preprocessor.js";
|
||||
|
||||
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
||||
|
||||
@@ -218,7 +213,7 @@ class NoteContentFulltextExp extends Expression {
|
||||
return;
|
||||
}
|
||||
|
||||
content = this.preprocessContent(content, type, mime);
|
||||
content = preprocessContent(content, type, mime, this.raw);
|
||||
|
||||
// Apply content size validation and preprocessing
|
||||
const processedContent = validateAndPreprocessContent(content, noteId);
|
||||
@@ -295,59 +290,22 @@ class NoteContentFulltextExp extends Expression {
|
||||
return content;
|
||||
}
|
||||
|
||||
preprocessContent(content: string | Buffer, type: string, mime: string) {
|
||||
content = normalize(content.toString());
|
||||
|
||||
if (type === "text" && mime === "text/html") {
|
||||
if (!this.raw) {
|
||||
// Content size already filtered at DB level, safe to process
|
||||
content = this.stripTags(content);
|
||||
}
|
||||
|
||||
content = content.replace(/ /g, " ");
|
||||
} else if (type === "mindMap" && mime === "application/json") {
|
||||
content = processMindmapContent(content);
|
||||
} else if (type === "canvas" && mime === "application/json") {
|
||||
interface Element {
|
||||
type: string;
|
||||
text?: string; // Optional since not all objects have a `text` property
|
||||
id: string;
|
||||
[key: string]: any; // Other properties that may exist
|
||||
}
|
||||
|
||||
const canvasContent = JSON.parse(content);
|
||||
const elements = canvasContent.elements;
|
||||
|
||||
if (Array.isArray(elements)) {
|
||||
const texts = elements
|
||||
.filter((element: Element) => element.type === "text" && element.text) // Filter for 'text' type elements with a 'text' property
|
||||
.map((element: Element) => element.text!); // Use `!` to assert `text` is defined after filtering
|
||||
|
||||
content = normalize(texts.join(" "));
|
||||
} else {
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a token matches content with optional fuzzy matching
|
||||
*/
|
||||
private tokenMatchesContent(token: string, content: string, noteId: string): boolean {
|
||||
const normalizedToken = normalizeSearchText(token);
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
|
||||
if (normalizedContent.includes(normalizedToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check flat text for default fulltext search
|
||||
if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -358,15 +316,15 @@ class NoteContentFulltextExp extends Expression {
|
||||
try {
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : "";
|
||||
|
||||
|
||||
// For phrase matching, check if tokens appear within reasonable proximity
|
||||
if (this.tokens.length > 1) {
|
||||
return this.matchesPhrase(normalizedContent, flatText);
|
||||
}
|
||||
|
||||
|
||||
// Single token fuzzy matching
|
||||
const token = normalizeSearchText(this.tokens[0]);
|
||||
return this.fuzzyMatchToken(token, normalizedContent) ||
|
||||
return this.fuzzyMatchToken(token, normalizedContent) ||
|
||||
(this.flatText && this.fuzzyMatchToken(token, flatText));
|
||||
} catch (error) {
|
||||
log.error(`Error in fuzzy matching for note ${noteId}: ${error}`);
|
||||
@@ -379,45 +337,45 @@ class NoteContentFulltextExp extends Expression {
|
||||
*/
|
||||
private matchesPhrase(content: string, flatText: string): boolean {
|
||||
const searchText = this.flatText ? `${content} ${flatText}` : content;
|
||||
|
||||
|
||||
// Apply content size limits for phrase matching
|
||||
const limitedText = validateAndPreprocessContent(searchText);
|
||||
if (!limitedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const words = limitedText.toLowerCase().split(/\s+/);
|
||||
|
||||
|
||||
// Only skip phrase matching for truly extreme word counts that could crash the system
|
||||
if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) {
|
||||
console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Warn about large word counts but still attempt matching
|
||||
if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) {
|
||||
console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`);
|
||||
}
|
||||
|
||||
|
||||
// Find positions of each token
|
||||
const tokenPositions: number[][] = this.tokens.map(token => {
|
||||
const normalizedToken = normalizeSearchText(token);
|
||||
const positions: number[] = [];
|
||||
|
||||
|
||||
words.forEach((word, index) => {
|
||||
if (this.fuzzyMatchSingle(normalizedToken, word)) {
|
||||
positions.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return positions;
|
||||
});
|
||||
|
||||
|
||||
// Check if we found all tokens
|
||||
if (tokenPositions.some(positions => positions.length === 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for phrase proximity using configurable distance
|
||||
return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY);
|
||||
}
|
||||
@@ -431,18 +389,18 @@ class NoteContentFulltextExp extends Expression {
|
||||
const [pos1, pos2] = tokenPositions;
|
||||
return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance));
|
||||
}
|
||||
|
||||
|
||||
// For more tokens, check if we can find a sequence where all tokens are within range
|
||||
const findSequence = (remaining: number[][], currentPos: number): boolean => {
|
||||
if (remaining.length === 0) return true;
|
||||
|
||||
|
||||
const [nextPositions, ...rest] = remaining;
|
||||
return nextPositions.some(pos =>
|
||||
Math.abs(pos - currentPos) <= maxDistance &&
|
||||
return nextPositions.some(pos =>
|
||||
Math.abs(pos - currentPos) <= maxDistance &&
|
||||
findSequence(rest, pos)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const [firstPositions, ...rest] = tokenPositions;
|
||||
return firstPositions.some(startPos => findSequence(rest, startPos));
|
||||
}
|
||||
@@ -455,12 +413,12 @@ class NoteContentFulltextExp extends Expression {
|
||||
// For short tokens, require exact match to avoid too many false positives
|
||||
return content.includes(token);
|
||||
}
|
||||
|
||||
|
||||
const words = content.split(/\s+/);
|
||||
|
||||
|
||||
// Only limit word processing for truly extreme cases to prevent system instability
|
||||
const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT);
|
||||
|
||||
|
||||
return limitedWords.some(word => this.fuzzyMatchSingle(token, word));
|
||||
}
|
||||
|
||||
@@ -471,83 +429,6 @@ class NoteContentFulltextExp extends Expression {
|
||||
// Use shared optimized fuzzy matching logic
|
||||
return fuzzyMatchWord(token, word, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
|
||||
}
|
||||
|
||||
|
||||
stripTags(content: string) {
|
||||
// we want to allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
|
||||
// we want to insert space in place of block tags (because they imply text separation)
|
||||
// but we don't want to insert text for typical formatting inline tags which can occur within one word
|
||||
const linkTag = "a";
|
||||
const inlineFormattingTags = ["b", "strong", "em", "i", "span", "big", "small", "font", "sub", "sup"];
|
||||
|
||||
// replace tags which imply text separation with a space
|
||||
content = striptags(content, [linkTag, ...inlineFormattingTags], " ");
|
||||
|
||||
// replace the inline formatting tags (but not links) without a space
|
||||
content = striptags(content, [linkTag], "");
|
||||
|
||||
// at least the closing link tag can be easily stripped
|
||||
return content.replace(/<\/a>/gi, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function processMindmapContent(content: string) {
|
||||
let mindMapcontent;
|
||||
|
||||
try {
|
||||
mindMapcontent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Define interfaces for the JSON structure
|
||||
interface MindmapNode {
|
||||
id: string;
|
||||
topic: string;
|
||||
children: MindmapNode[]; // Recursive structure
|
||||
direction?: number;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
interface MindmapData {
|
||||
nodedata: MindmapNode;
|
||||
arrows: any[]; // If you know the structure, replace `any` with the correct type
|
||||
summaries: any[];
|
||||
direction: number;
|
||||
theme: {
|
||||
name: string;
|
||||
type: string;
|
||||
palette: string[];
|
||||
cssvar: Record<string, string>; // Object with string keys and string values
|
||||
};
|
||||
}
|
||||
|
||||
// Recursive function to collect all topics
|
||||
function collectTopics(node?: MindmapNode): string[] {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collect the current node's topic
|
||||
let topics = [node.topic];
|
||||
|
||||
// If the node has children, collect topics recursively
|
||||
if (node.children && node.children.length > 0) {
|
||||
for (const child of node.children) {
|
||||
topics = topics.concat(collectTopics(child));
|
||||
}
|
||||
}
|
||||
|
||||
return topics;
|
||||
}
|
||||
|
||||
// Start extracting from the root node
|
||||
const topicsArray = collectTopics(mindMapcontent.nodedata);
|
||||
|
||||
// Combine topics into a single string
|
||||
const topicsString = topicsArray.join(", ");
|
||||
|
||||
return normalize(topicsString.toString());
|
||||
}
|
||||
|
||||
export default NoteContentFulltextExp;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { describe, expect,it } from "vitest";
|
||||
|
||||
import preprocessContent from "./note_content_fulltext_preprocessor";
|
||||
|
||||
describe("Mind map preprocessing", () => {
|
||||
const type: NoteType = "mindMap";
|
||||
const mime = "application/json";
|
||||
|
||||
it("supports empty JSON", () => {
|
||||
expect(preprocessContent("{}", type, mime)).toEqual("");
|
||||
});
|
||||
|
||||
it("supports blank text / invalid JSON", () => {
|
||||
expect(preprocessContent("", type, mime)).toEqual("");
|
||||
expect(preprocessContent(`{ "node": " }`, type, mime)).toEqual("");
|
||||
});
|
||||
|
||||
it("reads data", () => {
|
||||
expect(preprocessContent(`{ "nodedata": { "topic": "Root", "children": [ { "topic": "Child 1" }, { "topic": "Child 2", "children": [ { "topic": "Grandchild" } ] } ] } }`, type, mime)).toEqual("root, child 1, child 2, grandchild");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Canvas preprocessing", () => {
|
||||
const type: NoteType = "canvas";
|
||||
const mime = "application/json";
|
||||
|
||||
it("supports empty JSON", () => {
|
||||
expect(preprocessContent("{}", type, mime)).toEqual("");
|
||||
});
|
||||
|
||||
it("supports blank text / invalid JSON", () => {
|
||||
expect(preprocessContent("", type, mime)).toEqual("");
|
||||
});
|
||||
|
||||
it("reads elements", () => {
|
||||
expect(preprocessContent(`{ "elements": [ { "type": "text", "text": "Hello" } ] }`, type, mime)).toEqual("hello");
|
||||
expect(preprocessContent(`{ "elements": [ { "type": "text" }, { "type": "text", "text": "World" }, { "type": "rectangle", "text": "Ignored" } ] }`, type, mime)).toEqual("world");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import { normalize } from "../../utils.js";
|
||||
|
||||
export default function preprocessContent(content: string | Buffer, type: string, mime: string, raw?: boolean) {
|
||||
content = normalize(content.toString());
|
||||
|
||||
if (type === "text" && mime === "text/html") {
|
||||
if (!raw) {
|
||||
// Content size already filtered at DB level, safe to process
|
||||
content = stripTags(content);
|
||||
}
|
||||
|
||||
content = content.replace(/ /g, " ");
|
||||
} else if (type === "mindMap" && mime === "application/json") {
|
||||
content = processMindmapContent(content);
|
||||
} else if (type === "canvas" && mime === "application/json") {
|
||||
content = processCanvasContent(content);
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
function processMindmapContent(content: string) {
|
||||
let mindMapcontent;
|
||||
|
||||
try {
|
||||
mindMapcontent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Define interfaces for the JSON structure
|
||||
interface MindmapNode {
|
||||
id: string;
|
||||
topic: string;
|
||||
children: MindmapNode[]; // Recursive structure
|
||||
direction?: number;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
interface MindmapData {
|
||||
nodedata: MindmapNode;
|
||||
arrows: any[]; // If you know the structure, replace `any` with the correct type
|
||||
summaries: any[];
|
||||
direction: number;
|
||||
theme: {
|
||||
name: string;
|
||||
type: string;
|
||||
palette: string[];
|
||||
cssvar: Record<string, string>; // Object with string keys and string values
|
||||
};
|
||||
}
|
||||
|
||||
// Recursive function to collect all topics
|
||||
function collectTopics(node?: MindmapNode): string[] {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collect the current node's topic
|
||||
let topics = [node.topic];
|
||||
|
||||
// If the node has children, collect topics recursively
|
||||
if (node.children && node.children.length > 0) {
|
||||
for (const child of node.children) {
|
||||
topics = topics.concat(collectTopics(child));
|
||||
}
|
||||
}
|
||||
|
||||
return topics;
|
||||
}
|
||||
|
||||
// Start extracting from the root node
|
||||
const topicsArray = collectTopics(mindMapcontent.nodedata);
|
||||
|
||||
// Combine topics into a single string
|
||||
const topicsString = topicsArray.join(", ");
|
||||
|
||||
return normalize(topicsString.toString());
|
||||
}
|
||||
|
||||
function processCanvasContent(content: string) {
|
||||
interface Element {
|
||||
type: string;
|
||||
text?: string; // Optional since not all objects have a `text` property
|
||||
id: string;
|
||||
[key: string]: any; // Other properties that may exist
|
||||
}
|
||||
|
||||
let canvasContent;
|
||||
try {
|
||||
canvasContent = JSON.parse(content);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
const elements = canvasContent.elements;
|
||||
|
||||
if (Array.isArray(elements)) {
|
||||
const texts = elements
|
||||
.filter((element: Element) => element.type === "text" && element.text) // Filter for 'text' type elements with a 'text' property
|
||||
.map((element: Element) => element.text!); // Use `!` to assert `text` is defined after filtering
|
||||
|
||||
content = normalize(texts.join(" "));
|
||||
} else {
|
||||
content = "";
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function stripTags(content: string) {
|
||||
// we want to allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
|
||||
// we want to insert space in place of block tags (because they imply text separation)
|
||||
// but we don't want to insert text for typical formatting inline tags which can occur within one word
|
||||
const linkTag = "a";
|
||||
const inlineFormattingTags = ["b", "strong", "em", "i", "span", "big", "small", "font", "sub", "sup"];
|
||||
|
||||
// replace tags which imply text separation with a space
|
||||
content = striptags(content, [linkTag, ...inlineFormattingTags], " ");
|
||||
|
||||
// replace the inline formatting tags (but not links) without a space
|
||||
content = striptags(content, [linkTag], "");
|
||||
|
||||
// at least the closing link tag can be easily stripped
|
||||
return content.replace(/<\/a>/gi, "");
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import syncService from "./sync.js";
|
||||
import log from "./log.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import optionService from "./options.js";
|
||||
import syncOptions from "./sync_options.js";
|
||||
import request from "./request.js";
|
||||
import appInfo from "./app_info.js";
|
||||
import { timeLimit } from "./utils.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js";
|
||||
import appInfo from "./app_info.js";
|
||||
import log from "./log.js";
|
||||
import optionService from "./options.js";
|
||||
import request from "./request.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import syncService from "./sync.js";
|
||||
import syncOptions from "./sync_options.js";
|
||||
import { timeLimit } from "./utils.js";
|
||||
|
||||
async function hasSyncServerSchemaAndSeed() {
|
||||
const response = await requestToSyncServer<SetupStatusResponse>("GET", "/api/setup/status");
|
||||
@@ -55,13 +55,13 @@ async function requestToSyncServer<T>(method: string, path: string, body?: strin
|
||||
url: syncOptions.getSyncServerHost() + path,
|
||||
body,
|
||||
proxy: syncOptions.getSyncProxy(),
|
||||
timeout
|
||||
timeout: timeout
|
||||
}),
|
||||
timeout
|
||||
)) as T;
|
||||
}
|
||||
|
||||
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: string) {
|
||||
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) {
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
return {
|
||||
result: "failure",
|
||||
@@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string
|
||||
const resp = await request.exec<SetupSyncSeedResponse>({
|
||||
method: "get",
|
||||
url: `${syncServerHost}/api/setup/sync-seed`,
|
||||
auth: { password, totpToken },
|
||||
auth: { password },
|
||||
proxy: syncProxy,
|
||||
timeout: 30000 // seed request should not take long
|
||||
});
|
||||
@@ -111,30 +111,10 @@ function getSyncSeedOptions() {
|
||||
return [becca.getOption("documentId"), becca.getOption("documentSecret")];
|
||||
}
|
||||
|
||||
async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> {
|
||||
// Validate URL scheme to mitigate SSRF
|
||||
if (!syncServerHost.startsWith("http://") && !syncServerHost.startsWith("https://")) {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await request.exec<{ totpEnabled?: boolean }>({
|
||||
method: "get",
|
||||
url: `${syncServerHost}/api/setup/status`,
|
||||
proxy: null,
|
||||
timeout: 10000
|
||||
});
|
||||
return { totpEnabled: !!resp?.totpEnabled };
|
||||
} catch {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
hasSyncServerSchemaAndSeed,
|
||||
triggerSync,
|
||||
sendSeedToSyncServer,
|
||||
setupSyncFromSyncServer,
|
||||
getSyncSeedOptions,
|
||||
checkRemoteTotpStatus
|
||||
getSyncSeedOptions
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { generateSecret,Totp } from 'time2fa';
|
||||
|
||||
import totpEncryptionService from './encryption/totp_encryption.js';
|
||||
import { Totp, generateSecret } from 'time2fa';
|
||||
import options from './options.js';
|
||||
import totpEncryptionService from './encryption/totp_encryption.js';
|
||||
|
||||
function isTotpEnabled(): boolean {
|
||||
return options.getOptionOrNull('mfaEnabled') === "true" &&
|
||||
@@ -11,7 +10,7 @@ function isTotpEnabled(): boolean {
|
||||
|
||||
function createSecret(): { success: boolean; message?: string } {
|
||||
try {
|
||||
const secret = generateSecret(20);
|
||||
const secret = generateSecret();
|
||||
|
||||
totpEncryptionService.setTotpSecret(secret);
|
||||
|
||||
@@ -44,8 +43,6 @@ function validateTOTP(submittedPasscode: string): boolean {
|
||||
return Totp.validate({
|
||||
passcode: submittedPasscode,
|
||||
secret: secret.trim()
|
||||
}, {
|
||||
secretSize: secret.trim().length === 32 ? 20 : 10
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to validate TOTP:', e);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
import ejs from "ejs";
|
||||
import escapeHtml from "escape-html";
|
||||
@@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) {
|
||||
result.isEmpty = true;
|
||||
} else if (note.type === "webView") {
|
||||
renderWebView(note, result);
|
||||
} else if (note.type === "spreadsheet") {
|
||||
renderSpreadsheet(result);
|
||||
} else {
|
||||
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
|
||||
}
|
||||
@@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpreadsheet(result: Result) {
|
||||
if (typeof result.content !== "string" || !result.content?.trim()) {
|
||||
result.isEmpty = true;
|
||||
} else {
|
||||
result.content = renderSpreadsheetToHtml(result.content);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWebView(note: SNote | BNote, result: Result) {
|
||||
const url = note.getLabelValue("webViewSrc");
|
||||
if (!url) return;
|
||||
|
||||