Compare commits

..

9 Commits

89 changed files with 1495 additions and 3236 deletions

View File

@@ -41,7 +41,7 @@ jobs:
run: pnpm run --filter=client test
- name: Upload client test report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
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@v7
uses: actions/upload-artifact@v4
if: always()
with:
name: server-test-report

View File

@@ -229,7 +229,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up crane
uses: imjasonh/setup-crane@v0.5
uses: imjasonh/setup-crane@v0.4
- name: Login to GHCR
uses: docker/login-action@v4

View File

@@ -14,7 +14,7 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.32.0",
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@redocly/cli": "2.20.2",
"archiver": "7.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.102.1",
"version": "0.102.0",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -25,23 +25,17 @@
"@fullcalendar/rrule": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.1",
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.8.2",
"@preact/signals": "2.8.1",
"@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",
@@ -53,28 +47,28 @@
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"globals": "17.4.0",
"i18next": "25.8.14",
"i18next": "25.8.13",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.38",
"katex": "0.16.33",
"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.2",
"mind-elixir": "5.9.1",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.4",
"react-i18next": "16.5.6",
"react-i18next": "16.5.5",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
"rrule": "2.8.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.4.0",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {

View File

@@ -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", "spreadsheet"].includes(type)) {
} else if (["image", "canvas", "mindMap"].includes(type)) {
renderImage(entity, $renderedContent, options);
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
await renderFile(entity, type, $renderedContent);

View File

@@ -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, method = "PUT") {
async function upload(url: string, fileToUpload: File, componentId?: string) {
const formData = new FormData();
formData.append("upload", fileToUpload);
@@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string, met
"trilium-component-id": componentId
} : undefined),
data: formData,
type: method,
type: "PUT",
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false // NEEDED, DON'T REMOVE THIS

View File

@@ -1,7 +1,9 @@
import "jquery";
import utils from "./services/utils.js";
import ko from "knockout";
import utils from "./services/utils.js";
// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
@@ -16,6 +18,8 @@ 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;
@@ -27,6 +31,8 @@ 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);
@@ -40,7 +46,7 @@ class SetupModel {
return !!this.setupType();
}
selectSetupType() {
async selectSetupType() {
if (this.setupType() === "new-document") {
this.step("new-document-in-progress");
@@ -52,6 +58,24 @@ 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("");
@@ -72,11 +96,22 @@ 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: syncServerHost,
syncProxy: syncProxy,
password: password
syncServerHost,
syncProxy,
password,
totpToken
});
if (resp.result === "success") {

View File

@@ -1535,8 +1535,7 @@
"new-feature": "新建",
"collections": "集合",
"book": "集合",
"ai-chat": "AI聊天",
"spreadsheet": "电子表格"
"ai-chat": "AI聊天"
},
"protect_note": {
"toggle-on": "保护笔记",

View File

@@ -1488,21 +1488,20 @@
"mermaid-diagram": "Mermaid Diagramm",
"canvas": "Leinwand",
"web-view": "Webansicht",
"mind-map": "Mindmap",
"mind-map": "Mind Map",
"file": "Datei",
"image": "Bild",
"launcher": "Starter",
"doc": "Dokument",
"widget": "Widget",
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"confirm-change": "Es is 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",
"spreadsheet": "Tabelle"
"collections": "Sammlungen"
},
"protect_note": {
"toggle-on": "Notiz schützen",

View File

@@ -1036,25 +1036,6 @@
"file_preview_not_available": "File preview is not available for this file format.",
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
},
"video": {
"play": "Play (Space)",
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
"forward-30s": "Forward 30s",
"mute": "Mute (M)",
"unmute": "Unmute (M)",
"playback-speed": "Playback speed",
"loop": "Loop",
"disable-loop": "Disable loop",
"rotate": "Rotate",
"picture-in-picture": "Picture-in-picture",
"exit-picture-in-picture": "Exit picture-in-picture",
"fullscreen": "Fullscreen (F)",
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Video preview is not available for this file format.",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
},
"protected_session": {
"enter_password_instruction": "Showing protected note requires entering your password:",
"start_session_button": "Start protected session",

View File

@@ -1548,8 +1548,7 @@
"task-list": "Lista de tareas",
"book": "Colección",
"new-feature": "Nuevo",
"collections": "Colecciones",
"spreadsheet": "Hoja de cálculo"
"collections": "Colecciones"
},
"protect_note": {
"toggle-on": "Proteger la nota",
@@ -1651,8 +1650,7 @@
},
"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.",
"search_now": "Buscar ahora"
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
},
"spacer": {
"configure_launchbar": "Configurar barra de lanzamiento"

View File

@@ -1571,8 +1571,7 @@
"ai-chat": "Comhrá AI",
"task-list": "Liosta Tascanna",
"new-feature": "Nua",
"collections": "Bailiúcháin",
"spreadsheet": "Scarbhileog"
"collections": "Bailiúcháin"
},
"protect_note": {
"toggle-on": "Cosain an nóta",

View File

@@ -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,8 +1717,7 @@
"task-list": "Elenco delle attività",
"new-feature": "Nuovo",
"collections": "Collezioni",
"ai-chat": "Chat con IA",
"spreadsheet": "Foglio di calcolo"
"ai-chat": "Chat con IA"
},
"protect_note": {
"toggle-on": "Proteggi la nota",

View File

@@ -600,8 +600,7 @@
"task-list": "タスクリスト",
"new-feature": "New",
"collections": "コレクション",
"ai-chat": "AI チャット",
"spreadsheet": "スプレッドシート"
"ai-chat": "AI チャット"
},
"edited_notes": {
"no_edited_notes_found": "この日の編集されたノートはまだありません...",

View File

@@ -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": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<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": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\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_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
@@ -594,8 +594,7 @@
"display-week-numbers": "Отображать номера недель",
"hide-weekends": "Скрыть выходные",
"raster": "Растр",
"show-scale": "Показать масштаб",
"show-labels": "Показать названия маркеров"
"show-scale": "Показать масштаб"
},
"editorfeatures": {
"note_completion_enabled": "Включить автодополнение",
@@ -783,13 +782,7 @@
"shared-indicator-tooltip": "Эта заметка опубликована",
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
"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}} дочерних заметок"
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
},
"quick-search": {
"no-results": "Результаты не найдены",
@@ -833,9 +826,7 @@
"mind-map": "Mind Map",
"geo-map": "Географическая карта",
"task-list": "Список задач",
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
"ai-chat": "Чат с ИИ",
"spreadsheet": "Электронная таблица"
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
},
"tree-context-menu": {
"open-in-popup": "Быстрое редактирование",
@@ -1162,8 +1153,7 @@
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
"actions_executed": "Действия выполнены.",
"view_options": "Просмотреть опции:",
"option": "опция"
"view_options": "Просмотреть опции:"
},
"ancestor": {
"depth_label": "глубина",
@@ -1413,8 +1403,7 @@
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
},
"sync_2": {
"timeout_unit": "миллисекунд",
@@ -1724,8 +1713,7 @@
"delete_this_note": "Удалить эту заметку",
"insert_child_note": "Вставить дочернюю заметку",
"note_revisions": "История изменений",
"content_language_switcher": "Язык содержимого: {{language}}",
"backlinks": "Ссылки"
"content_language_switcher": "Язык содержимого: {{language}}"
},
"svg_export_button": {
"button_title": "Экспортировать диаграмму как SVG"
@@ -1802,8 +1790,7 @@
},
"search_result": {
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
"search_not_executed": "Поиск ещё не выполнен.",
"search_now": "Искать сейчас"
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
},
"empty": {
"search_placeholder": "поиск заметки по ее названию",
@@ -2001,12 +1988,10 @@
"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_error_title": "Не удалось напечатать",
"print_report_stack_trace": "Трассировка стека"
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
},
"book": {
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
"drag_locked_title": "Защищено от изменения",
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
},
@@ -2022,9 +2007,7 @@
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
},
"pagination": {
"total_notes": "{{count}} заметок",
"prev_page": "Предыдущая страница",
"next_page": "Следующая страница"
"total_notes": "{{count}} заметок"
},
"status_bar": {
"attributes_one": "{{count}} атрибут",
@@ -2154,49 +2137,5 @@
},
"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": "Узнать больше"
}
}

