mirror of
https://github.com/zadam/trilium.git
synced 2025-11-02 03:16:11 +01:00
Compare commits
144 Commits
40edd42740
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71eb1edf07 | ||
|
|
f3e6ba8f37 | ||
|
|
5b7e9d4c12 | ||
|
|
bee2fdb22f | ||
|
|
5c46a0dfa8 | ||
|
|
69b262040a | ||
|
|
8731fa6c31 | ||
|
|
f4e8fc4d83 | ||
|
|
dd5b3a3c1c | ||
|
|
17319d25e8 | ||
|
|
2f189b6961 | ||
|
|
b1f8d44576 | ||
|
|
7f22532a0a | ||
|
|
c7beb87980 | ||
|
|
5cd1fd53d4 | ||
|
|
2eadbe3f01 | ||
|
|
4e7493f648 | ||
|
|
b9d54a44f6 | ||
|
|
a1ad8be02b | ||
|
|
b02514f395 | ||
|
|
dcef3f2be5 | ||
|
|
585fdabd27 | ||
|
|
71fcb77a22 | ||
|
|
33ecf6aa6d | ||
|
|
1f75de83c6 | ||
|
|
31b52f72d2 | ||
|
|
01aaf81196 | ||
|
|
3ecfdd62e8 | ||
|
|
3c74d0714a | ||
|
|
f58d9adff2 | ||
|
|
0eecf5b132 | ||
|
|
9e3cca333a | ||
|
|
81c233463e | ||
|
|
87946e7e85 | ||
|
|
c3768a051d | ||
|
|
c579cd3ce7 | ||
|
|
945e2625d3 | ||
|
|
ff36414a55 | ||
|
|
8f184c5b10 | ||
|
|
c027a2bbfa | ||
|
|
91adc2258d | ||
|
|
6701e83927 | ||
|
|
3f54e589d8 | ||
|
|
f65be73f71 | ||
|
|
346e9282bd | ||
|
|
8f8ea7adc3 | ||
|
|
4affd3a955 | ||
|
|
bcce05cc4d | ||
|
|
ac16c42e23 | ||
|
|
5025329e92 | ||
|
|
507910b0ce | ||
|
|
b59fab9dba | ||
|
|
ac7e4580f6 | ||
|
|
27d1044ba8 | ||
|
|
96c949b2fc | ||
|
|
927cd0255e | ||
|
|
c2c8417c42 | ||
|
|
3bb224e682 | ||
|
|
6f126ea17b | ||
|
|
61a5cf1452 | ||
|
|
14b8d0a47e | ||
|
|
12df6a0d6e | ||
|
|
21d243eec1 | ||
|
|
161238ca11 | ||
|
|
4d5267e18b | ||
|
|
0fa52907b3 | ||
|
|
c4f57f3d15 | ||
|
|
6bde264156 | ||
|
|
4f72f81a95 | ||
|
|
c212c5d6ff | ||
|
|
f24880d42c | ||
|
|
ee9bf1d47b | ||
|
|
b069fab82f | ||
|
|
d5ce01a65b | ||
|
|
dbfa94a9ee | ||
|
|
86aaa97809 | ||
|
|
c4c8fe23a9 | ||
|
|
715fe77db3 | ||
|
|
40f5abd6e3 | ||
|
|
f3f7e5900b | ||
|
|
f4402a6d81 | ||
|
|
6966efd374 | ||
|
|
cd3e025fdc | ||
|
|
a224b774d3 | ||
|
|
f20078f3b0 | ||
|
|
56019e5449 | ||
|
|
7dd517d8f7 | ||
|
|
b2f1b3c910 | ||
|
|
2197fae700 | ||
|
|
3661733f07 | ||
|
|
52a6f2597e | ||
|
|
d8e9cad23d | ||
|
|
6ed333d222 | ||
|
|
ba26c478d6 | ||
|
|
055fcb7b2a | ||
|
|
f4468706ef | ||
|
|
212956201a | ||
|
|
1182592fc5 | ||
|
|
d534db29c9 | ||
|
|
1a68bdfe02 | ||
|
|
0c399a676a | ||
|
|
395f33cd5b | ||
|
|
21b20cf575 | ||
|
|
e3dd25b591 | ||
|
|
b9a4e7ab11 | ||
|
|
6ae67c410c | ||
|
|
4ef7667484 | ||
|
|
3660e2f127 | ||
|
|
357d294f2d | ||
|
|
bb636128b0 | ||
|
|
aa102ab393 | ||
|
|
ea53665e64 | ||
|
|
9cf7fa1997 | ||
|
|
fded714f18 | ||
|
|
06de06b501 | ||
|
|
9abdbbbc5b | ||
|
|
3ebfee8bd2 | ||
|
|
6d446c5b27 | ||
|
|
3a55490bbf | ||
|
|
bc4643fed2 | ||
|
|
a2110ca631 | ||
|
|
413137ac64 | ||
|
|
9bc966491d | ||
|
|
61dbc15fc6 | ||
|
|
b475037127 | ||
|
|
35622a2122 | ||
|
|
77e4c3d0ec | ||
|
|
8523050ab2 | ||
|
|
0efdf65202 | ||
|
|
acb0991d05 | ||
|
|
a9f68f5487 | ||
|
|
55bb2fdb9b | ||
|
|
e529633b8b | ||
|
|
dfd575b6eb | ||
|
|
c5196721d4 | ||
|
|
968c75b618 | ||
|
|
01beebf660 | ||
|
|
d3115e834a | ||
|
|
01a552ceb5 | ||
|
|
d8958adea5 | ||
|
|
4d5e866db6 | ||
|
|
f189deb415 | ||
|
|
9c460dbc87 | ||
|
|
2c6ba9ba2c |
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -12,7 +12,7 @@ runs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Install Node.js dependencies for the TypeScript script
|
||||
|
||||
2
.github/workflows/dev.yml
vendored
2
.github/workflows/dev.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
10
.github/workflows/main-docker.yml
vendored
10
.github/workflows/main-docker.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install npm dependencies
|
||||
@@ -116,12 +116,6 @@ jobs:
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm64
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm/v7
|
||||
image: ubuntu-24.04-arm
|
||||
- dockerfile: Dockerfile
|
||||
platform: linux/arm/v8
|
||||
image: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.image }}
|
||||
needs:
|
||||
- test_docker
|
||||
@@ -146,7 +140,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@stylistic/eslint-plugin": "5.5.0",
|
||||
"@types/express": "5.0.4",
|
||||
"@types/node": "22.18.12",
|
||||
"@types/express": "5.0.5",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/yargs": "17.0.34",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.38.0",
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.4.1",
|
||||
"mermaid": "11.12.0",
|
||||
"mermaid": "11.12.1",
|
||||
"mind-elixir": "5.3.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.2",
|
||||
"react-i18next": "16.2.0",
|
||||
"react-i18next": "16.2.1",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -218,12 +218,12 @@ export type CommandMappings = {
|
||||
/** Works only in the electron context menu. */
|
||||
replaceMisspelling: CommandData;
|
||||
|
||||
importMarkdownInline: CommandData;
|
||||
showPasswordNotSet: CommandData;
|
||||
showProtectedSessionPasswordDialog: CommandData;
|
||||
showUploadAttachmentsDialog: CommandData & { noteId: string };
|
||||
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
|
||||
showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
|
||||
closeProtectedSessionPasswordDialog: CommandData;
|
||||
copyImageReferenceToClipboard: CommandData;
|
||||
copyImageToClipboard: CommandData;
|
||||
|
||||
@@ -56,7 +56,20 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
|
||||
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
|
||||
}
|
||||
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
|
||||
containerRef.current?.replaceChildren(...$renderedContent);
|
||||
const container = containerRef.current!;
|
||||
container.replaceChildren(...$renderedContent);
|
||||
|
||||
// Wait for all images to load.
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
await Promise.all(
|
||||
images.map(img => {
|
||||
if (img.complete) return Promise.resolve();
|
||||
return new Promise<void>(resolve => {
|
||||
img.addEventListener("load", () => resolve(), { once: true });
|
||||
img.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
load().then(() => requestAnimationFrame(onReady))
|
||||
|
||||
@@ -20,9 +20,6 @@ function setupGlobs() {
|
||||
window.glob.froca = froca;
|
||||
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
|
||||
|
||||
// for CKEditor integration (button on block toolbar)
|
||||
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
|
||||
|
||||
window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
const string = String(msg).toLowerCase();
|
||||
|
||||
|
||||
@@ -9,16 +9,6 @@ async function ensureJQuery() {
|
||||
(window as any).$ = $;
|
||||
}
|
||||
|
||||
async function applyMath() {
|
||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
||||
if (!anyMathBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
|
||||
renderMathInElement(document.getElementById("content"));
|
||||
}
|
||||
|
||||
async function formatCodeBlocks() {
|
||||
const anyCodeBlock = document.querySelector("#content pre");
|
||||
if (!anyCodeBlock) {
|
||||
@@ -31,54 +21,4 @@ async function formatCodeBlocks() {
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
||||
*/
|
||||
async function fetchNote(noteId: string | null = null) {
|
||||
if (!noteId) {
|
||||
noteId = document.body.getAttribute("data-note-id");
|
||||
}
|
||||
|
||||
const resp = await fetch(`api/notes/${noteId}`);
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
|
||||
if (toggleMenuButton && layout) {
|
||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
value: fetchNote
|
||||
});
|
||||
|
||||
@@ -2034,9 +2034,9 @@ body.zen #right-pane,
|
||||
body.zen #mobile-sidebar-wrapper,
|
||||
body.zen .tab-row-container,
|
||||
body.zen .tab-row-widget,
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)),
|
||||
body.zen .ribbon-container:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row,
|
||||
body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)),
|
||||
body.zen .note-icon-widget,
|
||||
body.zen .title-row .icon-action,
|
||||
body.zen .floating-buttons-children > *:not(.bx-edit-alt),
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "删除此笔记的所有修订版本",
|
||||
"delete_all_button": "删除所有修订版本",
|
||||
"help_title": "关于笔记修订版本的帮助",
|
||||
"revision_last_edited": "此修订版本上次编辑于 {{date}}",
|
||||
"confirm_delete_all": "您是否要删除此笔记的所有修订版本?",
|
||||
"no_revisions": "此笔记暂无修订版本...",
|
||||
"restore_button": "恢复",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"homepage": "Startseite:",
|
||||
"app_version": "App-Version:",
|
||||
"db_version": "DB-Version:",
|
||||
"sync_version": "Synch-version:",
|
||||
"sync_version": "Sync-Version:",
|
||||
"build_date": "Build-Datum:",
|
||||
"build_revision": "Build-Revision:",
|
||||
"data_directory": "Datenverzeichnis:"
|
||||
@@ -260,7 +260,6 @@
|
||||
"delete_all_revisions": "Lösche alle Revisionen dieser Notiz",
|
||||
"delete_all_button": "Alle Revisionen löschen",
|
||||
"help_title": "Hilfe zu Notizrevisionen",
|
||||
"revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet",
|
||||
"confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?",
|
||||
"no_revisions": "Für diese Notiz gibt es noch keine Revisionen...",
|
||||
"confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.",
|
||||
@@ -991,7 +990,7 @@
|
||||
"enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:",
|
||||
"start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>",
|
||||
"started": "Geschützte Sitzung gestartet.",
|
||||
"wrong_password": "Passwort flasch.",
|
||||
"wrong_password": "Passwort falsch.",
|
||||
"protecting-finished-successfully": "Geschützt erfolgreich beendet.",
|
||||
"unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.",
|
||||
"protecting-in-progress": "Schützen läuft: {{count}}",
|
||||
@@ -1658,7 +1657,7 @@
|
||||
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
|
||||
"cut": "Ausschneiden",
|
||||
"copy": "Kopieren",
|
||||
"copy-link": "Link opieren",
|
||||
"copy-link": "Link kopieren",
|
||||
"paste": "Einfügen",
|
||||
"paste-as-plain-text": "Als unformatierten Text einfügen",
|
||||
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"export_status": "Export status",
|
||||
"export_in_progress": "Export in progress: {{progressCount}}",
|
||||
"export_finished_successfully": "Export finished successfully.",
|
||||
"format_pdf": "PDF - for printing or sharing purposes."
|
||||
"format_pdf": "PDF - for printing or sharing purposes.",
|
||||
"share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website."
|
||||
},
|
||||
"help": {
|
||||
"title": "Cheatsheet",
|
||||
@@ -260,7 +261,6 @@
|
||||
"delete_all_revisions": "Delete all revisions of this note",
|
||||
"delete_all_button": "Delete all revisions",
|
||||
"help_title": "Help on Note Revisions",
|
||||
"revision_last_edited": "This revision was last edited on {{date}}",
|
||||
"confirm_delete_all": "Do you want to delete all revisions of this note?",
|
||||
"no_revisions": "No revisions for this note yet...",
|
||||
"restore_button": "Restore",
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "Eliminar todas las revisiones de esta nota",
|
||||
"delete_all_button": "Eliminar todas las revisiones",
|
||||
"help_title": "Ayuda sobre revisiones de notas",
|
||||
"revision_last_edited": "Esta revisión se editó por última vez en {{date}}",
|
||||
"confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?",
|
||||
"no_revisions": "Aún no hay revisiones para esta nota...",
|
||||
"restore_button": "Restaurar",
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
"delete_all_revisions": "Supprimer toutes les versions de cette note",
|
||||
"delete_all_button": "Supprimer toutes les versions",
|
||||
"help_title": "Aide sur les versions de notes",
|
||||
"revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}",
|
||||
"confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?",
|
||||
"no_revisions": "Aucune version pour cette note pour l'instant...",
|
||||
"confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.",
|
||||
|
||||
5
apps/client/src/translations/hi/translation.json
Normal file
5
apps/client/src/translations/hi/translation.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "ट्रिलियम नोट्स के बारें में"
|
||||
}
|
||||
}
|
||||
@@ -867,7 +867,6 @@
|
||||
"delete_all_revisions": "Elimina tutte le revisioni di questa nota",
|
||||
"delete_all_button": "Elimina tutte le revisioni",
|
||||
"help_title": "Aiuto sulle revisioni delle note",
|
||||
"revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}",
|
||||
"confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
|
||||
"no_revisions": "Ancora nessuna revisione per questa nota...",
|
||||
"restore_button": "Ripristina",
|
||||
|
||||
@@ -610,7 +610,6 @@
|
||||
"delete_all_revisions": "このノートの変更履歴をすべて削除",
|
||||
"delete_all_button": "変更履歴をすべて削除",
|
||||
"help_title": "変更履歴のヘルプ",
|
||||
"revision_last_edited": "この変更は{{date}}に行われました",
|
||||
"confirm_delete_all": "このノートのすべての変更履歴を削除しますか?",
|
||||
"no_revisions": "このノートに変更履歴はまだありません...",
|
||||
"restore_button": "復元",
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"critical-error": {
|
||||
"title": "Kritische Error",
|
||||
"message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Starten widget mislukt",
|
||||
"message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Custom script laden mislukt"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"delete_all_revisions": "Usuń wszystkie wersje tej notatki",
|
||||
"delete_all_button": "Usuń wszystkie wersje",
|
||||
"help_title": "Pomoc dotycząca wersji notatki",
|
||||
"revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}",
|
||||
"confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?",
|
||||
"no_revisions": "Brak wersji dla tej notatki...",
|
||||
"restore_button": "Przywróć",
|
||||
|
||||
@@ -259,7 +259,6 @@
|
||||
"delete_all_revisions": "Apagar todas as versões desta nota",
|
||||
"delete_all_button": "Apagar todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Quer apagar todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
|
||||
@@ -415,7 +415,6 @@
|
||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||
"delete_all_button": "Excluir todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
|
||||
@@ -1090,7 +1090,6 @@
|
||||
"preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
|
||||
"restore_button": "Restaurează",
|
||||
"revision_deleted": "Revizia notiței a fost ștearsă.",
|
||||
"revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}",
|
||||
"revision_restored": "Revizia notiței a fost restaurată.",
|
||||
"revisions_deleted": "Notița reviziei a fost ștearsă.",
|
||||
"maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.",
|
||||
|
||||
@@ -366,7 +366,6 @@
|
||||
"delete_all_button": "Удалить все версии",
|
||||
"help_title": "Помощь по версиям заметок",
|
||||
"confirm_delete_all": "Вы хотите удалить все версии этой заметки?",
|
||||
"revision_last_edited": "Эта версия последний раз редактировалась {{date}}",
|
||||
"confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.",
|
||||
"confirm_delete": "Вы хотите удалить эту версию?",
|
||||
"revisions_deleted": "Версии заметки были удалены.",
|
||||
|
||||
@@ -256,7 +256,6 @@
|
||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||
"delete_all_button": "Obriši sve revizije",
|
||||
"help_title": "Pomoć za Revizije beleški",
|
||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||
"restore_button": "Vrati",
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
"delete_all_revisions": "刪除此筆記的所有歷史版本",
|
||||
"delete_all_button": "刪除所有歷史版本",
|
||||
"help_title": "關於筆記歷史版本的說明",
|
||||
"revision_last_edited": "此歷史版本上次於 {{date}} 編輯",
|
||||
"confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?",
|
||||
"no_revisions": "此筆記暫無歷史版本…",
|
||||
"confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。",
|
||||
|
||||
@@ -309,7 +309,6 @@
|
||||
"delete_all_revisions": "Видалити всі версії цієї нотатки",
|
||||
"delete_all_button": "Видалити всі версії",
|
||||
"help_title": "Довідка щодо Версій нотаток",
|
||||
"revision_last_edited": "Цю версію востаннє редагували {{date}}",
|
||||
"confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
|
||||
"no_revisions": "Поки що немає версій цієї нотатки...",
|
||||
"restore_button": "Відновити",
|
||||
|
||||
1
apps/client/src/types.d.ts
vendored
1
apps/client/src/types.d.ts
vendored
@@ -26,7 +26,6 @@ interface CustomGlobals {
|
||||
appContext: AppContext;
|
||||
froca: Froca;
|
||||
treeCache: Froca;
|
||||
importMarkdownInline: () => Promise<unknown>;
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
|
||||
@@ -79,7 +79,8 @@ export default function ExportDialog() {
|
||||
values={[
|
||||
{ value: "html", label: t("export.format_html_zip") },
|
||||
{ value: "markdown", label: t("export.format_markdown") },
|
||||
{ value: "opml", label: t("export.format_opml") }
|
||||
{ value: "opml", label: t("export.format_opml") },
|
||||
{ value: "share", label: t("export.share-format") }
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import utils from "../../services/utils";
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import EditableTextTypeWidget from "../type_widgets/editable_text";
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
@@ -14,39 +15,34 @@ interface RenderMarkdownResponse {
|
||||
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>();
|
||||
const [ text, setText ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
|
||||
const triggerImport = useCallback(() => {
|
||||
if (appContext.tabManager.getActiveContextNoteType() !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => {
|
||||
setTextTypeWidget(textTypeWidget);
|
||||
if (utils.isElectron()) {
|
||||
const { clipboard } = utils.dynamicRequire("electron");
|
||||
const text = clipboard.readText();
|
||||
|
||||
convertMarkdownToHtml(text);
|
||||
convertMarkdownToHtml(text, textTypeWidget);
|
||||
} else {
|
||||
setShown(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("importMarkdownInline", triggerImport);
|
||||
useTriliumEvent("pasteMarkdownIntoText", triggerImport);
|
||||
|
||||
async function sendForm() {
|
||||
await convertMarkdownToHtml(text);
|
||||
setText("");
|
||||
setShown(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg"
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />}
|
||||
footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />}
|
||||
onShown={() => markdownImportTextArea.current?.focus()}
|
||||
onHidden={() => setShown(false) }
|
||||
onHidden={async () => {
|
||||
if (textTypeWidget) {
|
||||
await convertMarkdownToHtml(text, textTypeWidget);
|
||||
}
|
||||
setShown(false);
|
||||
setText("");
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<p>{t("markdown_import.modal_body_text")}</p>
|
||||
@@ -56,26 +52,17 @@ export default function MarkdownImportDialog() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
sendForm();
|
||||
setShown(false);
|
||||
}
|
||||
}}></textarea>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
async function convertMarkdownToHtml(markdownContent: string) {
|
||||
async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) {
|
||||
const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent });
|
||||
|
||||
const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor();
|
||||
if (!textEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewFragment = textEditor.data.processor.toView(htmlContent);
|
||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
||||
|
||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
||||
textEditor.editing.view.focus();
|
||||
|
||||
await textTypeWidget.addHtmlToEditor(htmlContent);
|
||||
|
||||
toast.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
@@ -155,6 +155,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Avoid not showing recent notes when creating a new empty tab.
|
||||
if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return super.handleEventInChildren(name, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,11 +140,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
|
||||
<FormList onSelect={onSelect} fullHeight>
|
||||
{revisions.map((item) =>
|
||||
<FormListItem
|
||||
title={t("revisions.revision_last_edited", { date: item.dateLastEdited })}
|
||||
value={item.revisionId}
|
||||
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
||||
>
|
||||
{item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
|
||||
</FormListItem>
|
||||
)}
|
||||
</FormList>);
|
||||
|
||||
@@ -147,6 +147,12 @@ const categories: Category[] = [
|
||||
];
|
||||
|
||||
const icons: Icon[] = [
|
||||
{
|
||||
name: "empty",
|
||||
slug: "empty",
|
||||
category_id: 113,
|
||||
type_of_icon: "REGULAR"
|
||||
},
|
||||
{
|
||||
name: "child",
|
||||
slug: "child-regular",
|
||||
|
||||
@@ -56,4 +56,16 @@
|
||||
|
||||
.note-icon-widget .icon-list span:hover {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.note-icon-widget .icon-list span.bx-empty::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
border: 1px dashed var(--muted-text-color);
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
@@ -264,7 +264,6 @@
|
||||
position: absolute;
|
||||
inset-inline-end: 5px;
|
||||
bottom: 5px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.style-resolver {
|
||||
|
||||
@@ -329,6 +329,30 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
});
|
||||
}
|
||||
|
||||
async addHtmlToEditor(html: string) {
|
||||
await this.initialized;
|
||||
|
||||
const editor = this.watchdog.editor;
|
||||
if (!editor) return;
|
||||
|
||||
editor.model.change((writer) => {
|
||||
const viewFragment = editor.data.processor.toView(html);
|
||||
const modelFragment = editor.data.toModel(viewFragment);
|
||||
const insertPosition = editor.model.document.selection.getLastPosition();
|
||||
|
||||
if (insertPosition) {
|
||||
const range = editor.model.insertContent(modelFragment, insertPosition);
|
||||
|
||||
if (range) {
|
||||
writer.setSelection(range.end);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
editor.editing.view.focus();
|
||||
}
|
||||
|
||||
addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
@@ -385,6 +409,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText });
|
||||
}
|
||||
|
||||
pasteMarkdownIntoTextCommand() {
|
||||
this.triggerCommand("showPasteMarkdownDialog", { textTypeWidget: this });
|
||||
}
|
||||
|
||||
getSelectedText() {
|
||||
const range = this.watchdog.editor?.model.document.selection.getFirstRange();
|
||||
let text = "";
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1
|
||||
IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL
|
||||
|
||||
:POWERSHELL
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
||||
powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu"
|
||||
GOTO END
|
||||
|
||||
:BATCH
|
||||
|
||||
@@ -11,6 +11,7 @@ async function main() {
|
||||
// Copy assets.
|
||||
build.copy("src/assets", "assets/");
|
||||
build.copy("/apps/server/src/assets", "assets/");
|
||||
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||
|
||||
// Copy node modules dependencies
|
||||
|
||||
@@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js
|
||||
import debounce from "@triliumnext/client/src/services/debounce.js";
|
||||
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
|
||||
import cls from "@triliumnext/server/src/services/cls.js";
|
||||
import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js";
|
||||
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js";
|
||||
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
|
||||
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
|
||||
|
||||
@@ -75,7 +75,7 @@ async function setOptions() {
|
||||
optionsService.setOption("compressImages", "false");
|
||||
}
|
||||
|
||||
async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set<string>) {
|
||||
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {
|
||||
const zipFilePath = "output.zip";
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.21.0-bullseye-slim AS builder
|
||||
FROM node:24.10.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.21.0-bullseye-slim
|
||||
FROM node:24.10.0-bullseye-slim
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.21.0-alpine AS builder
|
||||
FROM node:24.10.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.21.0-alpine
|
||||
FROM node:24.10.0-alpine
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.21.0-alpine AS builder
|
||||
FROM node:24.10.0-alpine AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.21.0-alpine
|
||||
FROM node:24.10.0-alpine
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.21.0-bullseye-slim AS builder
|
||||
FROM node:24.10.0-bullseye-slim AS builder
|
||||
RUN corepack enable
|
||||
|
||||
# Install native dependencies since we might be building cross-platform.
|
||||
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
|
||||
# We have to use --no-frozen-lockfile due to CKEditor patches
|
||||
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
|
||||
|
||||
FROM node:22.21.0-bullseye-slim
|
||||
FROM node:24.10.0-bullseye-slim
|
||||
# Create a non-root user with configurable UID/GID
|
||||
ARG USER=trilium
|
||||
ARG UID=1001
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"@types/archiver": "6.0.4",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@types/archiver": "7.0.0",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cls-hooked": "4.3.9",
|
||||
"@types/compression": "1.8.1",
|
||||
@@ -56,17 +57,17 @@
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "1.15.10",
|
||||
"@types/serve-static": "2.2.0",
|
||||
"@types/stream-throttle": "0.1.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/turndown": "5.0.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.0",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ async function main() {
|
||||
|
||||
// Copy assets
|
||||
build.copy("src/assets", "assets/");
|
||||
build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/");
|
||||
build.copy("/packages/share-theme/src/templates", "share-theme/templates/");
|
||||
|
||||
// Copy node modules dependencies
|
||||
|
||||
@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
return childBranches;
|
||||
}
|
||||
|
||||
get encodedTitle() {
|
||||
return encodeURIComponent(this.title);
|
||||
}
|
||||
|
||||
getVisibleChildBranches() {
|
||||
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
|
||||
}
|
||||
|
||||
getVisibleChildNotes() {
|
||||
return this.getVisibleChildBranches().map((branch) => branch.getNote());
|
||||
}
|
||||
|
||||
hasVisibleChildren() {
|
||||
return this.getVisibleChildNotes().length > 0;
|
||||
}
|
||||
|
||||
get shareId() {
|
||||
return this.noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
||||
* @param attributeId - the id of the attribute owned by this note
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
@@ -149,8 +150,8 @@ function register(router: Router) {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const format = req.query.format || "html";
|
||||
|
||||
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
|
||||
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`);
|
||||
}
|
||||
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
@@ -159,7 +160,7 @@ function register(router: Router) {
|
||||
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
||||
const branch = note.getParentBranches()[0];
|
||||
|
||||
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
|
||||
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
|
||||
const taskContext = new TaskContext(taskId, "export", null);
|
||||
|
||||
try {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown")) {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
|
||||
zipExportService.exportToZip(taskContext, branch, format, res);
|
||||
} else if (type === "single") {
|
||||
if (format !== "html" && format !== "markdown") {
|
||||
|
||||
@@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) {
|
||||
AND (noteId NOT LIKE '_%')
|
||||
UNION ALL
|
||||
SELECT noteId FROM revisions
|
||||
WHERE revisions.dateLastEdited LIKE :date
|
||||
WHERE revisions.dateCreated LIKE :date
|
||||
)
|
||||
ORDER BY isDeleted
|
||||
LIMIT 50`,
|
||||
|
||||
@@ -44,6 +44,7 @@ async function register(app: express.Application) {
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
}
|
||||
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
|
||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
|
||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||
}
|
||||
|
||||
export function getShareThemeAssetDir() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const srcRoot = path.join(__dirname, "..", "..");
|
||||
return path.join(srcRoot, "../../packages/share-theme/dist");
|
||||
} else {
|
||||
const resourceDir = getResourceDir();
|
||||
return path.join(resourceDir, "share-theme/assets");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||
const note = branch.getNote();
|
||||
|
||||
if (note.type === "image" || note.type === "file") {
|
||||
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||
let payload, extension, mime;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
import html from "html";
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import mimeTypes from "mime-types";
|
||||
import mdService from "./markdown.js";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||
import HtmlExportProvider from "./zip/html.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import MarkdownExportProvider from "./zip/markdown.js";
|
||||
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown", "share"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
});
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
const provider = buildProvider();
|
||||
|
||||
const noteIdToMeta: Record<string, NoteMeta> = {};
|
||||
|
||||
function buildProvider() {
|
||||
const providerData: ZipExportProviderData = {
|
||||
getNoteTargetUrl,
|
||||
archive,
|
||||
branch,
|
||||
rewriteFn
|
||||
};
|
||||
switch (format) {
|
||||
case "html":
|
||||
return new HtmlExportProvider(providerData);
|
||||
case "markdown":
|
||||
return new MarkdownExportProvider(providerData);
|
||||
case "share":
|
||||
return new ShareThemeExportProvider(providerData);
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
||||
const lcFileName = fileName.toLowerCase();
|
||||
|
||||
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
let fileName = baseFileName.trim();
|
||||
if (!fileName) {
|
||||
fileName = "note";
|
||||
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
newExtension = "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
newExtension = "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
newExtension = "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
newExtension = "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
newExtension = null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
newExtension = "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
newExtension = "txt";
|
||||
} else {
|
||||
newExtension = mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||
|
||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
||||
fileName += `.${newExtension}`;
|
||||
}
|
||||
|
||||
|
||||
return getUniqueFilename(existingFileNames, fileName);
|
||||
}
|
||||
|
||||
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const notePath = parentMeta.notePath.concat([note.noteId]);
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
|
||||
const extension = provider.mapExtension("text", "text/html", "", format);
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
|
||||
|
||||
const meta: NoteMeta = {
|
||||
isClone: true,
|
||||
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName,
|
||||
type: "text", // export will have text description
|
||||
format: format
|
||||
format: (format === "markdown" ? "markdown" : "html")
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
if (note.type === "text") {
|
||||
meta.format = format;
|
||||
meta.format = (format === "markdown" ? "markdown" : "html");
|
||||
}
|
||||
|
||||
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
||||
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
note.sortChildren();
|
||||
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
||||
|
||||
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
|
||||
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
|
||||
if (format !== "share") {
|
||||
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
|
||||
}
|
||||
|
||||
// if it's a leaf, then we'll export it even if it's empty
|
||||
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
|
||||
if (shouldIncludeFile) {
|
||||
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
||||
}
|
||||
|
||||
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
return url;
|
||||
}
|
||||
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
|
||||
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
||||
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (["html", "markdown"].includes(noteMeta?.format || "")) {
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||
if (isText) {
|
||||
content = content.toString();
|
||||
content = rewriteFn(content, noteMeta);
|
||||
}
|
||||
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
content = provider.prepareContent(title, content, noteMeta, note, branch);
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
|
||||
} else if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
||||
@@ -377,7 +325,7 @@ ${markdownContent}`;
|
||||
|
||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
|
||||
content = prepareContent(noteMeta.title, content, noteMeta);
|
||||
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||
|
||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
|
||||
@@ -393,7 +341,7 @@ ${markdownContent}`;
|
||||
}
|
||||
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + noteMeta.dataFileName,
|
||||
@@ -429,138 +377,21 @@ ${markdownContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
function saveNavigationInner(meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += saveNavigationInner(child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${saveNavigationInner(rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
|
||||
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
|
||||
}
|
||||
provider.prepareMeta(metaFile);
|
||||
|
||||
try {
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
let navigationMeta: NoteMeta | null = null;
|
||||
let indexMeta: NoteMeta | null = null;
|
||||
let cssMeta: NoteMeta | null = null;
|
||||
|
||||
if (format === "html") {
|
||||
navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(navigationMeta);
|
||||
|
||||
indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(indexMeta);
|
||||
|
||||
cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
|
||||
metaFile.files.push(cssMeta);
|
||||
}
|
||||
|
||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||
// filter out relations which are not inside this export
|
||||
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
||||
@@ -584,34 +415,6 @@ ${markdownContent}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
if (format === "html") {
|
||||
if (!navigationMeta || !indexMeta || !cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
saveNavigation(rootMeta, navigationMeta);
|
||||
saveIndex(rootMeta, indexMeta);
|
||||
saveCss(rootMeta, cssMeta);
|
||||
}
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
taskContext.taskSucceeded(null);
|
||||
} catch (e: unknown) {
|
||||
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
||||
log.error(message);
|
||||
@@ -623,9 +426,30 @@ ${markdownContent}`;
|
||||
res.status(500).send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
provider.afterDone(rootMeta);
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
|
||||
|
||||
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Archiver } from "archiver";
|
||||
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import mimeTypes from "mime-types";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export type ExportFormat = "html" | "markdown" | "share";
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
export interface ZipExportProviderData {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
}
|
||||
|
||||
export abstract class ZipExportProvider {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
|
||||
constructor(data: ZipExportProviderData) {
|
||||
this.branch = data.branch;
|
||||
this.getNoteTargetUrl = data.getNoteTargetUrl;
|
||||
this.archive = data.archive;
|
||||
this.zipExportOptions = data.zipExportOptions;
|
||||
this.rewriteFn = data.rewriteFn;
|
||||
}
|
||||
|
||||
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||
abstract afterDone(rootMeta: NoteMeta): void;
|
||||
|
||||
/**
|
||||
* Determines the extension of the resulting file for a specific note type.
|
||||
*
|
||||
* @param type the type of the note.
|
||||
* @param mime the mime type of the note.
|
||||
* @param existingExtension the existing extension, including the leading period character.
|
||||
* @param format the format requested for export (e.g. HTML, Markdown).
|
||||
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
|
||||
*/
|
||||
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
return "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
return "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
return "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
return "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
return null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
return "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
return "txt";
|
||||
} else {
|
||||
return mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
176
apps/server/src/services/export/zip/html.ts
Normal file
176
apps/server/src/services/export/zip/html.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type NoteMeta from "../../meta/note_meta.js";
|
||||
import { escapeHtml, getResourceDir, isDev } from "../../utils";
|
||||
import html from "html";
|
||||
import { ZipExportProvider } from "./abstract_provider.js";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export default class HtmlExportProvider extends ZipExportProvider {
|
||||
|
||||
private navigationMeta: NoteMeta | null = null;
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
private cssMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile) {
|
||||
this.navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
metaFile.files.push(this.navigationMeta);
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
metaFile.files.push(this.indexMeta);
|
||||
|
||||
this.cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
metaFile.files.push(this.cssMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (content.length < 100_000) {
|
||||
content = html.prettyPrint(content, { indent_size: 2 })
|
||||
}
|
||||
content = this.rewriteFn(content as string, noteMeta);
|
||||
return content;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta) {
|
||||
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
this.#saveNavigation(rootMeta, this.navigationMeta);
|
||||
this.#saveIndex(rootMeta, this.indexMeta);
|
||||
this.#saveCss(rootMeta, this.cssMeta);
|
||||
}
|
||||
|
||||
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += this.#saveNavigationInner(rootMeta, child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
const cssContent = fs.readFileSync(cssFile, "utf-8");
|
||||
this.archive.append(cssContent, { name: cssMeta.dataFileName });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
27
apps/server/src/services/export/zip/markdown.ts
Normal file
27
apps/server/src/services/export/zip/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import NoteMeta from "../../meta/note_meta"
|
||||
import { ZipExportProvider } from "./abstract_provider.js"
|
||||
import mdService from "../markdown.js";
|
||||
|
||||
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||
|
||||
prepareMeta() { }
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
markdownContent = this.rewriteFn(markdownContent, noteMeta);
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone() { }
|
||||
|
||||
}
|
||||
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { join } from "path";
|
||||
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
|
||||
import { RESOURCE_DIR } from "../../resource_dir";
|
||||
import { getResourceDir, isDev } from "../../utils";
|
||||
import fs, { readdirSync } from "fs";
|
||||
import { renderNoteForExport } from "../../../share/content_renderer";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import { getShareThemeAssetDir } from "../../../routes/assets";
|
||||
|
||||
const shareThemeAssetDir = getShareThemeAssetDir();
|
||||
|
||||
export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
private assetsMeta: NoteMeta[] = [];
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile: NoteMetaFile): void {
|
||||
|
||||
const assets = [
|
||||
"icon-color.svg"
|
||||
];
|
||||
|
||||
for (const file of readdirSync(shareThemeAssetDir)) {
|
||||
assets.push(`assets/${file}`);
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetMeta = {
|
||||
noImport: true,
|
||||
dataFileName: asset
|
||||
};
|
||||
this.assetsMeta.push(assetMeta);
|
||||
metaFile.files.push(assetMeta);
|
||||
}
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
const basePath = "../".repeat(noteMeta.notePath.length - 1);
|
||||
|
||||
if (note) {
|
||||
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
|
||||
if (typeof content === "string") {
|
||||
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
|
||||
if (match.includes("/assets/")) return match;
|
||||
return `href="#root/${id}"`;
|
||||
});
|
||||
content = this.rewriteFn(content, noteMeta);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta): void {
|
||||
this.#saveAssets(rootMeta, this.assetsMeta);
|
||||
this.#saveIndex(rootMeta);
|
||||
}
|
||||
|
||||
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
|
||||
if (mime.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "html";
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta) {
|
||||
if (!this.indexMeta?.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.branch.getNote();
|
||||
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||
for (const assetMeta of assetsMeta) {
|
||||
if (!assetMeta.dataFileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
|
||||
this.archive.append(cssContent, { name: assetMeta.dataFileName });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getShareThemeAssets(nameWithExtension: string) {
|
||||
let path: string | undefined;
|
||||
if (nameWithExtension === "icon-color.svg") {
|
||||
path = join(RESOURCE_DIR, "images", nameWithExtension);
|
||||
} else if (nameWithExtension.startsWith("assets")) {
|
||||
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
|
||||
} else if (isDev) {
|
||||
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
|
||||
} else {
|
||||
path = join(getResourceDir(), "public", "src", nameWithExtension);
|
||||
}
|
||||
|
||||
return fs.readFileSync(path);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
import type AttachmentMeta from "./attachment_meta.js";
|
||||
import type AttributeMeta from "./attribute_meta.js";
|
||||
import type { ExportFormat } from "../export/zip/abstract_provider.js";
|
||||
|
||||
export interface NoteMetaFile {
|
||||
formatVersion: number;
|
||||
@@ -19,7 +20,7 @@ export default interface NoteMeta {
|
||||
type?: NoteType;
|
||||
mime?: string;
|
||||
/** 'html' or 'markdown', applicable to text notes only */
|
||||
format?: "html" | "markdown";
|
||||
format?: ExportFormat;
|
||||
dataFileName?: string;
|
||||
dirFileName?: string;
|
||||
/** this file should not be imported (e.g., HTML navigation) */
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { parse, HTMLElement, TextNode } from "node-html-parser";
|
||||
import { parse, HTMLElement, TextNode, Options } from "node-html-parser";
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import escapeHtml from "escape-html";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import { t } from "i18next";
|
||||
import SBranch from "./shaca/entities/sbranch.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import ejs from "ejs";
|
||||
import log from "../services/log.js";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
|
||||
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
|
||||
const templateCache: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Represents the output of the content renderer.
|
||||
@@ -16,7 +29,192 @@ export interface Result {
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote) {
|
||||
interface Subroot {
|
||||
note?: SNote | BNote;
|
||||
branch?: SBranch | BBranch
|
||||
}
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
|
||||
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (note instanceof BNote) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
}
|
||||
}
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) {
|
||||
const subRoot: Subroot = {
|
||||
branch: parentBranch,
|
||||
note: parentBranch.getNote()
|
||||
};
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: parentBranch.noteId,
|
||||
cssToLoad: [
|
||||
`${basePath}assets/styles.css`,
|
||||
`${basePath}assets/scripts.css`,
|
||||
],
|
||||
jsToLoad: [
|
||||
`${basePath}assets/scripts.js`
|
||||
],
|
||||
logoUrl: `${basePath}icon-color.svg`,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNoteContent(note: SNote) {
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
const ancestors: string[] = [];
|
||||
let notePointer = note;
|
||||
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
|
||||
const pointerParent = notePointer.parents[0];
|
||||
if (!pointerParent) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(pointerParent.noteId);
|
||||
notePointer = pointerParent;
|
||||
}
|
||||
|
||||
// Determine CSS to load.
|
||||
const cssToLoad: string[] = [];
|
||||
if (!note.isLabelTruthy("shareOmitDefaultCss")) {
|
||||
cssToLoad.push(`assets/styles.css`);
|
||||
cssToLoad.push(`assets/scripts.css`);
|
||||
}
|
||||
for (const cssRelation of note.getRelations("shareCss")) {
|
||||
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
|
||||
}
|
||||
|
||||
// Determine JS to load.
|
||||
const jsToLoad: string[] = [
|
||||
"assets/scripts.js"
|
||||
];
|
||||
for (const jsRelation of note.getRelations("shareJs")) {
|
||||
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
|
||||
}
|
||||
|
||||
const customLogoId = note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: "_share",
|
||||
cssToLoad,
|
||||
jsToLoad,
|
||||
logoUrl,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
subRoot: Subroot;
|
||||
rootNoteId: string;
|
||||
cssToLoad: string[];
|
||||
jsToLoad: string[];
|
||||
logoUrl: string;
|
||||
ancestors: string[];
|
||||
}
|
||||
|
||||
function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) {
|
||||
const { header, content, isEmpty } = getContent(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
assetPath: shareAdjustedAssetPath,
|
||||
assetUrlFragment,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils,
|
||||
...renderArgs
|
||||
};
|
||||
|
||||
// Check if the user has their own template.
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
return ejs.render(content, opts, { includer });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render with the default view otherwise.
|
||||
const templatePath = getDefaultTemplatePath("page");
|
||||
return ejs.render(readTemplate(templatePath), opts, {
|
||||
includer: (path) => {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return { template: readTemplate(getDefaultTemplatePath(path)) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultTemplatePath(template: string) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: join(getResourceDir(), `share-theme/templates/${template}.ejs`);
|
||||
}
|
||||
|
||||
function readTemplate(path: string) {
|
||||
const cachedTemplate = templateCache.get(path);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
const templateString = readFileSync(path, "utf-8");
|
||||
templateCache.set(path, templateString);
|
||||
return templateString;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote | BNote) {
|
||||
if (note.isProtected) {
|
||||
return {
|
||||
header: "",
|
||||
@@ -65,9 +263,12 @@ function renderIndex(result: Result) {
|
||||
result.content += "</ul>";
|
||||
}
|
||||
|
||||
function renderText(result: Result, note: SNote) {
|
||||
function renderText(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") return;
|
||||
const document = parse(result.content || "");
|
||||
const parseOpts: Partial<Options> = {
|
||||
blockTextElements: {}
|
||||
}
|
||||
const document = parse(result.content || "", parseOpts);
|
||||
|
||||
// Process include notes.
|
||||
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
|
||||
@@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) {
|
||||
const includedResult = getContent(note);
|
||||
if (typeof includedResult.content !== "string") continue;
|
||||
|
||||
const includedDocument = parse(includedResult.content).childNodes;
|
||||
const includedDocument = parse(includedResult.content, parseOpts).childNodes;
|
||||
if (includedDocument) {
|
||||
includeNoteEl.replaceWith(...includedDocument);
|
||||
}
|
||||
@@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) {
|
||||
result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
|
||||
|
||||
if (!result.isEmpty) {
|
||||
// Process attachment links.
|
||||
for (const linkEl of document.querySelectorAll("a")) {
|
||||
const href = linkEl.getAttribute("href");
|
||||
|
||||
@@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) {
|
||||
}
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (result.content.includes(`<span class="math-tex">`)) {
|
||||
result.header += `
|
||||
<script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script>
|
||||
<link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css">
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
renderMathInElement(document.getElementById('content'));
|
||||
});
|
||||
</script>`;
|
||||
// Apply syntax highlight.
|
||||
for (const codeEl of document.querySelectorAll("pre code")) {
|
||||
const highlightResult = highlightAuto(codeEl.innerText);
|
||||
codeEl.innerHTML = highlightResult.value;
|
||||
codeEl.classList.add("hljs");
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (note.hasLabel("shareIndex")) {
|
||||
renderIndex(result);
|
||||
}
|
||||
@@ -174,7 +370,7 @@ export function renderCode(result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMermaid(result: Result, note: SNote) {
|
||||
function renderMermaid(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") {
|
||||
return;
|
||||
}
|
||||
@@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderImage(result: Result, note: SNote) {
|
||||
function renderImage(result: Result, note: SNote | BNote) {
|
||||
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
|
||||
}
|
||||
|
||||
function renderFile(note: SNote, result: Result) {
|
||||
function renderFile(note: SNote | BNote, result: Result) {
|
||||
if (note.mime === "application/pdf") {
|
||||
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
|
||||
} else {
|
||||
|
||||
@@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express";
|
||||
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import shacaLoader from "./shaca/shaca_loader.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import contentRenderer from "./content_renderer.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import log from "../services/log.js";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import type SBranch from "./shaca/entities/sbranch.js";
|
||||
import type SAttachment from "./shaca/entities/sattachment.js";
|
||||
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import { t } from "i18next";
|
||||
import ejs from "ejs";
|
||||
import { join } from "path";
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
|
||||
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
import { renderNoteContent } from "./content_renderer.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
function addNoIndexHeader(note: SNote, res: Response) {
|
||||
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
|
||||
@@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
let svgString = "<svg/>";
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
if (!attachment) {
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
|
||||
return;
|
||||
}
|
||||
const content = attachment.getContent();
|
||||
@@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function render404(res: Response) {
|
||||
res.status(404);
|
||||
const shareThemePath = `../../share-theme/templates/404.ejs`;
|
||||
res.render(shareThemePath);
|
||||
}
|
||||
|
||||
function register(router: Router) {
|
||||
|
||||
function renderNote(note: SNote, req: Request, res: Response) {
|
||||
if (!note) {
|
||||
console.log("Unable to find note ", note);
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
render404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,63 +138,7 @@ function register(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { header, content, isEmpty } = contentRenderer.getContent(note);
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
subRoot,
|
||||
assetPath: isDev ? assetPath : `../${assetPath}`,
|
||||
assetUrlFragment,
|
||||
appPath: isDev ? appPath : `../${appPath}`,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils
|
||||
};
|
||||
let useDefaultView = true;
|
||||
|
||||
// Check if the user has their own template
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
const ejsResult = ejs.render(content, opts, { includer });
|
||||
res.send(ejsResult);
|
||||
useDefaultView = false; // Rendering went okay, don't use default view
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useDefaultView) {
|
||||
renderDefault(res, "page", opts);
|
||||
}
|
||||
res.send(renderNoteContent(note));
|
||||
}
|
||||
|
||||
router.get("/share/", (req, res) => {
|
||||
@@ -401,14 +322,6 @@ function register(router: Router) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderDefault(res: Response<any, Record<string, any>>, template: "page" | "404", opts: any = {}) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
const shareThemePath = process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: `../../share-theme/templates/${template}.ejs`;
|
||||
res.render(shareThemePath, opts);
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
6
apps/server/src/types.d.ts
vendored
6
apps/server/src/types.d.ts
vendored
@@ -38,3 +38,9 @@ declare module "@triliumnext/share-theme/styles.css" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css' {}
|
||||
declare module '*?raw' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.ico" />
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"preact": "10.27.2",
|
||||
"preact-iso": "2.11.0",
|
||||
"preact-render-to-string": "6.6.3",
|
||||
"react-i18next": "16.2.0"
|
||||
"react-i18next": "16.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
|
||||
BIN
apps/website/public/collection_presentation.webp
Normal file
BIN
apps/website/public/collection_presentation.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
1
apps/website/src/assets/boxicons/bx-slideshow.svg
Normal file
1
apps/website/src/assets/boxicons/bx-slideshow.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><!--Boxicons v3.0 https://boxicons.com | License https://docs.boxicons.com/free--><path d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h7v3H8v2h8v-2h-3v-3h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2M4 15V5h16v10z"/><path d="m10 13 5-3-5-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -19,6 +19,7 @@ describe("swapLocale", () => {
|
||||
expect(swapLocaleInUrl("/ro/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||
expect(swapLocaleInUrl("/en/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||
expect(swapLocaleInUrl("/ro/", "en")).toStrictEqual("/en/");
|
||||
expect(swapLocaleInUrl("/ro", "en")).toStrictEqual("/en");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
interface Locale {
|
||||
id: string;
|
||||
name: string;
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
i18next.use(initReactI18next);
|
||||
const localeFiles = import.meta.glob("./translations/*/translation.json", { eager: true });
|
||||
const resources: Record<string, Record<string, Record<string, string>>> = {};
|
||||
for (const [path, module] of Object.entries(localeFiles)) {
|
||||
const id = path.split("/").at(-2);
|
||||
if (!id) continue;
|
||||
|
||||
const translations = (module as any).default ?? module;
|
||||
resources[id] = { translation: translations };
|
||||
}
|
||||
|
||||
export function initTranslations(lng: string) {
|
||||
i18next.init({
|
||||
fallbackLng: "en",
|
||||
lng,
|
||||
returnEmptyString: false,
|
||||
resources,
|
||||
initAsync: false,
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const LOCALES: Locale[] = [
|
||||
{ id: "en", name: "English" },
|
||||
{ id: "ro", name: "Română" },
|
||||
@@ -35,7 +62,13 @@ export function mapLocale(locale: string) {
|
||||
export function swapLocaleInUrl(url: string, newLocale: string) {
|
||||
const components = url.split("/");
|
||||
if (components.length === 2) {
|
||||
return `/${newLocale}${url}`;
|
||||
const potentialLocale = components[1];
|
||||
const correspondingLocale = LOCALES.find(l => l.id === potentialLocale);
|
||||
if (correspondingLocale) {
|
||||
return `/${newLocale}`;
|
||||
} else {
|
||||
return `/${newLocale}${url}`;
|
||||
}
|
||||
} else {
|
||||
components[1] = newLocale;
|
||||
return components.join("/");
|
||||
|
||||
@@ -8,11 +8,9 @@ import Footer from './components/Footer.js';
|
||||
import GetStarted from './pages/GetStarted/get-started.js';
|
||||
import SupportUs from './pages/SupportUs/SupportUs.js';
|
||||
import { createContext } from 'preact';
|
||||
import { useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { default as i18next, changeLanguage } from 'i18next';
|
||||
import { extractLocaleFromUrl, LOCALES, mapLocale } from './i18n';
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { useLayoutEffect, useRef } from 'preact/hooks';
|
||||
import { changeLanguage } from 'i18next';
|
||||
import { extractLocaleFromUrl, initTranslations, LOCALES, mapLocale } from './i18n';
|
||||
|
||||
export const LocaleContext = createContext('en');
|
||||
|
||||
@@ -42,34 +40,26 @@ export function App(props: {repoStargazersCount: number}) {
|
||||
|
||||
export function LocaleProvider({ children }) {
|
||||
const { path } = useLocation();
|
||||
const localeId = mapLocale(extractLocaleFromUrl(path) || navigator.language);
|
||||
const [ loaded, setLoaded ] = useState(false);
|
||||
const localeId = getLocaleId(path);
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
i18next
|
||||
.use(HttpApi)
|
||||
.use(initReactI18next);
|
||||
i18next.init({
|
||||
lng: localeId,
|
||||
fallbackLng: "en",
|
||||
backend: {
|
||||
loadPath: "/translations/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
returnEmptyString: false
|
||||
}).then(() => setLoaded(true))
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!loaded) return;
|
||||
if (!loadedRef.current) {
|
||||
initTranslations(localeId);
|
||||
loadedRef.current = true;
|
||||
} else {
|
||||
changeLanguage(localeId);
|
||||
}
|
||||
|
||||
// Update html lang and dir attributes
|
||||
useLayoutEffect(() => {
|
||||
const correspondingLocale = LOCALES.find(l => l.id === localeId);
|
||||
document.documentElement.lang = localeId;
|
||||
document.documentElement.dir = correspondingLocale?.rtl ? "rtl" : "ltr";
|
||||
}, [ loaded, localeId ]);
|
||||
}, [localeId]);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={localeId}>
|
||||
{loaded && children}
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +68,26 @@ if (typeof window !== 'undefined') {
|
||||
hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!);
|
||||
}
|
||||
|
||||
function getLocaleId(path: string) {
|
||||
const extractedLocale = extractLocaleFromUrl(path);
|
||||
if (extractedLocale) return mapLocale(extractedLocale);
|
||||
if (typeof window === "undefined") return 'en';
|
||||
return mapLocale(navigator.language);
|
||||
}
|
||||
|
||||
export async function prerender(data) {
|
||||
// Fetch the stargazer count of the Trilium's GitHub repo on prerender to pass
|
||||
// it to the App component for SSR.
|
||||
// This ensures the GitHub API is not called on every page load in the client.
|
||||
const stargazersCount = await getRepoStargazersCount();
|
||||
|
||||
return await ssr(<App repoStargazersCount={stargazersCount} {...data} />);
|
||||
const { html, links } = await ssr(<App repoStargazersCount={stargazersCount} {...data} />);
|
||||
return {
|
||||
html,
|
||||
links,
|
||||
head: {
|
||||
lang: extractLocaleFromUrl(data.url) ?? "en"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,72 +183,43 @@ section.faq {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--brand-1);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 1em;
|
||||
display: grid;
|
||||
|
||||
@media (min-width: 720px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
margin: 0;
|
||||
|
||||
.card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
&.selected .card {
|
||||
border: 1px solid var(--brand-1);
|
||||
}
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
flex-basis: 50%;
|
||||
flex-shrink: 0;
|
||||
max-height: 35vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 719px) {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
ul {
|
||||
gap: 1em;
|
||||
display: grid;
|
||||
|
||||
@media (min-width: 720px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--brand-1);
|
||||
}
|
||||
|
||||
.details {
|
||||
max-height: 35vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
img {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import calendarIcon from "../../assets/boxicons/bx-calendar.svg?raw";
|
||||
import tableIcon from "../../assets/boxicons/bx-table.svg?raw";
|
||||
import boardIcon from "../../assets/boxicons/bx-columns-3.svg?raw";
|
||||
import geomapIcon from "../../assets/boxicons/bx-map.svg?raw";
|
||||
import presentationIcon from "../../assets/boxicons/bx-slideshow.svg?raw";
|
||||
import { getPlatform } from '../../download-helper.js';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
@@ -132,7 +133,7 @@ function NoteTypesSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section className="note-types" title={t("note_types.title")}>
|
||||
<ListWithScreenshot horizontal items={[
|
||||
<ListWithScreenshot items={[
|
||||
{
|
||||
title: t("note_types.text_title"),
|
||||
imageUrl: "/type_text.webp",
|
||||
@@ -240,45 +241,39 @@ function CollectionsSection() {
|
||||
imageUrl: "/collection_geomap.webp",
|
||||
moreInfo: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Note%20Types/Collections/Geo%20Map%20View.html",
|
||||
description: t("collections.geomap_description")
|
||||
},
|
||||
{
|
||||
title: t("collections.presentation_title"),
|
||||
iconSvg: presentationIcon,
|
||||
imageUrl: "/collection_presentation.webp",
|
||||
moreInfo: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Note%20Types/Collections/Presentation%20View.html",
|
||||
description: t("collections.presentation_description")
|
||||
}
|
||||
]} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ListWithScreenshot({ items, horizontal, cardExtra }: {
|
||||
function ListWithScreenshot({ items, cardExtra }: {
|
||||
items: { title: string, imageUrl: string, description: string, moreInfo: string, iconSvg?: string }[];
|
||||
horizontal?: boolean;
|
||||
cardExtra?: ComponentChildren;
|
||||
}) {
|
||||
const [ selectedItem, setSelectedItem ] = useState(items[0]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`list-with-screenshot ${horizontal ? "horizontal" : ""}`}>
|
||||
<div className={`list-with-screenshot`}>
|
||||
<ul>
|
||||
{items.map(item => (
|
||||
<li className={`${item === selectedItem ? "selected" : ""}`}>
|
||||
<li>
|
||||
<Card
|
||||
title={item.title}
|
||||
onMouseEnter={() => setSelectedItem(item)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
moreInfoUrl={item.moreInfo}
|
||||
iconSvg={item.iconSvg}
|
||||
imageUrl={item.imageUrl}
|
||||
>
|
||||
{item.description}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="details">
|
||||
{selectedItem && (
|
||||
<>
|
||||
<img src={selectedItem.imageUrl} alt={t("components.list_with_screenshot_alt")} loading="lazy" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@
|
||||
"board_title": "Board",
|
||||
"board_description": "Organize your tasks or project status into a Kanban board with an easy way to create new items and columns and simply changing their status by dragging across the board.",
|
||||
"geomap_title": "Geomap",
|
||||
"geomap_description": "Plan your vacations or mark your points of interest directly on a geographical map using customizable markers. Display recorded GPX tracks to track itineraries."
|
||||
"geomap_description": "Plan your vacations or mark your points of interest directly on a geographical map using customizable markers. Display recorded GPX tracks to track itineraries.",
|
||||
"presentation_title": "Presentation",
|
||||
"presentation_description": "Organize information into slides and present them in full-screen with smooth transitions. The slides can also be exported to PDF for easy sharing."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently Asked Questions",
|
||||
@@ -97,8 +99,7 @@
|
||||
"get_started": "Get started"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "Learn more...",
|
||||
"list_with_screenshot_alt": "Screenshot of the feature being selected"
|
||||
"link_learn_more": "Learn more..."
|
||||
},
|
||||
"download_now": {
|
||||
"text": "Download now ",
|
||||
@@ -95,8 +95,7 @@
|
||||
"get_started": "Comenzar"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "Saber más…",
|
||||
"list_with_screenshot_alt": "Captura de pantalla de la función seleccionada"
|
||||
"link_learn_more": "Saber más…"
|
||||
},
|
||||
"download_now": {
|
||||
"text": "Descarga ahora ",
|
||||
@@ -74,8 +74,7 @@
|
||||
"get_started": "Commencer"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "En savoir plus...",
|
||||
"list_with_screenshot_alt": "Capture d'écran de la fonctionnalité sélectionnée"
|
||||
"link_learn_more": "En savoir plus..."
|
||||
},
|
||||
"support_us": {
|
||||
"financial_donations_title": "Dons financiers",
|
||||
5
apps/website/src/translations/hi/translation.json
Normal file
5
apps/website/src/translations/hi/translation.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"get-started": {
|
||||
"title": "शुरू करें"
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@
|
||||
"mermaid_description": "Crea diagrammi come diagrammi di flusso, diagrammi di classe e sequenza, diagrammi di Gantt e molti altri, utilizzando la sintassi Mermaid.",
|
||||
"mindmap_title": "Mappe mentali",
|
||||
"mindmap_description": "Organizza i tuoi pensieri visivamente o fai una sessione di brainstorming.",
|
||||
"others_list": "e altri: <0>mappa delle note</0>, <1>mappa delle relazioni</1>, <2>ricerche salvate</2>, <3>renderizza nota</3> e <4>visualizzazioni web</4>."
|
||||
"others_list": "e altri: <0>mappa delle note</0>, <1>mappa delle relazioni</1>, <2>ricerche salvate</2>, <3>renderizza nota</3> e <4>visualizzazioni web</4>.",
|
||||
"title": "Diversi modi per rappresentare le tue informazioni"
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"title": "Condivisione ed estensibilità",
|
||||
@@ -72,7 +73,10 @@
|
||||
"board_title": "Board",
|
||||
"board_description": "Organizza le tue attività o lo stato dei tuoi progetti in una lavagna Kanban con un modo semplice per creare nuovi elementi e colonne e modificare facilmente il loro stato trascinandoli sulla lavagna.",
|
||||
"geomap_title": "Geomappa",
|
||||
"geomap_description": "Pianifica le tue vacanze o segna i tuoi punti di interesse direttamente su una mappa geografica utilizzando indicatori personalizzabili. Visualizza le tracce GPX registrate per seguire gli itinerari."
|
||||
"geomap_description": "Pianifica le tue vacanze o segna i tuoi punti di interesse direttamente su una mappa geografica utilizzando indicatori personalizzabili. Visualizza le tracce GPX registrate per seguire gli itinerari.",
|
||||
"title": "Collezioni",
|
||||
"presentation_title": "Presentazione",
|
||||
"presentation_description": "Organizza le informazioni in diapositive e presentale a schermo intero con transizioni fluide. Le diapositive possono anche essere esportate in formato PDF per una facile condivisione."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Domande frequenti",
|
||||
@@ -95,8 +99,7 @@
|
||||
"get_started": "Inizia ora"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "Per saperne di più...",
|
||||
"list_with_screenshot_alt": "Screenshot della funzione selezionata"
|
||||
"link_learn_more": "Per saperne di più..."
|
||||
},
|
||||
"download_now": {
|
||||
"text": "Scarica ora ",
|
||||
@@ -188,5 +191,10 @@
|
||||
"description": "Trilium Notes è ospitato su PikaPods, un servizio a pagamento che consente un facile accesso e una semplice gestione. Non è direttamente affiliato al team Trilium.",
|
||||
"download_pikapod": "Configurazione su PikaPods",
|
||||
"download_triliumcc": "In alternativa, consultare trilium.cc"
|
||||
},
|
||||
"header": {
|
||||
"get-started": "Inizia",
|
||||
"documentation": "Documentazione",
|
||||
"support-us": "Sostienici"
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@
|
||||
"mermaid_description": "Mermaid 構文を使用して、フローチャート、クラス図、シーケンス図、ガント チャートなどの図を作成します。",
|
||||
"mindmap_title": "マインドマップ",
|
||||
"mindmap_description": "考えを視覚的に整理したり、ブレインストーミング セッションを行ったりします。",
|
||||
"others_list": "その他: <0>ノートマップ</0>、<1>リレーションマップ</1>、<2>保存された検索</2>、<3>レンダリングノート</3>、<4>Web ビュー</4>。"
|
||||
"others_list": "その他: <0>ノートマップ</0>、<1>リレーションマップ</1>、<2>保存された検索</2>、<3>レンダリングノート</3>、<4>Web ビュー</4>。",
|
||||
"title": "情報を表現するための複数の方法"
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"title": "共有と拡張性",
|
||||
@@ -72,7 +73,10 @@
|
||||
"board_title": "ボード",
|
||||
"board_description": "新しい項目や列を簡単に作成し、ボード上でドラッグするだけでステータスを変更できるカンバン ボードで、タスクやプロジェクトのステータスを整理できます。",
|
||||
"geomap_title": "ジオマップ",
|
||||
"geomap_description": "カスタマイズ可能なマーカーを使って、休暇を計画したり、興味のある場所を地図上に直接マークしたりできます。記録されたGPXトラックを表示して、旅程を追跡できます。"
|
||||
"geomap_description": "カスタマイズ可能なマーカーを使って、休暇を計画したり、興味のある場所を地図上に直接マークしたりできます。記録されたGPXトラックを表示して、旅程を追跡できます。",
|
||||
"title": "コレクション",
|
||||
"presentation_title": "プレゼンテーション",
|
||||
"presentation_description": "情報をスライドに整理し、スムーズな遷移で全画面表示できます。スライドはPDFにエクスポートできるので、簡単に共有できます。"
|
||||
},
|
||||
"faq": {
|
||||
"title": "よくある質問",
|
||||
@@ -95,8 +99,7 @@
|
||||
"get_started": "はじめる"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "さらに詳しく...",
|
||||
"list_with_screenshot_alt": "選択中の機能のスクリーンショット"
|
||||
"link_learn_more": "さらに詳しく..."
|
||||
},
|
||||
"download_now": {
|
||||
"text": "今すぐダウンロード ",
|
||||
@@ -188,5 +191,10 @@
|
||||
"description": "Trilium Notesは、アクセスと管理を容易にする有料サービス PikaPods でホストされています。Trilium チームとは直接関係ありません。",
|
||||
"download_pikapod": "PikaPods にセットアップする",
|
||||
"download_triliumcc": "または、trilium.cc を参照してください"
|
||||
},
|
||||
"header": {
|
||||
"get-started": "はじめる",
|
||||
"documentation": "ドキュメント",
|
||||
"support-us": "サポート"
|
||||
}
|
||||
}
|
||||
6
apps/website/src/translations/ko/translation.json
Normal file
6
apps/website/src/translations/ko/translation.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"hero_section": {
|
||||
"github": "깃허브",
|
||||
"dockerhub": "도커 허브"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user