Merge branch 'main' into totp

This commit is contained in:
JYC333
2026-03-15 00:49:07 +00:00
committed by GitHub
220 changed files with 14499 additions and 7236 deletions

View File

@@ -69,7 +69,7 @@ runs:
# Post github action comment
- name: Post comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
with:
number: ${{ github.event.pull_request.number }}

View File

@@ -41,7 +41,7 @@ jobs:
run: pnpm run --filter=client test
- name: Upload client test report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: client-test-report
@@ -52,7 +52,7 @@ jobs:
run: pnpm run --filter=server test
- name: Upload server test report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: server-test-report
@@ -89,7 +89,7 @@ jobs:
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
- name: Trigger server build
run: pnpm run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: apps/server
@@ -124,7 +124,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build and export to Docker
uses: docker/build-push-action@v7

View File

@@ -40,7 +40,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
@@ -178,7 +178,7 @@ jobs:
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v4
@@ -229,7 +229,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up crane
uses: imjasonh/setup-crane@v0.4
uses: imjasonh/setup-crane@v0.5
- name: Login to GHCR
uses: docker/login-action@v4

1
.gitignore vendored
View File

@@ -46,7 +46,6 @@ upload
/.direnv
/result
.svelte-kit
# docs
site/

View File

@@ -14,9 +14,9 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.30.3",
"packageManager": "pnpm@10.32.1",
"devDependencies": {
"@redocly/cli": "2.20.2",
"@redocly/cli": "2.21.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.102.0",
"version": "0.102.1",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -25,19 +25,25 @@
"@fullcalendar/rrule": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.8.1",
"@preact/signals": "2.8.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-core": "0.16.1",
"@univerjs/presets": "0.16.1",
"@zumer/snapdom": "2.0.2",
"@univerjs/preset-sheets-conditional-formatting": "0.17.0",
"@univerjs/preset-sheets-core": "0.17.0",
"@univerjs/preset-sheets-data-validation": "0.17.0",
"@univerjs/preset-sheets-filter": "0.17.0",
"@univerjs/preset-sheets-find-replace": "0.17.0",
"@univerjs/preset-sheets-note": "0.17.0",
"@univerjs/preset-sheets-sort": "0.17.0",
"@univerjs/presets": "0.17.0",
"@zumer/snapdom": "2.1.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -45,30 +51,30 @@
"color": "5.0.3",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.8.13",
"i18next": "25.8.18",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.33",
"knockout": "3.5.1",
"katex": "0.16.38",
"knockout": "3.5.2",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.4",
"mermaid": "11.12.3",
"mind-elixir": "5.9.1",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.4",
"react-i18next": "16.5.5",
"preact": "10.29.0",
"react-i18next": "16.5.8",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"tabulator-tables": "6.4.0",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
@@ -79,12 +85,11 @@
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.3",
"lightningcss": "1.31.1",
"happy-dom": "20.8.4",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.2.0"
"vite-plugin-static-copy": "3.3.0"
}
}

View File

@@ -381,6 +381,10 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
// Collections must always display a note list, even if no children.
if (note.type === "book") {
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
return false;
}
const viewType = note.getLabelValue("viewType") ?? "grid";
if (!["list", "grid"].includes(viewType)) {
return true;

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

View File

@@ -110,7 +110,12 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
}
}
if (ec.componentId) {
// Only register as a content change if the protection status didn't change.
// When isProtected changes, the blobId change is a side effect of re-encryption,
// not a content edit. Registering it as content would cause the tree's content-only
// filter to incorrectly skip the note update (since both changes share the same
// componentId).
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
loadResults.addNoteContent(note.noteId, ec.componentId);
}
}

View File

@@ -1,15 +1,17 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import { AttributeRow } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type FBranch from "../entities/fbranch.js";
import type FNote from "../entities/fnote.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import protectedSessionHolder from "./protected_session_holder.js";
import server from "./server.js";
import ws from "./ws.js";
import froca from "./froca.js";
import treeService from "./tree.js";
import toastService from "./toast.js";
import { t } from "./i18n.js";
import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import treeService from "./tree.js";
import ws from "./ws.js";
export interface CreateNoteOpts {
isProtected?: boolean;
@@ -24,6 +26,8 @@ export interface CreateNoteOpts {
target?: string;
targetBranchId?: string;
textEditor?: CKTextEditor;
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
}
interface Response {
@@ -37,7 +41,7 @@ interface DuplicateResponse {
note: FNote;
}
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
options = Object.assign(
{
activate: true,
@@ -63,22 +67,15 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
options.content = `graph TD;
A-->B;
A-->C;
B-->D;
C-->D;`;
}
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
title: options.title,
content: options.content || "",
isProtected: options.isProtected,
type: options.type,
mime: options.mime,
templateNoteId: options.templateNoteId
});
templateNoteId: options.templateNoteId,
attributes: options.attributes
}, componentId);
if (options.saveSelection) {
// we remove the selection only after it was saved to server to make sure we don't lose anything
@@ -140,9 +137,8 @@ function parseSelectedHtml(selectedHtml: string) {
const content = selectedHtml.replace(dom[0].outerHTML, "");
return [title, content];
} else {
return [null, selectedHtml];
}
return [null, selectedHtml];
}
async function duplicateSubtree(noteId: string, parentNotePath: string) {

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

View File

@@ -158,6 +158,8 @@ function getSyncInProgress() {
}
addEventListener("DOMContentLoaded", (event) => {
ko.applyBindings(new SetupModel(getSyncInProgress()), document.getElementById("setup-dialog"));
const rootNode = document.getElementById("setup-dialog");
if (!rootNode) return;
ko.applyBindings(new SetupModel(getSyncInProgress()), rootNode);
$("#setup-dialog").show();
});

View File

@@ -1612,11 +1612,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
}
body.mobile #launcher-container {
justify-content: center;
}
body.mobile #launcher-container button {
margin: 0 16px;
justify-content: space-evenly;
}
body.mobile .modal.show {

View File

@@ -803,12 +803,13 @@
"web-view": "عرض الويب",
"mind-map": "خريطة ذهنية",
"geo-map": "خريطة جغرافية",
"task-list": "قائمة المهام"
"task-list": "قائمة المهام",
"spreadsheet": "جدول البيانات"
},
"shared_switch": {
"shared": "مشترك",
"toggle-on-title": "مشاركة الملاحظة",
"toggle-off-title": "الغاء مشاركة الملاحظة"
"toggle-off-title": "إلغاء مشاركة الملاحظة"
},
"template_switch": {
"template": "قالب"
@@ -1286,8 +1287,10 @@
"search-for": "بحث ل \"{{term}}\""
},
"protect_note": {
"toggle-off": "ازالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة"
"toggle-off": "إزالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة",
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
},
"open-help-page": "فتح صفحة المساعدة",
"empty": {

View File

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

View File

@@ -1488,20 +1488,21 @@
"mermaid-diagram": "Mermaid Diagramm",
"canvas": "Leinwand",
"web-view": "Webansicht",
"mind-map": "Mind Map",
"mind-map": "Mindmap",
"file": "Datei",
"image": "Bild",
"launcher": "Starter",
"doc": "Dokument",
"widget": "Widget",
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"geo-map": "Geo-Karte",
"beta-feature": "Beta",
"book": "Sammlung",
"ai-chat": "KI Chat",
"ai-chat": "KI-Chat",
"task-list": "Aufgabenliste",
"new-feature": "Neu",
"collections": "Sammlungen"
"collections": "Sammlungen",
"spreadsheet": "Tabelle"
},
"protect_note": {
"toggle-on": "Notiz schützen",

View File

@@ -1036,6 +1036,25 @@
"file_preview_not_available": "File preview is not available for this file format.",
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
},
"media": {
"play": "Play (Space)",
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
"forward-30s": "Forward 30s",
"mute": "Mute (M)",
"unmute": "Unmute (M)",
"playback-speed": "Playback speed",
"loop": "Loop",
"disable-loop": "Disable loop",
"rotate": "Rotate",
"picture-in-picture": "Picture-in-picture",
"exit-picture-in-picture": "Exit picture-in-picture",
"fullscreen": "Fullscreen (F)",
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
},
"protected_session": {
"enter_password_instruction": "Showing protected note requires entering your password:",
"start_session_button": "Start protected session",
@@ -2183,5 +2202,33 @@
},
"setup_form": {
"more_info": "Learn more"
},
"mermaid": {
"placeholder": "Type the content of your Mermaid diagram or use one of the sample diagrams below.",
"sample_diagrams": "Sample diagrams:",
"sample_flowchart": "Flowchart",
"sample_class": "Class",
"sample_sequence": "Sequence",
"sample_entity_relationship": "Entity Relationship",
"sample_state": "State",
"sample_mindmap": "Mindmap",
"sample_architecture": "Architecture",
"sample_block": "Block",
"sample_c4": "C4",
"sample_gantt": "Gantt",
"sample_git": "Git",
"sample_kanban": "Kanban",
"sample_packet": "Packet",
"sample_pie": "Pie",
"sample_quadrant": "Quadrant",
"sample_radar": "Radar",
"sample_requirement": "Requirement",
"sample_sankey": "Sankey",
"sample_timeline": "Timeline",
"sample_treemap": "Treemap",
"sample_user_journey": "User Journey",
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
}
}

View File

@@ -1548,7 +1548,8 @@
"task-list": "Lista de tareas",
"book": "Colección",
"new-feature": "Nuevo",
"collections": "Colecciones"
"collections": "Colecciones",
"spreadsheet": "Hoja de cálculo"
},
"protect_note": {
"toggle-on": "Proteger la nota",
@@ -1650,7 +1651,8 @@
},
"search_result": {
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
"search_now": "Buscar ahora"
},
"spacer": {
"configure_launchbar": "Configurar barra de lanzamiento"
@@ -2196,5 +2198,24 @@
},
"setup_form": {
"more_info": "Para saber más"
},
"media": {
"play": "Reproducir (Espacio)",
"pause": "Pausa (Espacio)",
"back-10s": "Retroceder 10s (tecla de flecha izquierda)",
"forward-30s": "Adelantar 30s",
"mute": "Silenciar (M)",
"unmute": "Activar sonido (M)",
"playback-speed": "Velocidad de reproducción",
"loop": "Bucle",
"disable-loop": "Deshabilitar bucle",
"rotate": "Rotar",
"picture-in-picture": "Imagen en imagen",
"exit-picture-in-picture": "Salir del modo imagen en imagen",
"fullscreen": "Pantalla completa (F)",
"exit-fullscreen": "Salir de la pantalla completa",
"unsupported-format": "La vista previa del medio no está disponible para este formato de archivo:\n{{mime}}",
"zoom-to-fit": "Acercamiento para llenar",
"zoom-reset": "Reiniciar acercamiento para llenar"
}
}

View File

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

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

View File

@@ -600,7 +600,8 @@
"task-list": "タスクリスト",
"new-feature": "New",
"collections": "コレクション",
"ai-chat": "AI チャット"
"ai-chat": "AI チャット",
"spreadsheet": "スプレッドシート"
},
"edited_notes": {
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
@@ -2167,5 +2168,24 @@
},
"setup_form": {
"more_info": "さらに詳しく"
},
"media": {
"play": "再生 (スペース)",
"pause": "一時停止 (スペース)",
"back-10s": "10 秒戻る (左矢印キー)",
"forward-30s": "30 秒進む",
"mute": "ミュート (M)",
"unmute": "ミュート解除 (M)",
"playback-speed": "再生速度",
"loop": "ループ",
"disable-loop": "ループを解除",
"rotate": "回転",
"picture-in-picture": "ピクチャーインピクチャー",
"exit-picture-in-picture": "ピクチャーインピクチャーを終了",
"fullscreen": "全画面表示 (F)",
"exit-fullscreen": "全画面表示を終了",
"unsupported-format": "このファイル形式ではメディアプレビューはご利用いただけません:\n{{mime}}",
"zoom-to-fit": "ズームして全体を表示",
"zoom-reset": "ズーム設定をリセット"
}
}