View File

@@ -3,38 +3,6 @@
"title": "Om Trilium Notes",
"homepage": "Hemsida:",
"app_version": "App 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"
"db_version": "DB version:"
}
}

View File

@@ -1496,8 +1496,7 @@
"task-list": "任務列表",
"new-feature": "新增",
"collections": "集合",
"ai-chat": "AI 聊天",
"spreadsheet": "試算表"
"ai-chat": "AI 聊天"
},
"protect_note": {
"toggle-on": "保護筆記",

View File

@@ -40,21 +40,6 @@ 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,
@@ -64,7 +49,7 @@ export default function NoteDetail() {
};
useEffect(() => {
if (!type || !hasTabBeenActive) return;
if (!type) return;
const requestId = ++widgetRequestId.current;
if (!noteTypesToRender[type]) {
@@ -83,7 +68,7 @@ export default function NoteDetail() {
} else {
setActiveNoteType(type);
}
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
}, [ note, viewScope, type, noteTypesToRender ]);
// Detect note type changes.
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
@@ -262,8 +247,9 @@ 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];
@@ -274,7 +260,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
height: isFullHeight ? "100%" : ""
}}
>
<Element {...cachedProps} />
{ <Element {...cachedProps} /> }
</div>
);
}

View File

@@ -14,7 +14,8 @@
height: 100%;
display: flex;
gap: 1em;
padding: 4px var(--content-margin-inline);
margin-inline: var(--content-margin-inline);
padding-block: 4px;
align-items: flex-start;
overflow-x: auto;
}
@@ -41,11 +42,7 @@ body.mobile .board-view-container {
body.mobile .board-view-container .board-column {
width: 75vw;
max-width: 300px;
}
body.mobile .board-view-container .board-column,
body.mobile .board-view-container .board-add-column {
scroll-snap-align: center;
scroll-snap-align: center;
}
.board-view-container .board-column.drag-over {

View File

@@ -272,8 +272,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
case "canvas":
case "mindMap":
case "mermaid":
case "spreadsheet": {
case "mermaid": {
const encodedTitle = encodeURIComponent(revisionItem.title);
return <img
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}

View File

@@ -36,10 +36,6 @@
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 {

View File

@@ -143,7 +143,7 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
isFullHeight: true
},
spreadsheet: {
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
view: () => import("./type_widgets/Spreadsheet"),
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true

View File

@@ -98,7 +98,6 @@ export interface SavedData {
mime: string;
content: string;
position: number;
encoding?: "base64";
}[];
}

View File

@@ -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", "spreadsheet"].includes(noteType);
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
const isContentAvailable = note.isContentAvailable();

View File

@@ -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", "spreadsheet" ].includes(note.type) || isSavedSqlite)
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <NoteAction

View File

@@ -1,6 +1,6 @@
import "./TableOfContents.css";
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
@@ -170,14 +170,11 @@ function EditableTextTableOfContents() {
const affectsHeadings = changes.some( change => {
return (
change.type === 'insert' || change.type === 'remove' ||
(change.type === 'attribute' && attributeChangeAffectsHeading(change, textEditor))
change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel')
);
});
if (affectsHeadings) {
requestAnimationFrame(() => {
setHeadings(extractTocFromTextEditor(textEditor));
});
setHeadings(extractTocFromTextEditor(textEditor));
}
};

View File

@@ -24,7 +24,8 @@
margin: 10px;
}
.note-detail-file > .pdf-preview {
.note-detail-file > .pdf-preview,
.note-detail-file > .video-preview {
width: 100%;
height: 100%;
flex-grow: 100;
@@ -37,4 +38,4 @@
right: 15px;
width: calc(100% - 30px);
transform: translateY(-50%);
}
}

View File

@@ -6,7 +6,6 @@ import { getUrlForDownload } from "../../services/open";
import Alert from "../react/Alert";
import { useNoteBlob } from "../react/hooks";
import PdfPreview from "./file/Pdf";
import VideoPreview from "./file/Video";
import { TypeWidgetProps } from "./type_widget";
const TEXT_MAX_NUM_CHARS = 5000;
@@ -43,6 +42,17 @@ function TextPreview({ content }: { content: string }) {
);
}
function VideoPreview({ note }: { note: FNote }) {
return (
<video
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
controls
/>
);
}
function AudioPreview({ note }: { note: FNote }) {
return (
<audio

View File

@@ -0,0 +1,125 @@
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;
}
};
}, []);
}

