mirror of
https://github.com/zadam/trilium.git
synced 2025-10-29 17:26:38 +01:00
Compare commits
248 Commits
feat/rice-
...
fix/fix-eq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d88411fda | ||
|
|
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 | ||
|
|
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 | ||
|
|
40edd42740 | ||
|
|
d2c7011735 | ||
|
|
a050d1741b | ||
|
|
18982865da | ||
|
|
3aa810fed7 | ||
|
|
c5ecc22c67 | ||
|
|
252f8ccb1f | ||
|
|
e1bb704383 | ||
|
|
dce0d9400b | ||
|
|
615c783fe3 | ||
|
|
f29411baf7 | ||
|
|
be5e70130c | ||
|
|
9ba1e9d732 | ||
|
|
e1dc4d1433 | ||
|
|
d0d268496c | ||
|
|
8a6950c945 | ||
|
|
477592d176 | ||
|
|
7e5c2ed79d | ||
|
|
bc580f2a88 | ||
|
|
71cd92e0b5 | ||
|
|
a4d92e12be | ||
|
|
c40279b480 | ||
|
|
4c7e7c157c | ||
|
|
c08386450a | ||
|
|
eb93762ecc | ||
|
|
2697f9a25d | ||
|
|
9515e2099b | ||
|
|
966c08da87 | ||
|
|
ea04446e81 | ||
|
|
e4f806ed14 | ||
|
|
49cf7ae1a3 | ||
|
|
1a6f5a027f | ||
|
|
f4796f0f9e | ||
|
|
30480b2c23 | ||
|
|
b7b1d17817 | ||
|
|
c4e5494c14 | ||
|
|
b0f63c02c9 | ||
|
|
2480509811 | ||
|
|
7872193ed0 | ||
|
|
1a68bdfe02 | ||
|
|
14e06c4555 | ||
|
|
b8e17959ae | ||
|
|
c16a135efc | ||
|
|
cbc756ba06 | ||
|
|
64daeb0826 | ||
|
|
e15839db47 | ||
|
|
dcdffed003 | ||
|
|
48e85fad43 | ||
|
|
189071deb8 | ||
|
|
354f1d65c1 | ||
|
|
b78893b106 | ||
|
|
9310315c6a | ||
|
|
1794f8546d | ||
|
|
b3bc0572e5 | ||
|
|
253ce1f223 | ||
|
|
2f3bf94b47 | ||
|
|
d802caa03b | ||
|
|
e69751a8b3 | ||
|
|
0760ea22fb | ||
|
|
8a8f407e99 | ||
|
|
e030dd96da | ||
|
|
01abfc2528 | ||
|
|
042b929dc5 | ||
|
|
ab1d5e31fb | ||
|
|
d073e4c37f | ||
|
|
d60d965a42 | ||
|
|
1c87cfbbd9 | ||
|
|
fee333512a | ||
|
|
38a3f46506 | ||
|
|
bf7506fcd8 | ||
|
|
6fbba426de | ||
|
|
d5bdec13b5 | ||
|
|
cc1b6eb42d | ||
|
|
8baf496f96 | ||
|
|
23a20c4490 | ||
|
|
c8b98f2db6 | ||
|
|
3f36f515db | ||
|
|
892eb5b95d | ||
|
|
62a69a0da0 | ||
|
|
3588e38543 | ||
|
|
41450ab85a | ||
|
|
0526d99560 | ||
|
|
557d576b85 | ||
|
|
041c961cfa | ||
|
|
dcc35bd507 | ||
|
|
09c3e5b56e | ||
|
|
950793377d | ||
|
|
7dac61dc26 | ||
|
|
42dcb8f141 | ||
|
|
43dc8a4b87 | ||
|
|
35316a4c45 | ||
|
|
1366489f99 | ||
|
|
0c399a676a | ||
|
|
31ee78b1aa | ||
|
|
808ba75ee0 | ||
|
|
ac1399a139 | ||
|
|
1e4793351a | ||
|
|
f502fe41c7 | ||
|
|
0ec0091357 | ||
|
|
0e2196f872 | ||
|
|
32dee254cd | ||
|
|
d4a6a297f4 | ||
|
|
a64d8cd8e2 | ||
|
|
bf4cfb9c02 | ||
|
|
a99dfecf43 | ||
|
|
1530d96eca | ||
|
|
5dc066f4c6 | ||
|
|
395f33cd5b | ||
|
|
21b20cf575 | ||
|
|
e3dd25b591 | ||
|
|
b9a4e7ab11 | ||
|
|
6ae67c410c | ||
|
|
4ef7667484 | ||
|
|
3660e2f127 | ||
|
|
357d294f2d | ||
|
|
bb636128b0 | ||
|
|
aa102ab393 | ||
|
|
ea53665e64 | ||
|
|
8e227a6146 | ||
|
|
b03cb1ce1b | ||
|
|
fb0d971e48 | ||
|
|
4fa4112840 | ||
|
|
50f0b88eff | ||
|
|
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
|
||||
|
||||
|
||||
12
.github/workflows/main-docker.yml
vendored
12
.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
|
||||
@@ -86,12 +86,12 @@ jobs:
|
||||
|
||||
- name: Upload Playwright trace
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: Playwright trace (${{ matrix.dockerfile }})
|
||||
path: test-output/playwright/output
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: Playwright report (${{ matrix.dockerfile }})
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
|
||||
path: /tmp/digests/*
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.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
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
name: Nightly Build
|
||||
|
||||
- name: Publish artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
with:
|
||||
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
|
||||
|
||||
4
.github/workflows/playwright.yml
vendored
4
.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
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: e2e report
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.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
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
|
||||
path: apps/desktop/upload/*.*
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: upload/*.*
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
|
||||
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,9 +37,9 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@stylistic/eslint-plugin": "5.5.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.18.12",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@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",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.4.1",
|
||||
"mermaid": "11.12.0",
|
||||
"mind-elixir": "5.3.3",
|
||||
"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.1.2",
|
||||
"react-i18next": "16.2.1",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
@@ -74,9 +74,9 @@
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.1",
|
||||
"@types/tabulator-tables": "6.2.11",
|
||||
"@types/tabulator-tables": "6.3.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.0.7",
|
||||
"happy-dom": "20.0.8",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "خطأ فادح"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "فشل في البدء بعنصر الواجهة"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@@ -26,7 +29,8 @@
|
||||
"edit_branch_prefix": "تعديل بادئة الفرع",
|
||||
"prefix": "البادئة: ",
|
||||
"save": "حفظ",
|
||||
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة"
|
||||
"help_on_tree_prefix": "مساعدة حول بادئة الشجرة",
|
||||
"branch_prefix_saved": "تم حفظ بادئة الفرع."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "اجراءات جماعية",
|
||||
@@ -83,7 +87,8 @@
|
||||
"workspace_calendar_root": "تحديد جذر التقويم لكل مساحة عمل",
|
||||
"hide_highlight_widget": "اخفاء عنصر واجهة قائمة التمييزات",
|
||||
"is_owned_by_note": "تخص الملاحظة",
|
||||
"and_more": "... و {{count}}مرات اكثر."
|
||||
"and_more": "... و {{count}}مرات اكثر.",
|
||||
"related_notes_title": "ملاحظات اخرى بنفس التسمية"
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "الى",
|
||||
@@ -127,7 +132,9 @@
|
||||
"delete_attachment": "حذف المرفق",
|
||||
"upload_new_revision": "رفع مراجعة جديدة",
|
||||
"copy_link_to_clipboard": "نسخ الرابط الى الحافظة",
|
||||
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة"
|
||||
"convert_attachment_into_note": "تحويل المرفق الى ملاحظة",
|
||||
"delete_success": "تم حذف المرفق \"{{title}}\" .",
|
||||
"enter_new_name": "ادخل اسم مرفق جديد"
|
||||
},
|
||||
"calendar": {
|
||||
"week": "أسبوع",
|
||||
@@ -259,7 +266,8 @@
|
||||
"note_paths": {
|
||||
"search": "بحث",
|
||||
"archived": "مؤرشف",
|
||||
"title": "مسارات الملاحظة"
|
||||
"title": "مسارات الملاحظة",
|
||||
"clone_button": "جار نسخ الملاحظة الى مكان جديد..."
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "استعلام",
|
||||
@@ -372,7 +380,8 @@
|
||||
"export_note_title": "تصدير الملاحظة",
|
||||
"export_status": "حالة التصدير",
|
||||
"export_finished_successfully": "اكتمل التصدير بنجاح.",
|
||||
"export_in_progress": "جار التصدير: {{progressCount}}"
|
||||
"export_in_progress": "جار التصدير: {{progressCount}}",
|
||||
"choose_export_type": "اختر نوع التصدير اولا من فضلك"
|
||||
},
|
||||
"help": {
|
||||
"troubleshooting": "أستكشاف الاخطاء واصلاحها",
|
||||
@@ -402,7 +411,10 @@
|
||||
"movingCloningNotes": "نقل/ استنساخ الملاحظات",
|
||||
"deleteNotes": "حذف الملاحظة/ الشجرة الفرعية",
|
||||
"collapseWholeTree": "طي شجرة الملاحظة باكملها",
|
||||
"followLink": "اتبع تلرابط تحت المؤشر"
|
||||
"followLink": "اتبع تلرابط تحت المؤشر",
|
||||
"onlyInDesktop": "في سطح المكتب فقط(Electron build)",
|
||||
"createEditLink": "انشاء/ تحرير رابط خارجي",
|
||||
"quickSearch": "الانتقال الى مربع البحث السريع"
|
||||
},
|
||||
"import": {
|
||||
"options": "خيارات",
|
||||
@@ -465,7 +477,13 @@
|
||||
"delete_all_button": "حذف كل المراجعات",
|
||||
"settings": "اعدادات مراجعة الملاحظة",
|
||||
"diff_not_available": "المقارنة غير متوفرة.",
|
||||
"help_title": "مساعدة حول مراجعات الملاحظة"
|
||||
"help_title": "مساعدة حول مراجعات الملاحظة",
|
||||
"diff_off_hint": "انقر لعرض محتويات الملاحظة",
|
||||
"revisions_deleted": "تم حذف جميع نسخ المراجعات للملاحظة.",
|
||||
"revision_restored": "تم استعادة نسخ المراجعة للملاحظة.",
|
||||
"revision_deleted": "تم حذف مراجعة الملاحظة.",
|
||||
"snapshot_interval": "فاصل زمني لحفظ لقطات اصدارات المراجعة: {{seconds}}",
|
||||
"maximum_revisions": "حد عدد لقطات اصدارات الملاحظة: {{number}}"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"title": "عنوان",
|
||||
@@ -479,13 +497,15 @@
|
||||
"sorting_direction": "اتجاه الترتيب",
|
||||
"natural_sort": "الترتيب الطبيعي",
|
||||
"natural_sort_language": "لغات الترتيب الطبيعي",
|
||||
"sort_children_by": "ترتيب العناصر الفرعية حسب..."
|
||||
"sort_children_by": "ترتيب العناصر الفرعية حسب...",
|
||||
"sort_folders_at_top": "ترتيب المجلدات في الاعلى"
|
||||
},
|
||||
"recent_changes": {
|
||||
"undelete_link": "الغاء الحذف",
|
||||
"title": "التغيرات الاخيرة",
|
||||
"no_changes_message": "لايوجد تغيير لحد الان...",
|
||||
"erase_notes_button": "مسح الملاحظات المحذوفة الان"
|
||||
"erase_notes_button": "مسح الملاحظات المحذوفة الان",
|
||||
"deleted_notes_message": "تم حذف الملاحظات نهائيا."
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(حذف)",
|
||||
@@ -705,7 +725,9 @@
|
||||
"default_token_name": "رمز جديد",
|
||||
"rename_token_title": "اعادة تسمية الرمز",
|
||||
"rename_token": "اعادة تسمية هذا الرمز",
|
||||
"create_token": "انشاء رمز PEAPI جديد"
|
||||
"create_token": "انشاء رمز PEAPI جديد",
|
||||
"new_token_title": "رمز ETAPI جديد",
|
||||
"token_created_title": "انشاء رمز ETAPI"
|
||||
},
|
||||
"password": {
|
||||
"heading": "كلمة المرور",
|
||||
@@ -811,7 +833,8 @@
|
||||
"help_on_links": "مساعدة حول الارتباطات التشعبية",
|
||||
"notes_to_clone": "ملاحظات للنسخ",
|
||||
"target_parent_note": "الملاحظة الاصلية الهدف",
|
||||
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة"
|
||||
"clone_to_selected_note": "استنساخ الى الملاحظة المحددة",
|
||||
"no_path_to_clone_to": "لايوجد مسار لنسخ المحتوى الية."
|
||||
},
|
||||
"table_of_contents": {
|
||||
"unit": "عناوين",
|
||||
@@ -1029,7 +1052,8 @@
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "حذف الملاحظة",
|
||||
"delete_matched_notes": "حف الملاحظات المطابقة"
|
||||
"delete_matched_notes": "حف الملاحظات المطابقة",
|
||||
"delete_matched_notes_description": "سوف يؤدي هذا الى حذف الملاحظات المطابقة."
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "اعادة تسمية الملاحظة",
|
||||
@@ -1312,7 +1336,8 @@
|
||||
"notes_to_move": "الملاحظات المراد نقلها",
|
||||
"target_parent_note": "ملاحظة الاصل الهدف",
|
||||
"dialog_title": "انقل الملاحظات الى...",
|
||||
"move_button": "نقل الىالملاحظة المحددة"
|
||||
"move_button": "نقل الىالملاحظة المحددة",
|
||||
"error_no_path": "لايوجد مسار لنقل العنصر الية."
|
||||
},
|
||||
"delete_revisions": {
|
||||
"delete_note_revisions": "حذف مراجعات الملاحظة"
|
||||
@@ -1363,7 +1388,8 @@
|
||||
"save_attributes": "حفظ السمات <enter>",
|
||||
"add_a_new_attribute": "اضافة سمة جديدة",
|
||||
"add_new_label_definition": "اضافة تعريف لتسمية جديدة",
|
||||
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة"
|
||||
"add_new_relation_definition": "اضافة تعريف لعلاقة جديدة",
|
||||
"add_new_relation": "اضافة علاقة جديدة <kbd data-command=\"addNewRelation\">"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "الخروج من وضع Zen"
|
||||
@@ -1434,5 +1460,8 @@
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "تصدير المخطط كملف PNG"
|
||||
},
|
||||
"protected_session_status": {
|
||||
"inactive": "انقر للدخول الى جلسة محمية"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"
|
||||
@@ -184,7 +184,8 @@
|
||||
},
|
||||
"import-status": "Importstatus",
|
||||
"in-progress": "Import läuft: {{progress}}",
|
||||
"successful": "Import erfolgreich abgeschlossen."
|
||||
"successful": "Import erfolgreich abgeschlossen.",
|
||||
"importZipRecommendation": "Beim Import einer ZIP-Datei wird die Notizhierarchie aus der Ordnerstruktur im Archiv übernommen."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Notiz beifügen",
|
||||
@@ -647,7 +648,8 @@
|
||||
"logout": "Abmelden",
|
||||
"show-cheatsheet": "Cheatsheet anzeigen",
|
||||
"toggle-zen-mode": "Zen Modus",
|
||||
"new-version-available": "Neues Update verfügbar"
|
||||
"new-version-available": "Neues Update verfügbar",
|
||||
"download-update": "Version {{latestVersion}} herunterladen"
|
||||
},
|
||||
"sync_status": {
|
||||
"unknown": "<p>Der Synchronisations-Status wird bekannt, sobald der nächste Synchronisierungsversuch gestartet wird.</p><p>Klicke, um eine Synchronisierung jetzt auszulösen.</p>",
|
||||
@@ -989,7 +991,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}}",
|
||||
@@ -1521,7 +1523,9 @@
|
||||
"window-on-top": "Dieses Fenster immer oben halten"
|
||||
},
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden"
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden",
|
||||
"printing": "Druckvorgang läuft…",
|
||||
"printing_pdf": "PDF-Export läuft…"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "Titel der Notiz hier eingeben…"
|
||||
@@ -1654,7 +1658,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"
|
||||
@@ -2079,6 +2083,7 @@
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Folie bearbeiten",
|
||||
"start-presentation": "Präsentation starten"
|
||||
"start-presentation": "Präsentation starten",
|
||||
"slide-overview": "Übersicht der Folien ein-/ausblenden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
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": "ट्रिलियम नोट्स के बारें में"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,50 @@
|
||||
{}
|
||||
{
|
||||
"about": {
|
||||
"title": "A Trilium Notes-ról",
|
||||
"homepage": "Kezdőlap:",
|
||||
"app_version": "Alkalmazás verziója:",
|
||||
"db_version": "Adatbázis verzió:",
|
||||
"sync_version": "Verzió szinkronizálás :",
|
||||
"build_revision": "Build revízió:",
|
||||
"data_directory": "Adatkönyvtár:",
|
||||
"build_date": "Build dátum:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritikus hiba",
|
||||
"message": "Kritikus hiba történt, amely megakadályozza a kliensalkalmazás indítását:\n\n{{message}}\n\nEzt valószínűleg egy váratlan szkripthiba okozza. Próbálja meg biztonságos módban elindítani az alkalmazást, és hárítsa el a problémát."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Nem sikerült inicializálni egy widgetet",
|
||||
"message-custom": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó egyéni widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}",
|
||||
"message-unknown": "Ismeretlen widget inicializálása sikertelen volt a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Link hozzáadása",
|
||||
"help_on_links": "Segítség a linkekhez",
|
||||
"note": "Jegyzet",
|
||||
"search_note": "név szerinti jegyzetkeresés",
|
||||
"link_title_mirrors": "A link cím tükrözi a jegyzet aktuális címét",
|
||||
"link_title_arbitrary": "link cím önkényesen módosítható",
|
||||
"link_title": "Link cím",
|
||||
"button_add_link": "Link hozzáadása"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Az elágazás előtagjának szerkesztése",
|
||||
"help_on_tree_prefix": "Segítség a fa előtagján",
|
||||
"prefix": "Az előtag: ",
|
||||
"save": "Mentés"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Tömeges akciók",
|
||||
"affected_notes": "Érintett jegyzetek",
|
||||
"labels": "Címkék",
|
||||
"relations": "Kapcsolatok",
|
||||
"notes": "Jegyzetek"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -320,7 +320,8 @@
|
||||
"explodeArchivesTooltip": "Если этот флажок установлен, Trilium будет читать файлы <code>.zip</code>, <code>.enex</code> и <code>.opml</code> и создавать заметки из файлов внутри этих архивов. Если флажок не установлен, Trilium будет прикреплять сами архивы к заметке.",
|
||||
"explodeArchives": "Прочитать содержимое архивов <code>.zip</code>, <code>.enex</code> и <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Если этот параметр включен, Trilium попытается уменьшить размер импортируемых изображений путём масштабирования и оптимизации, что может повлиять на воспринимаемое качество изображения. Если этот параметр не установлен, изображения будут импортированы без изменений.</p><p>Это не относится к импорту файлов <code>.zip</code> с метаданными, поскольку предполагается, что эти файлы уже оптимизированы.</p>",
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных"
|
||||
"codeImportedAsCode": "Импортировать распознанные файлы кода (например, <code>.json</code>) в виде заметок типа \"код\", если это неясно из метаданных",
|
||||
"importZipRecommendation": "При импорте ZIP файла иерархия заметок будет отражена в структуре папок внутри архива."
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Импорт Markdown",
|
||||
@@ -980,7 +981,8 @@
|
||||
"open_sql_console_history": "Открыть историю консоли SQL",
|
||||
"show_shared_notes_subtree": "Поддерево общедоступных заметок",
|
||||
"switch_to_mobile_version": "Перейти на мобильную версию",
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК"
|
||||
"switch_to_desktop_version": "Переключиться на версию для ПК",
|
||||
"new-version-available": "Доступно обновление"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"backlink": "{{count}} ссылки",
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,3 +57,15 @@
|
||||
.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
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"@electron-forge/cli": "7.10.2",
|
||||
"@electron-forge/maker-deb": "7.10.2",
|
||||
"@electron-forge/maker-dmg": "7.10.2",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/yargs": "17.0.33"
|
||||
"@types/yargs": "17.0.34"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/main.ts",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"fs-extra": "11.3.2"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
502
apps/server-e2e/src/exact_search.spec.ts
Normal file
502
apps/server-e2e/src/exact_search.spec.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import App from "./support/app";
|
||||
|
||||
const BASE_URL = "http://127.0.0.1:8082";
|
||||
|
||||
/**
|
||||
* E2E tests for exact search functionality using the leading "=" operator.
|
||||
*
|
||||
* These tests validate the GitHub issue:
|
||||
* - Searching for "pagio" returns many false positives (e.g., "page", "pages")
|
||||
* - Searching for "=pagio" should return ONLY exact matches for "pagio"
|
||||
*/
|
||||
|
||||
test.describe("Exact Search with Leading = Operator", () => {
|
||||
let csrfToken: string;
|
||||
let createdNoteIds: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
|
||||
// Get CSRF token
|
||||
csrfToken = await page.evaluate(() => {
|
||||
return (window as any).glob.csrfToken;
|
||||
});
|
||||
|
||||
expect(csrfToken).toBeTruthy();
|
||||
|
||||
// Create test notes with specific content patterns
|
||||
// Note 1: Contains exactly "pagio" in title
|
||||
const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Test Note with pagio",
|
||||
content: "This note contains the word pagio in the content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note1.ok()).toBeTruthy();
|
||||
const note1Data = await note1.json();
|
||||
createdNoteIds.push(note1Data.note.noteId);
|
||||
|
||||
// Note 2: Contains "page" (not exact match)
|
||||
const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Test Note with page",
|
||||
content: "This note contains the word page in the content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note2.ok()).toBeTruthy();
|
||||
const note2Data = await note2.json();
|
||||
createdNoteIds.push(note2Data.note.noteId);
|
||||
|
||||
// Note 3: Contains "pages" (plural, not exact match)
|
||||
const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Test Note with pages",
|
||||
content: "This note contains the word pages in the content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note3.ok()).toBeTruthy();
|
||||
const note3Data = await note3.json();
|
||||
createdNoteIds.push(note3Data.note.noteId);
|
||||
|
||||
// Note 4: Contains "homepage" (contains "page", not exact match)
|
||||
const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Homepage Note",
|
||||
content: "This note is about homepage content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note4.ok()).toBeTruthy();
|
||||
const note4Data = await note4.json();
|
||||
createdNoteIds.push(note4Data.note.noteId);
|
||||
|
||||
// Note 5: Another note with exact "pagio" in content
|
||||
const note5 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Another pagio Note",
|
||||
content: "This is another note with pagio content for testing exact matches.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note5.ok()).toBeTruthy();
|
||||
const note5Data = await note5.json();
|
||||
createdNoteIds.push(note5Data.note.noteId);
|
||||
|
||||
// Note 6: Contains "pagio" in title only
|
||||
const note6 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "pagio",
|
||||
content: "This note has pagio as the title.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note6.ok()).toBeTruthy();
|
||||
const note6Data = await note6.json();
|
||||
createdNoteIds.push(note6Data.note.noteId);
|
||||
|
||||
// Wait a bit for indexing
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Clean up created notes
|
||||
for (const noteId of createdNoteIds) {
|
||||
try {
|
||||
const taskId = `cleanup-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await page.request.delete(`${BASE_URL}/api/notes/${noteId}?taskId=${taskId}&last=true`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete note ${noteId}:`, e);
|
||||
}
|
||||
}
|
||||
createdNoteIds = [];
|
||||
});
|
||||
|
||||
test("Quick search without = operator returns all partial matches", async ({ page }) => {
|
||||
// Test the /quick-search endpoint without the = operator
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/pag`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
// Should return multiple notes including "page", "pages", "homepage"
|
||||
expect(data.searchResultNoteIds).toBeDefined();
|
||||
expect(data.searchResults).toBeDefined();
|
||||
|
||||
// Filter to only our test notes
|
||||
const testResults = data.searchResults.filter((result: any) =>
|
||||
result.noteTitle.includes("page") ||
|
||||
result.noteTitle.includes("pagio") ||
|
||||
result.noteTitle.includes("Homepage")
|
||||
);
|
||||
|
||||
// Should find at least "page", "pages", "homepage", and "pagio" notes
|
||||
expect(testResults.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
console.log("Quick search 'pag' found:", testResults.length, "matching notes");
|
||||
console.log("Note titles:", testResults.map((r: any) => r.noteTitle));
|
||||
});
|
||||
|
||||
test("Quick search with = operator returns only exact matches", async ({ page }) => {
|
||||
// Test the /quick-search endpoint WITH the = operator
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/=pagio`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
// Should return only notes with exact "pagio" match
|
||||
expect(data.searchResultNoteIds).toBeDefined();
|
||||
expect(data.searchResults).toBeDefined();
|
||||
|
||||
// Filter to only our test notes
|
||||
const testResults = data.searchResults.filter((result: any) =>
|
||||
createdNoteIds.includes(result.notePath.split("/").pop() || "")
|
||||
);
|
||||
|
||||
console.log("Quick search '=pagio' found:", testResults.length, "matching notes");
|
||||
console.log("Note titles:", testResults.map((r: any) => r.noteTitle));
|
||||
|
||||
// Should find exactly 3 notes: "Test Note with pagio", "Another pagio Note", "pagio"
|
||||
expect(testResults.length).toBe(3);
|
||||
|
||||
// Verify that none of the results contain "page" or "pages" (only "pagio")
|
||||
for (const result of testResults) {
|
||||
const title = result.noteTitle.toLowerCase();
|
||||
const hasPageNotPagio = (title.includes("page") && !title.includes("pagio"));
|
||||
expect(hasPageNotPagio).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Full search API without = operator returns partial matches", async ({ page }) => {
|
||||
// Test the /search endpoint without the = operator
|
||||
const response = await page.request.get(`${BASE_URL}/api/search/pag`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
// Should return an array of note IDs
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
|
||||
// Filter to only our test notes
|
||||
const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id));
|
||||
|
||||
console.log("Full search 'pag' found:", testNoteIds.length, "matching notes from our test set");
|
||||
|
||||
// Should find at least 4 notes
|
||||
expect(testNoteIds.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test("Full search API with = operator returns only exact matches", async ({ page }) => {
|
||||
// Test the /search endpoint WITH the = operator
|
||||
const response = await page.request.get(`${BASE_URL}/api/search/=pagio`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
// Should return an array of note IDs
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
|
||||
// Filter to only our test notes
|
||||
const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id));
|
||||
|
||||
console.log("Full search '=pagio' found:", testNoteIds.length, "matching notes from our test set");
|
||||
|
||||
// Should find exactly 3 notes with exact "pagio" match
|
||||
expect(testNoteIds.length).toBe(3);
|
||||
});
|
||||
|
||||
test("Exact search operator works with content search", async ({ page }) => {
|
||||
// Create a note with "test" in title but different content
|
||||
const noteWithTest = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Testing Content",
|
||||
content: "This note contains the exact word test in content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteWithTest.ok()).toBeTruthy();
|
||||
const noteWithTestData = await noteWithTest.json();
|
||||
const testNoteId = noteWithTestData.note.noteId;
|
||||
createdNoteIds.push(testNoteId);
|
||||
|
||||
// Create a note with "testing" (not exact match)
|
||||
const noteWithTesting = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Testing More",
|
||||
content: "This note has testing in the content.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteWithTesting.ok()).toBeTruthy();
|
||||
const noteWithTestingData = await noteWithTesting.json();
|
||||
createdNoteIds.push(noteWithTestingData.note.noteId);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search with exact operator
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/=test`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
const ourTestNotes = data.searchResults.filter((result: any) => {
|
||||
const noteId = result.notePath.split("/").pop();
|
||||
return noteId === testNoteId || noteId === noteWithTestingData.note.noteId;
|
||||
});
|
||||
|
||||
console.log("Exact search '=test' found our test notes:", ourTestNotes.length);
|
||||
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
|
||||
|
||||
// Should find the note with exact "test" match, but not "testing"
|
||||
// Note: This test may fail if the implementation doesn't properly handle exact matching in content
|
||||
expect(ourTestNotes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Exact search is case-insensitive", async ({ page }) => {
|
||||
// Create notes with different case variations
|
||||
const noteUpper = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "EXACT MATCH",
|
||||
content: "This note has EXACT in uppercase.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteUpper.ok()).toBeTruthy();
|
||||
const noteUpperData = await noteUpper.json();
|
||||
createdNoteIds.push(noteUpperData.note.noteId);
|
||||
|
||||
const noteLower = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "exact match",
|
||||
content: "This note has exact in lowercase.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteLower.ok()).toBeTruthy();
|
||||
const noteLowerData = await noteLower.json();
|
||||
createdNoteIds.push(noteLowerData.note.noteId);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search with exact operator in lowercase
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/=exact`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
const ourTestNotes = data.searchResults.filter((result: any) => {
|
||||
const noteId = result.notePath.split("/").pop();
|
||||
return noteId === noteUpperData.note.noteId || noteId === noteLowerData.note.noteId;
|
||||
});
|
||||
|
||||
console.log("Case-insensitive exact search found:", ourTestNotes.length, "notes");
|
||||
|
||||
// Should find both uppercase and lowercase versions
|
||||
expect(ourTestNotes.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Exact phrase matching with multi-word searches", async ({ page }) => {
|
||||
// Create notes with various phrase patterns
|
||||
const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "exact phrase",
|
||||
content: "This note contains the exact phrase.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note1.ok()).toBeTruthy();
|
||||
const note1Data = await note1.json();
|
||||
createdNoteIds.push(note1Data.note.noteId);
|
||||
|
||||
const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "exact phrase match",
|
||||
content: "This note has exact phrase followed by more words.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note2.ok()).toBeTruthy();
|
||||
const note2Data = await note2.json();
|
||||
createdNoteIds.push(note2Data.note.noteId);
|
||||
|
||||
const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "phrase exact",
|
||||
content: "This note has the words in reverse order.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note3.ok()).toBeTruthy();
|
||||
const note3Data = await note3.json();
|
||||
createdNoteIds.push(note3Data.note.noteId);
|
||||
|
||||
const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "this exact and that phrase",
|
||||
content: "Words are separated but both present.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(note4.ok()).toBeTruthy();
|
||||
const note4Data = await note4.json();
|
||||
createdNoteIds.push(note4Data.note.noteId);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search for exact phrase "exact phrase"
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/='exact phrase'`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
const ourTestNotes = data.searchResults.filter((result: any) => {
|
||||
const noteId = result.notePath.split("/").pop();
|
||||
return [note1Data.note.noteId, note2Data.note.noteId, note3Data.note.noteId, note4Data.note.noteId].includes(noteId || "");
|
||||
});
|
||||
|
||||
console.log("Exact phrase search '=\"exact phrase\"' found:", ourTestNotes.length, "notes");
|
||||
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
|
||||
|
||||
// Should find only notes 1 and 2 (consecutive "exact phrase")
|
||||
// Should NOT find note 3 (reversed order) or note 4 (words separated)
|
||||
expect(ourTestNotes.length).toBe(2);
|
||||
|
||||
const foundTitles = ourTestNotes.map((r: any) => r.noteTitle);
|
||||
expect(foundTitles).toContain("exact phrase");
|
||||
expect(foundTitles).toContain("exact phrase match");
|
||||
expect(foundTitles).not.toContain("phrase exact");
|
||||
expect(foundTitles).not.toContain("this exact and that phrase");
|
||||
});
|
||||
|
||||
test("Exact phrase matching respects word order", async ({ page }) => {
|
||||
// Create notes to test word order sensitivity
|
||||
const noteForward = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Testing Order",
|
||||
content: "This is a test sentence for verification.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteForward.ok()).toBeTruthy();
|
||||
const noteForwardData = await noteForward.json();
|
||||
createdNoteIds.push(noteForwardData.note.noteId);
|
||||
|
||||
const noteReverse = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Order Testing",
|
||||
content: "A sentence test is this for verification.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteReverse.ok()).toBeTruthy();
|
||||
const noteReverseData = await noteReverse.json();
|
||||
createdNoteIds.push(noteReverseData.note.noteId);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search for exact phrase "test sentence"
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/='test sentence'`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
const ourTestNotes = data.searchResults.filter((result: any) => {
|
||||
const noteId = result.notePath.split("/").pop();
|
||||
return noteId === noteForwardData.note.noteId || noteId === noteReverseData.note.noteId;
|
||||
});
|
||||
|
||||
console.log("Exact phrase search '=\"test sentence\"' found:", ourTestNotes.length, "notes");
|
||||
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
|
||||
|
||||
// Should find only the forward order note
|
||||
expect(ourTestNotes.length).toBe(1);
|
||||
expect(ourTestNotes[0].noteTitle).toBe("Testing Order");
|
||||
});
|
||||
|
||||
test("Multi-word exact search without quotes", async ({ page }) => {
|
||||
// Test that multi-word search with = but without quotes also does exact phrase matching
|
||||
const notePhrase = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Quick Test Note",
|
||||
content: "A simple note for multi word testing.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(notePhrase.ok()).toBeTruthy();
|
||||
const notePhraseData = await notePhrase.json();
|
||||
createdNoteIds.push(notePhraseData.note.noteId);
|
||||
|
||||
const noteScattered = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, {
|
||||
headers: { "x-csrf-token": csrfToken },
|
||||
data: {
|
||||
title: "Word Multi Testing",
|
||||
content: "Words are multi scattered in this testing example.",
|
||||
type: "text"
|
||||
}
|
||||
});
|
||||
expect(noteScattered.ok()).toBeTruthy();
|
||||
const noteScatteredData = await noteScattered.json();
|
||||
createdNoteIds.push(noteScatteredData.note.noteId);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search for "=multi word" without quotes (parser tokenizes as two words)
|
||||
const response = await page.request.get(`${BASE_URL}/api/quick-search/=multi word`, {
|
||||
headers: { "x-csrf-token": csrfToken }
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const data = await response.json();
|
||||
|
||||
const ourTestNotes = data.searchResults.filter((result: any) => {
|
||||
const noteId = result.notePath.split("/").pop();
|
||||
return noteId === notePhraseData.note.noteId || noteId === noteScatteredData.note.noteId;
|
||||
});
|
||||
|
||||
console.log("Multi-word exact search '=multi word' found:", ourTestNotes.length, "notes");
|
||||
console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle));
|
||||
|
||||
// Should find only the note with consecutive "multi word" phrase
|
||||
expect(ourTestNotes.length).toBe(1);
|
||||
expect(ourTestNotes[0].noteTitle).toBe("Quick Test Note");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.20.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.20.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.20.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.20.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.20.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.20.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.20.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.20.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,11 +36,12 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@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",
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/debounce": "1.2.4",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
@@ -56,18 +57,17 @@
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "1.15.9",
|
||||
"@types/session-file-store": "1.2.5",
|
||||
"@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",
|
||||
@@ -81,7 +81,7 @@
|
||||
"debounce": "2.2.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "3.1.10",
|
||||
"electron": "38.3.0",
|
||||
"electron": "38.4.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
@@ -100,7 +100,7 @@
|
||||
"i18next": "25.6.0",
|
||||
"i18next-fs-backend": "2.6.0",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "5.0.0",
|
||||
"ini": "6.0.0",
|
||||
"is-animated": "2.0.2",
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
@@ -110,7 +110,7 @@
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.6.0",
|
||||
"openai": "6.6.0",
|
||||
"openai": "6.7.0",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
@@ -125,9 +125,9 @@
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turndown": "7.2.1",
|
||||
"turndown": "7.2.2",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.1.11",
|
||||
"vite": "7.1.12",
|
||||
"ws": "8.18.3",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.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
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
"show-backend-log": "فتح صفحة \"سجل الخلفية\"",
|
||||
"edit-readonly-note": "تعديل ملاحظة القراءة فقط",
|
||||
"attributes-labels-and-relations": "سمات ( تسميات و علاقات)",
|
||||
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة"
|
||||
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة",
|
||||
"show-help": "فتح دليل التعليمات",
|
||||
"copy-without-formatting": "نسخ النص المحدد بدون تنسيق"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"note": "ملاحظة:",
|
||||
@@ -196,7 +198,8 @@
|
||||
"expand": "توسيع",
|
||||
"site-theme": "المظهر العام للموقع",
|
||||
"image_alt": "صورة المقال",
|
||||
"on-this-page": "في هذه السفحة"
|
||||
"on-this-page": "في هذه السفحة",
|
||||
"last-updated": "اخر تحديث {{- date}}"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"description": "الوصف",
|
||||
@@ -258,7 +261,8 @@
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "الأصل:",
|
||||
"child-notes": "الملاحظات الفرعية:"
|
||||
"child-notes": "الملاحظات الفرعية:",
|
||||
"no-content": "لاتحتوي هذة الملاحظة على محتوى."
|
||||
},
|
||||
"notes": {
|
||||
"duplicate-note-suffix": "(مكرر)",
|
||||
@@ -339,7 +343,24 @@
|
||||
"toggle-system-tray-icon": "تبديل ايقونة علبة النظام",
|
||||
"switch-to-first-tab": "التبديل الى التبويب الاول",
|
||||
"follow-link-under-cursor": "اتبع الرابط اسفل المؤشر",
|
||||
"paste-markdown-into-text": "لصق نص بتنسبق Markdown"
|
||||
"paste-markdown-into-text": "لصق نص بتنسبق Markdown",
|
||||
"move-note-up-in-hierarchy": "نقل الملاحظة للاعلى في الهيكل",
|
||||
"move-note-down-in-hierarchy": "نقل الملاحظة للاسفل في الهيكل",
|
||||
"select-all-notes-in-parent": "تحديد جميع الملاحظات التابعة للملاحظة الاصل",
|
||||
"add-note-above-to-selection": "اضافة ملاحظة فوق الملاحظة المحددة",
|
||||
"add-note-below-to-selection": "اصافة ملاحظة اسفل الملاحظة المحددة",
|
||||
"add-include-note-to-text": "اضافة الملاحظة الى النص",
|
||||
"toggle-ribbon-tab-image-properties": "اظهار/ اخفاء صورة علامة التبويب في الشريط.",
|
||||
"toggle-ribbon-tab-classic-editor": "عرض/اخفاء تبويب المحور الكلاسيكي",
|
||||
"toggle-ribbon-tab-basic-properties": "عرض/اخفاء تبويب الخصائص الاساسية",
|
||||
"toggle-ribbon-tab-book-properties": "عرض/اخفاء تبويب خصائص الدفتر",
|
||||
"toggle-ribbon-tab-file-properties": "عرض/ادخفاء تبويب خصائص الملف",
|
||||
"toggle-ribbon-tab-owned-attributes": "عرض/اخفاء تبويب المميزات المملوكة",
|
||||
"toggle-ribbon-tab-inherited-attributes": "عرض/اخفاء تبويب السمات الموروثة",
|
||||
"toggle-ribbon-tab-promoted-attributes": "عرض/ اخفاء تبويب السمات المعززة",
|
||||
"toggle-ribbon-tab-note-map": "عرض/اخفاء تبويب خريطة الملاحظات",
|
||||
"toggle-ribbon-tab-similar-notes": "عرض/اخفاء شريط الملاحظات المشابهة",
|
||||
"export-active-note-as-pdf": "تصدير الملاحظة النشطة كملفPDF"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "غير موجود",
|
||||
@@ -348,6 +369,7 @@
|
||||
"weekdayNumber": "الاسبوع{رقم الاسيوع}",
|
||||
"quarterNumber": "الربع {رقم الربع}",
|
||||
"pdf": {
|
||||
"export_filter": "مستند PDF (.pdf)"
|
||||
"export_filter": "مستند PDF (.pdf)",
|
||||
"unable-to-export-title": "تعذر التصدير كملف PDF"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,8 @@
|
||||
"export_filter": "PDF Dokument (*.pdf)",
|
||||
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
|
||||
"unable-to-export-title": "Export als PDF fehlgeschlagen",
|
||||
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen."
|
||||
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen.",
|
||||
"unable-to-print": "Notiz kann nicht gedruckt werden"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
"edit-note-title": "Ugrás fáról a jegyzet részleteihez és a cím szerkesztése",
|
||||
"edit-branch-prefix": "\"Ág címjelzésének szerkesztése\" ablak mutatása",
|
||||
"clone-notes-to": "Kijelölt jegyzetek másolása",
|
||||
"move-notes-to": "Kijelölt jegyzetek elhelyzése"
|
||||
"move-notes-to": "Kijelölt jegyzetek elhelyzése",
|
||||
"note-clipboard": "Megjegyzés vágólap",
|
||||
"copy-notes-to-clipboard": "Másolja a kiválasztott jegyzeteket a vágólapra",
|
||||
"paste-notes-from-clipboard": "A vágólapról szóló jegyzetek beillesztése aktív jegyzetbe",
|
||||
"cut-notes-to-clipboard": "A kiválasztott jegyzetek kivágása a vágólapra",
|
||||
"select-all-notes-in-parent": "Válassza ki az összes jegyzetet az aktuális jegyzetszintről",
|
||||
"activate-next-tab": "Aktiválja a jobb oldali fület",
|
||||
"activate-previous-tab": "Aktiválja a lapot a bal oldalon",
|
||||
"open-new-window": "Nyiss új üres ablakot"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -10,6 +10,8 @@ import cls from "../../services/cls.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type SearchResult from "../../services/search/search_result.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
|
||||
function searchFromNote(req: Request): SearchNoteResult {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
@@ -49,13 +51,41 @@ function quickSearch(req: Request) {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch: false,
|
||||
includeArchivedNotes: false,
|
||||
fuzzyAttributeSearch: false
|
||||
includeHiddenNotes: true,
|
||||
fuzzyAttributeSearch: true,
|
||||
ignoreInternalAttributes: true,
|
||||
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId()
|
||||
});
|
||||
|
||||
// Use the same highlighting logic as autocomplete for consistency
|
||||
const searchResults = searchService.searchNotesForAutocomplete(searchString, false);
|
||||
// Execute search with our context
|
||||
const allSearchResults = searchService.findResultsWithQuery(searchString, searchContext);
|
||||
const trimmed = allSearchResults.slice(0, 200);
|
||||
|
||||
// Extract snippets using highlightedTokens from our context
|
||||
for (const result of trimmed) {
|
||||
result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
result.attributeSnippet = searchService.extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
}
|
||||
|
||||
// Highlight the results
|
||||
searchService.highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
|
||||
|
||||
// Map to API format
|
||||
const searchResults = trimmed.map((result) => {
|
||||
const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId);
|
||||
return {
|
||||
notePath: result.notePath,
|
||||
noteTitle: title,
|
||||
notePathTitle: result.notePathTitle,
|
||||
highlightedNotePathTitle: result.highlightedNotePathTitle,
|
||||
contentSnippet: result.contentSnippet,
|
||||
highlightedContentSnippet: result.highlightedContentSnippet,
|
||||
attributeSnippet: result.attributeSnippet,
|
||||
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
|
||||
icon: icon
|
||||
};
|
||||
});
|
||||
|
||||
// Extract note IDs for backward compatibility
|
||||
const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -75,8 +75,16 @@ class NoteContentFulltextExp extends Expression {
|
||||
return inputNoteSet;
|
||||
}
|
||||
|
||||
// Add tokens to highlightedTokens so snippet extraction knows what to look for
|
||||
for (const token of this.tokens) {
|
||||
if (!searchContext.highlightedTokens.includes(token)) {
|
||||
searchContext.highlightedTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
const resultNoteSet = new NoteSet();
|
||||
|
||||
// Search through notes with content
|
||||
for (const row of sql.iterateRows<SearchRow>(`
|
||||
SELECT noteId, type, mime, content, isProtected
|
||||
FROM notes JOIN blobs USING (blobId)
|
||||
@@ -86,9 +94,82 @@ class NoteContentFulltextExp extends Expression {
|
||||
this.findInText(row, inputNoteSet, resultNoteSet);
|
||||
}
|
||||
|
||||
// For exact match with flatText, also search notes WITHOUT content (they may have matching attributes)
|
||||
if (this.flatText && (this.operator === "=" || this.operator === "!=")) {
|
||||
for (const noteId of inputNoteSet.noteIdSet) {
|
||||
// Skip if already found or doesn't exist
|
||||
if (resultNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const note = becca.notes[noteId];
|
||||
const flatText = note.getFlatText();
|
||||
|
||||
// For flatText, only check attribute values (format: #name=value or ~name=value)
|
||||
// Don't match against noteId, type, mime, or title which are also in flatText
|
||||
let matches = false;
|
||||
const phrase = this.tokens.join(" ");
|
||||
const normalizedPhrase = normalizeSearchText(phrase);
|
||||
const normalizedFlatText = normalizeSearchText(flatText);
|
||||
|
||||
// Check if =phrase appears in flatText (indicates attribute value match)
|
||||
matches = normalizedFlatText.includes(`=${normalizedPhrase}`);
|
||||
|
||||
if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content contains the exact word (with word boundaries) or exact phrase
|
||||
* This is case-insensitive since content and token are already normalized
|
||||
*/
|
||||
private containsExactWord(token: string, content: string): boolean {
|
||||
// Normalize both for case-insensitive comparison
|
||||
const normalizedToken = normalizeSearchText(token);
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
// If token contains spaces, it's a multi-word phrase from quotes
|
||||
// Check for substring match (consecutive phrase)
|
||||
if (normalizedToken.includes(' ')) {
|
||||
return normalizedContent.includes(normalizedToken);
|
||||
}
|
||||
|
||||
// For single words, split content into words and check for exact match
|
||||
const words = normalizedContent.split(/\s+/);
|
||||
return words.some(word => word === normalizedToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content contains the exact phrase (consecutive words in order)
|
||||
* This is case-insensitive since content and tokens are already normalized
|
||||
*/
|
||||
private containsExactPhrase(tokens: string[], content: string, checkFlatTextAttributes: boolean = false): boolean {
|
||||
const normalizedTokens = tokens.map(t => normalizeSearchText(t));
|
||||
const normalizedContent = normalizeSearchText(content);
|
||||
|
||||
// Join tokens with single space to form the phrase
|
||||
const phrase = normalizedTokens.join(" ");
|
||||
|
||||
// Check if the phrase appears as a substring (consecutive words)
|
||||
if (normalizedContent.includes(phrase)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For flatText, also check if the phrase appears in attribute values
|
||||
// Attributes in flatText appear as "#name=value" or "~name=value"
|
||||
// So we need to check for "=phrase" to match attribute values
|
||||
if (checkFlatTextAttributes && normalizedContent.includes(`=${phrase}`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
|
||||
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
||||
return;
|
||||
@@ -123,9 +204,25 @@ class NoteContentFulltextExp extends Expression {
|
||||
if (this.tokens.length === 1) {
|
||||
const [token] = this.tokens;
|
||||
|
||||
let matches = false;
|
||||
if (this.operator === "=") {
|
||||
matches = this.containsExactWord(token, content);
|
||||
// Also check flatText if enabled (includes attributes)
|
||||
if (!matches && this.flatText) {
|
||||
const flatText = becca.notes[noteId].getFlatText();
|
||||
matches = this.containsExactPhrase([token], flatText, true);
|
||||
}
|
||||
} else if (this.operator === "!=") {
|
||||
matches = !this.containsExactWord(token, content);
|
||||
// For negation, check flatText too
|
||||
if (matches && this.flatText) {
|
||||
const flatText = becca.notes[noteId].getFlatText();
|
||||
matches = !this.containsExactPhrase([token], flatText, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(this.operator === "=" && token === content) ||
|
||||
(this.operator === "!=" && token !== content) ||
|
||||
matches ||
|
||||
(this.operator === "*=" && content.endsWith(token)) ||
|
||||
(this.operator === "=*" && content.startsWith(token)) ||
|
||||
(this.operator === "*=*" && content.includes(token)) ||
|
||||
@@ -138,10 +235,26 @@ class NoteContentFulltextExp extends Expression {
|
||||
} else {
|
||||
// Multi-token matching with fuzzy support and phrase proximity
|
||||
if (this.operator === "~=" || this.operator === "~*") {
|
||||
// Fuzzy phrase matching
|
||||
if (this.matchesWithFuzzy(content, noteId)) {
|
||||
resultNoteSet.add(becca.notes[noteId]);
|
||||
}
|
||||
} else if (this.operator === "=" || this.operator === "!=") {
|
||||
// Exact phrase matching for = and !=
|
||||
let matches = this.containsExactPhrase(this.tokens, content, false);
|
||||
|
||||
// Also check flatText if enabled (includes attributes)
|
||||
if (!matches && this.flatText) {
|
||||
const flatText = becca.notes[noteId].getFlatText();
|
||||
matches = this.containsExactPhrase(this.tokens, flatText, true);
|
||||
}
|
||||
|
||||
if ((this.operator === "=" && matches) ||
|
||||
(this.operator === "!=" && !matches)) {
|
||||
resultNoteSet.add(becca.notes[noteId]);
|
||||
}
|
||||
} else {
|
||||
// Other operators: check all tokens present (any order)
|
||||
const nonMatchingToken = this.tokens.find(
|
||||
(token) =>
|
||||
!this.tokenMatchesContent(token, content, noteId)
|
||||
|
||||
@@ -13,8 +13,41 @@ function getRegex(str: string) {
|
||||
type Comparator<T> = (comparedValue: T) => (val: string) => boolean;
|
||||
|
||||
const stringComparators: Record<string, Comparator<string>> = {
|
||||
"=": (comparedValue) => (val) => val === comparedValue,
|
||||
"!=": (comparedValue) => (val) => val !== comparedValue,
|
||||
"=": (comparedValue) => (val) => {
|
||||
// For the = operator, check if the value contains the exact word or phrase
|
||||
// This is case-insensitive
|
||||
if (!val) return false;
|
||||
|
||||
const normalizedVal = normalizeSearchText(val);
|
||||
const normalizedCompared = normalizeSearchText(comparedValue);
|
||||
|
||||
// If comparedValue has spaces, it's a multi-word phrase
|
||||
// Check for substring match (consecutive phrase)
|
||||
if (normalizedCompared.includes(" ")) {
|
||||
return normalizedVal.includes(normalizedCompared);
|
||||
}
|
||||
|
||||
// For single word, split into words and check for exact word match
|
||||
const words = normalizedVal.split(/\s+/);
|
||||
return words.some(word => word === normalizedCompared);
|
||||
},
|
||||
"!=": (comparedValue) => (val) => {
|
||||
// Negation of exact word/phrase match
|
||||
if (!val) return true;
|
||||
|
||||
const normalizedVal = normalizeSearchText(val);
|
||||
const normalizedCompared = normalizeSearchText(comparedValue);
|
||||
|
||||
// If comparedValue has spaces, it's a multi-word phrase
|
||||
// Check for substring match (consecutive phrase) and negate
|
||||
if (normalizedCompared.includes(" ")) {
|
||||
return !normalizedVal.includes(normalizedCompared);
|
||||
}
|
||||
|
||||
// For single word, split into words and check for exact word match, then negate
|
||||
const words = normalizedVal.split(/\s+/);
|
||||
return !words.some(word => word === normalizedCompared);
|
||||
},
|
||||
">": (comparedValue) => (val) => val > comparedValue,
|
||||
">=": (comparedValue) => (val) => val >= comparedValue,
|
||||
"<": (comparedValue) => (val) => val < comparedValue,
|
||||
|
||||
@@ -38,11 +38,14 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leading
|
||||
|
||||
if (!searchContext.fastSearch) {
|
||||
// For exact match with "=", we need different behavior
|
||||
if (leadingOperator === "=" && tokens.length === 1) {
|
||||
// Exact match on title OR exact match on content
|
||||
if (leadingOperator === "=" && tokens.length >= 1) {
|
||||
// Exact match on title OR exact match on content OR exact match in flat text (includes attributes)
|
||||
// For multi-word, join tokens with space to form exact phrase
|
||||
const titleSearchValue = tokens.join(" ");
|
||||
return new OrExp([
|
||||
new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
|
||||
new NoteContentFulltextExp("=", { tokens, flatText: false })
|
||||
new PropertyComparisonExp(searchContext, "title", "=", titleSearchValue),
|
||||
new NoteContentFulltextExp("=", { tokens, flatText: false }),
|
||||
new NoteContentFulltextExp("=", { tokens, flatText: true })
|
||||
]);
|
||||
}
|
||||
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]);
|
||||
|
||||
@@ -242,18 +242,149 @@ describe("Search", () => {
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Using leading = for exact title match
|
||||
let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
// Using leading = for exact word match - should find notes containing the exact word "example"
|
||||
let searchResults = searchService.findResultsWithQuery("=example", searchContext);
|
||||
expect(searchResults.length).toEqual(2); // "Example Note" and "Sample" (has label "example")
|
||||
expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "Sample")).toBeTruthy();
|
||||
|
||||
// Without =, it should find all notes containing "example"
|
||||
// Without =, it should find all notes containing "example" (substring match)
|
||||
searchResults = searchService.findResultsWithQuery("example", searchContext);
|
||||
expect(searchResults.length).toEqual(3);
|
||||
expect(searchResults.length).toEqual(3); // All notes
|
||||
|
||||
// = operator should not match partial words
|
||||
searchResults = searchService.findResultsWithQuery("=Example", searchContext);
|
||||
expect(searchResults.length).toEqual(0);
|
||||
searchResults = searchService.findResultsWithQuery("=examples", searchContext);
|
||||
expect(searchResults.length).toEqual(1); // Only "Examples of Usage"
|
||||
expect(findNoteByTitle(searchResults, "Examples of Usage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leading = operator for exact match - comprehensive title tests", () => {
|
||||
// Create notes with varying titles to test exact vs contains matching
|
||||
rootNote
|
||||
.child(note("testing"))
|
||||
.child(note("testing123"))
|
||||
.child(note("My testing notes"))
|
||||
.child(note("123testing"))
|
||||
.child(note("test"));
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Test 1: Exact word match with leading = should find notes containing the exact word "testing"
|
||||
let searchResults = searchService.findResultsWithQuery("=testing", searchContext);
|
||||
expect(searchResults.length).toEqual(2); // "testing" and "My testing notes" (word boundary)
|
||||
expect(findNoteByTitle(searchResults, "testing")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "My testing notes")).toBeTruthy();
|
||||
|
||||
// Test 2: Without =, it should find all notes containing "testing" (substring contains behavior)
|
||||
searchResults = searchService.findResultsWithQuery("testing", searchContext);
|
||||
expect(searchResults.length).toEqual(4); // All notes with "testing" substring
|
||||
|
||||
// Test 3: Exact match should only find the exact composite word
|
||||
searchResults = searchService.findResultsWithQuery("=testing123", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "testing123")).toBeTruthy();
|
||||
|
||||
// Test 4: Exact match should only find the exact composite word
|
||||
searchResults = searchService.findResultsWithQuery("=123testing", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "123testing")).toBeTruthy();
|
||||
|
||||
// Test 5: Verify that "test" doesn't match "testing" with exact search
|
||||
searchResults = searchService.findResultsWithQuery("=test", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "test")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leading = operator with quoted phrases", () => {
|
||||
rootNote
|
||||
.child(note("exact phrase"))
|
||||
.child(note("exact phrase match"))
|
||||
.child(note("this exact phrase here"))
|
||||
.child(note("phrase exact"));
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Test 1: With = and quotes, treat as exact phrase match (consecutive words in order)
|
||||
let searchResults = searchService.findResultsWithQuery("='exact phrase'", searchContext);
|
||||
// Should match only notes containing the exact phrase "exact phrase"
|
||||
expect(searchResults.length).toEqual(3); // Only notes with consecutive "exact phrase"
|
||||
expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy();
|
||||
|
||||
// Test 2: Without =, quoted phrase should find substring/contains matches
|
||||
searchResults = searchService.findResultsWithQuery("'exact phrase'", searchContext);
|
||||
expect(searchResults.length).toEqual(3); // All notes containing the phrase substring
|
||||
expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy();
|
||||
expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy();
|
||||
|
||||
// Test 3: Verify word order matters with exact phrase matching
|
||||
searchResults = searchService.findResultsWithQuery("='phrase exact'", searchContext);
|
||||
expect(searchResults.length).toEqual(1); // Only "phrase exact" matches
|
||||
expect(findNoteByTitle(searchResults, "phrase exact")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leading = operator case sensitivity", () => {
|
||||
rootNote
|
||||
.child(note("TESTING"))
|
||||
.child(note("testing"))
|
||||
.child(note("Testing"))
|
||||
.child(note("TeStiNg"));
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Exact match should be case-insensitive (based on lex.ts line 4: str.toLowerCase())
|
||||
let searchResults = searchService.findResultsWithQuery("=testing", searchContext);
|
||||
expect(searchResults.length).toEqual(4); // All variants of "testing"
|
||||
|
||||
searchResults = searchService.findResultsWithQuery("=TESTING", searchContext);
|
||||
expect(searchResults.length).toEqual(4); // All variants
|
||||
|
||||
searchResults = searchService.findResultsWithQuery("=Testing", searchContext);
|
||||
expect(searchResults.length).toEqual(4); // All variants
|
||||
|
||||
searchResults = searchService.findResultsWithQuery("=TeStiNg", searchContext);
|
||||
expect(searchResults.length).toEqual(4); // All variants
|
||||
});
|
||||
|
||||
it("leading = operator with special characters", () => {
|
||||
rootNote
|
||||
.child(note("test-note"))
|
||||
.child(note("test_note"))
|
||||
.child(note("test.note"))
|
||||
.child(note("test note"))
|
||||
.child(note("testnote"));
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Each exact match should only find its specific variant (compound words are treated as single words)
|
||||
let searchResults = searchService.findResultsWithQuery("=test-note", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "test-note")).toBeTruthy();
|
||||
|
||||
searchResults = searchService.findResultsWithQuery("=test_note", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "test_note")).toBeTruthy();
|
||||
|
||||
searchResults = searchService.findResultsWithQuery("=test.note", searchContext);
|
||||
expect(searchResults.length).toEqual(1);
|
||||
expect(findNoteByTitle(searchResults, "test.note")).toBeTruthy();
|
||||
|
||||
// For phrases with spaces, use quotes to keep them together
|
||||
// With exact phrase matching, this finds notes with the consecutive phrase
|
||||
searchResults = searchService.findResultsWithQuery("='test note'", searchContext);
|
||||
expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase
|
||||
expect(findNoteByTitle(searchResults, "test note")).toBeTruthy();
|
||||
|
||||
// Without quotes, "test note" is tokenized as two separate tokens
|
||||
// and will be treated as an exact phrase search with = operator
|
||||
searchResults = searchService.findResultsWithQuery("=test note", searchContext);
|
||||
expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase
|
||||
|
||||
// Without =, should find all matches containing "test" substring
|
||||
searchResults = searchService.findResultsWithQuery("test", searchContext);
|
||||
expect(searchResults.length).toEqual(5);
|
||||
});
|
||||
|
||||
it("fuzzy attribute search", () => {
|
||||
|
||||
@@ -504,15 +504,34 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
|
||||
// If snippet contains linebreaks, limit to max 4 lines and override character limit
|
||||
const lines = snippet.split('\n');
|
||||
if (lines.length > 4) {
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
// Find which lines contain the search tokens to ensure they're included
|
||||
const normalizedLines = lines.map(line => normalizeString(line.toLowerCase()));
|
||||
const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase()));
|
||||
|
||||
// Find the first line that contains a search token
|
||||
let firstMatchLine = -1;
|
||||
for (let i = 0; i < normalizedLines.length; i++) {
|
||||
if (normalizedTokens.some(token => normalizedLines[i].includes(token))) {
|
||||
firstMatchLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstMatchLine !== -1) {
|
||||
// Center the 4-line window around the first match
|
||||
// Try to show 1 line before and 2 lines after the match
|
||||
const startLine = Math.max(0, firstMatchLine - 1);
|
||||
const endLine = Math.min(lines.length, startLine + 4);
|
||||
snippet = lines.slice(startLine, endLine).join('\n');
|
||||
} else {
|
||||
// No match found in lines (shouldn't happen), just take first 4
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
}
|
||||
// Add ellipsis if we truncated lines
|
||||
snippet = snippet + "...";
|
||||
} else if (lines.length > 1) {
|
||||
// For multi-line snippets, just limit to 4 lines (keep existing snippet)
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
if (lines.length > 4) {
|
||||
snippet = snippet + "...";
|
||||
}
|
||||
// For multi-line snippets that are 4 or fewer lines, keep them as-is
|
||||
// No need to truncate
|
||||
} else {
|
||||
// Single line content - apply original word boundary logic
|
||||
// Try to start/end at word boundaries
|
||||
@@ -770,5 +789,8 @@ export default {
|
||||
searchNotesForAutocomplete,
|
||||
findResultsWithQuery,
|
||||
findFirstNoteWithQuery,
|
||||
searchNotes
|
||||
searchNotes,
|
||||
extractContentSnippet,
|
||||
extractAttributeSnippet,
|
||||
highlightSearchResults
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -12,8 +13,8 @@
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.27.2",
|
||||
"preact-iso": "2.11.0",
|
||||
"preact-render-to-string": "6.6.2",
|
||||
"react-i18next": "16.1.2"
|
||||
"preact-render-to-string": "6.6.3",
|
||||
"react-i18next": "16.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
@@ -21,7 +22,7 @@
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
"vite": "7.1.11"
|
||||
"vite": "7.1.12"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
|
||||
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,8 +0,0 @@
|
||||
{
|
||||
"get-started": {
|
||||
"title": "Loslegen",
|
||||
"desktop_title": "Die Desktop-App herunterladen (v{{version}})",
|
||||
"architecture": "Architektur:",
|
||||
"older_releases": "Ältere Releases anzeigen"
|
||||
}
|
||||
}
|
||||
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 |
@@ -1,7 +1,7 @@
|
||||
import { ComponentChildren, HTMLAttributes } from "preact";
|
||||
import { Link } from "./Button.js";
|
||||
import Icon from "./Icon.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
title: ComponentChildren;
|
||||
@@ -13,6 +13,8 @@ interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
}
|
||||
|
||||
export default function Card({ title, children, imageUrl, iconSvg, className, moreInfoUrl, ...restProps }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`card ${className}`} {...restProps}>
|
||||
{imageUrl && <img class="image" src={imageUrl} loading="lazy" />}
|
||||
|
||||
@@ -3,18 +3,21 @@ import "./DownloadButton.css";
|
||||
import Button from "./Button.js";
|
||||
import downloadIcon from "../assets/boxicons/bx-arrow-in-down-square-half.svg?raw";
|
||||
import packageJson from "../../../../package.json" with { type: "json" };
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../i18n.js";
|
||||
import { useContext, useEffect, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LocaleContext } from "../index.js";
|
||||
|
||||
interface DownloadButtonProps {
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
export default function DownloadButton({ big }: DownloadButtonProps) {
|
||||
const locale = useContext(LocaleContext);
|
||||
const { t } = useTranslation();
|
||||
const [ recommendedDownload, setRecommendedDownload ] = useState<RecommendedDownload | null>();
|
||||
useEffect(() => {
|
||||
getRecommendedDownload()?.then(setRecommendedDownload);
|
||||
}, []);
|
||||
getRecommendedDownload(t)?.then(setRecommendedDownload);
|
||||
}, [ t ]);
|
||||
|
||||
return (recommendedDownload &&
|
||||
<>
|
||||
@@ -35,7 +38,7 @@ export default function DownloadButton({ big }: DownloadButtonProps) {
|
||||
) : (
|
||||
<Button
|
||||
className={`download-button desktop-only ${big ? "big" : ""}`}
|
||||
href="/get-started/"
|
||||
href={`/${locale}/get-started/`}
|
||||
iconSvg={downloadIcon}
|
||||
text={<>
|
||||
{t("download_now.text")}
|
||||
|
||||
@@ -5,17 +5,26 @@ footer {
|
||||
color: var(--muted-color);
|
||||
font-size: 0.8em;
|
||||
|
||||
.content-wrapper {
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column-reverse;
|
||||
gap: 2em;
|
||||
margin-bottom: 1em;
|
||||
|
||||
@media (min-width: 720px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
nav.languages {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
gap: 0.5em 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
|
||||
@@ -5,24 +5,46 @@ import githubDiscussionsIcon from "../assets/boxicons/bx-discussion.svg?raw";
|
||||
import matrixIcon from "../assets/boxicons/bx-message-dots.svg?raw";
|
||||
import redditIcon from "../assets/boxicons/bx-reddit.svg?raw";
|
||||
import { Link } from "./Button.js";
|
||||
import { t } from "../i18n";
|
||||
import { LOCALES, swapLocaleInUrl } from "../i18n";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "preact-iso";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { LocaleContext } from "..";
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation();
|
||||
const { url } = useLocation();
|
||||
const currentLocale = useContext(LocaleContext);
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<div class="content-wrapper">
|
||||
<div class="footer-text">
|
||||
© 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br />
|
||||
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>.
|
||||
<div class="row">
|
||||
<div class="footer-text">
|
||||
© 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br />
|
||||
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>.
|
||||
</div>
|
||||
|
||||
<SocialButtons />
|
||||
</div>
|
||||
|
||||
<SocialButtons />
|
||||
<div class="row">
|
||||
<nav class="languages">
|
||||
{LOCALES.map(locale => (
|
||||
locale.id !== currentLocale
|
||||
? <Link href={swapLocaleInUrl(url, locale.id)}>{locale.name}</Link>
|
||||
: <span className="active">{locale.name}</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export function SocialButtons({ className, withText }: { className?: string, withText?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`social-buttons ${className}`}>
|
||||
<SocialButton
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import "./Header.css";
|
||||
import { Link } from "./Button.js";
|
||||
import { SocialButtons, SocialButton } from "./Footer.js";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useLocation } from 'preact-iso';
|
||||
import DownloadButton from './DownloadButton.js';
|
||||
import githubIcon from "../assets/boxicons/bx-github.svg?raw";
|
||||
import Icon from "./Icon.js";
|
||||
import logoPath from "../assets/icon-color.svg";
|
||||
import menuIcon from "../assets/boxicons/bx-menu.svg?raw";
|
||||
import { LocaleContext } from "..";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { swapLocaleInUrl } from "../i18n";
|
||||
|
||||
interface HeaderLink {
|
||||
url: string;
|
||||
@@ -15,21 +18,26 @@ interface HeaderLink {
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
const HEADER_LINKS: HeaderLink[] = [
|
||||
{ url: "/get-started/", text: "Get started" },
|
||||
{ url: "https://docs.triliumnotes.org/", text: "Documentation", external: true },
|
||||
{ url: "/support-us/", text: "Support us" }
|
||||
]
|
||||
|
||||
export function Header(props: {repoStargazersCount: number}) {
|
||||
const { url } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const locale = useContext(LocaleContext);
|
||||
const [ mobileMenuShown, setMobileMenuShown ] = useState(false);
|
||||
|
||||
const [ headerLinks, setHeaderLinks ] = useState<HeaderLink[]>([]);
|
||||
useEffect(() => {
|
||||
setHeaderLinks([
|
||||
{ url: "/get-started", text: t("header.get-started") },
|
||||
{ url: "https://docs.triliumnotes.org/", text: t("header.documentation"), external: true },
|
||||
{ url: "/support-us", text: t("header.support-us") }
|
||||
]);
|
||||
}, [ locale, t ]);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<div class="content-wrapper">
|
||||
<div class="first-row">
|
||||
<a class="banner" href="/">
|
||||
<a class="banner" href={`/${locale}/`}>
|
||||
<img src={logoPath} width="300" height="300" alt="Trilium Notes logo" /> <span>Trilium Notes</span>
|
||||
</a>
|
||||
|
||||
@@ -46,16 +54,17 @@ export function Header(props: {repoStargazersCount: number}) {
|
||||
</div>
|
||||
|
||||
<nav className={`${mobileMenuShown ? "mobile-shown" : ""}`}>
|
||||
{HEADER_LINKS.map(link => (
|
||||
<Link
|
||||
href={link.url}
|
||||
className={url === link.url ? "active" : ""}
|
||||
{headerLinks.map(link => {
|
||||
const linkHref = link.external ? link.url : swapLocaleInUrl(link.url, locale);
|
||||
return (<Link
|
||||
href={linkHref}
|
||||
className={url === linkHref ? "active" : ""}
|
||||
openExternally={link.external}
|
||||
onClick={() => {
|
||||
setMobileMenuShown(false);
|
||||
}}
|
||||
>{link.text}</Link>
|
||||
))}
|
||||
>{link.text}</Link>)
|
||||
})}
|
||||
|
||||
<SocialButtons className="mobile-only" withText />
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import rootPackageJson from '../../../package.json' with { type: "json" };
|
||||
import { t } from './i18n';
|
||||
|
||||
export type App = "desktop" | "server";
|
||||
|
||||
@@ -34,151 +34,155 @@ export interface RecommendedDownload {
|
||||
type DownloadMatrix = Record<App, { [ P in Platform ]?: DownloadMatrixEntry }>;
|
||||
|
||||
// Keep compatibility info inline with https://github.com/electron/electron/blob/main/README.md#platform-support.
|
||||
export const downloadMatrix: DownloadMatrix = {
|
||||
desktop: {
|
||||
windows: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_windows.title_x64"),
|
||||
arm64: t("download_helper_desktop_windows.title_arm64")
|
||||
},
|
||||
description: {
|
||||
x64: t("download_helper_desktop_windows.description_x64"),
|
||||
arm64: t("download_helper_desktop_windows.description_arm64"),
|
||||
},
|
||||
quickStartTitle: t("download_helper_desktop_windows.quick_start"),
|
||||
quickStartCode: "winget install TriliumNext.Notes",
|
||||
downloads: {
|
||||
exe: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_windows.download_exe")
|
||||
export function getDownloadMatrix(t: TFunction<"translation", undefined>): DownloadMatrix {
|
||||
return {
|
||||
desktop: {
|
||||
windows: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_windows.title_x64"),
|
||||
arm64: t("download_helper_desktop_windows.title_arm64")
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_windows.download_zip")
|
||||
description: {
|
||||
x64: t("download_helper_desktop_windows.description_x64"),
|
||||
arm64: t("download_helper_desktop_windows.description_arm64"),
|
||||
},
|
||||
scoop: {
|
||||
name: t("download_helper_desktop_windows.download_scoop"),
|
||||
url: "https://scoop.sh/#/apps?q=trilium&id=7c08bc3c105b9ee5c00dd4245efdea0f091b8a5c"
|
||||
quickStartTitle: t("download_helper_desktop_windows.quick_start"),
|
||||
quickStartCode: "winget install TriliumNext.Notes",
|
||||
downloads: {
|
||||
exe: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_windows.download_exe")
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_windows.download_zip")
|
||||
},
|
||||
scoop: {
|
||||
name: t("download_helper_desktop_windows.download_scoop"),
|
||||
url: "https://scoop.sh/#/apps?q=trilium&id=7c08bc3c105b9ee5c00dd4245efdea0f091b8a5c"
|
||||
}
|
||||
}
|
||||
},
|
||||
linux: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_linux.title_x64"),
|
||||
arm64: t("download_helper_desktop_linux.title_arm64")
|
||||
},
|
||||
description: {
|
||||
x64: t("download_helper_desktop_linux.description_x64"),
|
||||
arm64: t("download_helper_desktop_linux.description_arm64"),
|
||||
},
|
||||
quickStartTitle: t("download_helper_desktop_linux.quick_start"),
|
||||
downloads: {
|
||||
deb: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_linux.download_deb")
|
||||
},
|
||||
rpm: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_linux.download_rpm")
|
||||
},
|
||||
flatpak: {
|
||||
name: t("download_helper_desktop_linux.download_flatpak")
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_linux.download_zip")
|
||||
},
|
||||
nixpkgs: {
|
||||
name: t("download_helper_desktop_linux.download_nixpkgs"),
|
||||
url: "https://search.nixos.org/packages?query=trilium-next"
|
||||
},
|
||||
aur: {
|
||||
name: t("download_helper_desktop_linux.download_aur"),
|
||||
url: "https://aur.archlinux.org/packages/triliumnext-bin"
|
||||
}
|
||||
}
|
||||
},
|
||||
macos: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_macos.title_x64"),
|
||||
arm64: t("download_helper_desktop_macos.title_arm64")
|
||||
},
|
||||
description: {
|
||||
x64: t("download_helper_desktop_macos.description_x64"),
|
||||
arm64: t("download_helper_desktop_macos.description_arm64"),
|
||||
},
|
||||
quickStartTitle: t("download_helper_desktop_macos.quick_start"),
|
||||
quickStartCode: "brew install --cask trilium-notes",
|
||||
downloads: {
|
||||
dmg: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_macos.download_dmg")
|
||||
},
|
||||
homebrew: {
|
||||
name: t("download_helper_desktop_macos.download_homebrew_cask"),
|
||||
url: "https://formulae.brew.sh/cask/trilium-notes#default"
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_macos.download_zip")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
linux: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_linux.title_x64"),
|
||||
arm64: t("download_helper_desktop_linux.title_arm64")
|
||||
},
|
||||
description: {
|
||||
x64: t("download_helper_desktop_linux.description_x64"),
|
||||
arm64: t("download_helper_desktop_linux.description_arm64"),
|
||||
},
|
||||
quickStartTitle: t("download_helper_desktop_linux.quick_start"),
|
||||
downloads: {
|
||||
deb: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_linux.download_deb")
|
||||
},
|
||||
rpm: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_linux.download_rpm")
|
||||
},
|
||||
flatpak: {
|
||||
name: t("download_helper_desktop_linux.download_flatpak")
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_linux.download_zip")
|
||||
},
|
||||
nixpkgs: {
|
||||
name: t("download_helper_desktop_linux.download_nixpkgs"),
|
||||
url: "https://search.nixos.org/packages?query=trilium-next"
|
||||
},
|
||||
aur: {
|
||||
name: t("download_helper_desktop_linux.download_aur"),
|
||||
url: "https://aur.archlinux.org/packages/triliumnext-bin"
|
||||
server: {
|
||||
docker: {
|
||||
title: t("download_helper_server_docker.title"),
|
||||
description: t("download_helper_server_docker.description"),
|
||||
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.html",
|
||||
quickStartCode: "docker pull triliumnext/trilium\ndocker run -p 8080:8080 -d -v ./data:/home/node/trilium-data triliumnext/trilium",
|
||||
downloads: {
|
||||
dockerhub: {
|
||||
name: t("download_helper_server_docker.download_dockerhub"),
|
||||
url: "https://hub.docker.com/r/triliumnext/trilium"
|
||||
},
|
||||
ghcr: {
|
||||
name: t("download_helper_server_docker.download_ghcr"),
|
||||
url: "https://github.com/TriliumNext/Trilium/pkgs/container/trilium"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
macos: {
|
||||
title: {
|
||||
x64: t("download_helper_desktop_macos.title_x64"),
|
||||
arm64: t("download_helper_desktop_macos.title_arm64")
|
||||
},
|
||||
description: {
|
||||
x64: t("download_helper_desktop_macos.description_x64"),
|
||||
arm64: t("download_helper_desktop_macos.description_arm64"),
|
||||
linux: {
|
||||
title: t("download_helper_server_linux.title"),
|
||||
description: t("download_helper_server_linux.description"),
|
||||
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Packaged%20version%20for%20Linux.html",
|
||||
downloads: {
|
||||
tarX64: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_linux.download_tar_x64"),
|
||||
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-x64.tar.xz`,
|
||||
},
|
||||
tarArm64: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_linux.download_tar_arm64"),
|
||||
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-arm64.tar.xz`
|
||||
},
|
||||
nixos: {
|
||||
name: t("download_helper_server_linux.download_nixos"),
|
||||
url: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/On%20NixOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
quickStartTitle: t("download_helper_desktop_macos.quick_start"),
|
||||
quickStartCode: "brew install --cask trilium-notes",
|
||||
downloads: {
|
||||
dmg: {
|
||||
recommended: true,
|
||||
name: t("download_helper_desktop_macos.download_dmg")
|
||||
},
|
||||
homebrew: {
|
||||
name: t("download_helper_desktop_macos.download_homebrew_cask"),
|
||||
url: "https://formulae.brew.sh/cask/trilium-notes#default"
|
||||
},
|
||||
zip: {
|
||||
name: t("download_helper_desktop_macos.download_zip")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
docker: {
|
||||
title: t("download_helper_server_docker.title"),
|
||||
description: t("download_helper_server_docker.description"),
|
||||
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.html",
|
||||
quickStartCode: "docker pull triliumnext/trilium\ndocker run -p 8080:8080 -d -v ./data:/home/node/trilium-data triliumnext/trilium",
|
||||
downloads: {
|
||||
dockerhub: {
|
||||
name: t("download_helper_server_docker.download_dockerhub"),
|
||||
url: "https://hub.docker.com/r/triliumnext/trilium"
|
||||
},
|
||||
ghcr: {
|
||||
name: t("download_helper_server_docker.download_ghcr"),
|
||||
url: "https://github.com/TriliumNext/Trilium/pkgs/container/trilium"
|
||||
}
|
||||
}
|
||||
},
|
||||
linux: {
|
||||
title: t("download_helper_server_linux.title"),
|
||||
description: t("download_helper_server_linux.description"),
|
||||
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Packaged%20version%20for%20Linux.html",
|
||||
downloads: {
|
||||
tarX64: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_linux.download_tar_x64"),
|
||||
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-x64.tar.xz`,
|
||||
},
|
||||
tarArm64: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_linux.download_tar_arm64"),
|
||||
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-arm64.tar.xz`
|
||||
},
|
||||
nixos: {
|
||||
name: t("download_helper_server_linux.download_nixos"),
|
||||
url: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/On%20NixOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
pikapod: {
|
||||
title: t("download_helper_server_hosted.title"),
|
||||
description: t("download_helper_server_hosted.description"),
|
||||
downloads: {
|
||||
pikapod: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_hosted.download_pikapod"),
|
||||
url: "https://www.pikapods.com/pods?run=trilium-next"
|
||||
},
|
||||
triliumcc: {
|
||||
name: t("download_helper_server_hosted.download_triliumcc"),
|
||||
url: "https://trilium.cc/"
|
||||
pikapod: {
|
||||
title: t("download_helper_server_hosted.title"),
|
||||
description: t("download_helper_server_hosted.description"),
|
||||
downloads: {
|
||||
pikapod: {
|
||||
recommended: true,
|
||||
name: t("download_helper_server_hosted.download_pikapod"),
|
||||
url: "https://www.pikapods.com/pods?run=trilium-next"
|
||||
},
|
||||
triliumcc: {
|
||||
name: t("download_helper_server_hosted.download_triliumcc"),
|
||||
url: "https://trilium.cc/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function buildDownloadUrl(app: App, platform: Platform, format: string, architecture: Architecture): string {
|
||||
export function buildDownloadUrl(t: TFunction<"translation", undefined>, app: App, platform: Platform, format: string, architecture: Architecture): string {
|
||||
const downloadMatrix = getDownloadMatrix(t);
|
||||
|
||||
if (app === "desktop") {
|
||||
return downloadMatrix.desktop[platform]?.downloads[format].url ??
|
||||
`https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-v${version}-${platform}-${architecture}.${format}`;
|
||||
@@ -218,8 +222,9 @@ export function getPlatform(): Platform | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecommendedDownload(): Promise<RecommendedDownload | null> {
|
||||
export async function getRecommendedDownload(t: TFunction<"translation", undefined>): Promise<RecommendedDownload | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
const downloadMatrix = getDownloadMatrix(t);
|
||||
|
||||
const architecture = await getArchitecture();
|
||||
const platform = getPlatform();
|
||||
@@ -233,7 +238,7 @@ export async function getRecommendedDownload(): Promise<RecommendedDownload | nu
|
||||
if (!recommendedDownload) return null;
|
||||
|
||||
const format = recommendedDownload[0];
|
||||
const url = buildDownloadUrl("desktop", platform, format || 'zip', architecture);
|
||||
const url = buildDownloadUrl(t, "desktop", platform, format || 'zip', architecture);
|
||||
|
||||
const platformTitle = platformInfo.title;
|
||||
const name = typeof platformTitle === "string" ? platformTitle : platformTitle[architecture] as string;
|
||||
|
||||
32
apps/website/src/i18n.spec.ts
Normal file
32
apps/website/src/i18n.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractLocaleFromUrl, mapLocale, swapLocaleInUrl } from "./i18n";
|
||||
|
||||
describe("mapLocale", () => {
|
||||
it("maps Chinese", () => {
|
||||
expect(mapLocale("zh-TW")).toStrictEqual("zh-Hant");
|
||||
expect(mapLocale("zh-CN")).toStrictEqual("zh-Hans");
|
||||
});
|
||||
|
||||
it("maps languages without countries", () => {
|
||||
expect(mapLocale("ro-RO")).toStrictEqual("ro");
|
||||
expect(mapLocale("ro")).toStrictEqual("ro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swapLocale", () => {
|
||||
it("swap locale in URL", () => {
|
||||
expect(swapLocaleInUrl("/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLocaleFromUrl", () => {
|
||||
it("properly extracts locale", () => {
|
||||
expect(extractLocaleFromUrl("/en/get-started")).toStrictEqual("en");
|
||||
expect(extractLocaleFromUrl("/get-started")).toStrictEqual(undefined);
|
||||
expect(extractLocaleFromUrl("/")).toStrictEqual(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,83 @@
|
||||
import { default as i18next } from "i18next";
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
i18next
|
||||
.use(HttpApi)
|
||||
.use(initReactI18next);
|
||||
interface Locale {
|
||||
id: string;
|
||||
name: string;
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
await i18next.init({
|
||||
debug: true,
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
backend: {
|
||||
loadPath: "/translations/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
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;
|
||||
|
||||
export const t = i18next.t;
|
||||
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ă" },
|
||||
{ id: "zh-Hans", name: "简体中文" },
|
||||
{ id: "zh-Hant", name: "繁體中文" },
|
||||
{ id: "fr", name: "Français" },
|
||||
{ id: "it", name: "Italiano" },
|
||||
{ id: "ja", name: "日本語" },
|
||||
{ id: "pl", name: "Polski" },
|
||||
{ id: "es", name: "Español" },
|
||||
{ id: "ar", name: "اَلْعَرَبِيَّةُ", rtl: true },
|
||||
].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export function mapLocale(locale: string) {
|
||||
if (!locale) return 'en';
|
||||
const lower = locale.toLowerCase();
|
||||
|
||||
if (lower.startsWith('zh')) {
|
||||
if (lower.includes('tw') || lower.includes('hk') || lower.includes('mo') || lower.includes('hant')) {
|
||||
return 'zh-Hant';
|
||||
}
|
||||
return 'zh-Hans';
|
||||
}
|
||||
|
||||
// Default for everything else
|
||||
return locale.split('-')[0]; // e.g. "en-US" -> "en"
|
||||
}
|
||||
|
||||
export function swapLocaleInUrl(url: string, newLocale: string) {
|
||||
const components = url.split("/");
|
||||
if (components.length === 2) {
|
||||
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("/");
|
||||
}
|
||||
}
|
||||
|
||||
export function extractLocaleFromUrl(url: string) {
|
||||
const localeId = url.split('/')[1];
|
||||
const correspondingLocale = LOCALES.find(l => l.id === localeId);
|
||||
if (!correspondingLocale) return undefined;
|
||||
return localeId;
|
||||
}
|
||||
|
||||
@@ -2,39 +2,92 @@ import './style.css';
|
||||
import { FALLBACK_STARGAZERS_COUNT, getRepoStargazersCount } from './github-utils.js';
|
||||
import { Header } from './components/Header.jsx';
|
||||
import { Home } from './pages/Home/index.jsx';
|
||||
import { LocationProvider, Router, Route, hydrate, prerender as ssr } from 'preact-iso';
|
||||
import { LocationProvider, Router, Route, hydrate, prerender as ssr, useLocation } from 'preact-iso';
|
||||
import { NotFound } from './pages/_404.jsx';
|
||||
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, useRef } from 'preact/hooks';
|
||||
import { changeLanguage } from 'i18next';
|
||||
import { extractLocaleFromUrl, initTranslations, LOCALES, mapLocale } from './i18n';
|
||||
|
||||
export const LocaleContext = createContext('en');
|
||||
|
||||
export function App(props: {repoStargazersCount: number}) {
|
||||
return (
|
||||
<LocationProvider>
|
||||
<Header repoStargazersCount={props.repoStargazersCount} />
|
||||
<main>
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
<Route default component={NotFound} />
|
||||
<Route path="/get-started" component={GetStarted} />
|
||||
<Route path="/support-us" component={SupportUs} />
|
||||
</Router>
|
||||
</main>
|
||||
<Footer />
|
||||
<LocaleProvider>
|
||||
<Header repoStargazersCount={props.repoStargazersCount} />
|
||||
<main>
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/get-started" component={GetStarted} />
|
||||
<Route path="/support-us" component={SupportUs} />
|
||||
|
||||
<Route path="/:locale:/" component={Home} />
|
||||
<Route path="/:locale:/get-started" component={GetStarted} />
|
||||
<Route path="/:locale:/support-us" component={SupportUs} />
|
||||
|
||||
<Route default component={NotFound} />
|
||||
</Router>
|
||||
</main>
|
||||
<Footer />
|
||||
</LocaleProvider>
|
||||
</LocationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }) {
|
||||
const { path } = useLocation();
|
||||
const localeId = getLocaleId(path);
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
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";
|
||||
}, [localeId]);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={localeId}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useLayoutEffect, useState } from "preact/hooks";
|
||||
import Card from "../../components/Card.js";
|
||||
import Section from "../../components/Section.js";
|
||||
import { App, Architecture, buildDownloadUrl, downloadMatrix, DownloadMatrixEntry, getArchitecture, getPlatform, Platform } from "../../download-helper.js";
|
||||
import { App, Architecture, buildDownloadUrl, DownloadMatrixEntry, getArchitecture, getDownloadMatrix, getPlatform, Platform } from "../../download-helper.js";
|
||||
import { usePageTitle } from "../../hooks.js";
|
||||
import Button, { Link } from "../../components/Button.js";
|
||||
import Icon from "../../components/Icon.js";
|
||||
import helpIcon from "../../assets/boxicons/bx-help-circle.svg?raw";
|
||||
import "./get-started.css";
|
||||
import packageJson from "../../../../../package.json" with { type: "json" };
|
||||
import { t } from "../../i18n.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function DownloadPage() {
|
||||
const { t } = useTranslation();
|
||||
const [ currentArch, setCurrentArch ] = useState<Architecture>("x64");
|
||||
const [ userPlatform, setUserPlatform ] = useState<Platform>();
|
||||
const downloadMatrix = getDownloadMatrix(t);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
getArchitecture().then((arch) => setCurrentArch(arch ?? "x64"));
|
||||
@@ -71,6 +73,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
||||
return (typeof text === "string" ? text : text[arch]);
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const allDownloads = Object.entries(entry.downloads);
|
||||
const recommendedDownloads = allDownloads.filter(download => download[1].recommended);
|
||||
const restDownloads = allDownloads.filter(download => !download[1].recommended);
|
||||
@@ -107,7 +110,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
||||
{recommendedDownloads.map(recommendedDownload => (
|
||||
<Button
|
||||
className="recommended"
|
||||
href={buildDownloadUrl(app, platform as Platform, recommendedDownload[0], arch)}
|
||||
href={buildDownloadUrl(t, app, platform as Platform, recommendedDownload[0], arch)}
|
||||
text={recommendedDownload[1].name}
|
||||
openExternally={!!recommendedDownload[1].url}
|
||||
/>
|
||||
@@ -117,7 +120,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
||||
<div class="other-options">
|
||||
{restDownloads.map(download => (
|
||||
<Link
|
||||
href={buildDownloadUrl(app, platform as Platform, download[0], arch)}
|
||||
href={buildDownloadUrl(t, app, platform as Platform, download[0], arch)}
|
||||
openExternally={!!download[1].url}
|
||||
>
|
||||
{download[1].name}
|
||||
|
||||
@@ -57,6 +57,8 @@ section.hero-section {
|
||||
color: transparent;
|
||||
line-height: 1.1;
|
||||
font-weight: 400;
|
||||
font-size: 2em;
|
||||
margin-block: 0.65em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,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,10 +29,10 @@ 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 { t } from '../../i18n.js';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
export function Home() {
|
||||
usePageTitle("");
|
||||
@@ -52,6 +52,7 @@ export function Home() {
|
||||
}
|
||||
|
||||
function HeroSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = getPlatform();
|
||||
const colorScheme = useColorScheme();
|
||||
const [ screenshotUrl, setScreenshotUrl ] = useState<string>();
|
||||
@@ -96,6 +97,7 @@ function HeroSection() {
|
||||
}
|
||||
|
||||
function OrganizationBenefitsSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Section className="benefits" title={t("organization_benefits.title")}>
|
||||
@@ -110,6 +112,7 @@ function OrganizationBenefitsSection() {
|
||||
}
|
||||
|
||||
function ProductivityBenefitsSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Section className="benefits accented" title={t("productivity_benefits.title")}>
|
||||
@@ -127,9 +130,10 @@ function ProductivityBenefitsSection() {
|
||||
}
|
||||
|
||||
function NoteTypesSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section className="note-types" title="Multiple ways to represent your information">
|
||||
<ListWithScreenshot horizontal items={[
|
||||
<Section className="note-types" title={t("note_types.title")}>
|
||||
<ListWithScreenshot items={[
|
||||
{
|
||||
title: t("note_types.text_title"),
|
||||
imageUrl: "/type_text.webp",
|
||||
@@ -190,6 +194,7 @@ function NoteTypesSection() {
|
||||
}
|
||||
|
||||
function ExtensibilityBenefitsSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Section className="benefits accented" title={t("extensibility_benefits.title")}>
|
||||
@@ -205,8 +210,9 @@ function ExtensibilityBenefitsSection() {
|
||||
}
|
||||
|
||||
function CollectionsSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section className="collections" title="Collections">
|
||||
<Section className="collections" title={t("collections.title")}>
|
||||
<ListWithScreenshot items={[
|
||||
{
|
||||
title: t("collections.calendar_title"),
|
||||
@@ -235,49 +241,45 @@ 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]);
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function FaqSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section className="faq" title={t("faq.title")}>
|
||||
<div class="grid-2-cols">
|
||||
@@ -301,6 +303,7 @@ function FaqItem({ question, children }: { question: string; children: Component
|
||||
}
|
||||
|
||||
function FinalCta() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section className="final-cta accented" title={t("final_cta.title")}>
|
||||
<p>{t("final_cta.description")}</p>
|
||||
|
||||
@@ -6,10 +6,10 @@ import buyMeACoffeeIcon from "../../assets/boxicons/bx-buy-me-a-coffee.svg?raw";
|
||||
import Button, { Link } from "../../components/Button.js";
|
||||
import Card from "../../components/Card.js";
|
||||
import { usePageTitle } from "../../hooks.js";
|
||||
import { t } from "../../i18n.js";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export default function Donate() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("support_us.title"));
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Section from "../components/Section.js";
|
||||
import { usePageTitle } from "../hooks.js";
|
||||
import { t } from "../i18n.js";
|
||||
import "./_404.css";
|
||||
|
||||
export function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("404.title"));
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,13 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
font-family: Inter,
|
||||
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", "Noto Sans CJK SC",
|
||||
"Hiragino Sans", "Hiragino Kaku Gothic ProN",
|
||||
"Microsoft YaHei", "Meiryo", "Malgun Gothic",
|
||||
"PingFang SC", "Source Han Sans SC",
|
||||
"Source Han Sans JP", "Source Han Sans KR";
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"download_helper_server_hosted": {
|
||||
"title": "استضافة مدفوعة",
|
||||
"download_triliumcc": "بدلا من ذلك، راجع trillum. cc"
|
||||
"download_triliumcc": "بدلا من ذلك، راجع trillum. cc",
|
||||
"download_pikapod": "اعدلد على PikaPods"
|
||||
},
|
||||
"get-started": {
|
||||
"architecture": "المعمارية:",
|
||||
@@ -22,7 +23,8 @@
|
||||
"organization_benefits": {
|
||||
"title": "تنظيم",
|
||||
"note_structure_title": "هيكيلية الملاحظة",
|
||||
"hoisting_title": "مساحات العمل والتركيز على الملاحظة"
|
||||
"hoisting_title": "مساحات العمل والتركيز على الملاحظة",
|
||||
"attributes_title": "العلاقات وجداول الملاحظة"
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"sync_title": "المزامنة",
|
||||
@@ -30,7 +32,8 @@
|
||||
"protected_notes_title": "الملاحظات المحمية",
|
||||
"search_title": "البحث القوي",
|
||||
"web_clipper_title": "اداة قص الويب",
|
||||
"title": "الانتاجية والسلامة"
|
||||
"title": "الانتاجية والسلامة",
|
||||
"jump_to_title": "الاوامر والبحث السريع"
|
||||
},
|
||||
"note_types": {
|
||||
"canvas_title": "مساحة العمل",
|
||||
@@ -66,14 +69,16 @@
|
||||
"paypal": "PayPal",
|
||||
"title": "ادعمنا",
|
||||
"financial_donations_title": "التبرعات المالية",
|
||||
"github_sponsors": "الرعاة على GitHub"
|
||||
"github_sponsors": "الرعاة على GitHub",
|
||||
"buy_me_a_coffee": "Buy Me A Coffee"
|
||||
},
|
||||
"download_helper_desktop_windows": {
|
||||
"download_scoop": "Scoop",
|
||||
"download_exe": "تحميل ملف التثبيت (exe.)",
|
||||
"title_x64": "ويندوز 64 بت",
|
||||
"download_zip": "النسخة المحمولة بصيغة zip",
|
||||
"title_arm64": "نظام ويندوز عاى ARM"
|
||||
"title_arm64": "نظام ويندوز عاى ARM",
|
||||
"quick_start": "للتثبيت باستخدام Winget:"
|
||||
},
|
||||
"download_helper_desktop_linux": {
|
||||
"download_deb": ".deb",
|
||||
@@ -112,6 +117,11 @@
|
||||
"download_dmg": "تحميل ملف التثبيت (dmg.)",
|
||||
"download_homebrew_cask": "Homebrew Cask",
|
||||
"download_zip": "النسخة المحمولة بصيغة zip",
|
||||
"title_x64": "نظام macOS لاصدار intel"
|
||||
"title_x64": "نظام macOS لاصدار intel",
|
||||
"title_arm64": "نظام macOS لمعالجة اجهزة Apple Silicon",
|
||||
"quick_start": "للتثبيت بواسطة Homebrew:"
|
||||
},
|
||||
"contribute": {
|
||||
"title": "طرق اخرى للمساهمة"
|
||||
}
|
||||
}
|
||||
46
apps/website/src/translations/de/translation.json
Normal file
46
apps/website/src/translations/de/translation.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"get-started": {
|
||||
"title": "Loslegen",
|
||||
"desktop_title": "Die Desktop-App herunterladen (v{{version}})",
|
||||
"architecture": "Architektur:",
|
||||
"older_releases": "Ältere Releases anzeigen",
|
||||
"server_title": "Richte einen Server für den Zugriff auf mehreren Geräten ein"
|
||||
},
|
||||
"hero_section": {
|
||||
"github": "GitHub",
|
||||
"get_started": "Loslegen",
|
||||
"dockerhub": "Docker Hub",
|
||||
"title": "Organisieren Sie Ihre Gedanken. Bauen Sie Ihre persönliche Wissensdatenbank auf.",
|
||||
"subtitle": "Trilium ist eine Open-Source-Lösung zum Erstellen von Notizen und Organisieren einer persönlichen Wissensdatenbank. Sie kann lokal auf dem Desktop verwendet oder mit einem selbst gehosteten Server synchronisieren werden, um erstellte Notizen überall verfügbar zu haben.",
|
||||
"screenshot_alt": "Screenshot der Desktop-Anwendung Trilium Notes"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Organisation",
|
||||
"note_structure_title": "Notizstruktur",
|
||||
"attributes_title": "Notiz Labels und Beziehungen"
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"revisions_title": "Notizrevisionen",
|
||||
"title": "Produktivität und Sicherheit",
|
||||
"sync_title": "Synchronisation",
|
||||
"protected_notes_title": "Geschützte Notizen",
|
||||
"jump_to_title": "Schnellsuche und Kommandos",
|
||||
"search_title": "Leistungsstarke Suche",
|
||||
"web_clipper_title": "Web clipper"
|
||||
},
|
||||
"note_types": {
|
||||
"text_title": "Text Notizen",
|
||||
"code_title": "Code Notizen",
|
||||
"canvas_title": "Canvas",
|
||||
"mermaid_title": "Mermaid Diagramm",
|
||||
"mindmap_title": "Mind Map"
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"import_export_title": "Import/Export",
|
||||
"scripting_title": "Erweitertes Scripting",
|
||||
"api_title": "REST API"
|
||||
},
|
||||
"collections": {
|
||||
"calendar_title": "Kalender"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
"web_clipper_content": "Grab web pages (or screenshots) and place them directly into Trilium using the web clipper browser extension."
|
||||
},
|
||||
"note_types": {
|
||||
"title": "Multiple ways to represent your information",
|
||||
"text_title": "Text notes",
|
||||
"text_description": "The notes are edited using a visual (WYSIWYG) editor, with support for tables, images, math expressions, code blocks with syntax highlighting. Quickly format the text using Markdown-like syntax or using slash commands.",
|
||||
"code_title": "Code notes",
|
||||
@@ -65,6 +66,7 @@
|
||||
"api_description": "Interact with Trilium programatically using its builtin REST API."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"calendar_title": "Calendar",
|
||||
"calendar_description": "Organize your personal or professional events using a calendar, with support for all-day and multi-day events. See your events at a glance with the week, month and year views. Easy interaction to add or drag events.",
|
||||
"table_title": "Table",
|
||||
@@ -72,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",
|
||||
@@ -95,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 ",
|
||||
@@ -106,6 +109,11 @@
|
||||
"linux_small": "for Linux",
|
||||
"more_platforms": "More platforms & server setup"
|
||||
},
|
||||
"header": {
|
||||
"get-started": "Get started",
|
||||
"documentation": "Documentation",
|
||||
"support-us": "Support us"
|
||||
},
|
||||
"footer": {
|
||||
"copyright_and_the": " and the ",
|
||||
"copyright_community": "community"
|
||||
@@ -163,7 +171,7 @@
|
||||
"download_helper_desktop_macos": {
|
||||
"title_x64": "macOS for Intel",
|
||||
"title_arm64": "macOS for Apple Silicon",
|
||||
"description_x64": "For Intel-based Macs running macOS Big Sur or later.",
|
||||
"description_x64": "For Intel-based Macs running macOS Monterey or later.",
|
||||
"description_arm64": "For Apple Silicon Macs such as those with M1 and M2 chips.",
|
||||
"quick_start": "To install via Homebrew:",
|
||||
"download_dmg": "Download Installer (.dmg)",
|
||||
@@ -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 ",
|
||||
@@ -163,7 +162,7 @@
|
||||
"download_helper_desktop_macos": {
|
||||
"title_x64": "macOS para Intel",
|
||||
"title_arm64": "macOS para Apple Silicon",
|
||||
"description_x64": "Para Macs con procesador Intel que ejecuten macOS Big Sur o posterior.",
|
||||
"description_x64": "Para Macs con procesador Intel que ejecuten macOS Monterey o posterior.",
|
||||
"description_arm64": "Para Macs con Apple Silicon, como los que tienen chips M1 y M2.",
|
||||
"quick_start": "Para instalar mediante Homebrew:",
|
||||
"download_dmg": "Descargar instalador (.dmg)",
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user