View File

@@ -1780,7 +1780,8 @@
"ai-chat": "Czat AI",
"task-list": "Lista zadań",
"new-feature": "Nowość",
"collections": "Kolekcje"
"collections": "Kolekcje",
"spreadsheet": "Arkusz"
},
"protect_note": {
"toggle-on": "Chroń notatkę",

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": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
@@ -594,7 +594,8 @@
"display-week-numbers": "Отображать номера недель",
"hide-weekends": "Скрыть выходные",
"raster": "Растр",
"show-scale": "Показать масштаб"
"show-scale": "Показать масштаб",
"show-labels": "Показать названия маркеров"
},
"editorfeatures": {
"note_completion_enabled": "Включить автодополнение",
@@ -782,7 +783,13 @@
"shared-indicator-tooltip": "Эта заметка опубликована",
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
"subtree-hidden-moved-title": "Добавлено в {{title}}",
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
},
"quick-search": {
"no-results": "Результаты не найдены",
@@ -826,7 +833,9 @@
"mind-map": "Mind Map",
"geo-map": "Географическая карта",
"task-list": "Список задач",
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
"ai-chat": "Чат с ИИ",
"spreadsheet": "Электронная таблица"
},
"tree-context-menu": {
"open-in-popup": "Быстрое редактирование",
@@ -1153,7 +1162,8 @@
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
"actions_executed": "Действия выполнены.",
"view_options": "Просмотреть опции:"
"view_options": "Просмотреть опции:",
"option": "опция"
},
"ancestor": {
"depth_label": "глубина",
@@ -1403,7 +1413,8 @@
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
},
"sync_2": {
"timeout_unit": "миллисекунд",
@@ -1713,7 +1724,8 @@
"delete_this_note": "Удалить эту заметку",
"insert_child_note": "Вставить дочернюю заметку",
"note_revisions": "История изменений",
"content_language_switcher": "Язык содержимого: {{language}}"
"content_language_switcher": "Язык содержимого: {{language}}",
"backlinks": "Ссылки"
},
"svg_export_button": {
"button_title": "Экспортировать диаграмму как SVG"
@@ -1790,7 +1802,8 @@
},
"search_result": {
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
"search_not_executed": "Поиск ещё не выполнен.",
"search_now": "Искать сейчас"
},
"empty": {
"search_placeholder": "поиск заметки по ее названию",
@@ -1988,10 +2001,12 @@
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
"print_report_collection_details_button": "Подробнее",
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
"print_report_error_title": "Не удалось напечатать",
"print_report_stack_trace": "Трассировка стека"
},
"book": {
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
"drag_locked_title": "Защищено от изменения",
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
},
@@ -2007,7 +2022,9 @@
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
},
"pagination": {
"total_notes": "{{count}} заметок"
"total_notes": "{{count}} заметок",
"prev_page": "Предыдущая страница",
"next_page": "Следующая страница"
},
"status_bar": {
"attributes_one": "{{count}} атрибут",
@@ -2137,5 +2154,49 @@
},
"platform_indicator": {
"available_on": "Доступно для {{platform}}"
},
"render": {
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
"disabled_button_enable": "Включить заметки для рендера"
},
"web_view_setup": {
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
"create_button": "Создать веб-просмотр",
"invalid_url_title": "Неверный адрес",
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
"disabled_button_enable": "Включить просмотр веб-страниц"
},
"active_content_badges": {
"type_icon_pack": "Набор иконок",
"type_backend_script": "Бэкенд скрипт",
"type_frontend_script": "Фронтенд скрипт",
"type_widget": "Виджет",
"type_app_css": "Пользовательский CSS",
"type_render_note": "Заметка для рендера",
"type_web_view": "Просмотр веб-страницы",
"type_app_theme": "Пользовательская тема",
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
"menu_docs": "Открытая документация",
"menu_execute_now": "Выполнить скрипт сейчас",
"menu_run": "Выполнять автоматически",
"menu_run_disabled": "Вручную",
"menu_run_backend_startup": "При запуске бэкенда",
"menu_run_hourly": "Ежечасно",
"menu_run_daily": "Ежедневно",
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
"menu_change_to_widget": "Изменить виджет",
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
"menu_theme_base": "Базовая тема"
},
"setup_form": {
"more_info": "Узнать больше"
}
}

View File

@@ -3,6 +3,38 @@
"title": "Om Trilium Notes",
"homepage": "Hemsida:",
"app_version": "App version:",
"db_version": "DB version:"
"db_version": "DB version:",
"sync_version": "Sync version:",
"build_date": "Bygg datum:",
"build_revision": "Bygg version:",
"data_directory": "Data sökväg:"
},
"toast": {
"critical-error": {
"title": "Kritiskt fel",
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
},
"widget-error": {
"title": "Misslyckades att starta widget",
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
},
"bundle-error": {
"title": "Misslyckades att starta ett anpassat skript",
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
},
"widget-list-error": {
"title": "Misslyckades att hämta widget-listan från servern"
},
"widget-render-error": {
"title": "Misslyckades att renderera en anpassad React-widget"
},
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
"open-script-note": "Öppna skriptanteckning",
"scripting-error": "Fel i anpassat skript: {{title}}"
},
"add_link": {
"add_link": "Infoga länk",
"help_on_links": "Hjälp om länkar"
}
}

View File

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

View File

@@ -40,6 +40,21 @@ export default function NoteDetail() {
const widgetRequestId = useRef(0);
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
// Special contexts (ntxId starting with "_", e.g. popup editor) are always considered active.
const isSpecialContext = ntxId?.startsWith("_") ?? false;
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => isSpecialContext || (noteContext?.isActive() ?? false));
useEffect(() => {
if (!hasTabBeenActive && noteContext?.isActive()) {
setHasTabBeenActive(true);
}
}, [ noteContext, hasTabBeenActive ]);
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
if (eventNtxId === ntxId && !hasTabBeenActive) {
setHasTabBeenActive(true);
}
});
const props: TypeWidgetProps = {
note: note!,
viewScope,
@@ -49,7 +64,7 @@ export default function NoteDetail() {
};
useEffect(() => {
if (!type) return;
if (!type || !hasTabBeenActive) return;
const requestId = ++widgetRequestId.current;
if (!noteTypesToRender[type]) {
@@ -68,7 +83,7 @@ export default function NoteDetail() {
} else {
setActiveNoteType(type);
}
}, [ note, viewScope, type, noteTypesToRender ]);
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
// Detect note type changes.
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
@@ -247,9 +262,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
useEffect(() => {
if (isVisible) {
setCachedProps(props);
} else {
// Do nothing, keep the old props.
}
// When not visible, keep the old props to avoid re-rendering in the background.
}, [ props, isVisible ]);
const typeMapping = TYPE_MAPPINGS[type];
@@ -260,7 +274,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
height: isFullHeight ? "100%" : ""
}}
>
{ <Element {...cachedProps} /> }
<Element {...cachedProps} />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { BulkAction } from "@triliumnext/commons";
import { BoardViewData } from ".";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
@@ -9,6 +9,7 @@ import froca from "../../../services/froca";
import { t } from "../../../services/i18n";
import note_create from "../../../services/note_create";
import server from "../../../services/server";
import { BoardViewData } from ".";
import { ColumnMap } from "./data";
export default class BoardApi {
@@ -35,13 +36,11 @@ export default class BoardApi {
async createNewItem(column: string, title: string) {
try {
// Get the parent note path
const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
const { note: newNote, branch: newBranch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
title
title,
isProtected: this.parentNote.isProtected
});
if (newNote && newBranch) {
@@ -87,7 +86,7 @@ export default class BoardApi {
const action: BulkAction = this.isRelationMode
? { name: "deleteRelation", relationName: this.statusAttribute }
: { name: "deleteLabel", labelName: this.statusAttribute }
: { name: "deleteLabel", labelName: this.statusAttribute };
await executeBulkActions(noteIds, [ action ]);
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
this.saveConfig(this.viewConfig);
@@ -99,7 +98,7 @@ export default class BoardApi {
// Change the value in the notes.
const action: BulkAction = this.isRelationMode
? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue }
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue }
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue };
await executeBulkActions(noteIds, [ action ]);
// Rename the column in the persisted data.
@@ -137,9 +136,9 @@ export default class BoardApi {
}
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
targetBranchId: relativeToBranchId,
@@ -179,9 +178,8 @@ export default class BoardApi {
if (!note) return;
if (this.isRelationMode) {
return attributes.removeOwnedRelationByName(note, this.statusAttribute);
} else {
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
}
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
}
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {

View File

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

View File

@@ -1,8 +1,8 @@
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
import { AttributeRow } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import server from "../../../services/server";
import note_create from "../../../services/note_create";
interface NewEventOpts {
title: string;
@@ -51,11 +51,13 @@ export async function newEvent(parentNote: FNote, { title, startDate, endDate, s
}
// Create the note.
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
await note_create.createNote(parentNote.noteId, {
title,
isProtected: parentNote.isProtected,
content: "",
type: "text",
attributes
attributes,
activate: false
}, componentId);
}

View File

@@ -1,10 +1,11 @@
import type { LatLng, LeafletMouseEvent } from "leaflet";
import { LOCATION_ATTRIBUTE } from ".";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
import { prompt } from "../../../services/dialog";
import server from "../../../services/server";
import { t } from "../../../services/i18n";
import { CreateChildrenResponse } from "@triliumnext/commons";
import note_create from "../../../services/note_create";
import { LOCATION_ATTRIBUTE } from ".";
const CHILD_NOTE_ICON = "bx bx-pin";
@@ -13,16 +14,20 @@ export async function moveMarker(noteId: string, latLng: LatLng | null) {
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
}
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
export async function createNewNote(parentNote: FNote, e: LeafletMouseEvent) {
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
if (title?.trim()) {
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
await note_create.createNote(parentNote.noteId, {
title,
content: "",
type: "text"
type: "text",
activate: false,
isProtected: parentNote.isProtected,
attributes: [
{ type: "label", name: LOCATION_ATTRIBUTE, value: [e.latlng.lat, e.latlng.lng].join(",") },
{ type: "label", name: "iconClass", value: CHILD_NOTE_ICON }
]
});
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
moveMarker(note.noteId, e.latlng);
}
}

View File

@@ -1,12 +1,14 @@
import type { LatLng, LeafletMouseEvent } from "leaflet";
import appContext, { type CommandMappings } from "../../../components/app_context.js";
import FNote from "../../../entities/fnote.js";
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx";
import { t } from "../../../services/i18n.js";
import { createNewNote } from "./api.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import { t } from "../../../services/i18n.js";
import link from "../../../services/link.js";
import { createNewNote } from "./api.js";
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
@@ -44,7 +46,7 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is
});
}
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
export function openMapContextMenu(note: FNote, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
...buildGeoLocationItem(e)
];
@@ -55,10 +57,10 @@ export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEdita
{ kind: "separator" },
{
title: t("geo-map-context.add-note"),
handler: () => createNewNote(noteId, e),
handler: () => createNewNote(note, e),
uiIcon: "bx bx-plus"
}
]
];
}
contextMenu.show({

View File

@@ -93,14 +93,14 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
const onClick = useCallback(async (e: LeafletMouseEvent) => {
if (state === State.NewNote) {
toast.closePersistent("geo-new-note");
await createNewNote(note.noteId, e);
await createNewNote(note, e);
setState(State.Normal);
}
}, [ state ]);
}, [ note, state ]);
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
openMapContextMenu(note.noteId, e, !isReadOnly);
}, [ note.noteId, isReadOnly ]);
openMapContextMenu(note, e, !isReadOnly);
}, [ note, isReadOnly ]);
// Dragging
const containerRef = useRef<HTMLDivElement>(null);

View File

@@ -2,8 +2,8 @@ import "./index.css";
import { RefObject } from "preact";
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import Reveal from "reveal.js";
import slideBaseStylesheet from "reveal.js/dist/reveal.css?raw";
import Reveal, { RevealApi } from "reveal.js";
import slideBaseStylesheet from "reveal.js/reveal.css?raw";
import { openInCurrentNoteContext } from "../../../components/note_context";
import FNote from "../../../entities/fnote";
@@ -20,7 +20,7 @@ import { DEFAULT_THEME, loadPresentationTheme } from "./themes";
export default function PresentationView({ note, noteIds, media, onReady, onProgressChanged }: ViewModeProps<{}>) {
const [ presentation, setPresentation ] = useState<PresentationModel>();
const containerRef = useRef<HTMLDivElement>(null);
const [ api, setApi ] = useState<Reveal.Api>();
const [ api, setApi ] = useState<RevealApi>();
const stylesheets = usePresentationStylesheets(note, media);
function refresh() {
@@ -98,7 +98,7 @@ function usePresentationStylesheets(note: FNote, media: ViewModeMedia) {
return stylesheets;
}
function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivElement>, api: Reveal.Api | undefined }) {
function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivElement>, api: RevealApi | undefined }) {
const [ isOverviewActive, setIsOverviewActive ] = useState(false);
useEffect(() => {
if (!api) return;
@@ -144,9 +144,9 @@ function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivE
);
}
function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: Reveal.Api | undefined) => void }) {
function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: RevealApi | undefined) => void }) {
const containerRef = useRef<HTMLDivElement>(null);
const [revealApi, setRevealApi] = useState<Reveal.Api>();
const [revealApi, setRevealApi] = useState<RevealApi>();
useEffect(() => {
if (!containerRef.current) return;
@@ -222,7 +222,7 @@ function getNoteIdFromSlide(slide: HTMLElement | undefined) {
return slide.dataset.noteId;
}
function rewireLinks(container: HTMLElement, api: Reveal.Api) {
function rewireLinks(container: HTMLElement, api: RevealApi) {
const links = container.querySelectorAll<HTMLLinkElement>("a.reference-link");
for (const link of links) {
link.addEventListener("click", () => {

View File

@@ -3,49 +3,49 @@ export const DEFAULT_THEME = "white";
const themes = {
black: {
name: "Black",
loadTheme: () => import("reveal.js/dist/theme/black.css?raw")
loadTheme: () => import("reveal.js/theme/black.css?raw")
},
white: {
name: "White",
loadTheme: () => import("reveal.js/dist/theme/white.css?raw")
loadTheme: () => import("reveal.js/theme/white.css?raw")
},
beige: {
name: "Beige",
loadTheme: () => import("reveal.js/dist/theme/beige.css?raw")
loadTheme: () => import("reveal.js/theme/beige.css?raw")
},
serif: {
name: "Serif",
loadTheme: () => import("reveal.js/dist/theme/serif.css?raw")
loadTheme: () => import("reveal.js/theme/serif.css?raw")
},
simple: {
name: "Simple",
loadTheme: () => import("reveal.js/dist/theme/simple.css?raw")
loadTheme: () => import("reveal.js/theme/simple.css?raw")
},
solarized: {
name: "Solarized",
loadTheme: () => import("reveal.js/dist/theme/solarized.css?raw")
loadTheme: () => import("reveal.js/theme/solarized.css?raw")
},
moon: {
name: "Moon",
loadTheme: () => import("reveal.js/dist/theme/moon.css?raw")
loadTheme: () => import("reveal.js/theme/moon.css?raw")
},
dracula: {
name: "Dracula",
loadTheme: () => import("reveal.js/dist/theme/dracula.css?raw")
loadTheme: () => import("reveal.js/theme/dracula.css?raw")
},
sky: {
name: "Sky",
loadTheme: () => import("reveal.js/dist/theme/sky.css?raw")
loadTheme: () => import("reveal.js/theme/sky.css?raw")
},
blood: {
name: "Blood",
loadTheme: () => import("reveal.js/dist/theme/blood.css?raw")
loadTheme: () => import("reveal.js/theme/blood.css?raw")
}
} as const;
export function getPresentationThemes() {
return Object.entries(themes).map(([ id, theme ]) => ({
id: id,
id,
name: theme.name
}));
}

View File

@@ -18,14 +18,14 @@ import useRowTableEditing from "./row_editing";
import { TableData } from "./rows";
import Tabulator from "./tabulator";
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
export default function TableView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
const tabulatorRef = useRef<VanillaTabulator>(null);
const parentComponent = useContext(ParentComponent);
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
const persistenceProps = usePersistence(viewConfig, saveConfig);
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath);
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, note);
const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note);
const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition);
const dataTreeProps = useMemo<Options>(() => {

View File

@@ -1,24 +1,27 @@
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
import { CommandListenerData } from "../../../components/app_context";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import { useLegacyImperativeHandlers } from "../../react/hooks";
import { RefObject } from "preact";
import { setAttribute, setLabel } from "../../../services/attributes";
import froca from "../../../services/froca";
import server from "../../../services/server";
import branches from "../../../services/branches";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
import { CommandListenerData } from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import branches from "../../../services/branches";
import froca from "../../../services/froca";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import server from "../../../services/server";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import { useLegacyImperativeHandlers } from "../../react/hooks";
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote): Partial<EventCallBackMethods> {
// Adding new rows
useLegacyImperativeHandlers({
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
const notePath = customNotePath ?? parentNotePath;
const notePath = customNotePath ?? parentNote.noteId;
if (notePath) {
const opts: CreateNoteOpts = {
activate: false,
isProtected: parentNote.isProtected,
...customOpts
}
};
note_create.createNote(notePath, opts).then(({ branch }) => {
if (branch) {
setTimeout(() => {
@@ -26,7 +29,7 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
focusOnBranch(api.current, branch?.branchId);
}, 100);
}
})
});
}
}
});
@@ -91,14 +94,14 @@ function focusOnBranch(api: Tabulator, branchId: string) {
}
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
for (let row of rows) {
for (const row of rows) {
const item = row.getIndex() as string;
if (item === branchId) {
return row;
}
let found = findRowDataById(row.getTreeChildren(), branchId);
const found = findRowDataById(row.getTreeChildren(), branchId);
if (found) return found;
}
return null;

View File

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

View File

@@ -36,6 +36,10 @@
animation: fadeOut 250ms ease-in 5s forwards;
pointer-events: none;
}
body#trilium-app.motion-disabled &.saved {
animation: fadeOut 0s 5s forwards !important;
}
}
&.active-content-badge { --color: var(--badge-active-content-background-color); }
&.active-content-badge.disabled {

View File

@@ -0,0 +1,19 @@
.note-content-switcher {
--badge-radius: 12px;
position: relative;
display: flex;
min-height: 35px;
gap: 5px;
padding: 5px;
flex-wrap: wrap;
flex-shrink: 0;
font-size: 0.9rem;
align-items: center;
.ext-badge {
--color: var(--input-background-color);
color: var(--main-text-color);
font-size: 1em;
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,39 @@
import "./NoteContentSwitcher.css";
import FNote from "../../entities/fnote";
import server from "../../services/server";
import { Badge } from "../react/Badge";
import { useNoteSavedData } from "../react/hooks";
export interface NoteContentTemplate {
name: string;
content: string;
}
interface NoteContentSwitcherProps {
text: string;
note: FNote;
templates: NoteContentTemplate[];
}
export default function NoteContentSwitcher({ text, note, templates }: NoteContentSwitcherProps) {
const blob = useNoteSavedData(note?.noteId);
return (blob?.trim().length === 0 &&
<div className="note-content-switcher">
{text}{" "}
{templates.map(sample => (
<Badge
key={sample.name}
text={sample.name}
onClick={async () => {
await server.put(`notes/${note.noteId}/data`, {
content: sample.content
});
}}
/>
))}
</div>
);
}

View File

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

View File

@@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
return true;
}
@@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
return true;
}

View File

@@ -8,6 +8,7 @@
color: var(--muted-text-color);
height: 100%;
text-align: center;
white-space: pre-line;
.tn-icon {
font-size: 4em;

View File

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

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import "./File.css";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { getUrlForDownload } from "../../services/open";
import Alert from "../react/Alert";
import { useNoteBlob } from "../react/hooks";
import AudioPreview from "./file/Audio";
import PdfPreview from "./file/Pdf";
import VideoPreview from "./file/Video";
import { TypeWidgetProps } from "./type_widget";
const TEXT_MAX_NUM_CHARS = 5000;
@@ -42,27 +42,6 @@ function TextPreview({ content }: { content: string }) {
);
}
function VideoPreview({ note }: { note: FNote }) {
return (
<video
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
controls
/>
);
}
function AudioPreview({ note }: { note: FNote }) {
return (
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
controls
/>
);
}
function NoPreview() {
return (
<Alert className="file-preview-not-available" type="info">

View File

@@ -1,125 +0,0 @@
import "@univerjs/preset-sheets-core/lib/index.css";
import "./Spreadsheet.css";
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { CommandType, createUniver, FUniver, IDisposable, IWorkbookData, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import { useColorScheme, useEditorSpacedUpdate } from "../react/hooks";
import { TypeWidgetProps } from "./type_widget";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef);
return <div ref={containerRef} className="spreadsheet" />;
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
UniverPresetSheetsCoreEnUS
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
})
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>) {
const changeListener = useRef<IDisposable>(null);
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
return {
content: JSON.stringify(content)
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
},
});
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@@ -30,6 +30,7 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps
onContentChanged?: (content: string) => void;
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
dataSaved?: () => void;
placeholder?: string;
}
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
@@ -74,7 +75,7 @@ function formatViewSource(note: FNote, content: string) {
return content;
}
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, ...editorProps }: EditableCodeProps) {
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, ...editorProps }: EditableCodeProps) {
const editorRef = useRef<VanillaCodeMirror>(null);
const containerRef = useRef<HTMLPreElement>(null);
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
@@ -115,7 +116,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
editorRef={editorRef} containerRef={containerRef}
mime={mime ?? "text/plain"}
className="note-detail-code-editor"
placeholder={t("editable_code.placeholder")}
placeholder={placeholder ?? t("editable_code.placeholder")}
vimKeybindings={vimKeymapEnabled}
tabIndex={300}
onContentChanged={() => {

View File

@@ -0,0 +1,112 @@
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
export default function AudioPreview({ note }: { note: FNote }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const togglePlayback = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}, []);
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
useEffect(() => setError(false), [note.noteId]);
const onError = useCallback(() => setError(true), []);
if (error) {
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
ref={audioRef}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="audio-preview-icon-wrapper">
<Icon icon="bx bx-music" className="audio-preview-icon" />
</div>
<div className="media-preview-controls">
<SeekBar mediaRef={audioRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={audioRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={audioRef} />
</div>
<div className="right">
<VolumeControl mediaRef={audioRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
return useCallback((e: KeyboardEvent) => {
const audio = audioRef.current;
if (!audio) return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlayback();
break;
case "ArrowLeft":
e.preventDefault();
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
break;
case "ArrowRight":
e.preventDefault();
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
break;
case "m":
case "M":
e.preventDefault();
audio.muted = !audio.muted;
break;
case "ArrowUp":
e.preventDefault();
audio.volume = Math.min(1, audio.volume + 0.05);
break;
case "ArrowDown":
e.preventDefault();
audio.volume = Math.max(0, audio.volume - 0.05);
break;
case "Home":
e.preventDefault();
audio.currentTime = 0;
break;
case "End":
e.preventDefault();
audio.currentTime = audio.duration;
break;
}
}, [ audioRef, togglePlayback ]);
}

View File

@@ -0,0 +1,98 @@
.media-preview-controls {
padding: 1.25em;
display: flex;
flex-direction: column;
gap: 0.5em;
.media-buttons-row {
display: flex;
> * {
flex: 1;
align-items: center;
gap: 0.5em;
display: flex;
}
.spacer {
width: var(--icon-button-size, 32px);
height: var(--icon-button-size, 32px);
}
.center {
justify-content: center;
}
.right {
display: flex;
justify-content: flex-end;
}
.play-button {
--icon-button-size: 48px;
}
}
.media-seekbar-row {
display: flex;
align-items: center;
gap: 0.5em;
.media-time {
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.media-trackbar {
flex: 1;
cursor: pointer;
}
}
.media-volume-row {
display: flex;
align-items: center;
gap: 0.25em;
.media-volume-slider {
width: 80px;
cursor: pointer;
}
}
.speed-dropdown {
position: relative;
.tn-icon {
transform: translateY(-10%);
}
.media-speed-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transform: translateY(15%);
text-align: center;
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
}
}
}
.audio-preview-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.audio-preview-icon-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 8em;
opacity: 0.6;
}
}

View File

@@ -0,0 +1,220 @@
import "./MediaPlayer.css";
import { RefObject } from "preact";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
const onTimeUpdate = () => setCurrentTime(media.currentTime);
const onDurationChange = () => setDuration(media.duration);
media.addEventListener("timeupdate", onTimeUpdate);
media.addEventListener("durationchange", onDurationChange);
return () => {
media.removeEventListener("timeupdate", onTimeUpdate);
media.removeEventListener("durationchange", onDurationChange);
};
}, [ mediaRef ]);
const onSeek = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
};
return (
<div class="media-seekbar-row">
<span class="media-time">{formatTime(currentTime)}</span>
<input
type="range"
class="media-trackbar"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onInput={onSeek}
/>
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
);
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function PlayPauseButton({ playing, togglePlayback }: {
playing: boolean,
togglePlayback: () => void
}) {
return (
<ActionButton
className="play-button"
icon={playing ? "bx bx-pause" : "bx bx-play"}
text={playing ? t("media.pause") : t("media.play")}
onClick={togglePlayback}
/>
);
}
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
// Sync state when the media element changes volume externally.
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setVolume(media.volume);
setMuted(media.muted);
const onVolumeChange = () => {
setVolume(media.volume);
setMuted(media.muted);
};
media.addEventListener("volumechange", onVolumeChange);
return () => media.removeEventListener("volumechange", onVolumeChange);
}, [ mediaRef ]);
const onVolumeChange = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
const val = parseFloat((e.target as HTMLInputElement).value);
media.volume = val;
setVolume(val);
if (val > 0 && media.muted) {
media.muted = false;
setMuted(false);
}
};
const toggleMute = () => {
const media = mediaRef.current;
if (!media) return;
media.muted = !media.muted;
setMuted(media.muted);
};
return (
<div class="media-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("media.unmute") : t("media.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
);
}
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
const skip = () => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
};
return (
<ActionButton icon={icon} text={text} onClick={skip} />
);
}
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setLoop(media.loop);
const observer = new MutationObserver(() => setLoop(media.loop));
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, [ mediaRef ]);
const toggle = () => {
const media = mediaRef.current;
if (!media) return;
media.loop = !media.loop;
setLoop(media.loop);
};
return (
<ActionButton
className={loop ? "active" : ""}
icon="bx bx-repeat"
text={loop ? t("media.disable-loop") : t("media.loop")}
onClick={toggle}
/>
);
}
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setSpeed(media.playbackRate);
const onRateChange = () => setSpeed(media.playbackRate);
media.addEventListener("ratechange", onRateChange);
return () => media.removeEventListener("ratechange", onRateChange);
}, [ mediaRef ]);
const selectSpeed = (rate: number) => {
const media = mediaRef.current;
if (!media) return;
media.playbackRate = rate;
setSpeed(rate);
};
return (
<Dropdown
iconAction
hideToggleArrow
buttonClassName="speed-dropdown"
text={<>
<Icon icon="bx bx-tachometer" />
<span class="media-speed-label">{speed}x</span>
</>}
title={t("media.playback-speed")}
>
{PLAYBACK_SPEEDS.map((rate) => (
<li key={rate}>
<button
class={`dropdown-item ${rate === speed ? "active" : ""}`}
onClick={() => selectSpeed(rate)}
>
{rate}x
</button>
</li>
))}
</Dropdown>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
.note-detail-file > .video-preview-wrapper {
width: 100%;
height: 100%;
position: relative;
background-color: black;
.video-preview {
background-color: black;
width: 100%;
height: 100%;
}
&.controls-hidden {
cursor: pointer;
.media-preview-controls {
opacity: 0;
pointer-events: none;
}
}
.media-preview-controls {
--icon-button-hover-color: white;
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
opacity: 1;
transition: opacity 300ms ease;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
color: white;
}
}

View File

@@ -0,0 +1,298 @@
import "./Video.css";
import { RefObject } from "preact";
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
const AUTO_HIDE_DELAY = 3000;
export default function VideoPreview({ note }: { note: FNote }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const { visible: controlsVisible, onMouseMove, flash: flashControls } = useAutoHideControls(videoRef, playing);
useEffect(() => setError(false), [note.noteId]);
const onError = useCallback(() => setError(true), []);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="media-preview-controls">
<SeekBar mediaRef={videoRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={videoRef} />
<RotateButton videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={videoRef} />
</div>
<div className="right">
<VolumeControl mediaRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
return useCallback((e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlayback();
flashControls();
break;
case "ArrowLeft":
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - (e.ctrlKey ? 60 : 10));
flashControls();
break;
case "ArrowRight":
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + (e.ctrlKey ? 60 : 10));
flashControls();
break;
case "f":
case "F":
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapperRef.current?.requestFullscreen();
}
break;
case "m":
case "M":
e.preventDefault();
video.muted = !video.muted;
flashControls();
break;
case "ArrowUp":
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.05);
flashControls();
break;
case "ArrowDown":
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.05);
flashControls();
break;
case "Home":
e.preventDefault();
video.currentTime = 0;
flashControls();
break;
case "End":
e.preventDefault();
video.currentTime = video.duration;
flashControls();
break;
}
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
}
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
const [visible, setVisible] = useState(true);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
const scheduleHide = useCallback(() => {
clearTimeout(hideTimerRef.current);
if (videoRef.current && !videoRef.current.paused) {
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
}
}, [ videoRef]);
const onMouseMove = useCallback(() => {
setVisible(true);
scheduleHide();
}, [scheduleHide]);
// Hide immediately when playback starts, show when paused.
useEffect(() => {
if (playing) {
setVisible(false);
} else {
clearTimeout(hideTimerRef.current);
setVisible(true);
}
return () => clearTimeout(hideTimerRef.current);
}, [playing, scheduleHide]);
return { visible, onMouseMove, flash: onMouseMove };
}
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [rotation, setRotation] = useState(0);
const rotate = () => {
const video = videoRef.current;
if (!video) return;
const next = (rotation + 90) % 360;
setRotation(next);
const isSideways = next === 90 || next === 270;
if (isSideways) {
// Scale down so the rotated video fits within its container.
const container = video.parentElement;
if (container) {
const ratio = container.clientWidth / container.clientHeight;
video.style.transform = `rotate(${next}deg) scale(${1 / ratio})`;
} else {
video.style.transform = `rotate(${next}deg)`;
}
} else {
video.style.transform = next === 0 ? "" : `rotate(${next}deg)`;
}
};
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("media.rotate")}
onClick={rotate}
/>
);
}
function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [fitted, setFitted] = useState(false);
const toggle = () => {
const video = videoRef.current;
if (!video) return;
const next = !fitted;
video.style.objectFit = next ? "cover" : "";
setFitted(next);
};
return (
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
onClick={toggle}
/>
);
}
function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [active, setActive] = useState(false);
// The standard PiP API is only supported in Chromium-based browsers.
// Firefox uses its own proprietary PiP implementation.
const supported = "requestPictureInPicture" in HTMLVideoElement.prototype;
useEffect(() => {
const video = videoRef.current;
if (!video || !supported) return;
const onEnter = () => setActive(true);
const onLeave = () => setActive(false);
video.addEventListener("enterpictureinpicture", onEnter);
video.addEventListener("leavepictureinpicture", onLeave);
return () => {
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, [ videoRef, supported ]);
if (!supported) return null;
const toggle = () => {
const video = videoRef.current;
if (!video) return;
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
video.requestPictureInPicture();
}
};
return (
<ActionButton
icon={active ? "bx bx-exit" : "bx bx-window-open"}
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
onClick={toggle}
/>
);
}
function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> }) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFullscreenChange);
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
}, []);
const toggleFullscreen = () => {
const target = targetRef.current;
if (!target) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
target.requestFullscreen();
}
};
return (
<ActionButton
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
onClick={toggleFullscreen}
/>
);
}

View File

@@ -27,12 +27,18 @@
margin: 0 !important;
}
body.desktop .note-detail-split .note-detail-code-editor {
border-radius: 6px;
margin-top: 1px;
}
.note-detail-split .note-detail-error-container {
font-family: var(--monospace-font-family);
margin: 5px;
white-space: pre-wrap;
font-size: 0.85em;
overflow: auto;
user-select: text;
}
.note-detail-split .note-detail-split-preview {

View File

@@ -19,6 +19,7 @@ export interface SplitEditorProps extends EditableCodeProps {
previewButtons?: ComponentChildren;
editorBefore?: ComponentChildren;
forceOrientation?: "horizontal" | "vertical";
extraContent?: ComponentChildren;
}
/**
@@ -41,7 +42,7 @@ export default function SplitEditor(props: SplitEditorProps) {
}
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const splitEditorOrientation = useSplitOrientation(forceOrientation);
@@ -57,9 +58,12 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
{...editorProps}
/>
</div>
{error && <Admonition type="caution" className="note-detail-error-container">
{error}
</Admonition>}
{error && (
<Admonition type="caution" className="note-detail-error-container">
{error}
</Admonition>
)}
{extraContent}
</div>
);

View File

@@ -117,6 +117,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
error={error}
onContentChanged={onContentChanged}
dataSaved={onSave}
placeholder={t("mermaid.placeholder")}
previewContent={(
<RawHtmlBlock
className="render-container"
@@ -151,6 +152,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg: string | undefined) {
const lastPanZoom = useRef<{ pan: SvgPanZoom.Point, zoom: number }>();
const lastNoteId = useRef<string>();
const wasEmpty = useRef<boolean>(false);
const zoomRef = useRef<SvgPanZoom.Instance>();
const width = useElementSize(containerRef);
@@ -158,9 +160,14 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
useEffect(() => {
if (zoomRef.current || width?.width === 0) return;
const shouldPreservePanZoom = (lastNoteId.current === noteId);
const shouldPreservePanZoom = (lastNoteId.current === noteId) && !wasEmpty.current;
const svgEl = containerRef.current?.querySelector("svg");
if (!svgEl) return;
if (!svgEl) {
if (svg?.trim().length === 0) {
wasEmpty.current = true;
}
return;
};
const zoomInstance = svgPanZoom(svgEl, {
zoomEnabled: true,
@@ -186,7 +193,7 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
zoomRef.current = undefined;
zoomInstance.destroy();
};
}, [ svg, width ]);
}, [ containerRef, noteId, svg, width ]);
// React to container changes.
useEffect(() => {

View File

@@ -1,7 +1,11 @@
import { useCallback } from "preact/hooks";
import SvgSplitEditor from "./helpers/SvgSplitEditor";
import { TypeWidgetProps } from "./type_widget";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid";
import { t } from "../../../services/i18n";
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../../services/mermaid";
import NoteContentSwitcher from "../../layout/NoteContentSwitcher";
import SvgSplitEditor from "../helpers/SvgSplitEditor";
import { TypeWidgetProps } from "../type_widget";
import SAMPLE_DIAGRAMS from "./sample_diagrams";
let idCounter = 1;
let registeredErrorReporter = false;
@@ -15,6 +19,10 @@ export default function Mermaid(props: TypeWidgetProps) {
registeredErrorReporter = true;
}
if (!content.trim()) {
return "";
}
mermaid.initialize({
startOnLoad: false,
...(getMermaidConfig() as any),
@@ -30,6 +38,12 @@ export default function Mermaid(props: TypeWidgetProps) {
attachmentName="mermaid-export"
renderSvg={renderSvg}
noteType="mermaid"
extraContent={(
<NoteContentSwitcher
text={t("mermaid.sample_diagrams")}
note={props.note}
templates={SAMPLE_DIAGRAMS} />
)}
{...props}
/>
);

View File

@@ -0,0 +1,512 @@
import { t } from "../../../services/i18n";
import type { NoteContentTemplate } from "../../layout/NoteContentSwitcher";
const SAMPLE_DIAGRAMS: NoteContentTemplate[] = [
{
name: t("mermaid.sample_flowchart"),
content: `\
flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`
},
{
name: t("mermaid.sample_class"),
content: `\
classDiagram
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}
`
},
{
name: t("mermaid.sample_sequence"),
content: `\
sequenceDiagram
Alice->>+John: Hello John, how are you?
Alice->>+John: John, can you hear me?
John-->>-Alice: Hi Alice, I can hear you!
John-->>-Alice: I feel great!
`
},
{
name: t("mermaid.sample_entity_relationship"),
content: `\
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ ORDER_ITEM : contains
PRODUCT ||--o{ ORDER_ITEM : includes
CUSTOMER {
string id
string name
string email
}
ORDER {
string id
date orderDate
string status
}
PRODUCT {
string id
string name
float price
}
ORDER_ITEM {
int quantity
float price
}
`
},
{
name: t("mermaid.sample_state"),
content: `\
stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]
`
},
{
name: t("mermaid.sample_mindmap"),
content: `\
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
`
},
{
name: t("mermaid.sample_architecture"),
content: `\
architecture-beta
group api(cloud)[API]
service db(database)[Database] in api
service disk1(disk)[Storage] in api
service disk2(disk)[Storage] in api
service server(server)[Server] in api
db:L -- R:server
disk1:T -- B:server
disk2:T -- B:db
`
},
{
name: t("mermaid.sample_block"),
content: `\
block-beta
columns 1
db(("DB"))
blockArrowId6<["&nbsp;&nbsp;&nbsp;"]>(down)
block:ID
A
B["A wide one in the middle"]
C
end
space
D
ID --> D
C --> D
style B fill:#969,stroke:#333,stroke-width:4px
`
},
{
name: t("mermaid.sample_c4"),
content: `\
C4Context
title System Context diagram for Internet Banking System
Enterprise_Boundary(b0, "BankBoundary0") {
Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.")
Person(customerB, "Banking Customer B")
Person_Ext(customerC, "Banking Customer C", "desc")
Person(customerD, "Banking Customer D", "A customer of the bank, <br/> with personal bank accounts.")
System(SystemAA, "Internet Banking System", "Allows customers to view information about their bank accounts, and make payments.")
Enterprise_Boundary(b1, "BankBoundary") {
SystemDb_Ext(SystemE, "Mainframe Banking System", "Stores all of the core banking information about customers, accounts, transactions, etc.")
System_Boundary(b2, "BankBoundary2") {
System(SystemA, "Banking System A")
System(SystemB, "Banking System B", "A system of the bank, with personal bank accounts. next line.")
}
System_Ext(SystemC, "E-mail system", "The internal Microsoft Exchange e-mail system.")
SystemDb(SystemD, "Banking System D Database", "A system of the bank, with personal bank accounts.")
Boundary(b3, "BankBoundary3", "boundary") {
SystemQueue(SystemF, "Banking System F Queue", "A system of the bank.")
SystemQueue_Ext(SystemG, "Banking System G Queue", "A system of the bank, with personal bank accounts.")
}
}
}
BiRel(customerA, SystemAA, "Uses")
BiRel(SystemAA, SystemE, "Uses")
Rel(SystemAA, SystemC, "Sends e-mails", "SMTP")
Rel(SystemC, customerA, "Sends e-mails to")
`
},
{
name: t("mermaid.sample_gantt"),
content: `\
gantt
title A Gantt Diagram
dateFormat YYYY-MM-DD
section Section
A task :a1, 2014-01-01, 30d
Another task :after a1 , 20d
section Another
Task in sec :2014-01-12 , 12d
another task : 24d
`
},
{
name: t("mermaid.sample_git"),
content: `\
gitGraph
commit
branch develop
checkout develop
commit
commit
checkout main
merge develop
commit
branch feature
checkout feature
commit
commit
checkout main
merge feature
`
},
{
name: t("mermaid.sample_kanban"),
content: `\
---
config:
kanban:
ticketBaseUrl: 'https://github.com/mermaid-js/mermaid/issues/#TICKET#'
---
kanban
Todo
[Create Documentation]
docs[Create Blog about the new diagram]
[In progress]
id6[Create renderer so that it works in all cases. We also add some extra text here for testing purposes. And some more just for the extra flare.]
id9[Ready for deploy]
id8[Design grammar]@{ assigned: 'knsv' }
id10[Ready for test]
id4[Create parsing tests]@{ ticket: 2038, assigned: 'K.Sveidqvist', priority: 'High' }
id66[last item]@{ priority: 'Very Low', assigned: 'knsv' }
id11[Done]
id5[define getData]
id2[Title of diagram is more than 100 chars when user duplicates diagram with 100 char]@{ ticket: 2036, priority: 'Very High'}
id3[Update DB function]@{ ticket: 2037, assigned: knsv, priority: 'High' }
id12[Can't reproduce]
id3[Weird flickering in Firefox]
`
},
{
name: t("mermaid.sample_packet"),
content: `\
---
title: "TCP Packet"
---
packet
0-15: "Source Port"
16-31: "Destination Port"
32-63: "Sequence Number"
64-95: "Acknowledgment Number"
96-99: "Data Offset"
100-105: "Reserved"
106: "URG"
107: "ACK"
108: "PSH"
109: "RST"
110: "SYN"
111: "FIN"
112-127: "Window"
128-143: "Checksum"
144-159: "Urgent Pointer"
160-191: "(Options and Padding)"
192-255: "Data (variable length)"
`
},
{
name: t("mermaid.sample_pie"),
content: `\
pie title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15
`
},
{
name: t("mermaid.sample_quadrant"),
content: `\
quadrantChart
title Reach and engagement of campaigns
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 We should expand
quadrant-2 Need to promote
quadrant-3 Re-evaluate
quadrant-4 May be improved
Campaign A: [0.3, 0.6]
Campaign B: [0.45, 0.23]
Campaign C: [0.57, 0.69]
Campaign D: [0.78, 0.34]
Campaign E: [0.40, 0.34]
Campaign F: [0.35, 0.78]
`
},
{
name: t("mermaid.sample_radar"),
content: `\
---
title: "Grades"
---
radar-beta
axis m["Math"], s["Science"], e["English"]
axis h["History"], g["Geography"], a["Art"]
curve a["Alice"]{85, 90, 80, 70, 75, 90}
curve b["Bob"]{70, 75, 85, 80, 90, 85}
max 100
min 0
`
},
{
name: t("mermaid.sample_requirement"),
content: `\
requirementDiagram
requirement test_req {
id: 1
text: the test text.
risk: high
verifymethod: test
}
element test_entity {
type: simulation
}
test_entity - satisfies -> test_req
`
},
{
name: t("mermaid.sample_sankey"),
content: `\
---
config:
sankey:
showValues: false
---
sankey-beta
Agricultural 'waste',Bio-conversion,124.729
Bio-conversion,Liquid,0.597
Bio-conversion,Losses,26.862
Bio-conversion,Solid,280.322
Bio-conversion,Gas,81.144
Biofuel imports,Liquid,35
Biomass imports,Solid,35
Coal imports,Coal,11.606
Coal reserves,Coal,63.965
Coal,Solid,75.571
District heating,Industry,10.639
District heating,Heating and cooling - commercial,22.505
District heating,Heating and cooling - homes,46.184
Electricity grid,Over generation / exports,104.453
Electricity grid,Heating and cooling - homes,113.726
Electricity grid,H2 conversion,27.14
Electricity grid,Industry,342.165
Electricity grid,Road transport,37.797
Electricity grid,Agriculture,4.412
Electricity grid,Heating and cooling - commercial,40.858
Electricity grid,Losses,56.691
Electricity grid,Rail transport,7.863
Electricity grid,Lighting & appliances - commercial,90.008
Electricity grid,Lighting & appliances - homes,93.494
Gas imports,NGas,40.719
Gas reserves,NGas,82.233
Gas,Heating and cooling - commercial,0.129
Gas,Losses,1.401
Gas,Thermal generation,151.891
Gas,Agriculture,2.096
Gas,Industry,48.58
Geothermal,Electricity grid,7.013
H2 conversion,H2,20.897
H2 conversion,Losses,6.242
H2,Road transport,20.897
Hydro,Electricity grid,6.995
Liquid,Industry,121.066
Liquid,International shipping,128.69
Liquid,Road transport,135.835
Liquid,Domestic aviation,14.458
Liquid,International aviation,206.267
Liquid,Agriculture,3.64
Liquid,National navigation,33.218
Liquid,Rail transport,4.413
Marine algae,Bio-conversion,4.375
NGas,Gas,122.952
Nuclear,Thermal generation,839.978
Oil imports,Oil,504.287
Oil reserves,Oil,107.703
Oil,Liquid,611.99
Other waste,Solid,56.587
Other waste,Bio-conversion,77.81
Pumped heat,Heating and cooling - homes,193.026
Pumped heat,Heating and cooling - commercial,70.672
Solar PV,Electricity grid,59.901
Solar Thermal,Heating and cooling - homes,19.263
Solar,Solar Thermal,19.263
Solar,Solar PV,59.901
Solid,Agriculture,0.882
Solid,Thermal generation,400.12
Solid,Industry,46.477
Thermal generation,Electricity grid,525.531
Thermal generation,Losses,787.129
Thermal generation,District heating,79.329
Tidal,Electricity grid,9.452
UK land based bioenergy,Bio-conversion,182.01
Wave,Electricity grid,19.013
Wind,Electricity grid,289.366
`
},
{
name: t("mermaid.sample_timeline"),
content: `\
timeline
title History of Social Media Platform
2002 : LinkedIn
2004 : Facebook
: Google
2005 : YouTube
2006 : Twitter
`
},
{
name: t("mermaid.sample_treemap"),
content: `\
treemap-beta
"Section 1"
"Leaf 1.1": 12
"Section 1.2"
"Leaf 1.2.1": 12
"Section 2"
"Leaf 2.1": 20
"Leaf 2.2": 25
`
},
{
name: t("mermaid.sample_user_journey"),
content: `\
journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 5: Me
`
},
{
name: t("mermaid.sample_xy"),
content: `\
xychart-beta
title "Sales Revenue"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
y-axis "Revenue (in $)" 4000 --> 11000
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
`
},
{
name: t("mermaid.sample_venn"),
content: `\
venn-beta
title Web Dev
set Frontend
text React
text shadcn-ui
text Firebase
set Backend
text Hono
text PostgreSQL
text S3
text Lambda
union Frontend,Backend["APIs"]
`
},
{
name: t("mermaid.sample_ishikawa"),
content: `\
ishikawa-beta
Blurry Photo
Process
Out of focus
Shutter speed too slow
Protective film not removed
Beautification filter applied
User
Shaky hands
Equipment
LENS
Inappropriate lens
Damaged lens
Dirty lens
SENSOR
Damaged sensor
Dirty sensor
Environment
Subject moved too quickly
Too dark
`
}
];
export default SAMPLE_DIAGRAMS;

View File

@@ -0,0 +1,153 @@
import "./Spreadsheet.css";
import "@univerjs/preset-sheets-core/lib/index.css";
import "@univerjs/preset-sheets-sort/lib/index.css";
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
import "@univerjs/preset-sheets-find-replace/lib/index.css";
import "@univerjs/preset-sheets-note/lib/index.css";
import "@univerjs/preset-sheets-filter/lib/index.css";
import "@univerjs/preset-sheets-data-validation/lib/index.css";
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
export default function Spreadsheet(props: TypeWidgetProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
}
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
useSearchIntegration(apiRef);
useFixRadixPortals();
// Focus the spreadsheet when the note is focused.
useTriliumEvent("focusOnDetail", () => {
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
if (focusable instanceof HTMLElement) {
focusable.focus();
}
});
return <div ref={containerRef} className="spreadsheet" />;
}
/**
* Univer's design system uses Radix UI primitives whose DismissableLayer detects
* "outside" clicks/focus via document-level pointerdown/focusin listeners combined
* with a React capture-phase flag. In React, portal events bubble through the
* component tree so onPointerDownCapture fires on the DismissableLayer, setting an
* internal flag that suppresses the "outside" detection. With preact/compat, portal
* events don't bubble through the React tree, so the flag never gets set and Radix
* immediately dismisses popups.
*
* Radix dispatches cancelable custom events ("dismissableLayer.pointerDownOutside"
* and "dismissableLayer.focusOutside") on the original event target before calling
* onDismiss. The dismiss is skipped if defaultPrevented is true. This hook intercepts
* those custom events in the capture phase and prevents default when the target is
* inside a Radix portal, restoring the expected behavior.
*/
function useFixRadixPortals() {
useEffect(() => {
function preventDismiss(e: Event) {
if (e.target instanceof HTMLElement && e.target.closest("[id^='radix-']")) {
e.preventDefault();
}
}
document.addEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true);
document.addEventListener("dismissableLayer.focusOutside", preventDismiss, true);
return () => {
document.removeEventListener("dismissableLayer.pointerDownOutside", preventDismiss, true);
document.removeEventListener("dismissableLayer.focusOutside", preventDismiss, true);
};
}, []);
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
sheetsCoreEnUS,
sheetsFindReplaceEnUS,
sheetsNoteEnUS,
UniverPresetSheetsFilterEnUS,
UniverPresetSheetsSortEnUS,
UniverPresetSheetsDataValidationEnUS,
UniverPresetSheetsConditionalFormattingEnUS,
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
toolbar: !readOnly,
contextMenu: !readOnly,
formulaBar: !readOnly,
footer: readOnly ? false : undefined,
menu: {
"sheet.contextMenu.permission": { hidden: true },
"sheet-permission.operation.openPanel": { hidden: true },
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
},
}),
UniverSheetsFindReplacePreset(),
UniverSheetsNotePreset(),
UniverSheetsFilterPreset(),
UniverSheetsSortPreset(),
UniverSheetsDataValidationPreset(),
UniverSheetsConditionalFormattingPreset()
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef, readOnly ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
useTriliumEvent("findInText", () => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
// Open find/replace panel and populate the search term.
univerAPI.executeCommand("ui.operation.open-find-dialog");
});
}

View File

@@ -0,0 +1,194 @@
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../../components/note_context";
import FNote from "../../../entities/fnote";
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
interface SpreadsheetViewState {
activeSheetId?: string;
cursorRow?: number;
cursorCol?: number;
scrollRow?: number;
scrollCol?: number;
}
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
const changeListener = useRef<IDisposable>(null);
const pendingContent = useRef<string | null>(null);
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
const state: SpreadsheetViewState = {};
try {
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return state;
const activeSheet = workbook.getActiveSheet();
state.activeSheetId = activeSheet?.getSheetId();
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
if (currentCell) {
state.cursorRow = currentCell.actualRow;
state.cursorCol = currentCell.actualColumn;
}
const scrollState = activeSheet?.getScrollState?.();
if (scrollState) {
state.scrollRow = scrollState.sheetViewStartRow;
state.scrollCol = scrollState.sheetViewStartColumn;
}
} catch {
// Ignore errors when reading state from a workbook being disposed.
}
return state;
}
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
try {
if (state.activeSheetId) {
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
if (targetSheet) {
workbook.setActiveSheet(targetSheet);
}
}
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
}
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
}
} catch {
// Ignore errors when restoring state (e.g. sheet no longer exists).
}
}
function applyContent(univerAPI: FUniver, newContent: string) {
const viewState = saveViewState(univerAPI);
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (readOnly) {
workbook.disableSelection();
const permission = workbook.getPermission();
permission.setWorkbookEditPermission(workbook.getId(), false);
permission.setPermissionDialogVisible(false);
}
restoreViewState(workbook, viewState);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
}
function isContainerVisible() {
const el = containerRef.current;
if (!el) return false;
return el.offsetWidth > 0 && el.offsetHeight > 0;
}
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
async getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
const attachments: SavedData["attachments"] = [];
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
if (canvasEl) {
const dataUrl = canvasEl.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
attachments.push({
role: "image",
title: "spreadsheet-export.png",
mime: "image/png",
content: base64,
position: 0,
encoding: "base64"
});
}
return {
content: JSON.stringify(content),
attachments
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Defer content application if the container is hidden (zero size),
// since the spreadsheet library cannot calculate layout in that state.
if (!isContainerVisible()) {
pendingContent.current = newContent;
return;
}
pendingContent.current = null;
applyContent(univerAPI, newContent);
},
});
// Apply pending content once the container becomes visible (non-zero size).
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
if (pendingContent.current === null || !isContainerVisible()) return;
const univerAPI = apiRef.current;
if (!univerAPI) return;
const content = pendingContent.current;
pendingContent.current = null;
applyContent(univerAPI, content);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
}, [ containerRef ]);
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@@ -8,8 +8,7 @@
"preact"
],
"rootDir": "src",
"jsx": "preserve",
"jsxFactory": "h",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"module": "esnext",
"moduleResolution": "bundler",