View File

@@ -184,7 +184,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
<PdfViewer
iframeRef={iframeRef}
tabIndex={300}
pdfUrl={new URL(`${window.glob.baseApiUrl}notes/${note.noteId}/open`, window.location.href).pathname}
pdfUrl={`../../api/notes/${note.noteId}/open`}
onLoad={() => {
const win = iframeRef.current?.contentWindow;
if (win) {

View File

@@ -1,8 +1,7 @@
import type { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
interface FontDefinition {
name: string;
@@ -11,11 +10,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, ideally use absolute paths (but without domain name) to avoid issues with some proxies. */
/** Note: URLs are relative to /pdfjs/web. */
pdfUrl: string;
onLoad?(): void;
/**
@@ -38,7 +37,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
ref={iframeRef}
class="pdf-preview"
style={{width: "100%", height: "100%"}}
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
src={`pdfjs/web/viewer.html?file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
onLoad={() => {
injectStyles();
onLoad?.();
@@ -64,7 +63,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.
@@ -108,4 +107,4 @@ function injectFont(font: FontDefinition) {
src: url('${font.url}');
}
`;
}
}

View File

@@ -1,114 +0,0 @@
.note-detail-file > .video-preview-wrapper {
width: 100%;
height: 100%;
position: relative;
background-color: black;
.video-preview {
background-color: black;
width: 100%;
height: 100%;
}
&.controls-hidden {
cursor: pointer;
.video-preview-controls {
opacity: 0;
pointer-events: none;
}
}
.video-preview-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.25em;
display: flex;
flex-direction: column;
gap: 0.5em;
background: rgba(0, 0, 0, 0.5);
color: white;
--icon-button-hover-color: white;
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
opacity: 1;
transition: opacity 300ms ease;
.video-buttons-row {
display: flex;
> * {
flex: 1;
align-items: center;
gap: 0.5em;
display: flex;
}
.spacer {
width: var(--icon-button-size, 32px);
height: var(--icon-button-size, 32px);
}
.center {
justify-content: center;
}
.right {
display: flex;
justify-content: flex-end;
}
.play-button {
--icon-button-size: 48px;
}
}
}
.video-seekbar-row {
display: flex;
align-items: center;
gap: 0.5em;
}
.video-trackbar {
flex: 1;
cursor: pointer;
}
.video-time {
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.video-volume-row {
display: flex;
align-items: center;
gap: 0.25em;
}
.video-volume-slider {
width: 80px;
cursor: pointer;
}
.speed-dropdown {
position: relative;
.tn-icon {
transform: translateY(-10%);
}
.video-speed-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transform: translateY(15%);
text-align: center;
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
}
}
}

View File

@@ -1,514 +0,0 @@
import "./Video.css";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
const AUTO_HIDE_DELAY = 3000;
export default function VideoPreview({ note }: { note: FNote }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const { visible: controlsVisible, onMouseMove, flash: flashControls } = useAutoHideControls(videoRef, playing);
useEffect(() => setError(false), [note.noteId]);
const onError = useCallback(() => setError(true), []);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlayback();
flashControls();
break;
case "ArrowLeft":
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - (e.ctrlKey ? 60 : 10));
flashControls();
break;
case "ArrowRight":
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + (e.ctrlKey ? 60 : 10));
flashControls();
break;
case "f":
case "F":
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapperRef.current?.requestFullscreen();
}
break;
case "m":
case "M":
e.preventDefault();
video.muted = !video.muted;
flashControls();
break;
case "ArrowUp":
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.05);
flashControls();
break;
case "ArrowDown":
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.05);
flashControls();
break;
case "Home":
e.preventDefault();
video.currentTime = 0;
flashControls();
break;
case "End":
e.preventDefault();
video.currentTime = video.duration;
flashControls();
break;
}
}, [togglePlayback, flashControls]);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("video.unsupported-format")} />;
}
return (
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="video-preview-controls">
<SeekBar videoRef={videoRef} />
<div class="video-buttons-row">
<div className="left">
<PlaybackSpeed videoRef={videoRef} />
<RotateButton videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton videoRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("video.back-10s")} />
<PlayPauseButton videoRef={videoRef} playing={playing} />
<SkipButton videoRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("video.forward-30s")} />
<LoopButton videoRef={videoRef} />
</div>
<div className="right">
<VolumeControl videoRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
</div>
</div>
</div>
);
}
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
const [visible, setVisible] = useState(true);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
const scheduleHide = useCallback(() => {
clearTimeout(hideTimerRef.current);
if (videoRef.current && !videoRef.current.paused) {
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
}
}, []);
const onMouseMove = useCallback(() => {
setVisible(true);
scheduleHide();
}, [scheduleHide]);
// Hide immediately when playback starts, show when paused.
useEffect(() => {
if (playing) {
setVisible(false);
} else {
clearTimeout(hideTimerRef.current);
setVisible(true);
}
return () => clearTimeout(hideTimerRef.current);
}, [playing, scheduleHide]);
return { visible, onMouseMove, flash: onMouseMove };
}
function PlayPauseButton({ videoRef, playing }: { videoRef: RefObject<HTMLVideoElement>, playing: boolean }) {
const togglePlayback = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
};
return (
<ActionButton
className="play-button"
icon={playing ? "bx bx-pause" : "bx bx-play"}
text={playing ? t("video.pause") : t("video.play")}
onClick={togglePlayback}
/>
);
}
function SkipButton({ videoRef, seconds, icon, text }: { videoRef: RefObject<HTMLVideoElement>, seconds: number, icon: string, text: string }) {
const skip = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
};
return (
<ActionButton icon={icon} text={text} onClick={skip} />
);
}
function SeekBar({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTimeUpdate = () => setCurrentTime(video.currentTime);
const onDurationChange = () => setDuration(video.duration);
video.addEventListener("timeupdate", onTimeUpdate);
video.addEventListener("durationchange", onDurationChange);
return () => {
video.removeEventListener("timeupdate", onTimeUpdate);
video.removeEventListener("durationchange", onDurationChange);
};
}, []);
const onSeek = (e: Event) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = parseFloat((e.target as HTMLInputElement).value);
};
return (
<div class="video-seekbar-row">
<span class="video-time">{formatTime(currentTime)}</span>
<input
type="range"
class="video-trackbar"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onInput={onSeek}
/>
<span class="video-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
);
}
function VolumeControl({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [volume, setVolume] = useState(() => videoRef.current?.volume ?? 1);
const [muted, setMuted] = useState(() => videoRef.current?.muted ?? false);
// Sync state when the video element changes volume externally.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setVolume(video.volume);
setMuted(video.muted);
const onVolumeChange = () => {
setVolume(video.volume);
setMuted(video.muted);
};
video.addEventListener("volumechange", onVolumeChange);
return () => video.removeEventListener("volumechange", onVolumeChange);
}, []);
const onVolumeChange = (e: Event) => {
const video = videoRef.current;
if (!video) return;
const val = parseFloat((e.target as HTMLInputElement).value);
video.volume = val;
setVolume(val);
if (val > 0 && video.muted) {
video.muted = false;
setMuted(false);
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setMuted(video.muted);
};
return (
<div class="video-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("video.unmute") : t("video.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="video-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
);
}
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
function PlaybackSpeed({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setSpeed(video.playbackRate);
const onRateChange = () => setSpeed(video.playbackRate);
video.addEventListener("ratechange", onRateChange);
return () => video.removeEventListener("ratechange", onRateChange);
}, []);
const selectSpeed = (rate: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = rate;
setSpeed(rate);
};
return (
<Dropdown
iconAction
hideToggleArrow
buttonClassName="speed-dropdown"
text={<>
<Icon icon="bx bx-tachometer" />
<span class="video-speed-label">{speed}x</span>
</>}
title={t("video.playback-speed")}
>
{PLAYBACK_SPEEDS.map((rate) => (
<li key={rate}>
<button
class={`dropdown-item ${rate === speed ? "active" : ""}`}
onClick={() => selectSpeed(rate)}
>
{rate}x
</button>
</li>
))}
</Dropdown>
);
}
function LoopButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setLoop(video.loop);
const observer = new MutationObserver(() => setLoop(video.loop));
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, []);
const toggle = () => {
const video = videoRef.current;
if (!video) return;
video.loop = !video.loop;
setLoop(video.loop);
};
return (
<ActionButton
className={loop ? "active" : ""}
icon="bx bx-repeat"
text={loop ? t("video.disable-loop") : t("video.loop")}
onClick={toggle}
/>
);
}
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [rotation, setRotation] = useState(0);
const rotate = () => {
const video = videoRef.current;
if (!video) return;
const next = (rotation + 90) % 360;
setRotation(next);
const isSideways = next === 90 || next === 270;
if (isSideways) {
// Scale down so the rotated video fits within its container.
const container = video.parentElement;
if (container) {
const ratio = container.clientWidth / container.clientHeight;
video.style.transform = `rotate(${next}deg) scale(${1 / ratio})`;
} else {
video.style.transform = `rotate(${next}deg)`;
}
} else {
video.style.transform = next === 0 ? "" : `rotate(${next}deg)`;
}
};
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("video.rotate")}
onClick={rotate}
/>
);
}
function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [fitted, setFitted] = useState(false);
const toggle = () => {
const video = videoRef.current;
if (!video) return;
const next = !fitted;
video.style.objectFit = next ? "cover" : "";
setFitted(next);
};
return (
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
onClick={toggle}
/>
);
}
function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [active, setActive] = useState(false);
// The standard PiP API is only supported in Chromium-based browsers.
// Firefox uses its own proprietary PiP implementation.
const supported = "requestPictureInPicture" in HTMLVideoElement.prototype;
useEffect(() => {
const video = videoRef.current;
if (!video || !supported) return;
const onEnter = () => setActive(true);
const onLeave = () => setActive(false);
video.addEventListener("enterpictureinpicture", onEnter);
video.addEventListener("leavepictureinpicture", onLeave);
return () => {
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, [supported]);
if (!supported) return null;
const toggle = () => {
const video = videoRef.current;
if (!video) return;
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
video.requestPictureInPicture();
}
};
return (
<ActionButton
icon={active ? "bx bx-exit" : "bx bx-window-open"}
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
onClick={toggle}
/>
);
}
function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> }) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFullscreenChange);
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
}, []);
const toggleFullscreen = () => {
const target = targetRef.current;
if (!target) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
target.requestFullscreen();
}
};
return (
<ActionButton
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
onClick={toggleFullscreen}
/>
);
}

View File

@@ -1,120 +0,0 @@
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");
});
}

View File

@@ -1,194 +0,0 @@
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;
}
};
}, []);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/desktop",
"version": "0.102.1",
"version": "0.102.0",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "src/main.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/edit-docs",
"version": "0.102.1",
"version": "0.102.0",
"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": {

View File

@@ -1,51 +0,0 @@
# 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
```

View File

@@ -1,19 +0,0 @@
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:

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.102.1",
"version": "0.102.0",
"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.1.0",
"@types/multer": "2.0.0",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.16.1",
"@types/sanitize-html": "2.16.0",
"@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.1",
"express-rate-limit": "8.3.0",
"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.14",
"i18next": "25.8.13",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"ini": "6.0.0",

View File

@@ -155,6 +155,8 @@
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
"password": "密码",
"password-placeholder": "密码",
"totp-token": "TOTP 验证码",
"totp-token-placeholder": "请输入 TOTP 验证码",
"back": "返回",
"finish-setup": "完成设置"
},

View File

@@ -252,6 +252,8 @@
"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"
},

View File

@@ -156,8 +156,7 @@
"go-to-next-note-title": "К следующей заметке",
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
"zen-mode": "Режим \"Дзен\"",
"command-palette": "Открыть панель команд",
"tab-switcher-title": "Переключатель вкладок"
"command-palette": "Открыть панель команд"
},
"tray": {
"bookmarks": "Закладки",
@@ -314,7 +313,7 @@
"title": "Настройка",
"heading": "Настройка Trilium",
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
"sync-from-desktop": "У меня уже есть приложение ПК, и я хочу настроить синхронизацию с ним",
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
"init-in-progress": "Идет инициализация документа",
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
@@ -398,8 +397,8 @@
"clipped-from": "Эта заметка изначально была вырезана из {{- url}}"
},
"setup_sync-from-desktop": {
"heading": "Синхронизация с настольной версией",
"description": "Это настройку нужно выполнить с помощью настольной версии:",
"heading": "Синхронизация с приложения ПК",
"description": "Эту настройку необходимо инициировать из приложения для ПК:",
"step1": "Откройте приложение Trilium Notes на ПК.",
"step2": "В меню Trilium выберите «Параметры».",
"step3": "Нажмите на категорию «Синхронизация».",

View File

@@ -2,24 +2,6 @@
"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-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"
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
}
}

View File

@@ -155,6 +155,8 @@
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
"password": "密碼",
"password-placeholder": "密碼",
"totp-token": "TOTP 驗證碼",
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
"back": "返回",
"finish-setup": "完成設定"
},

View File

@@ -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" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost, event: { blur: checkTotpStatus }" 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,6 +141,10 @@
<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>

View File

@@ -8,6 +8,7 @@ export declare module "express-serve-static-core" {
authorization?: string;
"trilium-cred"?: string;
"trilium-totp"?: string;
"x-csrf-token"?: string;
"trilium-component-id"?: string;

View File

@@ -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", "spreadsheet"].includes(image.type)) {
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
return res.sendStatus(400);
}
@@ -33,8 +33,6 @@ 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");
@@ -62,18 +60,6 @@ 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);

View File

@@ -1,16 +1,19 @@
"use strict";
import sqlInit from "../../services/sql_init.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";
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";
function getStatus() {
return {
isInitialized: sqlInit.isDbInitialized(),
schemaExists: sqlInit.schemaExists(),
syncVersion: appInfo.syncVersion
syncVersion: appInfo.syncVersion,
totpEnabled: totp.isTotpEnabled()
};
}
@@ -19,9 +22,9 @@ async function setupNewDocument() {
}
function setupSyncFromServer(req: Request) {
const { syncServerHost, syncProxy, password } = req.body;
const { syncServerHost, syncProxy, password, totpToken } = req.body;
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken);
}
function saveSyncSeed(req: Request) {
@@ -82,10 +85,26 @@ 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
saveSyncSeed,
checkServerTotpStatus
};

View File

@@ -244,6 +244,7 @@ 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);

View File

@@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons";
export interface SetupStatusResponse {
syncVersion: number;
schemaExists: boolean;
totpEnabled: boolean;
}
/**

View File

@@ -9,6 +9,10 @@ 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;
@@ -72,4 +76,49 @@ 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);

View File

@@ -1,15 +1,17 @@
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 type { NextFunction, Request, Response } from "express";
import attributes from "./attributes.js";
import config from "./config.js";
import passwordService from "./encryption/password.js";
import totp from "./totp.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 openID from "./open_id.js";
import options from "./options.js";
import attributes from "./attributes.js";
import type { NextFunction, Request, Response } from "express";
import sqlInit from "./sql_init.js";
import totp from "./totp.js";
import { isElectron } from "./utils.js";
let noAuthentication = false;
refreshAuth();
@@ -161,9 +163,28 @@ 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.`);
} else {
next();
return;
}
// 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 {

View File

@@ -1,9 +1,10 @@
import optionService from "../options.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";
import optionService from "../options.js";
import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import myScryptService from "./my_scrypt.js";
const TOTP_OPTIONS: Record<string, OptionNames> = {
SALT: "totpEncryptionSalt",
ENCRYPTED_SECRET: "totpEncryptedSecret",

View File

@@ -10449,12 +10449,6 @@
"terms": [
"virus-block"
]
},
"bx-empty": {
"glyph": "",
"terms": [
"empty"
]
}
}
}

View File

@@ -772,20 +772,16 @@ 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, encoding } of attachments) {
const decodedContent = encoding === "base64" && typeof content === "string"
? Buffer.from(content, "base64")
: content;
for (const { attachmentId, role, mime, title, position, content } of attachments) {
const existingAttachment = existingAttachmentsByTitle.get(title);
if (attachmentId || !existingAttachment) {
note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position });
note.saveAttachment({ attachmentId, role, mime, title, content, position });
} else {
existingAttachment.role = role;
existingAttachment.mime = mime;
existingAttachment.position = position;
if (decodedContent) {
existingAttachment.setContent(decodedContent, { forceSave: true });
if (content) {
existingAttachment.setContent(content, { forceSave: true });
}
}
}

View File

@@ -62,6 +62,9 @@ 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({

View File

@@ -14,6 +14,7 @@ export interface ExecOpts {
cookieJar?: CookieJar;
auth?: {
password?: string;
totpToken?: string;
};
timeout: number;
body?: string | {};

View File

@@ -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) {
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: 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 },
auth: { password, totpToken },
proxy: syncProxy,
timeout: 30000 // seed request should not take long
});
@@ -111,10 +111,30 @@ 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
getSyncSeedOptions,
checkRemoteTotpStatus
};

View File

@@ -1,6 +1,7 @@
import { Totp, generateSecret } from 'time2fa';
import options from './options.js';
import { generateSecret,Totp } from 'time2fa';
import totpEncryptionService from './encryption/totp_encryption.js';
import options from './options.js';
function isTotpEnabled(): boolean {
return options.getOptionOrNull('mfaEnabled') === "true" &&
@@ -10,7 +11,7 @@ function isTotpEnabled(): boolean {
function createSecret(): { success: boolean; message?: string } {
try {
const secret = generateSecret();
const secret = generateSecret(20);
totpEncryptionService.setTotpSecret(secret);
@@ -43,6 +44,8 @@ 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);

View File

@@ -1,5 +1,4 @@
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";
@@ -287,8 +286,6 @@ 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>`;
}
@@ -490,14 +487,6 @@ 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;

View File

@@ -13,7 +13,7 @@
"postinstall": "wxt prepare"
},
"keywords": [],
"packageManager": "pnpm@10.32.0",
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.1",
"wxt": "0.20.18"

View File

@@ -9,16 +9,16 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.8.14",
"i18next": "25.8.13",
"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.6"
"react-i18next": "16.5.5"
},
"devDependencies": {
"@preact/preset-vite": "2.10.3",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",

View File

@@ -43,7 +43,7 @@
"code_title": "Code Notizen",
"canvas_title": "Leinwand",
"mermaid_title": "Mermaid Diagramm",
"mindmap_title": "Mindmap",
"mindmap_title": "Mind Map",
"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",

View File

@@ -12,7 +12,7 @@
"get_started": "Начало работы",
"github": "GitHub",
"dockerhub": "Docker Hub",
"screenshot_alt": "Скриншот приложения Trilium Notes для настольного приложения"
"screenshot_alt": "Скриншот приложения Trilium Notes для ПК"
},
"organization_benefits": {
"title": "Структура",
@@ -202,7 +202,6 @@
"title": "Ресурсы",
"icon_packs": "Наборы иконок",
"download": "Скачать",
"website": "Сайт",
"icon_packs_intro": "Расширьте выбор значков для заметок, используя набор иконок. Подробнее о наборах иконок смотрите в <DocumentationLink>официальной документации</DocumentationLink>."
"website": "Сайт"
}
}

View File

@@ -2,30 +2,6 @@
"get-started": {
"title": "Kom igång",
"desktop_title": "Ladda ner skrivbordsprogrammet (v{{version}})",
"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."
"architecture": "Arkitektur:"
}
}

45
docs/README-sv.md vendored
View File

@@ -37,39 +37,40 @@ 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) -
ostabil utvecklings version, uppdaterad dagligen med de senaste funktionerna
och fixarna.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly)
unstable development version, updated daily with the latest features and
fixes.
## 📚 Dokumentation
## 📚 Documentation
**Läs mer i vår omfattande dokumentation
**Visit our comprehensive documentation at
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
r dokumentation är tillgänglig i flera format:
- **Webb dokumentation**: Läs hela dokumentationen på
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **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
- **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
### Snabblänkar
- [Snabbstartsguide](https://docs.triliumnotes.org/)
- [Installationsanvisning](https://docs.triliumnotes.org/user-guide/setup)
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
- [Docker
Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
- [Uppdaterar
- [Upgrading
TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
- [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)
- [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)
## 🎁 Funktioner
## 🎁 Features
* 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))
* 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))
* 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)

View File

@@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.102.0",
"appVersion": "0.101.3",
"files": [
{
"isClone": false,
@@ -61,32 +61,6 @@
"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",
@@ -95,7 +69,7 @@
"d582eD4RY4OM"
],
"title": "v0.102.0",
"notePosition": 20,
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -121,7 +95,7 @@
"IlBzLeN3MJhw"
],
"title": "v0.101.3",
"notePosition": 30,
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -147,7 +121,7 @@
"vcBthaXcwAm6"
],
"title": "v0.101.2",
"notePosition": 40,
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -173,7 +147,7 @@
"AgUcrU9nFXuW"
],
"title": "v0.101.1",
"notePosition": 50,
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -199,7 +173,7 @@
"uYwlZ594eyJu"
],
"title": "v0.101.0",
"notePosition": 60,
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -225,7 +199,7 @@
"iPGKEk7pwJXK"
],
"title": "v0.100.0",
"notePosition": 70,
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -251,7 +225,7 @@
"7HKMTjmopLcM"
],
"title": "v0.99.5",
"notePosition": 80,
"notePosition": 70,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -277,7 +251,7 @@
"RMBaNYPsRpIr"
],
"title": "v0.99.4",
"notePosition": 90,
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -303,7 +277,7 @@
"yuroLztFfpu5"
],
"title": "v0.99.3",
"notePosition": 100,
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -329,7 +303,7 @@
"z207sehwMJ6C"
],
"title": "v0.99.2",
"notePosition": 110,
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -355,7 +329,7 @@
"WGQsXq2jNyTi"
],
"title": "v0.99.1",
"notePosition": 120,
"notePosition": 110,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -381,7 +355,7 @@
"cyw2Yue9vXf3"
],
"title": "v0.99.0",
"notePosition": 130,
"notePosition": 120,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -407,7 +381,7 @@
"QOJwjruOUr4k"
],
"title": "v0.98.1",
"notePosition": 140,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -433,7 +407,7 @@
"PLUoryywi0BC"
],
"title": "v0.98.0",
"notePosition": 150,
"notePosition": 140,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -459,7 +433,7 @@
"lvOuiWsLDv8F"
],
"title": "v0.97.2",
"notePosition": 160,
"notePosition": 150,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -485,7 +459,7 @@
"OtFZ6Nd9vM3n"
],
"title": "v0.97.1",
"notePosition": 170,
"notePosition": 160,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -511,7 +485,7 @@
"SJZ5PwfzHSQ1"
],
"title": "v0.97.0",
"notePosition": 180,
"notePosition": 170,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -537,7 +511,7 @@
"mYXFde3LuNR7"
],
"title": "v0.96.0",
"notePosition": 190,
"notePosition": 180,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -563,7 +537,7 @@
"jthwbL0FdaeU"
],
"title": "v0.95.0",
"notePosition": 200,
"notePosition": 190,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -589,7 +563,7 @@
"7HGYsJbLuhnv"
],
"title": "v0.94.1",
"notePosition": 210,
"notePosition": 200,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -615,7 +589,7 @@
"Neq53ujRGBqv"
],
"title": "v0.94.0",
"notePosition": 220,
"notePosition": 210,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -641,7 +615,7 @@
"VN3xnce1vLkX"
],
"title": "v0.93.0",
"notePosition": 230,
"notePosition": 220,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -659,7 +633,7 @@
"WRaBfQqPr6qo"
],
"title": "v0.92.7",
"notePosition": 240,
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -685,7 +659,7 @@
"a2rwfKNmUFU1"
],
"title": "v0.92.6",
"notePosition": 250,
"notePosition": 240,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -703,7 +677,7 @@
"fEJ8qErr0BKL"
],
"title": "v0.92.5-beta",
"notePosition": 260,
"notePosition": 250,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -721,7 +695,7 @@
"kkkZQQGSXjwy"
],
"title": "v0.92.4",
"notePosition": 270,
"notePosition": 260,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -739,7 +713,7 @@
"vAroNixiezaH"
],
"title": "v0.92.3-beta",
"notePosition": 280,
"notePosition": 270,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -757,7 +731,7 @@
"mHEq1wxAKNZd"
],
"title": "v0.92.2-beta",
"notePosition": 290,
"notePosition": 280,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -775,7 +749,7 @@
"IykjoAmBpc61"
],
"title": "v0.92.1-beta",
"notePosition": 300,
"notePosition": 290,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -793,7 +767,7 @@
"dq2AJ9vSBX4Y"
],
"title": "v0.92.0-beta",
"notePosition": 310,
"notePosition": 300,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -811,7 +785,7 @@
"3a8aMe4jz4yM"
],
"title": "v0.91.6",
"notePosition": 320,
"notePosition": 310,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -829,7 +803,7 @@
"8djQjkiDGESe"
],
"title": "v0.91.5",
"notePosition": 330,
"notePosition": 320,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -847,7 +821,7 @@
"OylxVoVJqNmr"
],
"title": "v0.91.4-beta",
"notePosition": 340,
"notePosition": 330,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -865,7 +839,7 @@
"tANGQDvnyhrj"
],
"title": "v0.91.3-beta",
"notePosition": 350,
"notePosition": 340,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -883,7 +857,7 @@
"hMoBfwSoj1SC"
],
"title": "v0.91.2-beta",
"notePosition": 360,
"notePosition": 350,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -901,7 +875,7 @@
"a2XMSKROCl9z"
],
"title": "v0.91.1-beta",
"notePosition": 370,
"notePosition": 360,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -919,7 +893,7 @@
"yqXFvWbLkuMD"
],
"title": "v0.90.12",
"notePosition": 380,
"notePosition": 370,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -937,7 +911,7 @@
"veS7pg311yJP"
],
"title": "v0.90.11-beta",
"notePosition": 390,
"notePosition": 380,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -955,7 +929,7 @@
"sq5W9TQxRqMq"
],
"title": "v0.90.10-beta",
"notePosition": 400,
"notePosition": 390,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -973,7 +947,7 @@
"yFEGVCUM9tPx"
],
"title": "v0.90.9-beta",
"notePosition": 410,
"notePosition": 400,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -991,7 +965,7 @@
"o4wAGqOQuJtV"
],
"title": "v0.90.8",
"notePosition": 420,
"notePosition": 410,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1024,7 +998,7 @@
"i4A5g9iOg9I0"
],
"title": "v0.90.7-beta",
"notePosition": 430,
"notePosition": 420,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1042,7 +1016,7 @@
"ThNf2GaKgXUs"
],
"title": "v0.90.6-beta",
"notePosition": 440,
"notePosition": 430,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1060,7 +1034,7 @@
"G4PAi554kQUr"
],
"title": "v0.90.5-beta",
"notePosition": 450,
"notePosition": 440,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1087,7 +1061,7 @@
"zATRobGRCmBn"
],
"title": "v0.90.4",
"notePosition": 460,
"notePosition": 450,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1105,7 +1079,7 @@
"sCDLf8IKn3Iz"
],
"title": "v0.90.3",
"notePosition": 470,
"notePosition": 460,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1123,7 +1097,7 @@
"VqqyBu4AuTjC"
],
"title": "v0.90.2-beta",
"notePosition": 480,
"notePosition": 470,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1141,7 +1115,7 @@
"RX3Nl7wInLsA"
],
"title": "v0.90.1-beta",
"notePosition": 490,
"notePosition": 480,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1159,7 +1133,7 @@
"GyueACukPWjk"
],
"title": "v0.90.0-beta",
"notePosition": 500,
"notePosition": 490,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1177,7 +1151,7 @@
"kzjHexDTTeVB"
],
"title": "v0.48",
"notePosition": 510,
"notePosition": 500,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1244,7 +1218,7 @@
"wyurrlcDl416"
],
"title": "Release Template",
"notePosition": 520,
"notePosition": 510,
"prefix": null,
"isExpanded": false,
"type": "text",

View File

@@ -1,22 +0,0 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/source",
"version": "0.102.1",
"version": "0.102.0",
"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.3",
"eslint": "10.0.2",
"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.57.0",
"typescript-eslint": "8.56.1",
"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.32.0",
"packageManager": "pnpm@10.30.3",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

View File

@@ -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.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.2",
"lint-staged": "16.3.1",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -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.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.2",
"lint-staged": "16.3.1",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -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.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.2",
"lint-staged": "16.3.1",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -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.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.2",
"lint-staged": "16.3.1",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -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.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint": "10.0.2",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.2",
"lint-staged": "16.3.1",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.4.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.40",
"@smithy/middleware-retry": "4.4.37",
"@types/jquery": "4.0.0"
}
}

View File

@@ -11,7 +11,6 @@ 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";

View File

@@ -1,28 +0,0 @@
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;
}

View File

@@ -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.5",
"@codemirror/lang-javascript": "6.2.4",
"@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.16",
"@codemirror/view": "6.39.15",
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
"@fsegurai/codemirror-theme-abyss": "6.2.3",
"@fsegurai/codemirror-theme-android-studio": "6.2.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/commons",
"version": "0.102.1",
"version": "0.102.0",
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
"private": true,
"type": "module",

View File

@@ -15,4 +15,3 @@ 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";

View File

@@ -17,8 +17,6 @@ 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 {

View File

@@ -1,421 +0,0 @@
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("&lt;script&gt;");
});
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");
});
});

View File

@@ -1,451 +0,0 @@
/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
;
}
// #endregion

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/pdfjs-viewer",
"version": "0.102.1",
"version": "0.102.0",
"private": true,
"scripts": {
"build": "tsx scripts/build.ts",

View File

@@ -71,17 +71,13 @@ function patchCacheBuster(htmlFilePath: string) {
const version = packageJson.version;
console.log(`Versioned URLs: ${version}.`)
let html = readFileSync(htmlFilePath, "utf-8");
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>`
);
}
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>`
);
writeFileSync(htmlFilePath, html);
}

View File

@@ -25,17 +25,17 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.38",
"katex": "0.16.33",
"mermaid": "11.12.3"
},
"devDependencies": {
"@digitak/esrun": "3.2.26",
"@triliumnext/ckeditor5": "workspace:*",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"dotenv": "17.3.1",
"esbuild": "0.27.3",
"eslint": "10.0.3",
"eslint": "10.0.2",
"highlight.js": "11.11.1",
"typescript": "5.9.3"
}

1733
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff