mirror of
https://github.com/zadam/trilium.git
synced 2026-03-11 06:30:23 +01:00
Compare commits
126 Commits
totp
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614f43cb8a | ||
|
|
415bcac641 | ||
|
|
9527017314 | ||
|
|
1d3d7c77f8 | ||
|
|
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 | ||
|
|
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
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
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,17 +25,23 @@
|
||||
"@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",
|
||||
"autocomplete.js": "0.38.1",
|
||||
@@ -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",
|
||||
"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": {
|
||||
@@ -83,7 +89,7 @@
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.3",
|
||||
"lightningcss": "1.31.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "この日の編集されたノートはまだありません...",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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}');
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
|
||||
// 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" />;
|
||||
}
|
||||
|
||||
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
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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
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
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",
|
||||
@@ -89,7 +89,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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
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,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;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.32.0",
|
||||
"devDependencies": {
|
||||
"@wxt-dev/auto-icons": "1.1.1",
|
||||
"wxt": "0.20.18"
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.8.13",
|
||||
"i18next": "25.8.17",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.28.4",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.6",
|
||||
"react-i18next": "16.5.5"
|
||||
"react-i18next": "16.5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"code_title": "Code Notizen",
|
||||
"canvas_title": "Leinwand",
|
||||
"mermaid_title": "Mermaid Diagramm",
|
||||
"mindmap_title": "Mind Map",
|
||||
"mindmap_title": "Mindmap",
|
||||
"text_description": "Die Notizen werden mit einem visuellen Editor (WYSIWYG) bearbeitet, der Tabellen, Bilder, mathematische Ausdrücke und Code-Blöcke mit Syntaxhervorhebung unterstützt. Formatieren Sie den Text schnell mit einer Markdown-ähnlichen Syntax oder mit Slash-Befehlen.",
|
||||
"code_description": "Große Quellcode- oder Skriptdateien werden mit einem speziellen Editor bearbeitet, der Syntaxhervorhebung für viele Programmiersprachen und diverse Farbschemata bietet.",
|
||||
"title": "Verschiedene Darstellungsformen für Ihre Informationen",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"get_started": "Начало работы",
|
||||
"github": "GitHub",
|
||||
"dockerhub": "Docker Hub",
|
||||
"screenshot_alt": "Скриншот приложения Trilium Notes для ПК"
|
||||
"screenshot_alt": "Скриншот приложения Trilium Notes для настольного приложения"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Структура",
|
||||
@@ -202,6 +202,7 @@
|
||||
"title": "Ресурсы",
|
||||
"icon_packs": "Наборы иконок",
|
||||
"download": "Скачать",
|
||||
"website": "Сайт"
|
||||
"website": "Сайт",
|
||||
"icon_packs_intro": "Расширьте выбор значков для заметок, используя набор иконок. Подробнее о наборах иконок смотрите в <DocumentationLink>официальной документации</DocumentationLink>."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
"get-started": {
|
||||
"title": "Kom igång",
|
||||
"desktop_title": "Ladda ner skrivbordsprogrammet (v{{version}})",
|
||||
"architecture": "Arkitektur:"
|
||||
"architecture": "Arkitektur:",
|
||||
"older_releases": "Se äldre versioner",
|
||||
"server_title": "Skapa en server för åtkomst på flera enheter"
|
||||
},
|
||||
"hero_section": {
|
||||
"title": "Organisera dina tankar. Skapa din personliga kunskapsbank.",
|
||||
"subtitle": "Trilium är en lösning med öppen källkod som möjliggör anteckningar och organisering av en personlig kunskapsbank. Använd den lokalt på ditt skrivbord, eller synka till en självhostad server för att komma åt dina anteckningar överallt.",
|
||||
"screenshot_alt": "Skärmdump av Trilium Notes skrivbordsapplikation",
|
||||
"get_started": "Kom igång",
|
||||
"github": "GitHub",
|
||||
"dockerhub": "Docker Hub"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Organisation",
|
||||
"note_structure_title": "Anteckningsstruktur",
|
||||
"note_structure_description": "Anteckningar kan sorteras hierarkiskt. Det behövs inga mappar eftersom varje anteckning kan innehålla underordnade anteckningar. En enskild anteckning kan placeras på flera ställen samtidigt i hierarkin.",
|
||||
"attributes_title": "Hantera etiketter och relationer",
|
||||
"attributes_description": "Använd relationer mellan anteckningar eller lägg till etiketter för enkel kategorisering. Använd framhävda attribut för att ange strukturerad information som sedan kan visas i tabeller och tavlor.",
|
||||
"hoisting_title": "Arbetsyta och fokusområde",
|
||||
"hoisting_description": "Separera enkelt privata- och jobbanteckningar genom att gruppera dem på en arbetsyta, vilket fokuserar anteckningshierarkin att enbart visa en viss grupp av anteckningar."
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"title": "Produktivitet och säkerhet",
|
||||
"revisions_title": "Anteckningshistorik",
|
||||
"revisions_content": "Anteckningar sparas regelbundet i bakgrunden och versioner kan användas för att söka- eller ångra oavsiktliga ändringar. En version kan också skapas manuellt."
|
||||
}
|
||||
}
|
||||
|
||||
45
docs/README-sv.md
vendored
45
docs/README-sv.md
vendored
@@ -37,40 +37,39 @@ antecknings app med fokus på att bygga en stor personlig kunskapsbas.
|
||||
## Ladda ner
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) –
|
||||
stabil version, rekommenderas för dom flesta användare.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) –
|
||||
unstable development version, updated daily with the latest features and
|
||||
fixes.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) -
|
||||
ostabil utvecklings version, uppdaterad dagligen med de senaste funktionerna
|
||||
och fixarna.
|
||||
|
||||
## 📚 Documentation
|
||||
## 📚 Dokumentation
|
||||
|
||||
**Visit our comprehensive documentation at
|
||||
**Läs mer i vår omfattande dokumentation på
|
||||
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
|
||||
Our documentation is available in multiple formats:
|
||||
- **Online Documentation**: Browse the full documentation at
|
||||
Vår dokumentation är tillgänglig i flera format:
|
||||
- **Webb dokumentation**: Läs hela dokumentationen på
|
||||
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
|
||||
- **In-App Help**: Press `F1` within Trilium to access the same documentation
|
||||
directly in the application
|
||||
- **GitHub**: Navigate through the [User Guide](./User%20Guide/User%20Guide/) in
|
||||
this repository
|
||||
- **I-appen-hjälp**: Tryck `F1` i Trilium för att läsa samma dokumentation inuti
|
||||
programmet
|
||||
- **GitHub**: Läs [användarhandboken](./User%20Guide/User%20Guide/) i denna repo
|
||||
|
||||
### Quick Links
|
||||
- [Getting Started Guide](https://docs.triliumnotes.org/)
|
||||
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
|
||||
### Snabblänkar
|
||||
- [Snabbstartsguide](https://docs.triliumnotes.org/)
|
||||
- [Installationsanvisning](https://docs.triliumnotes.org/user-guide/setup)
|
||||
- [Docker
|
||||
Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
|
||||
- [Upgrading
|
||||
- [Uppdaterar
|
||||
TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
|
||||
- [Basic Concepts and
|
||||
Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Patterns of Personal Knowledge
|
||||
Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
- [Grundläggande koncept och
|
||||
funktioner](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Modeller av personlig
|
||||
kunskapsbas](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
|
||||
## 🎁 Features
|
||||
## 🎁 Funktioner
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed
|
||||
into multiple places in the tree (see
|
||||
[cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Anteckningar kan sorteras som en trädstruktur. En enskild anteckning kan
|
||||
placeras på fler än en plats i trädet (se
|
||||
[kloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Rich WYSIWYG note editor including e.g. tables, images and
|
||||
[math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown
|
||||
[autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
|
||||
|
||||
130
docs/Release Notes/!!!meta.json
vendored
130
docs/Release Notes/!!!meta.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"appVersion": "0.101.3",
|
||||
"appVersion": "0.102.0",
|
||||
"files": [
|
||||
{
|
||||
"isClone": false,
|
||||
@@ -61,6 +61,32 @@
|
||||
"attachments": [],
|
||||
"dirFileName": "Release Notes",
|
||||
"children": [
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "4FTGCuCiG7s7",
|
||||
"notePath": [
|
||||
"hD3V4hiu2VW4",
|
||||
"4FTGCuCiG7s7"
|
||||
],
|
||||
"title": "v0.102.1",
|
||||
"notePosition": 10,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "template",
|
||||
"value": "wyurrlcDl416",
|
||||
"isInheritable": false,
|
||||
"position": 60
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "v0.102.1.md",
|
||||
"attachments": []
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "d582eD4RY4OM",
|
||||
@@ -69,7 +95,7 @@
|
||||
"d582eD4RY4OM"
|
||||
],
|
||||
"title": "v0.102.0",
|
||||
"notePosition": 10,
|
||||
"notePosition": 20,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -95,7 +121,7 @@
|
||||
"IlBzLeN3MJhw"
|
||||
],
|
||||
"title": "v0.101.3",
|
||||
"notePosition": 20,
|
||||
"notePosition": 30,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -121,7 +147,7 @@
|
||||
"vcBthaXcwAm6"
|
||||
],
|
||||
"title": "v0.101.2",
|
||||
"notePosition": 30,
|
||||
"notePosition": 40,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -147,7 +173,7 @@
|
||||
"AgUcrU9nFXuW"
|
||||
],
|
||||
"title": "v0.101.1",
|
||||
"notePosition": 40,
|
||||
"notePosition": 50,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -173,7 +199,7 @@
|
||||
"uYwlZ594eyJu"
|
||||
],
|
||||
"title": "v0.101.0",
|
||||
"notePosition": 50,
|
||||
"notePosition": 60,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -199,7 +225,7 @@
|
||||
"iPGKEk7pwJXK"
|
||||
],
|
||||
"title": "v0.100.0",
|
||||
"notePosition": 60,
|
||||
"notePosition": 70,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -225,7 +251,7 @@
|
||||
"7HKMTjmopLcM"
|
||||
],
|
||||
"title": "v0.99.5",
|
||||
"notePosition": 70,
|
||||
"notePosition": 80,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -251,7 +277,7 @@
|
||||
"RMBaNYPsRpIr"
|
||||
],
|
||||
"title": "v0.99.4",
|
||||
"notePosition": 80,
|
||||
"notePosition": 90,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -277,7 +303,7 @@
|
||||
"yuroLztFfpu5"
|
||||
],
|
||||
"title": "v0.99.3",
|
||||
"notePosition": 90,
|
||||
"notePosition": 100,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -303,7 +329,7 @@
|
||||
"z207sehwMJ6C"
|
||||
],
|
||||
"title": "v0.99.2",
|
||||
"notePosition": 100,
|
||||
"notePosition": 110,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -329,7 +355,7 @@
|
||||
"WGQsXq2jNyTi"
|
||||
],
|
||||
"title": "v0.99.1",
|
||||
"notePosition": 110,
|
||||
"notePosition": 120,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -355,7 +381,7 @@
|
||||
"cyw2Yue9vXf3"
|
||||
],
|
||||
"title": "v0.99.0",
|
||||
"notePosition": 120,
|
||||
"notePosition": 130,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -381,7 +407,7 @@
|
||||
"QOJwjruOUr4k"
|
||||
],
|
||||
"title": "v0.98.1",
|
||||
"notePosition": 130,
|
||||
"notePosition": 140,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -407,7 +433,7 @@
|
||||
"PLUoryywi0BC"
|
||||
],
|
||||
"title": "v0.98.0",
|
||||
"notePosition": 140,
|
||||
"notePosition": 150,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -433,7 +459,7 @@
|
||||
"lvOuiWsLDv8F"
|
||||
],
|
||||
"title": "v0.97.2",
|
||||
"notePosition": 150,
|
||||
"notePosition": 160,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -459,7 +485,7 @@
|
||||
"OtFZ6Nd9vM3n"
|
||||
],
|
||||
"title": "v0.97.1",
|
||||
"notePosition": 160,
|
||||
"notePosition": 170,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -485,7 +511,7 @@
|
||||
"SJZ5PwfzHSQ1"
|
||||
],
|
||||
"title": "v0.97.0",
|
||||
"notePosition": 170,
|
||||
"notePosition": 180,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -511,7 +537,7 @@
|
||||
"mYXFde3LuNR7"
|
||||
],
|
||||
"title": "v0.96.0",
|
||||
"notePosition": 180,
|
||||
"notePosition": 190,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -537,7 +563,7 @@
|
||||
"jthwbL0FdaeU"
|
||||
],
|
||||
"title": "v0.95.0",
|
||||
"notePosition": 190,
|
||||
"notePosition": 200,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -563,7 +589,7 @@
|
||||
"7HGYsJbLuhnv"
|
||||
],
|
||||
"title": "v0.94.1",
|
||||
"notePosition": 200,
|
||||
"notePosition": 210,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -589,7 +615,7 @@
|
||||
"Neq53ujRGBqv"
|
||||
],
|
||||
"title": "v0.94.0",
|
||||
"notePosition": 210,
|
||||
"notePosition": 220,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -615,7 +641,7 @@
|
||||
"VN3xnce1vLkX"
|
||||
],
|
||||
"title": "v0.93.0",
|
||||
"notePosition": 220,
|
||||
"notePosition": 230,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -633,7 +659,7 @@
|
||||
"WRaBfQqPr6qo"
|
||||
],
|
||||
"title": "v0.92.7",
|
||||
"notePosition": 230,
|
||||
"notePosition": 240,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -659,7 +685,7 @@
|
||||
"a2rwfKNmUFU1"
|
||||
],
|
||||
"title": "v0.92.6",
|
||||
"notePosition": 240,
|
||||
"notePosition": 250,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -677,7 +703,7 @@
|
||||
"fEJ8qErr0BKL"
|
||||
],
|
||||
"title": "v0.92.5-beta",
|
||||
"notePosition": 250,
|
||||
"notePosition": 260,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -695,7 +721,7 @@
|
||||
"kkkZQQGSXjwy"
|
||||
],
|
||||
"title": "v0.92.4",
|
||||
"notePosition": 260,
|
||||
"notePosition": 270,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -713,7 +739,7 @@
|
||||
"vAroNixiezaH"
|
||||
],
|
||||
"title": "v0.92.3-beta",
|
||||
"notePosition": 270,
|
||||
"notePosition": 280,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -731,7 +757,7 @@
|
||||
"mHEq1wxAKNZd"
|
||||
],
|
||||
"title": "v0.92.2-beta",
|
||||
"notePosition": 280,
|
||||
"notePosition": 290,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -749,7 +775,7 @@
|
||||
"IykjoAmBpc61"
|
||||
],
|
||||
"title": "v0.92.1-beta",
|
||||
"notePosition": 290,
|
||||
"notePosition": 300,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -767,7 +793,7 @@
|
||||
"dq2AJ9vSBX4Y"
|
||||
],
|
||||
"title": "v0.92.0-beta",
|
||||
"notePosition": 300,
|
||||
"notePosition": 310,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -785,7 +811,7 @@
|
||||
"3a8aMe4jz4yM"
|
||||
],
|
||||
"title": "v0.91.6",
|
||||
"notePosition": 310,
|
||||
"notePosition": 320,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -803,7 +829,7 @@
|
||||
"8djQjkiDGESe"
|
||||
],
|
||||
"title": "v0.91.5",
|
||||
"notePosition": 320,
|
||||
"notePosition": 330,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -821,7 +847,7 @@
|
||||
"OylxVoVJqNmr"
|
||||
],
|
||||
"title": "v0.91.4-beta",
|
||||
"notePosition": 330,
|
||||
"notePosition": 340,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -839,7 +865,7 @@
|
||||
"tANGQDvnyhrj"
|
||||
],
|
||||
"title": "v0.91.3-beta",
|
||||
"notePosition": 340,
|
||||
"notePosition": 350,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -857,7 +883,7 @@
|
||||
"hMoBfwSoj1SC"
|
||||
],
|
||||
"title": "v0.91.2-beta",
|
||||
"notePosition": 350,
|
||||
"notePosition": 360,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -875,7 +901,7 @@
|
||||
"a2XMSKROCl9z"
|
||||
],
|
||||
"title": "v0.91.1-beta",
|
||||
"notePosition": 360,
|
||||
"notePosition": 370,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -893,7 +919,7 @@
|
||||
"yqXFvWbLkuMD"
|
||||
],
|
||||
"title": "v0.90.12",
|
||||
"notePosition": 370,
|
||||
"notePosition": 380,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -911,7 +937,7 @@
|
||||
"veS7pg311yJP"
|
||||
],
|
||||
"title": "v0.90.11-beta",
|
||||
"notePosition": 380,
|
||||
"notePosition": 390,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -929,7 +955,7 @@
|
||||
"sq5W9TQxRqMq"
|
||||
],
|
||||
"title": "v0.90.10-beta",
|
||||
"notePosition": 390,
|
||||
"notePosition": 400,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -947,7 +973,7 @@
|
||||
"yFEGVCUM9tPx"
|
||||
],
|
||||
"title": "v0.90.9-beta",
|
||||
"notePosition": 400,
|
||||
"notePosition": 410,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -965,7 +991,7 @@
|
||||
"o4wAGqOQuJtV"
|
||||
],
|
||||
"title": "v0.90.8",
|
||||
"notePosition": 410,
|
||||
"notePosition": 420,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -998,7 +1024,7 @@
|
||||
"i4A5g9iOg9I0"
|
||||
],
|
||||
"title": "v0.90.7-beta",
|
||||
"notePosition": 420,
|
||||
"notePosition": 430,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1016,7 +1042,7 @@
|
||||
"ThNf2GaKgXUs"
|
||||
],
|
||||
"title": "v0.90.6-beta",
|
||||
"notePosition": 430,
|
||||
"notePosition": 440,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1034,7 +1060,7 @@
|
||||
"G4PAi554kQUr"
|
||||
],
|
||||
"title": "v0.90.5-beta",
|
||||
"notePosition": 440,
|
||||
"notePosition": 450,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1061,7 +1087,7 @@
|
||||
"zATRobGRCmBn"
|
||||
],
|
||||
"title": "v0.90.4",
|
||||
"notePosition": 450,
|
||||
"notePosition": 460,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1079,7 +1105,7 @@
|
||||
"sCDLf8IKn3Iz"
|
||||
],
|
||||
"title": "v0.90.3",
|
||||
"notePosition": 460,
|
||||
"notePosition": 470,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1097,7 +1123,7 @@
|
||||
"VqqyBu4AuTjC"
|
||||
],
|
||||
"title": "v0.90.2-beta",
|
||||
"notePosition": 470,
|
||||
"notePosition": 480,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1115,7 +1141,7 @@
|
||||
"RX3Nl7wInLsA"
|
||||
],
|
||||
"title": "v0.90.1-beta",
|
||||
"notePosition": 480,
|
||||
"notePosition": 490,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1133,7 +1159,7 @@
|
||||
"GyueACukPWjk"
|
||||
],
|
||||
"title": "v0.90.0-beta",
|
||||
"notePosition": 490,
|
||||
"notePosition": 500,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1151,7 +1177,7 @@
|
||||
"kzjHexDTTeVB"
|
||||
],
|
||||
"title": "v0.48",
|
||||
"notePosition": 500,
|
||||
"notePosition": 510,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
@@ -1218,7 +1244,7 @@
|
||||
"wyurrlcDl416"
|
||||
],
|
||||
"title": "Release Template",
|
||||
"notePosition": 510,
|
||||
"notePosition": 520,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
|
||||
22
docs/Release Notes/Release Notes/v0.102.1.md
vendored
Normal file
22
docs/Release Notes/Release Notes/v0.102.1.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# v0.102.1
|
||||
> [!NOTE]
|
||||
> If you enjoyed this release, consider showing a token of appreciation by:
|
||||
>
|
||||
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
|
||||
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
|
||||
> * If you are interested in an [official mobile application](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/7447) ([#7447](https://github.com/TriliumNext/Trilium/issues/7447)) or [multi-user support](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/4956) ([#4956](https://github.com/TriliumNext/Trilium/issues/4956)), consider offering financial support via IssueHunt (see links).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is a hotfix of v0.102.0, addressing some blocking issues. For more information about the previous major version, see [v0.102.0 changelog](https://github.com/TriliumNext/Trilium/releases/tag/v0.102.0).
|
||||
|
||||
## 🐞 Bugfixes
|
||||
|
||||
* [Mind Map feature breaks rendering in v0.102.0](https://github.com/TriliumNext/Trilium/issues/8879)
|
||||
* Fixes for the PDF viewer:
|
||||
* [PDF view is '403 Forbidden' on Nginx Proxy Manager](https://github.com/TriliumNext/Trilium/issues/8877)
|
||||
* [PDF: address some layout issues](https://github.com/TriliumNext/Trilium/commit/8712e7dd160564f9a923a88bf5871e63c79d40f0) by @adoriandoran
|
||||
* Cache not properly invalidated across versions.
|
||||
|
||||
## 🛠️ Technical updates
|
||||
|
||||
* [Rework Docker infrastructure to use crane](https://github.com/TriliumNext/Trilium/pull/8869) by @perfectra1n
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/source",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Build your personal knowledge base with Trilium Notes",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
@@ -58,7 +58,7 @@
|
||||
"cross-env": "10.1.0",
|
||||
"dpdm": "4.0.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.9.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1",
|
||||
"typescript-eslint": "8.57.0",
|
||||
"upath": "2.0.1",
|
||||
"vite": "7.3.1",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
@@ -93,7 +93,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.32.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.3",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -25,15 +25,15 @@
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.3",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.3",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.3",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.3",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"http-server": "14.1.1",
|
||||
"lint-staged": "16.3.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"stylelint": "17.4.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.37",
|
||||
"@smithy/middleware-retry": "4.4.40",
|
||||
"@types/jquery": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, Model
|
||||
export type { TemplateDefinition } from "ckeditor5-premium-features";
|
||||
export { default as buildExtraCommands } from "./extra_slash_commands.js";
|
||||
export { default as getCkLocale } from "./i18n.js";
|
||||
export * from "./utils.js";
|
||||
|
||||
// Import with sideffects to ensure that type augmentations are present.
|
||||
import "@triliumnext/ckeditor5-math";
|
||||
|
||||
28
packages/ckeditor5/src/utils.ts
Normal file
28
packages/ckeditor5/src/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { DifferItemAttribute, Editor, ModelDocumentFragment, ModelElement, ModelNode } from "ckeditor5";
|
||||
|
||||
function hasHeadingAncestor(node: ModelElement | ModelNode | ModelDocumentFragment | null): boolean {
|
||||
let current: ModelElement | ModelNode | ModelDocumentFragment | null = node;
|
||||
while (current) {
|
||||
if (!!current && current.is('element') && (current as ModelElement).name.startsWith("heading")) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: Editor): boolean {
|
||||
if (change.type !== "attribute") return false;
|
||||
|
||||
// Fast checks on range boundaries
|
||||
if (hasHeadingAncestor(change.range.start.parent) || hasHeadingAncestor(change.range.end.parent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Robust check across the whole changed range
|
||||
const range = editor.model.createRange(change.range.start, change.range.end);
|
||||
for (const item of range.getItems()) {
|
||||
const baseNode = item.is("$textProxy") ? item.parent : item;
|
||||
if (hasHeadingAncestor(baseNode)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"@codemirror/commands": "6.10.2",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-php": "6.0.2",
|
||||
@@ -16,7 +16,7 @@
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@codemirror/view": "6.39.17",
|
||||
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-abyss": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-android-studio": "6.2.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/commons",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./lib/dayjs.js";
|
||||
export * from "./lib/notes.js";
|
||||
export * from "./lib/week_utils.js";
|
||||
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
|
||||
export * from "./lib/spreadsheet/render_to_html.js";
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AttachmentRow {
|
||||
deleteId?: string;
|
||||
contentLength?: number;
|
||||
content?: Buffer | string;
|
||||
/** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */
|
||||
encoding?: "base64";
|
||||
}
|
||||
|
||||
export interface RevisionRow {
|
||||
|
||||
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderSpreadsheetToHtml } from "./render_to_html.js";
|
||||
|
||||
describe("renderSpreadsheetToHtml", () => {
|
||||
it("renders a basic spreadsheet with values and styles", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
id: "test",
|
||||
sheetOrder: ["sheet1"],
|
||||
name: "",
|
||||
appVersion: "0.16.1",
|
||||
locale: "zhCN",
|
||||
styles: {
|
||||
boldStyle: { bl: 1 }
|
||||
},
|
||||
sheets: {
|
||||
sheet1: {
|
||||
id: "sheet1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 1000,
|
||||
columnCount: 20,
|
||||
defaultColumnWidth: 88,
|
||||
defaultRowHeight: 24,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"1": {
|
||||
"1": { v: "lol", t: 1 }
|
||||
},
|
||||
"3": {
|
||||
"0": { v: "wut", t: 1 },
|
||||
"2": { s: "boldStyle", v: "Bold string", t: 1 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
showGridlines: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
|
||||
// Should contain a table.
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toContain("</table>");
|
||||
|
||||
// Should contain cell values.
|
||||
expect(html).toContain("lol");
|
||||
expect(html).toContain("wut");
|
||||
expect(html).toContain("Bold string");
|
||||
|
||||
// Bold cell should have font-weight:bold.
|
||||
expect(html).toContain("font-weight:bold");
|
||||
|
||||
// Should not render sheet header for single sheet.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("renders multiple visible sheets with headers", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Data",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "A1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Summary",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "B1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("<h3>Data</h3>");
|
||||
expect(html).toContain("<h3>Summary</h3>");
|
||||
expect(html).toContain("A1");
|
||||
expect(html).toContain("B1");
|
||||
});
|
||||
|
||||
it("skips hidden sheets", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Visible",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "shown" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Hidden",
|
||||
hidden: 1,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "secret" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("shown");
|
||||
expect(html).not.toContain("secret");
|
||||
// Single visible sheet, no header.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("handles merged cells", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [
|
||||
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }
|
||||
],
|
||||
cellData: {
|
||||
"0": { "0": { v: "merged" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain('rowspan="2"');
|
||||
expect(html).toContain('colspan="2"');
|
||||
expect(html).toContain("merged");
|
||||
});
|
||||
|
||||
it("escapes HTML in cell values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": { "0": { v: "<script>alert('xss')</script>" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("handles invalid JSON gracefully", () => {
|
||||
const html = renderSpreadsheetToHtml("not json");
|
||||
expect(html).toContain("Unable to parse");
|
||||
});
|
||||
|
||||
it("handles empty workbook", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("Empty sheet");
|
||||
});
|
||||
|
||||
it("renders boolean values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": { v: true, t: 3 },
|
||||
"1": { v: false, t: 3 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("TRUE");
|
||||
expect(html).toContain("FALSE");
|
||||
});
|
||||
|
||||
it("applies inline styles for colors, alignment, and borders", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "styled",
|
||||
s: {
|
||||
bg: { rgb: "#FF0000" },
|
||||
cl: { rgb: "#FFFFFF" },
|
||||
ht: 2,
|
||||
bd: {
|
||||
b: { s: 1, cl: { rgb: "#000000" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("background-color:#FF0000");
|
||||
expect(html).toContain("color:#FFFFFF");
|
||||
expect(html).toContain("text-align:center");
|
||||
expect(html).toContain("border-bottom:");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in color values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
bg: { rgb: "red;background:url(//evil.com/steal)" },
|
||||
cl: { rgb: "#FFF;color:expression(alert(1))" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("evil.com");
|
||||
expect(html).not.toContain("expression");
|
||||
expect(html).toContain("transparent");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in font-family", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
ff: "Arial;}</style><script>alert(1)</script>"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("</style>");
|
||||
expect(html).toContain("font-family:Arial");
|
||||
});
|
||||
|
||||
it("sanitizes CSS injection in border colors", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "test",
|
||||
s: {
|
||||
bd: {
|
||||
b: { s: 1, cl: { rgb: "#000;background:url(//evil.com)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("evil.com");
|
||||
expect(html).toContain("transparent");
|
||||
});
|
||||
});
|
||||
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Converts a UniversJS workbook JSON structure into a static HTML table representation.
|
||||
* This is used for rendering spreadsheets in shared notes and exports.
|
||||
*
|
||||
* Only the subset of UniversJS types needed for rendering is defined here,
|
||||
* to avoid depending on @univerjs/core.
|
||||
*/
|
||||
|
||||
// #region UniversJS type subset
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: IWorkbookData;
|
||||
}
|
||||
|
||||
interface IWorkbookData {
|
||||
sheetOrder: string[];
|
||||
styles?: Record<string, IStyleData | null>;
|
||||
sheets: Record<string, IWorksheetData>;
|
||||
}
|
||||
|
||||
interface IWorksheetData {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: number;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
defaultColumnWidth?: number;
|
||||
defaultRowHeight?: number;
|
||||
mergeData?: IRange[];
|
||||
cellData: CellMatrix;
|
||||
rowData?: Record<number, IRowData>;
|
||||
columnData?: Record<number, IColumnData>;
|
||||
showGridlines?: number;
|
||||
}
|
||||
|
||||
type CellMatrix = Record<number, Record<number, ICellData>>;
|
||||
|
||||
interface ICellData {
|
||||
v?: string | number | boolean | null;
|
||||
t?: number | null;
|
||||
s?: IStyleData | string | null;
|
||||
}
|
||||
|
||||
interface IStyleData {
|
||||
bl?: number;
|
||||
it?: number;
|
||||
ul?: ITextDecoration;
|
||||
st?: ITextDecoration;
|
||||
fs?: number;
|
||||
ff?: string | null;
|
||||
bg?: IColorStyle | null;
|
||||
cl?: IColorStyle | null;
|
||||
ht?: number | null;
|
||||
vt?: number | null;
|
||||
bd?: IBorderData | null;
|
||||
}
|
||||
|
||||
interface ITextDecoration {
|
||||
s?: number;
|
||||
}
|
||||
|
||||
interface IColorStyle {
|
||||
rgb?: string | null;
|
||||
}
|
||||
|
||||
interface IBorderData {
|
||||
t?: IBorderStyleData | null;
|
||||
r?: IBorderStyleData | null;
|
||||
b?: IBorderStyleData | null;
|
||||
l?: IBorderStyleData | null;
|
||||
}
|
||||
|
||||
interface IBorderStyleData {
|
||||
s?: number;
|
||||
cl?: IColorStyle;
|
||||
}
|
||||
|
||||
interface IRange {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
}
|
||||
|
||||
interface IRowData {
|
||||
h?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
interface IColumnData {
|
||||
w?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
// Alignment enums (from UniversJS)
|
||||
const enum HorizontalAlign {
|
||||
LEFT = 1,
|
||||
CENTER = 2,
|
||||
RIGHT = 3
|
||||
}
|
||||
|
||||
const enum VerticalAlign {
|
||||
TOP = 1,
|
||||
MIDDLE = 2,
|
||||
BOTTOM = 3
|
||||
}
|
||||
|
||||
// Border style enum
|
||||
const enum BorderStyle {
|
||||
THIN = 1,
|
||||
MEDIUM = 6,
|
||||
THICK = 9,
|
||||
DASHED = 3,
|
||||
DOTTED = 4
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Parses the raw JSON content of a spreadsheet note and renders it as HTML.
|
||||
* Returns an HTML string containing one `<table>` per visible sheet.
|
||||
*/
|
||||
export function renderSpreadsheetToHtml(jsonContent: string): string {
|
||||
let data: PersistedData;
|
||||
try {
|
||||
data = JSON.parse(jsonContent);
|
||||
} catch {
|
||||
return "<p>Unable to parse spreadsheet data.</p>";
|
||||
}
|
||||
|
||||
if (!data?.workbook?.sheets) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const { workbook } = data;
|
||||
const sheetIds = workbook.sheetOrder ?? Object.keys(workbook.sheets);
|
||||
const visibleSheets = sheetIds
|
||||
.map((id) => workbook.sheets[id])
|
||||
.filter((s) => s && !s.hidden);
|
||||
|
||||
if (visibleSheets.length === 0) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const sheet of visibleSheets) {
|
||||
if (visibleSheets.length > 1) {
|
||||
parts.push(`<h3>${escapeHtml(sheet.name)}</h3>`);
|
||||
}
|
||||
parts.push(renderSheet(sheet, workbook.styles ?? {}));
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function renderSheet(sheet: IWorksheetData, styles: Record<string, IStyleData | null>): string {
|
||||
const { cellData, mergeData = [], columnData = {}, rowData = {} } = sheet;
|
||||
|
||||
// Determine the actual bounds (only cells with data).
|
||||
const bounds = computeBounds(cellData, mergeData);
|
||||
if (!bounds) {
|
||||
return "<p>Empty sheet.</p>";
|
||||
}
|
||||
|
||||
const { minRow, maxRow, minCol, maxCol } = bounds;
|
||||
|
||||
// Build a set of cells that are hidden by merges (non-origin cells).
|
||||
const mergeMap = buildMergeMap(mergeData, minRow, maxRow, minCol, maxCol);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('<table class="spreadsheet-table">');
|
||||
|
||||
// Colgroup for column widths.
|
||||
const defaultWidth = sheet.defaultColumnWidth ?? 88;
|
||||
lines.push("<colgroup>");
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
const colMeta = columnData[col];
|
||||
if (colMeta?.hd) continue;
|
||||
const width = isFiniteNumber(colMeta?.w) ? colMeta.w : defaultWidth;
|
||||
lines.push(`<col style="width:${width}px">`);
|
||||
}
|
||||
lines.push("</colgroup>");
|
||||
|
||||
const defaultHeight = sheet.defaultRowHeight ?? 24;
|
||||
|
||||
for (let row = minRow; row <= maxRow; row++) {
|
||||
const rowMeta = rowData[row];
|
||||
if (rowMeta?.hd) continue;
|
||||
|
||||
const height = isFiniteNumber(rowMeta?.h) ? rowMeta.h : defaultHeight;
|
||||
lines.push(`<tr style="height:${height}px">`);
|
||||
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
if (columnData[col]?.hd) continue;
|
||||
|
||||
const mergeInfo = mergeMap.get(cellKey(row, col));
|
||||
if (mergeInfo === "hidden") continue;
|
||||
|
||||
const cell = cellData[row]?.[col];
|
||||
const cellStyle = resolveCellStyle(cell, styles);
|
||||
const cssText = buildCssText(cellStyle);
|
||||
const value = formatCellValue(cell);
|
||||
|
||||
const attrs: string[] = [];
|
||||
if (cssText) attrs.push(`style="${cssText}"`);
|
||||
if (mergeInfo) {
|
||||
if (mergeInfo.rowSpan > 1) attrs.push(`rowspan="${mergeInfo.rowSpan}"`);
|
||||
if (mergeInfo.colSpan > 1) attrs.push(`colspan="${mergeInfo.colSpan}"`);
|
||||
}
|
||||
|
||||
lines.push(`<td${attrs.length ? " " + attrs.join(" ") : ""}>${value}</td>`);
|
||||
}
|
||||
|
||||
lines.push("</tr>");
|
||||
}
|
||||
|
||||
lines.push("</table>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// #region Bounds computation
|
||||
|
||||
interface Bounds {
|
||||
minRow: number;
|
||||
maxRow: number;
|
||||
minCol: number;
|
||||
maxCol: number;
|
||||
}
|
||||
|
||||
function computeBounds(cellData: CellMatrix, mergeData: IRange[]): Bounds | null {
|
||||
let minRow = Infinity;
|
||||
let maxRow = -Infinity;
|
||||
let minCol = Infinity;
|
||||
let maxCol = -Infinity;
|
||||
|
||||
for (const rowStr of Object.keys(cellData)) {
|
||||
const row = Number(rowStr);
|
||||
const cols = cellData[row];
|
||||
for (const colStr of Object.keys(cols)) {
|
||||
const col = Number(colStr);
|
||||
if (minRow > row) minRow = row;
|
||||
if (maxRow < row) maxRow = row;
|
||||
if (minCol > col) minCol = col;
|
||||
if (maxCol < col) maxCol = col;
|
||||
}
|
||||
}
|
||||
|
||||
// Extend bounds to cover merged ranges.
|
||||
for (const range of mergeData) {
|
||||
if (minRow > range.startRow) minRow = range.startRow;
|
||||
if (maxRow < range.endRow) maxRow = range.endRow;
|
||||
if (minCol > range.startColumn) minCol = range.startColumn;
|
||||
if (maxCol < range.endColumn) maxCol = range.endColumn;
|
||||
}
|
||||
|
||||
if (minRow > maxRow) return null;
|
||||
return { minRow, maxRow, minCol, maxCol };
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Merge handling
|
||||
|
||||
interface MergeOrigin {
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type MergeInfo = MergeOrigin | "hidden";
|
||||
|
||||
function cellKey(row: number, col: number): string {
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
function buildMergeMap(mergeData: IRange[], minRow: number, maxRow: number, minCol: number, maxCol: number): Map<string, MergeInfo> {
|
||||
const map = new Map<string, MergeInfo>();
|
||||
|
||||
for (const range of mergeData) {
|
||||
const startRow = Math.max(range.startRow, minRow);
|
||||
const endRow = Math.min(range.endRow, maxRow);
|
||||
const startCol = Math.max(range.startColumn, minCol);
|
||||
const endCol = Math.min(range.endColumn, maxCol);
|
||||
|
||||
map.set(cellKey(range.startRow, range.startColumn), {
|
||||
rowSpan: endRow - startRow + 1,
|
||||
colSpan: endCol - startCol + 1
|
||||
});
|
||||
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
if (r === range.startRow && c === range.startColumn) continue;
|
||||
map.set(cellKey(r, c), "hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Style resolution
|
||||
|
||||
function resolveCellStyle(cell: ICellData | undefined, styles: Record<string, IStyleData | null>): IStyleData | null {
|
||||
if (!cell?.s) return null;
|
||||
|
||||
if (typeof cell.s === "string") {
|
||||
return styles[cell.s] ?? null;
|
||||
}
|
||||
|
||||
return cell.s;
|
||||
}
|
||||
|
||||
function buildCssText(style: IStyleData | null): string {
|
||||
if (!style) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (style.bl) parts.push("font-weight:bold");
|
||||
if (style.it) parts.push("font-style:italic");
|
||||
if (style.ul?.s) parts.push("text-decoration:underline");
|
||||
if (style.st?.s) {
|
||||
// Combine with underline if both are set.
|
||||
const existing = parts.findIndex((p) => p.startsWith("text-decoration:"));
|
||||
if (existing >= 0) {
|
||||
parts[existing] = "text-decoration:underline line-through";
|
||||
} else {
|
||||
parts.push("text-decoration:line-through");
|
||||
}
|
||||
}
|
||||
if (style.fs && isFiniteNumber(style.fs)) parts.push(`font-size:${style.fs}pt`);
|
||||
if (style.ff) parts.push(`font-family:${sanitizeCssValue(style.ff)}`);
|
||||
if (style.bg?.rgb) parts.push(`background-color:${sanitizeCssColor(style.bg.rgb)}`);
|
||||
if (style.cl?.rgb) parts.push(`color:${sanitizeCssColor(style.cl.rgb)}`);
|
||||
|
||||
if (style.ht != null) {
|
||||
const align = horizontalAlignToCss(style.ht);
|
||||
if (align) parts.push(`text-align:${align}`);
|
||||
}
|
||||
if (style.vt != null) {
|
||||
const valign = verticalAlignToCss(style.vt);
|
||||
if (valign) parts.push(`vertical-align:${valign}`);
|
||||
}
|
||||
|
||||
if (style.bd) {
|
||||
appendBorderCss(parts, "border-top", style.bd.t);
|
||||
appendBorderCss(parts, "border-right", style.bd.r);
|
||||
appendBorderCss(parts, "border-bottom", style.bd.b);
|
||||
appendBorderCss(parts, "border-left", style.bd.l);
|
||||
}
|
||||
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function horizontalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case HorizontalAlign.LEFT: return "left";
|
||||
case HorizontalAlign.CENTER: return "center";
|
||||
case HorizontalAlign.RIGHT: return "right";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verticalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case VerticalAlign.TOP: return "top";
|
||||
case VerticalAlign.MIDDLE: return "middle";
|
||||
case VerticalAlign.BOTTOM: return "bottom";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendBorderCss(parts: string[], property: string, border: IBorderStyleData | null | undefined): void {
|
||||
if (!border) return;
|
||||
const width = borderStyleToWidth(border.s);
|
||||
const color = sanitizeCssColor(border.cl?.rgb ?? "#000");
|
||||
const style = borderStyleToCss(border.s);
|
||||
parts.push(`${property}:${width} ${style} ${color}`);
|
||||
}
|
||||
|
||||
function borderStyleToWidth(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.MEDIUM: return "2px";
|
||||
case BorderStyle.THICK: return "3px";
|
||||
default: return "1px";
|
||||
}
|
||||
}
|
||||
|
||||
function borderStyleToCss(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.DASHED: return "dashed";
|
||||
case BorderStyle.DOTTED: return "dotted";
|
||||
default: return "solid";
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks that a value is a finite number (guards against stringified payloads from JSON). */
|
||||
function isFiniteNumber(v: unknown): v is number {
|
||||
return typeof v === "number" && Number.isFinite(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an arbitrary string for use as a CSS value by removing characters
|
||||
* that could break out of a property (semicolons, braces, angle brackets, etc.).
|
||||
*/
|
||||
function sanitizeCssValue(value: string): string {
|
||||
return value.replace(/[;<>{}\\/()'"]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a CSS color string. Accepts hex colors (#rgb, #rrggbb, #rrggbbaa),
|
||||
* named colors (letters only), and rgb()/rgba()/hsl()/hsla() functional notation
|
||||
* with safe characters. Returns "transparent" for anything that doesn't match.
|
||||
*/
|
||||
function sanitizeCssColor(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
// Hex colors
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
// Named colors (letters only, reasonable length)
|
||||
if (/^[a-zA-Z]{1,30}$/.test(trimmed)) return trimmed;
|
||||
// Functional notation: rgb(), rgba(), hsl(), hsla() — allow digits, commas, dots, spaces, %
|
||||
if (/^(?:rgb|hsl)a?\([0-9.,\s%]+\)$/.test(trimmed)) return trimmed;
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Value formatting
|
||||
|
||||
function formatCellValue(cell: ICellData | undefined): string {
|
||||
if (!cell || cell.v == null) return "";
|
||||
|
||||
if (typeof cell.v === "boolean") {
|
||||
return cell.v ? "TRUE" : "FALSE";
|
||||
}
|
||||
|
||||
return escapeHtml(String(cell.v));
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/pdfjs-viewer",
|
||||
"version": "0.102.0",
|
||||
"version": "0.102.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
|
||||
@@ -71,13 +71,17 @@ function patchCacheBuster(htmlFilePath: string) {
|
||||
const version = packageJson.version;
|
||||
console.log(`Versioned URLs: ${version}.`)
|
||||
let html = readFileSync(htmlFilePath, "utf-8");
|
||||
html = html.replace(
|
||||
`<link rel="stylesheet" href="custom.css" />`,
|
||||
`<link rel="stylesheet" href="custom.css?v=${version}" />`);
|
||||
html = html.replace(
|
||||
`<script src="custom.mjs" type="module"></script>`,
|
||||
`<script src="custom.mjs?v=${version}" type="module"></script>`
|
||||
);
|
||||
for (const file of [ "viewer.css", "custom.css" ]) {
|
||||
html = html.replace(
|
||||
`<link rel="stylesheet" href="${file}" />`,
|
||||
`<link rel="stylesheet" href="${file}?v=${version}" />`);
|
||||
}
|
||||
for (const file of [ "viewer.mjs", "custom.mjs" ]) {
|
||||
html = html.replace(
|
||||
`<script src="${file}" type="module"></script>`,
|
||||
`<script src="${file}?v=${version}" type="module"></script>`
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(htmlFilePath, html);
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.33",
|
||||
"katex": "0.16.38",
|
||||
"mermaid": "11.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@digitak/esrun": "3.2.26",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"dotenv": "17.3.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint": "10.0.3",
|
||||
"highlight.js": "11.11.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
1999
pnpm-lock.yaml
generated
1999
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user