View File

@@ -6,8 +6,7 @@
"node",
"vitest"
],
"jsx": "preserve",
"jsxFactory": "h",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"module": "esnext",
"moduleResolution": "bundler"

View File

@@ -103,10 +103,7 @@ export default defineConfig(() => ({
return "src/[name].js";
},
chunkFileNames: "src/[name]-[hash].js",
assetFileNames: "src/[name]-[hash].[ext]",
manualChunks: {
"ckeditor5": [ "@triliumnext/ckeditor5" ]
},
assetFileNames: "src/[name]-[hash].[ext]"
},
onwarn(warning, rollupWarn) {
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {

View File

@@ -7,7 +7,7 @@
"colors": "1.4.0",
"diff": "8.0.3",
"sqlite": "5.1.1",
"sqlite3": "5.1.7"
"sqlite3": "6.0.1"
},
"scripts": {
"dev": "tsx src/compare.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/desktop",
"version": "0.102.0",
"version": "0.102.1",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "src/main.ts",
@@ -23,7 +23,7 @@
},
"dependencies": {
"@electron/remote": "2.1.3",
"better-sqlite3": "12.6.2",
"better-sqlite3": "12.8.0",
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
@@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "41.0.2",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -4,7 +4,7 @@
"description": "Standalone tool to dump contents of Trilium document.db file into a directory tree of notes",
"private": true,
"dependencies": {
"better-sqlite3": "12.6.2",
"better-sqlite3": "12.8.0",
"mime-types": "3.0.2",
"sanitize-filename": "1.6.3",
"tsx": "4.21.0",

View File

@@ -1,18 +1,18 @@
{
"name": "@triliumnext/edit-docs",
"version": "0.102.0",
"version": "0.102.1",
"private": true,
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
"dependencies": {
"archiver": "7.0.1",
"better-sqlite3": "12.6.2"
"better-sqlite3": "12.8.0"
},
"devDependencies": {
"@triliumnext/client": "workspace:*",
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "41.0.2",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -0,0 +1,51 @@
# Nginx Proxy Manager (for testing reverse proxy setups)
## Quick start
1. Start Trilium on the host (default port 8080):
```bash
pnpm run server:start
```
2. Start Nginx Proxy Manager:
```bash
docker compose up -d
```
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
- Email: `admin@example.com`
- Password: `changeme`
(You'll be asked to change these on first login.)
4. Add a proxy host:
- **Domain Names**: `localhost`
- **Scheme**: `http`
- **Forward Hostname / IP**: `host.docker.internal`
- **Forward Port**: `8080`
- Enable **Websockets Support** (required for Trilium sync)
5. Access Trilium through NPM at **http://localhost:8090**.
## With a subpath
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
```nginx
location /trilium/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://host.docker.internal:8080/;
proxy_cookie_path / /trilium/;
proxy_read_timeout 90;
}
```
## Cleanup
```bash
docker compose down -v
```

View File

@@ -0,0 +1,19 @@
services:
nginx-proxy-manager:
image: "jc21/nginx-proxy-manager:latest"
restart: unless-stopped
ports:
# Public HTTP port
- "8090:80"
# Admin panel
- "8081:81"
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
# Use host network mode so NPM can reach Trilium on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
npm_data:
npm_letsencrypt:

View File

@@ -1,5 +1,5 @@
{
"dependencies": {
"better-sqlite3": "12.6.2"
"better-sqlite3": "12.8.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.102.0",
"version": "0.102.1",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"main": "./src/main.ts",
@@ -29,7 +29,7 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"better-sqlite3": "12.6.2",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
"sucrase": "3.35.1"
@@ -55,9 +55,9 @@
"@types/html": "1.0.4",
"@types/ini": "4.1.1",
"@types/mime-types": "3.0.1",
"@types/multer": "2.0.0",
"@types/multer": "2.1.0",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.16.0",
"@types/sanitize-html": "2.16.1",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "2.2.0",
@@ -78,27 +78,27 @@
"cls-hooked": "4.2.2",
"compression": "1.8.1",
"cookie-parser": "1.4.7",
"csrf-csrf": "3.2.2",
"csrf-csrf": "4.0.2",
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "40.8.0",
"electron": "41.0.2",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.4",
"express-rate-limit": "8.3.0",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.4",
"helmet": "8.1.0",
"html": "1.0.0",
"html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"i18next": "25.8.13",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "25.8.18",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"ini": "6.0.0",
@@ -125,9 +125,9 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "7.3.1",
"vite": "8.0.0",
"ws": "8.19.0",
"xml2js": "0.6.2",
"yauzl": "3.2.0"
"yauzl": "3.2.1"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -4,21 +4,17 @@
maintaining and supporting it long-term proved to be unsustainable.</p>
<p>When upgrading to v0.102.0, your Chat notes will be preserved, but instead
of the dedicated chat window they will be turned to a normal&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_6f9hih2hXXZk">Code</a>&nbsp;note,
revealing the underlying JSON of the conversation.</p>
href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;note, revealing the underlying
JSON of the conversation.</p>
<h2>Alternative solutions (MCP)</h2>
<p>Given the recent advancements of the AI scene, MCP has grown to be more
powerful and facilitates easier integrations with various application.</p>
<p>As such, there are third-party solutions that integrate an MCP server
that can be used with Trilium:</p>
<ul>
<li>
<p><a href="https://github.com/tan-yong-sheng/triliumnext-mcp">tan-yong-sheng/triliumnext-mcp</a>
</p>
<li><a href="https://github.com/tan-yong-sheng/triliumnext-mcp">tan-yong-sheng/triliumnext-mcp</a>
</li>
<li>
<p><a href="https://github.com/perfectra1n/triliumnext-mcp">perfectra1n/triliumnext-mcp</a>
</p>
<li><a href="https://github.com/perfectra1n/triliumnext-mcp">perfectra1n/triliumnext-mcp</a>
</li>
</ul>
<aside class="admonition important">

View File

@@ -209,7 +209,7 @@
<tr>
<td><code spellcheck="false">#calendar:color</code>
</td>
<td><strong>❌️ Removed since v0.100.0. Use</strong> <code spellcheck="false">**#color**</code> <strong>instead.</strong>&nbsp;
<td><strong>❌️ Removed since v0.100.0. Use</strong> <code spellcheck="false">**#color**</code> <strong>instead.</strong>&nbsp;&nbsp;
<br>
<br>Similar to <code spellcheck="false">#color</code>, but applies the color
only for the event in the calendar and not for other places such as the
@@ -233,15 +233,15 @@
<td><code spellcheck="false">#calendar:displayedAttributes</code>
</td>
<td>Allows displaying the value of one or more attributes in the calendar
like this:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
like this:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br>
<img src="7_Calendar_image.png">&nbsp;&nbsp;&nbsp;&nbsp;
<img src="7_Calendar_image.png">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br><code spellcheck="false">#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>&nbsp;&nbsp;&nbsp;&nbsp;
<br><code spellcheck="false">#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"</code>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br>It can also be used with relations, case in which it will display the
title of the target note:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
title of the target note:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<br>
<br><code spellcheck="false">~assignee=@My assignee #calendar:displayedAttributes="assignee"</code>
</td>
@@ -294,44 +294,27 @@
<p>When not used in a Journal, the calendar is recursive. That is, it will
look for events not just in its child notes but also in the children of
these child notes.</p>
<p>&nbsp;</p>
<h2>Recurrence</h2>
<p>The built in calendar view also supports repeating tasks. If a child note
of the calendar has a #recurrence label with a valid recurrence, that event
will repeat on the calendar according to the recurrence string.&nbsp;</p>
<p>For example, to make a note repeat on the calendar:</p>
<ul>
<li>
<p>Every Day - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=1"</code>
</p>
<li>Every Day - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=1"</code>
</li>
<li>
<p>Every 3 days - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=3"</code>
</p>
<li>Every 3 days - <code spellcheck="false">#recurrence="FREQ=DAILY;INTERVAL=3"</code>
</li>
<li>
<p>Every week - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=1"</code>
</p>
<li>Every week - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=1"</code>
</li>
<li>
<p>Every 2 weeks on Monday, Wednesday and Friday - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"</code>
</p>
<li>Every 2 weeks on Monday, Wednesday and Friday - <code spellcheck="false">#recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"</code>
</li>
<li>
<p>Every 3 months - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=3"</code>
</p>
<li>Every 3 months - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=3"</code>
</li>
<li>
<p>Every 2 months on the First Sunday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"</code>
</p>
<li>Every 2 months on the First Sunday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"</code>
</li>
<li>
<p>Every month on the Last Friday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"</code>
</p>
</li>
<li>
<p>And so on.</p>
<li>Every month on the Last Friday - <code spellcheck="false">#recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"</code>
</li>
<li>And so on.</li>
</ul>
<p>For other examples of valid <code spellcheck="false">RRULE</code> strings
see <a href="https://icalendar.org/rrule-tool.html">https://icalendar.org/rrule-tool.html</a>
@@ -352,7 +335,6 @@
note ID and title of the note with the erroneous recurrence message. This
note will not be added to the calendar</p>
</aside>
<p>&nbsp;</p>
<h2>Use-cases</h2>
<h3>Using with the Journal / calendar</h3>
<p>It is possible to integrate the calendar view into the Journal with day

View File

@@ -80,7 +80,7 @@
<td><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Collections</a>
</td>
<td>Displays the children of the note either as a grid, a list, or for a more
specialized case: a calendar.
specialized case: a calendar.&nbsp;
<br>
<br>Generally useful for easy reading of short notes.</td>
</tr>
@@ -108,7 +108,7 @@
<td>Easy for brainstorming ideas, by placing them in a hierarchical layout.</td>
</tr>
<tr>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map View</a>
<td><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
</td>
<td>Displays the children of the note as a geographical map, one use-case
would be to plan vacations. It even has basic support for tracks. Notes

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -13,7 +13,7 @@
<p>See&nbsp;<a class="reference-link" href="#root/_help_XJGJrpu7F9sh">PDFs</a>.</p>
<h3>Images</h3>
<figure class="image image-style-align-center image_resized" style="width:50%;">
<img style="aspect-ratio:879/766;" src="3_File_image.png"
<img style="aspect-ratio:879/766;" src="2_File_image.png"
width="879" height="766">
</figure>
<p>Interaction:</p>
@@ -30,25 +30,10 @@
</li>
</ul>
<h3>Videos</h3>
<figure class="image image-style-align-center image_resized" style="width:50%;">
<img style="aspect-ratio:854/700;" src="File_image.png"
width="854" height="700">
</figure>
<p>Video files can be added in as well. The file is streamed directly, so
when accessing the note from a server it doesn't have to download the entire
video to start playing it.</p>
<aside class="admonition caution">
<p>Although Trilium offers support for videos, it is generally not meant
to be used with very large files. Uploading large videos will cause the&nbsp;
<a
class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a>&nbsp;to balloon as well as the any&nbsp;<a class="reference-link"
href="#root/_help_ODY7qQn5m2FT">Backup</a>&nbsp;of it. In addition to that, there
might be slowdowns when first uploading the files. Otherwise, a large database
should not impact the general performance of Trilium significantly.</p>
</aside>
<p>See&nbsp;<a class="reference-link" href="#root/_help_AjqEeiDUOzj4">Videos</a>.</p>
<h3>Audio</h3>
<figure class="image image-style-align-center image_resized" style="width:50%;">
<img style="aspect-ratio:850/243;" src="2_File_image.png"
<img style="aspect-ratio:850/243;" src="1_File_image.png"
width="850" height="243">
</figure>
<p>Adding a supported audio file will reveal a basic audio player that can
@@ -64,7 +49,7 @@
</ul>
<h3>Text files</h3>
<figure class="image image-style-align-center image_resized" style="width:50%;">
<img style="aspect-ratio:926/347;" src="1_File_image.png"
<img style="aspect-ratio:926/347;" src="File_image.png"
width="926" height="347">
</figure>
<p>Files that are identified as containing text will show a preview of their
@@ -83,7 +68,7 @@
application.</p>
<h3>Unknown file types</h3>
<figure class="image image-style-align-center image_resized" style="width:50%;">
<img style="aspect-ratio:532/240;" src="4_File_image.png"
<img style="aspect-ratio:532/240;" src="3_File_image.png"
width="532" height="240">
</figure>
<p>If the file could not be identified as any of the supported file types
@@ -110,7 +95,7 @@
<p>Files are also displayed in the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;based
on their type:</p>
<img class="image_resized" style="aspect-ratio:853/315;width:50%;"
src="5_File_image.png" width="853" height="315">
src="4_File_image.png" width="853" height="315">
</li>
<li>
<p>Non-image files can be embedded into text notes as read-only widgets via

View File

@@ -0,0 +1,131 @@
<figure class="image image-style-align-right image_resized" style="width:61.8%;">
<img style="aspect-ratio:953/587;" src="Videos_image.png"
width="953" height="587">
</figure>
<p>Starting with v0.103.0, Trilium has a custom video player which offers
more features than the built-in video player.</p>
<p>Versions prior to v0.103.0 also support videos, but using the built-in
player.</p>
<p>The file is streamed directly, so when accessing the note from a server
it doesn't have to download the entire video to start playing it.</p>
<h2>Note on large video files</h2>
<p>Although Trilium offers support for videos, it is generally not meant
to be used with very large files. Uploading large videos will cause the&nbsp;
<a
class="reference-link" href="#root/_help_wX4HbRucYSDD">Database</a>&nbsp;to balloon as well as the any&nbsp;<a class="reference-link"
href="#root/_help_ODY7qQn5m2FT">Backup</a>&nbsp;of it. In addition to that, there
might be slowdowns when first uploading the files. Otherwise, a large database
should not impact the general performance of Trilium significantly.</p>
<h2>Supported formats</h2>
<p>Trilium uses the built-in video decoding mechanism of the browser (or
Electron/Chromium when running on the desktop). Starting with v0.103.0,
a message will be displayed instead when a video format is not supported.</p>
<h2>Interactions</h2>
<p>To play/pause the video, simply click anywhere on the video.</p>
<p>The controls at the bottom will hide automatically after playing, simply
move the mouse to show them again.</p>
<p>The bottom bar has the following features:</p>
<ul>
<li>
<p>A track bar to seek across the video.</p>
</li>
<li>
<p>On the left of the track bar, the current time is indicated.</p>
</li>
<li>
<p>On the right of the track bar, the remaining time is indicated.</p>
</li>
<li>
<p>On the left side there are buttons to:</p>
<ul>
<li>Adjust the playback speed (e.g. 0.5x, 1x).</li>
<li>Rotate the video by 90 degrees.</li>
</ul>
</li>
<li>
<p>In the center:</p>
<ul>
<li>Go back by 10s</li>
<li>Play/pause</li>
<li>Go forward by 30s</li>
<li>Loop, which when enabled will restart the video once it reaches the end.</li>
</ul>
</li>
<li>
<p>On the right side:</p>
<ul>
<li>Mute button</li>
<li>Volume adjustment</li>
<li>Full screen</li>
<li>Zoom to fill, which will crop the video so that it fills the entire window.</li>
<li>Picture-in-picture (if the browser supports it).</li>
</ul>
</li>
</ul>
<h2>Keyboard shortcuts</h2>
<p>The following keyboard shortcuts are supported by the video player:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>Space</kbd>
</td>
<td>Play/pause</td>
</tr>
<tr>
<td><kbd>Left arrow key</kbd>
</td>
<td>Go back by 10s</td>
</tr>
<tr>
<td><kbd>Right arrow key</kbd>
</td>
<td>Go forward by 10s</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Left arrow key</kbd>
</td>
<td>Go back by 1 min</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Right arrow key</kbd>
</td>
<td>Go right by 1 min</td>
</tr>
<tr>
<td><kbd>F</kbd>
</td>
<td>Toggle full-screen</td>
</tr>
<tr>
<td><kbd>M</kbd>
</td>
<td>Mute/unmute</td>
</tr>
<tr>
<td><kbd>Home</kbd>
</td>
<td>Go to the beginning of the video</td>
</tr>
<tr>
<td><kbd>End</kbd>
</td>
<td>Go to the end of the video</td>
</tr>
<tr>
<td><kbd>Up</kbd>
</td>
<td>Increase volume by 5%</td>
</tr>
<tr>
<td><kbd>Down</kbd>
</td>
<td>Decrease volume by 5%</td>
</tr>
</tbody>
</table>

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -6,10 +6,20 @@
<img style="aspect-ratio:886/663;" src="2_Mermaid Diagrams_image.png"
width="886" height="663">
</figure>
<h2>Types of diagrams</h2>
<p>Trilium supports Mermaid, which adds support for various diagrams such
as flowchart, sequence diagram, class diagram, state diagram, pie charts,
etc., all using a text description of the chart instead of manually drawing
the diagram.</p>
<p>Starting with v0.103.0, Mermaid diagrams no longer start with a sample
flowchart, but instead a pane at the bottom will show all the supported
diagrams with sample code for each:</p>
<ul>
<li>Simply click on any of the samples to apply it.</li>
<li>The pane will disappear as soon as something is typed in the code editor
or a sample is selected. To make it appear again, simply remove the content
of the note.</li>
</ul>
<h2>Layouts</h2>
<p>Depending on the chart being edited and user preference, there are two
layouts supported by the Mermaid note type:</p>
@@ -38,30 +48,34 @@
<img src="1_Mermaid Diagrams_image.png">
</li>
<li>The preview can be moved around by holding the left mouse button and dragging.</li>
<li>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
<li
>Zooming can also be done by using the scroll wheel.</li>
<li>The zoom and position on the preview will remain fixed as the diagram
changes, to be able to work more easily with large diagrams.</li>
</ul>
</li>
<li>The size of the source/preview panes can be adjusted by hovering over
the border between them and dragging it with the mouse.</li>
<li>In the&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;area:
<ul>
<li>The source/preview can be laid out left-right or bottom-top via the <em>Move editing pane to the left / bottom</em> option.</li>
<li>Press <em>Lock editing</em> to automatically mark the note as read-only.
<li
>Press <em>Lock editing</em> to automatically mark the note as read-only.
In this mode, the code pane is hidden and the diagram is displayed full-size.
Similarly, press <em>Unlock editing</em> to mark a read-only note as editable.</li>
<li>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li
>Press the <em>Copy image reference to the clipboard</em> to be able to insert
the image representation of the diagram into a text note. See&nbsp;<a class="reference-link"
href="#root/_help_0Ofbk1aSuVRu">Image references</a>&nbsp;for more information.</li>
<li
>Press the <em>Export diagram as SVG</em> to download a scalable/vector rendering
of the diagram. Can be used to present the diagram without degrading when
zooming.</li>
<li>Press the <em>Export diagram as PNG</em> to download a normal image (at
1x scale, raster) of the diagram. Can be used to send the diagram in more
traditional channels such as e-mail.</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2>Errors in the diagram</h2>
<p>If there is an error in the source code, the error will be displayed in

View File

@@ -0,0 +1,91 @@
<figure class="image">
<img style="aspect-ratio:1102/573;" src="Spreadsheets_image.png"
width="1102" height="573">
</figure>
<aside class="admonition important">
<p>Spreadsheets are a new type of note introduced in v0.103.0 and are currently
considered experimental/beta. As such, expect major changes to occur to
this note type.</p>
</aside>
<p>Spreadsheets provide a familiar experience to Microsoft Excel or LibreOffice
Calc, with support for formulas, data validation and text formatting.</p>
<h2>Spreadsheets vs. collections</h2>
<p>There is a slight overlap between spreadsheets and the&nbsp;<a class="reference-link"
href="#root/_help_2FvYrpmOXm29">Table</a>&nbsp;collection. In general the table
collection is useful to track meta-information about notes (for example
a collection of people and their birthdays), whereas spreadsheets are quite
useful for calculations since they support formulas.</p>
<p>Spreadsheets also benefit from a wider range of features such as data
validation, formatting and can work on a relatively large dataset.</p>
<h2>Important statement regarding data format</h2>
<p>For Trilium as a knowledge database, it is important that data is stored
in a format that is easy to convert to something else. For example,&nbsp;
<a
class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>&nbsp;notes can be exported to either HTML or Markdown, making
it relatively easy to migrate to another software or simply to stand the
test of time.</p>
<p>For spreadsheets, Trilium uses a technology called <a href="https://docs.univer.ai/">Univer Sheets</a>,
developed by DreamNum Co., Ltd. Although this software library is quite
powerful and has a good track record (starting with Luckysheet from 2020,
becoming Univer somewhere in 2023), it uses its own JSON format to store
the sheets.</p>
<p>As such, if Univer were to become unmaintained or incompatible for some
reason, your data might become vendor locked-in.</p>
<p>With that in mind, spreadsheets can be really useful for quick calculations,
but it's important not to have critical information on it that you might
not want to need in a few years time.</p>
<h2>Regarding data export</h2>
<p>Currently, in Trilium there is no way to export the spreadsheets to CSV
or Excel formats. We might manage to add support for it at some point,
but currently this is not the case.</p>
<h2>Supported features</h2>
<p>The spreadsheet has support for the following features:</p>
<ul>
<li>Filtering</li>
<li>Sorting</li>
<li>Data validation</li>
<li>Conditional formatting</li>
<li>Notes / annotations</li>
<li>Find / replace</li>
</ul>
<p>We might consider adding <a href="https://docs.univer.ai/guides/sheets/features/filter">other features</a> from
Univer at some point. If there is a particular feature that can be added
easily, it can be discussed over <a href="#root/_help_wy8So3yZZlH9">GitHub Issues</a>.</p>
<h2>Features not supported yet</h2>
<h3>Regarding Pro features</h3>
<p>Univer spreadsheets also feature a <a href="https://univer.ai/pro">Pro plan</a> which
adds quite a lot of functionality such as charts, printing, pivot tables,
export, etc.</p>
<p>As the Pro plan needs a license, Trilium does not support any of the premium
features. Theoretically, pro features can be used in trial mode with some
limitations, we might explore this direction at some point.</p>
<h3>Planned features</h3>
<p>There are a few features that are already planned but are not supported
yet:</p>
<ul>
<li>Trilium-specific formulas (e.g. to obtain the title of a note).</li>
<li>User-defined formulas</li>
<li>Cross-workbook calculation</li>
</ul>
<p>If you would like us to work on these features, consider <a href="https://triliumnotes.org/en/support-us">supporting us</a>.</p>
<h2>Known limitations</h2>
<ul>
<li>
<p>It is possible to share a spreadsheet, case in which a best-effort HTML
rendering of the spreadsheet is done.</p>
<ul>
<li>For more advanced use cases, this will most likely not work as intended.
Feel free to <a href="#root/_help_wy8So3yZZlH9">report issues</a>, but keep in
mind that we might not be able to have a complete feature parity with all
the features of Univer.</li>
</ul>
</li>
<li>
<p>There is currently no export functionality, as stated previously.</p>
</li>
<li>
<p>There is no dedicated mobile support. Mobile support is currently experimental
in Univer and when it becomes stable, we could potentially integrate it
into Trilium as well.</p>
</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

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

View File

@@ -2,6 +2,24 @@
"keyboard_actions": {
"back-in-note-history": "Gå till föregående anteckning i historiken",
"forward-in-note-history": "Gå till nästa anteckning i historiken",
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog",
"open-command-palette": "Öppna kommandomenyn",
"quick-search": "Öppna snabbsökning",
"search-in-subtree": "Sök anteckningar nedåt i anteckningshierarkin",
"expand-subtree": "Expandera hierarkin under denna anteckning",
"collapse-tree": "Stänger anteckningshierarkin",
"collapse-subtree": "Stänger hierarkin under aktuell anteckning",
"sort-child-notes": "Sortera underordnade anteckningar",
"creating-and-moving-notes": "Skapa och flytta anteckningar",
"create-note-after": "Skapa ny anteckning efter aktiv anteckning",
"create-note-into": "Skapa ny anteckning underordnad aktiv anteckning",
"create-note-into-inbox": "Skapa en anteckning i inboxen (om angiven) eller som daganteckning",
"delete-note": "Radera anteckning",
"move-note-up": "Flytta anteckning uppåt",
"move-note-down": "Flytta anteckning nedåt",
"scroll-to-active-note": "Bläddra i anteckningshierarkin till aktiv anteckning",
"move-note-up-in-hierarchy": "Flytta anteckning uppåt i hierarkin",
"move-note-down-in-hierarchy": "Flytta anteckning neråt i hierarkin",
"edit-note-title": "Hoppa från träd till anteckning och redigera titel"
}
}

View File

@@ -27,5 +27,7 @@ export declare module "express-session" {
totpEnabled: boolean;
ssoEnabled: boolean;
};
/** Set during /bootstrap to mark the session as modified so express-session persists it and sends the cookie. */
csrfInitialized?: true;
}
}

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"].includes(image.type)) {
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
return res.sendStatus(400);
}
@@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mermaid-export.svg");
} else if (image.type === "mindMap") {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.type === "spreadsheet") {
renderPngAttachment(image, res, "spreadsheet-export.png");
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
res.send(svg);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
res.set("Content-Type", "image/png");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
} else {
res.sendStatus(404);
}
}
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
const attachment = becca.getAttachment(req.params.attachmentId);

View File

@@ -2,6 +2,8 @@ import { doubleCsrf } from "csrf-csrf";
import sessionSecret from "../services/session_secret.js";
import { isElectron } from "../services/utils.js";
export const CSRF_COOKIE_NAME = "trilium-csrf";
const doubleCsrfUtilities = doubleCsrf({
getSecret: () => sessionSecret,
cookieOptions: {
@@ -10,7 +12,8 @@ const doubleCsrfUtilities = doubleCsrf({
sameSite: "strict",
httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966
},
cookieName: "_csrf"
cookieName: CSRF_COOKIE_NAME,
getSessionIdentifier: (req) => req.session.id
});
export const { generateToken, doubleCsrfProtection } = doubleCsrfUtilities;
export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrfUtilities;

View File

@@ -3,6 +3,7 @@ import log from "../services/log.js";
import NotFoundError from "../errors/not_found_error.js";
import ForbiddenError from "../errors/forbidden_error.js";
import HttpError from "../errors/http_error.js";
import { CSRF_COOKIE_NAME } from "./csrf_protection.js";
function register(app: Application) {
@@ -14,7 +15,10 @@ function register(app: Application) {
&& err.code === "EBADCSRFTOKEN";
if (isCsrfTokenError) {
log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`);
const csrfHeader = req.headers["x-csrf-token"];
const csrfHeaderPrefix = typeof csrfHeader === "string" ? csrfHeader.slice(0, 8) : undefined;
const tokenInfo = csrfHeaderPrefix ? ` (token prefix: ${csrfHeaderPrefix})` : "";
log.error(`Invalid CSRF token on ${req.method} ${req.url}${tokenInfo}`);
return next(new ForbiddenError("Invalid CSRF token"));
}

View File

@@ -11,19 +11,28 @@ import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPP
import log from "../services/log.js";
import optionService from "../services/options.js";
import protectedSessionService from "../services/protected_session.js";
import { generateCsrfToken } from "./csrf_protection.js";
import sql from "../services/sql.js";
import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js";
import { generateToken as generateCsrfToken } from "./csrf_protection.js";
type View = "desktop" | "mobile" | "print";
export function bootstrap(req: Request, res: Response) {
const options = optionService.getOptionMap();
//'overwrite' set to false (default) => the existing token will be re-used and validated
//'validateOnReuse' set to false => if validation fails, generate a new token instead of throwing an error
const csrfToken = generateCsrfToken(req, res, false, false);
// csrf-csrf v4 binds CSRF tokens to the session ID via HMAC. With saveUninitialized: false,
// a brand-new session is never persisted unless explicitly modified, so its cookie is never
// sent to the browser — meaning every request gets a different ephemeral session ID, and
// CSRF validation fails. Setting this flag marks the session as modified, which causes
// express-session to persist it and send the session cookie in this response.
if (!req.session.csrfInitialized) {
req.session.csrfInitialized = true;
}
const csrfToken = generateCsrfToken(req, res, {
overwrite: false,
validateOnReuse: false // if validation fails, generate a new token instead of throwing an error
});
log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`);
const view = getView(req);

Some files were not shown because too many files have changed in this diff Show More