mirror of
https://github.com/zadam/trilium.git
synced 2026-01-26 17:19:13 +01:00
Compare commits
5 Commits
web-clippe
...
week-note
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7e47d8f0 | ||
|
|
457a7a03fb | ||
|
|
6e486c64f1 | ||
|
|
4f3575d765 | ||
|
|
f5f1b27754 |
69
.github/workflows/web-clipper.yml
vendored
69
.github/workflows/web-clipper.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: Deploy web clipper extension
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/web-clipper/**"
|
||||
tags:
|
||||
- "web-clipper-v*"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/web-clipper/**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build web clipper extension
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter web-clipper --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build the web clipper extension
|
||||
run: |
|
||||
pnpm --filter web-clipper zip
|
||||
pnpm --filter web-clipper zip:firefox
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||
with:
|
||||
name: web-clipper-extension
|
||||
path: apps/web-clipper/.output/*.zip
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
- name: Release web clipper extension
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||
with:
|
||||
draft: false
|
||||
fail_on_unmatched_files: true
|
||||
files: apps/web-clipper/.output/*.zip
|
||||
discussion_category_name: Releases
|
||||
make_latest: false
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.9",
|
||||
"@redocly/cli": "2.14.5",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.6.1",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -44,7 +44,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.8.0",
|
||||
"i18next": "25.7.4",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.3.7",
|
||||
"lightningcss": "1.31.1",
|
||||
"happy-dom": "20.3.3",
|
||||
"lightningcss": "1.30.2",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.5"
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ describe("Set boolean with inheritance", () => {
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes boolean normally without inheritance", async () => {
|
||||
@@ -91,7 +91,7 @@ describe("Set boolean with inheritance", () => {
|
||||
name: "foo",
|
||||
value: "false",
|
||||
isInheritable: false
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides boolean with inherited false", async () => {
|
||||
@@ -112,7 +112,7 @@ describe("Set boolean with inheritance", () => {
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes override boolean with inherited false with already existing value", async () => {
|
||||
@@ -134,6 +134,6 @@ describe("Set boolean with inheritance", () => {
|
||||
name: "foo",
|
||||
value: "",
|
||||
isInheritable: false
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,13 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
|
||||
});
|
||||
}
|
||||
|
||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false, componentId?: string) {
|
||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name,
|
||||
value,
|
||||
isInheritable,
|
||||
}, componentId);
|
||||
isInheritable
|
||||
});
|
||||
}
|
||||
|
||||
export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) {
|
||||
@@ -117,15 +117,15 @@ function removeOwnedRelationByName(note: FNote, relationName: string) {
|
||||
* @param name the name of the attribute to set.
|
||||
* @param value the value of the attribute to set.
|
||||
*/
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined, componentId?: string) {
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
|
||||
if (value !== null && value !== undefined) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }, componentId);
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
} else {
|
||||
// Remove the attribute if it exists on the server but we don't define a value for it.
|
||||
const attributeId = note.getAttribute(type, name)?.attributeId;
|
||||
if (attributeId) {
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId);
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
|
||||
* @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s).
|
||||
* @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded.
|
||||
*/
|
||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true, componentId?: string) {
|
||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) {
|
||||
branchIdsToDelete = filterRootNote(branchIdsToDelete);
|
||||
|
||||
if (branchIdsToDelete.length === 0) {
|
||||
@@ -139,9 +139,9 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
|
||||
const branch = froca.getBranch(branchIdToDelete);
|
||||
|
||||
if (deleteAllClones && branch) {
|
||||
await server.remove(`notes/${branch.noteId}${query}`, componentId);
|
||||
await server.remove(`notes/${branch.noteId}${query}`);
|
||||
} else {
|
||||
await server.remove(`branches/${branchIdToDelete}${query}`, componentId);
|
||||
await server.remove(`branches/${branchIdToDelete}${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import shortcuts, { isIMEComposing, keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
import shortcuts, { isIMEComposing,keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
@@ -62,10 +62,9 @@ describe("shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string, extraProps: Partial<KeyboardEvent> = {}) => ({
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`,
|
||||
...extraProps
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
@@ -103,23 +102,17 @@ describe("shortcuts", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should match azerty keys", () => {
|
||||
const event = createKeyboardEvent("A", "KeyQ");
|
||||
expect(keyMatches(event, "a")).toBe(true);
|
||||
expect(keyMatches(event, "q")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match letter keys using code when key is a special character (macOS Alt behavior)", () => {
|
||||
// On macOS, pressing Option/Alt + A produces 'å' as the key, but code is still 'KeyA'
|
||||
const macOSAltAEvent = createKeyboardEvent("å", "KeyA", { altKey: true });
|
||||
const macOSAltAEvent = createKeyboardEvent("å", "KeyA");
|
||||
expect(keyMatches(macOSAltAEvent, "a")).toBe(true);
|
||||
|
||||
// Option + H produces '˙'
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH", { altKey: true });
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH");
|
||||
expect(keyMatches(macOSAltHEvent, "h")).toBe(true);
|
||||
|
||||
// Option + S produces 'ß'
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS", { altKey: true });
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS");
|
||||
expect(keyMatches(macOSAltSEvent, "s")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -223,15 +216,6 @@ describe("shortcuts", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("matches azerty", () => {
|
||||
const event = createKeyboardEvent({
|
||||
key: "a",
|
||||
code: "KeyQ",
|
||||
ctrlKey: true
|
||||
});
|
||||
expect(matchesShortcut(event, "Ctrl+A")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match Alt+letter shortcuts on macOS where key is a special character", () => {
|
||||
// On macOS, pressing Option/Alt + A produces 'å' but code remains 'KeyA'
|
||||
const macOSAltAEvent = createKeyboardEvent({
|
||||
@@ -265,7 +249,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
@@ -296,7 +280,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
@@ -306,7 +290,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,7 +301,7 @@ describe("shortcuts", () => {
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,8 +110,9 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
// Add the event listener in capture phase to intercept events before they reach
|
||||
// child elements like CodeMirror
|
||||
element.addEventListener('keydown', listener, true);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
@@ -138,15 +139,16 @@ export function removeIndividualBinding(binding: ShortcutBinding) {
|
||||
if (activeBindingsInNamespace) {
|
||||
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
|
||||
}
|
||||
binding.element.removeEventListener("keydown", binding.listener);
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener("keydown", binding.listener, true);
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener('keydown', binding.listener, true);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
@@ -215,12 +217,9 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
// For letter keys, use the physical key code for consistency
|
||||
// On macOS, Option/Alt key produces special characters, so we must use e.code
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
if (e.altKey) {
|
||||
// e.code is like "KeyA", "KeyB", etc.
|
||||
const expectedCode = `Key${key.toUpperCase()}`;
|
||||
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
return e.key.toLowerCase() === key.toLowerCase();
|
||||
// e.code is like "KeyA", "KeyB", etc.
|
||||
const expectedCode = `Key${key.toUpperCase()}`;
|
||||
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { MimeType } from "@triliumnext/commons";
|
||||
import { type AutoHighlightResult, ensureMimeTypes, highlight, highlightAuto, type HighlightResult, loadTheme, type Theme,Themes } from "@triliumnext/highlightjs";
|
||||
|
||||
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type AutoHighlightResult, type HighlightResult, type Theme } from "@triliumnext/highlightjs";
|
||||
import mime_types from "./mime_types.js";
|
||||
import options from "./options.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
|
||||
import { isShare } from "./utils.js";
|
||||
import { MimeType } from "@triliumnext/commons";
|
||||
|
||||
let highlightingLoaded = false;
|
||||
|
||||
@@ -77,15 +76,13 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
|
||||
}
|
||||
|
||||
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
|
||||
if (!mimeTypeHint && highlightingLoaded) {
|
||||
if (highlightingLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load theme.
|
||||
if (!highlightingLoaded) {
|
||||
const currentThemeName = String(options.get("codeBlockTheme"));
|
||||
await loadHighlightingTheme(currentThemeName);
|
||||
}
|
||||
const currentThemeName = String(options.get("codeBlockTheme"));
|
||||
await loadHighlightingTheme(currentThemeName);
|
||||
|
||||
// Load mime types.
|
||||
let mimeTypes: MimeType[];
|
||||
@@ -97,7 +94,7 @@ export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
|
||||
enabled: true,
|
||||
mime: mimeTypeHint.replace("-", "/")
|
||||
}
|
||||
];
|
||||
]
|
||||
} else {
|
||||
mimeTypes = mime_types.getMimeTypes();
|
||||
}
|
||||
@@ -127,9 +124,9 @@ export function isSyntaxHighlightEnabled() {
|
||||
if (!isShare) {
|
||||
const theme = options.get("codeBlockTheme");
|
||||
return !!theme && theme !== "none";
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1639,11 +1639,7 @@
|
||||
"configure_launchbar": "配置启动栏"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "此查询没有返回任何数据",
|
||||
"not_executed": "查询尚未执行。",
|
||||
"failed": "SQL 查询执行失败",
|
||||
"execute_now": "立即执行",
|
||||
"statement_result": "执行结果"
|
||||
"no_rows": "此查询没有返回任何数据"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "表"
|
||||
|
||||
@@ -1608,11 +1608,7 @@
|
||||
"configure_launchbar": "Startleiste konfigurieren"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "Es wurden keine Zeilen für diese Abfrage zurückgegeben",
|
||||
"not_executed": "Die Abfrage wurde noch nicht ausgeführt.",
|
||||
"failed": "SQL-Abfrage ist fehlgeschlagen",
|
||||
"execute_now": "Jetzt ausführen",
|
||||
"statement_result": "Anweisung Ergebnis"
|
||||
"no_rows": "Es wurden keine Zeilen für diese Abfrage zurückgegeben"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabellen"
|
||||
|
||||
@@ -21,12 +21,8 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Gagal memuat skrip kustom",
|
||||
"message": "Skrip tidak dapat dijalankan karena:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Gagal mendapatkan daftar widget dari server"
|
||||
},
|
||||
"open-script-note": "Buka skrip catatan"
|
||||
"message": "Skrip dari catatan dengan ID \"{{id}}\", berjudul \"{{title}}\" tidak dapat dijalankan karena:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Tambah tautan",
|
||||
|
||||
@@ -1288,11 +1288,7 @@
|
||||
"search_not_executed": "検索はまだ実行されていません。上の「検索」ボタンをクリックすると、検索結果が表示されます。"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "このクエリでは行が返されませんでした",
|
||||
"not_executed": "クエリはまだ実行されていません。",
|
||||
"failed": "SQLクエリの実行に失敗しました",
|
||||
"statement_result": "ステートメント結果",
|
||||
"execute_now": "今すぐ実行"
|
||||
"no_rows": "このクエリでは行が返されませんでした"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "テーブル"
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"attachment_detail_2": {
|
||||
"deletion_reason": ", deoarece nu există o legătură către atașament în conținutul notiței. Pentru a preveni ștergerea, trebuie adăugată înapoi o legătură către atașament în conținut sau atașamentul trebuie convertit în notiță.",
|
||||
"link_copied": "O legătură către atașament a fost copiată în clipboard.",
|
||||
"role_and_size": "Rol: {{role}}, dimensiune: {{size}}, MIME: {{- mimeType}}",
|
||||
"role_and_size": "Rol: {{role}}, dimensiune: {{size}}",
|
||||
"unrecognized_role": "Rol atașament necunoscut: „{{role}}”.",
|
||||
"will_be_deleted_in": "Acest atașament va fi șters automat în {{time}}",
|
||||
"will_be_deleted_soon": "Acest atașament va fi șters automat în curând"
|
||||
@@ -293,8 +293,7 @@
|
||||
"expand_tooltip": "Expandează subnotițele directe ale acestei colecții (un singur nivel de adâncime). Pentru mai multe opțiuni, apăsați săgeata din dreapta.",
|
||||
"expand_first_level": "Expandează subnotițele directe",
|
||||
"expand_nth_level": "Expandează pe {{depth}} nivele",
|
||||
"expand_all_levels": "Expandează pe toate nivelele",
|
||||
"hide_child_notes": "Ascunde subnotițele din arbore"
|
||||
"expand_all_levels": "Expandează pe toate nivelele"
|
||||
},
|
||||
"bookmark_switch": {
|
||||
"bookmark": "Semn de carte",
|
||||
@@ -570,7 +569,7 @@
|
||||
"file_size": "Dimensiunea fișierului",
|
||||
"file_type": "Tipul fișierului",
|
||||
"note_id": "ID-ul notiței",
|
||||
"open": "Deschide în exterior",
|
||||
"open": "Deschide",
|
||||
"original_file_name": "Denumirea originală a fișierului",
|
||||
"title": "Fișier",
|
||||
"upload_failed": "Încărcarea a unei noi revizii ale fișierului a eșuat.",
|
||||
@@ -796,8 +795,7 @@
|
||||
},
|
||||
"inherited_attribute_list": {
|
||||
"no_inherited_attributes": "Niciun atribut moștenit.",
|
||||
"title": "Atribute moștenite",
|
||||
"none": "niciunul"
|
||||
"title": "Atribute moștenite"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Caută în întregul conținut",
|
||||
@@ -882,11 +880,7 @@
|
||||
"convert_into_attachment_prompt": "Doriți convertirea notiței „{{title}}” într-un atașament al notiței părinte?",
|
||||
"print_pdf": "Exportare ca PDF...",
|
||||
"open_note_on_server": "Deschide notița pe server",
|
||||
"view_revisions": "Revizii ale notițelor...",
|
||||
"export_as_image": "Exportează ca imagine",
|
||||
"export_as_image_png": "PNG (bitmap)",
|
||||
"export_as_image_svg": "SVG (vectorial)",
|
||||
"note_map": "Harta notițelor"
|
||||
"view_revisions": "Revizii ale notițelor..."
|
||||
},
|
||||
"note_erasure_timeout": {
|
||||
"deleted_notes_erased": "Notițele șterse au fost eliminate permanent.",
|
||||
@@ -905,9 +899,7 @@
|
||||
"note_size_info": "Dimensiunea notiței reprezintă o aproximare a cerințelor de stocare ale acestei notițe. Ia în considerare conținutul notiței dar și ale reviziilor sale.",
|
||||
"subtree_size": "(dimensiunea sub-arborelui: {{size}} în {{count}} notițe)",
|
||||
"title": "Informații despre notiță",
|
||||
"type": "Tip",
|
||||
"mime": "Tip MIME",
|
||||
"show_similar_notes": "Afișează notițe similare"
|
||||
"type": "Tip"
|
||||
},
|
||||
"note_launcher": {
|
||||
"this_launcher_doesnt_define_target_note": "Acesată scurtătură nu definește o notiță-destinație."
|
||||
@@ -1165,8 +1157,7 @@
|
||||
"search_parameters": "Parametrii de căutare",
|
||||
"search_script": "script de căutare",
|
||||
"search_string": "șir de căutat",
|
||||
"unknown_search_option": "Opțiune de căutare necunoscută „{{searchOptionName}}”",
|
||||
"view_options": "Opțiuni de afișare:"
|
||||
"unknown_search_option": "Opțiune de căutare necunoscută „{{searchOptionName}}”"
|
||||
},
|
||||
"search_engine": {
|
||||
"baidu": "Baidu",
|
||||
@@ -1318,17 +1309,8 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Eroare la încărcarea unui script personalizat",
|
||||
"message": "Scriptul nu a putut fi executat din cauza:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Nu s-a putut obține lista de widget-uri de la server"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Nu s-a putut randa un widget React"
|
||||
},
|
||||
"widget-missing-parent": "Widget-ul personalizat nu are definită proprietatea necesară „{{property}}“.\n\nDacă acest script este menit să ruleze fără interfață grafică, folosiți '#run=frontendStartup'.",
|
||||
"open-script-note": "Deschide notița scriptului",
|
||||
"scripting-error": "Eroare script personalizat: {{title}}"
|
||||
"message": "Scriptul din notița cu ID-ul „{{id}}”, întitulată „{{title}}” nu a putut fi executată din cauza:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"enable_tray": "Activează system tray-ul (este necesară repornirea aplicației pentru a avea efect)",
|
||||
@@ -1435,10 +1417,7 @@
|
||||
"convert-to-attachment-confirm": "Doriți convertirea notițelor selectate în atașamente ale notiței părinte? Această operațiune se aplică doar notițelor de tip imagine, celelalte vor fi ignorate.",
|
||||
"open-in-popup": "Editare rapidă",
|
||||
"archive": "Arhivează",
|
||||
"unarchive": "Dezarhivează",
|
||||
"open-in-a-new-window": "Deschide în fereastră nouă",
|
||||
"hide-subtree": "Ascunde subnotițele",
|
||||
"show-subtree": "Afișează subnotițele"
|
||||
"unarchive": "Dezarhivează"
|
||||
},
|
||||
"shared_info": {
|
||||
"help_link": "Pentru informații vizitați <a href=\"https://triliumnext.github.io/Docs/Wiki/sharing.html\">wiki-ul</a>.",
|
||||
@@ -1499,27 +1478,12 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"options": "Setări",
|
||||
"title": "Listă de evidențieri",
|
||||
"title_with_count_one": "{{count}} evidențiere",
|
||||
"title_with_count_few": "{{count}} evidențieri",
|
||||
"title_with_count_other": "{{count}} de evidențieri",
|
||||
"modal_title": "Configurează lista de evidențieri",
|
||||
"menu_configure": "Configurează lista de evidențieri...",
|
||||
"no_highlights": "Nu există nicio evidențiere."
|
||||
"title": "Listă de evidențieri"
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Schimbă iconița notiței",
|
||||
"reset-default": "Resetează la iconița implicită",
|
||||
"search": "Căutare:",
|
||||
"search_placeholder_one": "Caută printre {{number}} iconițe dintr-un pachet",
|
||||
"search_placeholder_few": "Caută printre {{number}} iconițe din {{count}} pachete",
|
||||
"search_placeholder_other": "Caută printre {{number}} iconițe din {{count}} de pachete",
|
||||
"search_placeholder_filtered": "Căutați printre {{number}} iconițe în {{name}}",
|
||||
"filter": "Filtrează",
|
||||
"filter-none": "Toate iconițele",
|
||||
"filter-default": "Iconițele implicite",
|
||||
"icon_tooltip": "{{name}}\nPachet iconițe: {{iconPack}}",
|
||||
"no_results": "Nu s-a găsit nicio iconiță."
|
||||
"search": "Căutare:"
|
||||
},
|
||||
"show_highlights_list_widget_button": {
|
||||
"show_highlights_list": "Afișează lista de evidențieri"
|
||||
@@ -1557,17 +1521,7 @@
|
||||
"refresh-saved-search-results": "Reîmprospătează căutarea salvată",
|
||||
"unhoist": "Defocalizează notița",
|
||||
"toggle-sidebar": "Comută bara laterală",
|
||||
"dropping-not-allowed": "Aici nu este permisă plasarea notițelor.",
|
||||
"clone-indicator-tooltip": "Această notiță are {{- count}} părinți: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Această notiță este clonată (un singur părinte: {{- parent}})",
|
||||
"shared-indicator-tooltip": "Această notiță este partajată public",
|
||||
"shared-indicator-tooltip-with-url": "Această notiță este partajată public la: {{- url}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} subnotiță ascunsă din arbore",
|
||||
"subtree-hidden-tooltip_few": "{{count}} subnotițe ascunse din arbore",
|
||||
"subtree-hidden-tooltip_other": "{{count}} de subnotițe ascunse din arbore",
|
||||
"subtree-hidden-moved-title": "Adăugat în {{title}}",
|
||||
"subtree-hidden-moved-description-collection": "Subnotițele din această colecție sunt ascunse din arbore.",
|
||||
"subtree-hidden-moved-description-other": "Subnotițele din această notiță sunt ascunse."
|
||||
"dropping-not-allowed": "Aici nu este permisă plasarea notițelor."
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Menține fereastra mereu vizibilă"
|
||||
@@ -1575,24 +1529,12 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Nu s-a putut găsi widget-ul corespunzător tipului „{{type}}”",
|
||||
"printing": "Imprimare în curs...",
|
||||
"printing_pdf": "Exportare ca PDF în curs...",
|
||||
"print_report_title": "Raport de imprimare",
|
||||
"print_report_collection_content_one": "{{count}} notiță din colecție nu a putut fi imprimată deoarece nu este suportată sau este protejată.",
|
||||
"print_report_collection_content_few": "{{count}} notițe din colecție nu au putut fi imprimate deoarece nu sunt suportate sau sunt protejate.",
|
||||
"print_report_collection_content_other": "{{count}} de notițe din colecție nu au putut fi imprimate deoarece nu sunt suportate sau sunt protejate.",
|
||||
"print_report_collection_details_button": "Afișează detalii",
|
||||
"print_report_collection_details_ignored_notes": "Notițe ignorate"
|
||||
"printing_pdf": "Exportare ca PDF în curs..."
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "introduceți titlul notiței aici...",
|
||||
"created_on": "Creată la <Value />",
|
||||
"last_modified": "Modificată la <Value />",
|
||||
"note_type_switcher_label": "Schimbă din {{type}} la:",
|
||||
"note_type_switcher_others": "Mai multe tipuri de notițe",
|
||||
"note_type_switcher_templates": "Șablon",
|
||||
"note_type_switcher_collection": "Colecție",
|
||||
"edited_notes": "Notițe editate în această zi",
|
||||
"promoted_attributes": "Atribute promovate"
|
||||
"last_modified": "Modificată la <Value />"
|
||||
},
|
||||
"revisions_snapshot_limit": {
|
||||
"erase_excess_revision_snapshots": "Șterge acum reviziile excesive",
|
||||
@@ -1613,11 +1555,7 @@
|
||||
"configure_launchbar": "Configurează bara de lansare"
|
||||
},
|
||||
"sql_result": {
|
||||
"no_rows": "Nu s-a găsit niciun rând pentru această interogare",
|
||||
"not_executed": "Această interogare nu a fost executată încă.",
|
||||
"failed": "Interogarea SQL a eșuat",
|
||||
"statement_result": "Rezultatul comenzii SQL",
|
||||
"execute_now": "Execută acum"
|
||||
"no_rows": "Nu s-a găsit niciun rând pentru această interogare"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabele"
|
||||
@@ -1639,8 +1577,7 @@
|
||||
},
|
||||
"toc": {
|
||||
"options": "Setări",
|
||||
"table_of_contents": "Cuprins",
|
||||
"no_headings": "Niciun titlu."
|
||||
"table_of_contents": "Cuprins"
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Fișierul <code class=\"file-path\"></code> a fost ultima oară modificat la data de <span class=\"file-last-modified\"></span>.",
|
||||
@@ -2075,7 +2012,7 @@
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Ascunde weekend-urile",
|
||||
"display-week-numbers": "Afișează numărul săptămânii",
|
||||
"map-style": "Stil hartă",
|
||||
"map-style": "Stil hartă:",
|
||||
"max-nesting-depth": "Nivel maxim de imbricare:",
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vectorial (culoare deschisă)",
|
||||
@@ -2132,10 +2069,7 @@
|
||||
"next_theme_title": "Încercați noua temă Trilium",
|
||||
"next_theme_message": "Utilizați tema clasică, doriți să încercați noua temă?",
|
||||
"next_theme_button": "Testează noua temă",
|
||||
"dismiss": "Treci peste",
|
||||
"new_layout_title": "Aspect nou",
|
||||
"new_layout_message": "Am introdus un aspect modernizat pentru Trilium. Panglică a fost integrată în restul interfeței, cu o bară de stare nouă și secțiuni expandabile (precum atributele promovate) ce preiau funcționalitatea de bază.\n\nNoul aspect este activat în mod implicit, și se poate dezactiva momentan din Opțiuni → Aspect.",
|
||||
"new_layout_button": "Mai multe informații"
|
||||
"dismiss": "Treci peste"
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "Setări de performanță",
|
||||
@@ -2150,10 +2084,7 @@
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Tema de culori pentru blocuri de cod în notițe de tip text",
|
||||
"related_code_notes": "Tema de culori pentru notițele de tip cod",
|
||||
"ui": "Interfață grafică",
|
||||
"ui_old_layout": "Aspect vechi",
|
||||
"ui_new_layout": "Aspect nou"
|
||||
"related_code_notes": "Tema de culori pentru notițele de tip cod"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
@@ -2209,77 +2140,6 @@
|
||||
"read_only_temporarily_disabled": "Editabilă temporar",
|
||||
"read_only_temporarily_disabled_description": "Această notiță se poate modifica, deși în mod normal ea este doar în citire. Notița va reveni la modul doar în citire imediat ce navigați către altă notiță.\n\nClick pentru a re-activa modul doar în citire.",
|
||||
"shared_publicly": "Partajată public",
|
||||
"shared_locally": "Partajată local",
|
||||
"shared_copy_to_clipboard": "Copiază legătură în clipboard",
|
||||
"shared_open_in_browser": "Deschide legătura în browser",
|
||||
"shared_unshare": "Înlătură partajarea",
|
||||
"clipped_note": "Decupare web",
|
||||
"clipped_note_description": "Această notiță a fost preluată de la {{url}}.\n\nClic pentru a naviga la pagina web sursă.",
|
||||
"execute_script": "Rulează script",
|
||||
"execute_script_description": "Această notiță este un script. Clic pentru a executa scriptul.",
|
||||
"execute_sql": "Rulează SQL",
|
||||
"execute_sql_description": "Această notiță este de tip SQL. Clic pentru a executa interogarea SQL.",
|
||||
"save_status_saved": "Salvat",
|
||||
"save_status_saving": "Se salvează...",
|
||||
"save_status_unsaved": "Nesalvat",
|
||||
"save_status_error": "Salvarea a eșuat",
|
||||
"save_status_saving_tooltip": "Modificările sunt în curs de salvare.",
|
||||
"save_status_unsaved_tooltip": "Există schimbări ce nu au fost încă salvate. Acestea vor fi salvate automat într-un moment.",
|
||||
"save_status_error_tooltip": "A intervenit o eroare la salvarea notiței. Dacă este posibil, încercați să copiați conținutul notiței într-un alt loc și să reîmprospătați aplicația."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"hoisted_badge": "Focalizat",
|
||||
"hoisted_badge_title": "Defocalizează",
|
||||
"workspace_badge": "Spațiu de lucru",
|
||||
"scroll_to_top_title": "Sari la începutul notiței",
|
||||
"create_new_note": "Crează subnotiță",
|
||||
"empty_hide_archived_notes": "Ascunde notițele arhivate"
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "Schimbă limba conținutului",
|
||||
"note_info_title": "Afișează informații despre notiță precum data modificării și dimensiunea",
|
||||
"backlinks_one": "{{count}} legătură de retur",
|
||||
"backlinks_few": "{{count}} legături de retur",
|
||||
"backlinks_other": "{{count}} de legături de retur",
|
||||
"backlinks_title_one": "Afișează legătura de retur",
|
||||
"backlinks_title_few": "Afișează legăturile de retur",
|
||||
"backlinks_title_other": "Afișează legăturile de retur",
|
||||
"attachments_one": "{{count}} atașament",
|
||||
"attachments_few": "{{count}} atașamente",
|
||||
"attachments_other": "{{count}} de atașamente",
|
||||
"attachments_title_one": "Deschide atașamentul într-un tab nou",
|
||||
"attachments_title_few": "Deschide atașamentele într-un tab nou",
|
||||
"attachments_title_other": "Deschide atașamentele într-un tab nou",
|
||||
"attributes_one": "{{count}} atribut",
|
||||
"attributes_few": "{{count}} atribute",
|
||||
"attributes_other": "{{count}} de atribute",
|
||||
"attributes_title": "Atribute proprii și moștenite",
|
||||
"note_paths_one": "O cale",
|
||||
"note_paths_few": "{{count}} căi",
|
||||
"note_paths_other": "{{count}} de căi",
|
||||
"note_paths_title": "Căi ale notiței",
|
||||
"code_note_switcher": "Schimbă limbajul"
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "Atributele notiței"
|
||||
},
|
||||
"right_pane": {
|
||||
"empty_message": "Nimic de afișat pentru această notiță",
|
||||
"empty_button": "Ascunde panoul",
|
||||
"toggle": "Comută panoul din dreapta",
|
||||
"custom_widget_go_to_source": "Mergi la codul sursă"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} atașament",
|
||||
"attachments_few": "{{count}} atașamente",
|
||||
"attachments_other": "{{count}} de atașamente",
|
||||
"layers_one": "{{count}} strat",
|
||||
"layers_few": "{{count}} straturi",
|
||||
"layers_other": "{{count}} de straturi",
|
||||
"pages_one": "{{count}} pagină",
|
||||
"pages_few": "{{count}} pagini",
|
||||
"pages_other": "{{count}} de pagini",
|
||||
"pages_alt": "Pagina {{pageNumber}}",
|
||||
"pages_loading": "Încărcare..."
|
||||
"shared_locally": "Partajată local"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
"edit_branch_prefix": "Редагувати префікс гілки",
|
||||
"help_on_tree_prefix": "Довідка щодо префіксу дерева",
|
||||
"prefix": "Префікс: ",
|
||||
"branch_prefix_saved": "Префікс гілки збережено.",
|
||||
"edit_branch_prefix_multiple": "Редагувати префікс гілки для {{count}} гілок",
|
||||
"branch_prefix_saved_multiple": "Префікс гілки збережено для {{count}} гілок.",
|
||||
"affected_branches": "Уражені гілки ({{count}}):"
|
||||
"branch_prefix_saved": "Префікс гілки збережено."
|
||||
},
|
||||
"about": {
|
||||
"app_version": "Версія програми:",
|
||||
@@ -73,17 +70,8 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Не вдалося завантажити користувацький скрипт",
|
||||
"message": "Скрипт не вдалося виконати через:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Не вдалося отримати список віджетів з сервера"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Не вдалося відобразити користувацький віджет"
|
||||
},
|
||||
"widget-missing-parent": "Для власного віджета не визначено {{property}} обов'язкову властивість\n\nЯкщо цей скрипт призначений для запуску без елемента інтерфейсу користувача, використовуйте замість нього '#run=frontendStartup'.",
|
||||
"open-script-note": "Відкрити нотатку сценарію",
|
||||
"scripting-error": "Помилка користувацького скрипта: {{title}}"
|
||||
"message": "Скрипт з нотатки ID \"{{id}}\" з заголовком \"{{title}}\" не вдалося виконати через:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Масові дії",
|
||||
@@ -211,8 +199,7 @@
|
||||
"export_status": "Статус експорту",
|
||||
"export_in_progress": "Триває експорт: {{progressCount}}",
|
||||
"export_finished_successfully": "Експорт успішно завершено.",
|
||||
"format_pdf": "PDF – для друку або спільного використання.",
|
||||
"share-format": "HTML для веб-публікацій – використовує ту саму тему, що й для спільних нотаток, але може бути опублікований як статичний веб-сайт."
|
||||
"format_pdf": "PDF – для друку або спільного використання."
|
||||
},
|
||||
"help": {
|
||||
"title": "Шпаргалка",
|
||||
@@ -266,8 +253,7 @@
|
||||
"showSQLConsole": "показати консоль SQL",
|
||||
"other": "Інше",
|
||||
"quickSearch": "фокус на швидкому введенні пошуку",
|
||||
"inPageSearch": "пошук на сторінці",
|
||||
"editShortcuts": "Редагувати комбінації клавіш"
|
||||
"inPageSearch": "пошук на сторінці"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Імпортувати в нотатку",
|
||||
@@ -863,10 +849,7 @@
|
||||
"note_icon": {
|
||||
"change_note_icon": "Змінити значок нотатки",
|
||||
"search": "Пошук:",
|
||||
"reset-default": "Скинути значок до стандартного значення",
|
||||
"search_placeholder_one": "Пошук {{number}} значка у {{count}} пакеті",
|
||||
"search_placeholder_few": "Пошук {{number}} значків у {{count}} пакетах",
|
||||
"search_placeholder_many": "Пошук {{number}} значків у {{count}} пакетах"
|
||||
"reset-default": "Скинути значок до стандартного значення"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Тип нотатки",
|
||||
@@ -901,7 +884,7 @@
|
||||
"file_type": "Тип файлу",
|
||||
"file_size": "Розмір файлу",
|
||||
"download": "Завантажити",
|
||||
"open": "Відкрити зовні",
|
||||
"open": "Відкрити",
|
||||
"upload_new_revision": "Завантажити нову версію",
|
||||
"upload_success": "Завантажено нову версію файлу.",
|
||||
"upload_failed": "Не вдалося завантажити нову версію файлу.",
|
||||
@@ -1606,19 +1589,13 @@
|
||||
"refresh-saved-search-results": "Оновити збережені результати пошуку",
|
||||
"create-child-note": "Створити дочірню нотатку",
|
||||
"unhoist": "Відкріпити",
|
||||
"toggle-sidebar": "Перемикання бічної панелі",
|
||||
"subtree-hidden-tooltip_one": "{{count}} дочірня нотатка, прихована від дерев",
|
||||
"subtree-hidden-tooltip_few": "{{count}} дочірніх нотатки, прихованих від дерев",
|
||||
"subtree-hidden-tooltip_many": "{{count}} дочірніх нотаток, прихованих від дерев"
|
||||
"toggle-sidebar": "Перемикання бічної панелі"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Тримати вікно зверху"
|
||||
},
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Не вдалося знайти typeWidget для типу '{{type}}'",
|
||||
"print_report_collection_content_one": "{{count}} нотатку з колекції не вдалося роздрукувати, тому що вони не підтримуються або захищені.",
|
||||
"print_report_collection_content_few": "{{count}} нотатки з колекції не вдалося роздрукувати, тому що вони не підтримуються або захищені.",
|
||||
"print_report_collection_content_many": "{{count}} нотаток з колекції не вдалося роздрукувати, тому що вони не підтримуються або захищені."
|
||||
"could_not_find_typewidget": "Не вдалося знайти typeWidget для типу '{{type}}'"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "введіть тут заголовок нотатки..."
|
||||
@@ -1766,7 +1743,7 @@
|
||||
"unknown_widget": "Невідомий віджет для \"{{id}}\"."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "Мову не встановлено",
|
||||
"not_set": "Не встановлено",
|
||||
"configure-languages": "Налаштувати мови..."
|
||||
},
|
||||
"content_language": {
|
||||
@@ -1833,7 +1810,7 @@
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Приховати вихідні",
|
||||
"display-week-numbers": "Відображення номерів тижнів",
|
||||
"map-style": "Стиль карти",
|
||||
"map-style": "Стиль карти:",
|
||||
"max-nesting-depth": "Максимальна глибина вкладення:",
|
||||
"raster": "Растр",
|
||||
"vector_light": "Вектор (Світла)",
|
||||
@@ -1886,7 +1863,7 @@
|
||||
"will_be_deleted_in": "Це вкладення буде автоматично видалено через {{time}}",
|
||||
"will_be_deleted_soon": "Це вкладення незабаром буде автоматично видалено",
|
||||
"deletion_reason": ", оскільки вкладення не має посилання у вмісті нотатки. Щоб запобігти видаленню, додайте посилання на вкладення назад у вміст або перетворіть вкладення на нотатку.",
|
||||
"role_and_size": "Роль: {{role}}, розмір: {{size}}, формат даних: {{- mimeType}}",
|
||||
"role_and_size": "Роль: {{role}}, Розмір: {{size}}",
|
||||
"link_copied": "Посилання на вкладення скопійовано в буфер обміну.",
|
||||
"unrecognized_role": "Нерозпізнана роль вкладення '{{role}}'."
|
||||
},
|
||||
@@ -1937,7 +1914,7 @@
|
||||
"import-into-note": "Імпортувати в нотатку",
|
||||
"apply-bulk-actions": "Застосувати масові дії",
|
||||
"converted-to-attachments": "({{count}}) нотаток перетворено на вкладення.",
|
||||
"convert-to-attachment-confirm": "Ви впевнені, що хочете конвертувати вибрані нотатки у вкладення до їхніх батьківських нотаток? Ця операція застосовується лише до нотаток із зображеннями, інші нотатки будуть пропущені.",
|
||||
"convert-to-attachment-confirm": "Ви впевнені, що хочете конвертувати вибрані нотатки у вкладення до їхніх батьківських нотаток?",
|
||||
"open-in-popup": "Швидке редагування",
|
||||
"archive": "Архівувати",
|
||||
"unarchive": "Розархівувати"
|
||||
@@ -2001,10 +1978,7 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Список основних моментів",
|
||||
"options": "Параметри",
|
||||
"title_with_count_one": "{{count}} виділення",
|
||||
"title_with_count_few": "{{count}} виділення",
|
||||
"title_with_count_many": "{{count}} виділень"
|
||||
"options": "Параметри"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Видалити рядок"
|
||||
@@ -2077,36 +2051,5 @@
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Не вдалося показати вміст через помилку."
|
||||
},
|
||||
"status_bar": {
|
||||
"backlinks_one": "{{count}} зворотне посилання",
|
||||
"backlinks_few": "{{count}} зворотні посилання",
|
||||
"backlinks_many": "{{count}} зворотних посилань",
|
||||
"backlinks_title_one": "Переглянути зворотне посилання",
|
||||
"backlinks_title_few": "Переглянути зворотні посилання",
|
||||
"backlinks_title_many": "Переглянути зворотніх посилань",
|
||||
"attachments_one": "{{count}} вкладення",
|
||||
"attachments_few": "{{count}} вкладення",
|
||||
"attachments_many": "{{count}} вкладень",
|
||||
"attachments_title_one": "Переглянути вкладення в новій вкладці",
|
||||
"attachments_title_few": "Переглянути вкладення в новій вкладці",
|
||||
"attachments_title_many": "Переглянути вкладень в новій вкладці",
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
"attributes_few": "{{count}} атрибути",
|
||||
"attributes_many": "{{count}} атрибутів",
|
||||
"note_paths_one": "{{count}} шлях",
|
||||
"note_paths_few": "{{count}} шляхи",
|
||||
"note_paths_many": "{{count}} шляхів"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} вкладення",
|
||||
"attachments_few": "{{count}} вкладення",
|
||||
"attachments_many": "{{count}} вкладень",
|
||||
"layers_one": "{{count}} шар",
|
||||
"layers_few": "{{count}} шари",
|
||||
"layers_many": "{{count}} шарів",
|
||||
"pages_one": "{{count}} сторінка",
|
||||
"pages_few": "{{count}} сторінки",
|
||||
"pages_many": "{{count}} сторінок"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,6 @@ function LabelInput({ inputId, ...props }: CellProps & { inputId: string }) {
|
||||
id={inputId}
|
||||
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
|
||||
value={valueAttr.value}
|
||||
checked={definition.labelType === "boolean" ? valueAttr.value === "true" : undefined}
|
||||
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||
data-attribute-id={valueAttr.attributeId}
|
||||
data-attribute-type={valueAttr.type}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
|
||||
|
||||
import { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import server from "../../../services/server";
|
||||
import froca from "../../../services/froca";
|
||||
|
||||
interface NewEventOpts {
|
||||
title: string;
|
||||
@@ -10,7 +10,6 @@ interface NewEventOpts {
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
interface ChangeEventOpts {
|
||||
@@ -18,48 +17,30 @@ interface ChangeEventOpts {
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime, componentId }: NewEventOpts) {
|
||||
const attributes: Omit<AttributeRow, "noteId" | "attributeId">[] = [];
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "startDate",
|
||||
value: startDate
|
||||
});
|
||||
if (endDate) {
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "endDate",
|
||||
value: endDate
|
||||
});
|
||||
}
|
||||
if (startTime) {
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "startTime",
|
||||
value: startTime
|
||||
});
|
||||
}
|
||||
if (endTime) {
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "endTime",
|
||||
value: endTime
|
||||
});
|
||||
}
|
||||
|
||||
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime }: NewEventOpts) {
|
||||
// Create the note.
|
||||
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text",
|
||||
attributes
|
||||
}, componentId);
|
||||
type: "text"
|
||||
});
|
||||
|
||||
// Set the attributes.
|
||||
setLabel(note.noteId, "startDate", startDate);
|
||||
if (endDate) {
|
||||
setLabel(note.noteId, "endDate", endDate);
|
||||
}
|
||||
if (startTime) {
|
||||
setLabel(note.noteId, "startTime", startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
setLabel(note.noteId, "endTime", endTime);
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime, componentId }: ChangeEventOpts) {
|
||||
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime }: ChangeEventOpts) {
|
||||
// Don't store the end date if it's empty.
|
||||
if (endDate === startDate) {
|
||||
endDate = undefined;
|
||||
@@ -71,12 +52,12 @@ export async function changeEvent(note: FNote, { startDate, endDate, startTime,
|
||||
let endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate";
|
||||
|
||||
const noteId = note.noteId;
|
||||
setLabel(noteId, startAttribute, startDate, false, componentId);
|
||||
setAttribute(note, "label", endAttribute, endDate, componentId);
|
||||
setLabel(noteId, startAttribute, startDate);
|
||||
setAttribute(note, "label", endAttribute, endDate);
|
||||
|
||||
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
|
||||
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
|
||||
|
||||
setAttribute(note, "label", startAttribute, startTime, componentId);
|
||||
setAttribute(note, "label", endAttribute, endTime, componentId);
|
||||
setAttribute(note, "label", startAttribute, startTime);
|
||||
setAttribute(note, "label", endAttribute, endTime);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
|
||||
import { getArchiveMenuItem } from "../../../menus/context_menu_utils";
|
||||
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker";
|
||||
import link_context_menu from "../../../menus/link_context_menu";
|
||||
import branches from "../../../services/branches";
|
||||
import { getArchiveMenuItem } from "../../../menus/context_menu_utils";
|
||||
import { t } from "../../../services/i18n";
|
||||
|
||||
export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote, componentId?: string) {
|
||||
export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -30,16 +30,16 @@ export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parent
|
||||
}
|
||||
|
||||
if (branchIdToDelete) {
|
||||
await branches.deleteNotes([ branchIdToDelete ], false, false, componentId);
|
||||
await branches.deleteNotes([ branchIdToDelete ], false, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "custom",
|
||||
componentFn: () => NoteColorPicker({note})
|
||||
componentFn: () => NoteColorPicker({note: note})
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
|
||||
import clsx from "clsx";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import froca from "../../../services/froca";
|
||||
import server from "../../../services/server";
|
||||
import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import server from "../../../services/server";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Event {
|
||||
startDate: string,
|
||||
@@ -106,8 +105,7 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
|
||||
|
||||
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
|
||||
const eventData: EventInput = {
|
||||
id: note.noteId,
|
||||
title,
|
||||
title: title,
|
||||
start: startDate,
|
||||
url: `#${note.noteId}?popup`,
|
||||
noteId: note.noteId,
|
||||
@@ -150,12 +148,12 @@ async function parseCustomTitle(customTitlettributeName: string | null, note: FN
|
||||
}
|
||||
|
||||
async function buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
|
||||
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name));
|
||||
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
|
||||
const result: Array<[string, string]> = [];
|
||||
|
||||
for (const attribute of filteredDisplayedAttributes) {
|
||||
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
|
||||
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""]);
|
||||
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, Local
|
||||
import { DateClickArg } from "@fullcalendar/interaction";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
@@ -17,7 +17,6 @@ import { isMobile } from "../../../services/utils";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Button, { ButtonGroup } from "../../react/Button";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import { ParentComponent } from "../../react/react_utils";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { changeEvent, newEvent } from "./api";
|
||||
@@ -88,7 +87,6 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
|
||||
};
|
||||
|
||||
export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarViewData>) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
|
||||
@@ -107,34 +105,26 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
|
||||
const eventBuilder = useMemo(() => {
|
||||
if (!isCalendarRoot) {
|
||||
return async () => await buildEvents(noteIds);
|
||||
}
|
||||
}
|
||||
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
|
||||
|
||||
}, [isCalendarRoot, noteIds]);
|
||||
|
||||
const plugins = usePlugins(isEditable, isCalendarRoot);
|
||||
const locale = useLocale();
|
||||
|
||||
const { eventDidMount } = useEventDisplayCustomization(note, parentComponent?.componentId);
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot, parentComponent?.componentId);
|
||||
const { eventDidMount } = useEventDisplayCustomization(note);
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot);
|
||||
|
||||
// React to changes.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
const api = calendarRef.current;
|
||||
if (!api) return;
|
||||
|
||||
// Subnote attribute change.
|
||||
if (loadResults.getAttributeRows(parentComponent?.componentId).some((a) => noteIds.includes(a.noteId ?? ""))) {
|
||||
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|
||||
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
|
||||
{
|
||||
// Defer execution after the load results are processed so that the event builder has the updated data to work with.
|
||||
setTimeout(() => api.refetchEvents(), 0);
|
||||
return; // early return since we'll refresh the events anyway
|
||||
}
|
||||
|
||||
// Title change.
|
||||
for (const noteId of loadResults.getNoteIds().filter(noteId => noteIds.includes(noteId))) {
|
||||
const event = api.getEventById(noteId);
|
||||
const note = froca.getNoteFromCache(noteId);
|
||||
if (!event || !note) continue;
|
||||
event.setProp("title", note.title);
|
||||
setTimeout(() => {
|
||||
calendarRef.current?.refetchEvents();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,7 +222,7 @@ function useLocale() {
|
||||
return calendarLocale;
|
||||
}
|
||||
|
||||
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, componentId: string | undefined) {
|
||||
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e);
|
||||
if (!startDate) return;
|
||||
@@ -244,8 +234,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
|
||||
return;
|
||||
}
|
||||
|
||||
newEvent(note, { title, startDate, endDate, startTime, endTime, componentId });
|
||||
}, [ note, componentId ]);
|
||||
newEvent(note, { title, startDate, endDate, startTime, endTime });
|
||||
}, [ note ]);
|
||||
|
||||
const onEventChange = useCallback(async (e: EventChangeArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
|
||||
@@ -254,8 +244,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
|
||||
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
|
||||
const note = await froca.getNote(e.event.extendedProps.noteId);
|
||||
if (!note) return;
|
||||
changeEvent(note, { startDate, endDate, startTime, endTime, componentId });
|
||||
}, [ componentId ]);
|
||||
changeEvent(note, { startDate, endDate, startTime, endTime });
|
||||
}, []);
|
||||
|
||||
// Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
|
||||
const onDateClick = useCallback(async (e: DateClickArg) => {
|
||||
@@ -274,7 +264,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
|
||||
};
|
||||
}
|
||||
|
||||
function useEventDisplayCustomization(parentNote: FNote, componentId: string | undefined) {
|
||||
function useEventDisplayCustomization(parentNote: FNote) {
|
||||
const eventDidMount = useCallback((e: EventMountArg) => {
|
||||
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
||||
|
||||
@@ -331,7 +321,7 @@ function useEventDisplayCustomization(parentNote: FNote, componentId: string | u
|
||||
const note = await froca.getNote(e.event.extendedProps.noteId);
|
||||
if (!note) return;
|
||||
|
||||
openCalendarContextMenu(contextMenuEvent, note, parentNote, componentId);
|
||||
openCalendarContextMenu(contextMenuEvent, note, parentNote);
|
||||
}
|
||||
|
||||
if (isMobile()) {
|
||||
|
||||
@@ -82,10 +82,6 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .note-detail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal.popup-editor-dialog .note-detail.full-height {
|
||||
flex-grow: 0;
|
||||
height: 100%;
|
||||
@@ -110,4 +106,4 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import clsx from "clsx";
|
||||
import server from "../../services/server";
|
||||
import { TargetedMouseEvent, VNode } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Dayjs } from "@triliumnext/commons";
|
||||
import { Dayjs, getWeekInfo, WeekSettings } from "@triliumnext/commons";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
interface DateNotesForMonth {
|
||||
@@ -22,6 +22,7 @@ const DAYS_OF_WEEK = [
|
||||
|
||||
interface DateRangeInfo {
|
||||
weekNumbers: number[];
|
||||
weekYears: number[];
|
||||
dates: Dayjs[];
|
||||
}
|
||||
|
||||
@@ -36,19 +37,27 @@ export interface CalendarArgs {
|
||||
|
||||
export default function Calendar(args: CalendarArgs) {
|
||||
const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
|
||||
const [ firstWeekOfYear ] = useTriliumOptionInt("firstWeekOfYear");
|
||||
const [ minDaysInFirstWeek ] = useTriliumOptionInt("minDaysInFirstWeek");
|
||||
const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek);
|
||||
|
||||
const weekSettings = {
|
||||
firstDayOfWeek: firstDayOfWeekISO,
|
||||
firstWeekOfYear: firstWeekOfYear ?? 0,
|
||||
minDaysInFirstWeek: minDaysInFirstWeek ?? 4
|
||||
};
|
||||
|
||||
const date = args.date;
|
||||
const firstDay = date.startOf('month');
|
||||
const firstDayISO = firstDay.isoWeekday();
|
||||
const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO);
|
||||
const monthInfo = getMonthInformation(date, firstDayISO, weekSettings);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeekHeader rawFirstDayOfWeek={rawFirstDayOfWeek} />
|
||||
<div className="calendar-body" data-calendar-area="month">
|
||||
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} {...args} />}
|
||||
<CurrentMonthDays firstDayOfWeekISO={firstDayOfWeekISO} {...args} />
|
||||
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} weekSettings={weekSettings} {...args} />}
|
||||
<CurrentMonthDays weekSettings={weekSettings} {...args} />
|
||||
<NextMonthDays dates={monthInfo.nextMonth.dates} {...args} />
|
||||
</div>
|
||||
</>
|
||||
@@ -67,7 +76,7 @@ function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }
|
||||
)
|
||||
}
|
||||
|
||||
function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) {
|
||||
function PreviousMonthDays({ date, info: { dates, weekNumbers, weekYears }, weekSettings, ...args }: { date: Dayjs, info: DateRangeInfo, weekSettings: WeekSettings } & CalendarArgs) {
|
||||
const prevMonth = date.subtract(1, 'month').format('YYYY-MM');
|
||||
const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
@@ -77,27 +86,28 @@ function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { da
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeek date={date} weekNumber={weekNumbers[0]} {...args} />
|
||||
<CalendarWeek date={date} weekNumber={weekNumbers[0]} weekYear={weekYears[0]} {...args} />
|
||||
{dates.map(date => <CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForPrevMonth} className="calendar-date-prev-month" {...args} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) {
|
||||
function CurrentMonthDays({ date, weekSettings, ...args }: { date: Dayjs, weekSettings: WeekSettings } & CalendarArgs) {
|
||||
let dateCursor = date;
|
||||
const currentMonth = date.month();
|
||||
const items: VNode[] = [];
|
||||
const curMonthString = date.format('YYYY-MM');
|
||||
const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState<DateNotesForMonth>();
|
||||
const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = weekSettings;
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth);
|
||||
}, [ date ]);
|
||||
|
||||
while (dateCursor.month() === currentMonth) {
|
||||
const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO);
|
||||
if (dateCursor.isoWeekday() === firstDayOfWeekISO) {
|
||||
items.push(<CalendarWeek key={`${dateCursor.year()}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} {...args}/>)
|
||||
const { weekYear, weekNumber } = getWeekInfo(dateCursor, weekSettings);
|
||||
if (dateCursor.isoWeekday() === firstDayOfWeek) {
|
||||
items.push(<CalendarWeek key={`${weekYear}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} weekYear={weekYear} {...args}/>)
|
||||
}
|
||||
|
||||
items.push(<CalendarDay key={dateCursor.toISOString()} date={dateCursor} dateNotesForMonth={dateNotesForCurMonth} {...args} />)
|
||||
@@ -141,14 +151,8 @@ function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDat
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
|
||||
const localDate = date.local();
|
||||
|
||||
// Handle case where week is in between years.
|
||||
let year = localDate.year();
|
||||
if (localDate.month() === 11 && weekNumber === 1) year++;
|
||||
|
||||
const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
function CalendarWeek({ date, weekNumber, weekYear, weekNotes, onWeekClicked }: { weekNumber: number, weekYear: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
|
||||
const weekString = `${weekYear}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
|
||||
if (onWeekClicked) {
|
||||
return (
|
||||
@@ -169,33 +173,33 @@ function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumb
|
||||
>{weekNumber}</span>);
|
||||
}
|
||||
|
||||
export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) {
|
||||
export function getMonthInformation(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings) {
|
||||
return {
|
||||
prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO),
|
||||
nextMonth: getNextMonthDays(date, firstDayOfWeekISO)
|
||||
prevMonth: getPrevMonthDays(date, firstDayISO, weekSettings),
|
||||
nextMonth: getNextMonthDays(date, weekSettings.firstDayOfWeek)
|
||||
}
|
||||
}
|
||||
|
||||
function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
function getPrevMonthDays(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings): DateRangeInfo {
|
||||
const prevMonthLastDay = date.subtract(1, 'month').endOf('month');
|
||||
const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7;
|
||||
const daysToAdd = (firstDayISO - weekSettings.firstDayOfWeek + 7) % 7;
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const firstDay = date.startOf('month');
|
||||
const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO);
|
||||
const { weekYear, weekNumber } = getWeekInfo(firstDay, weekSettings);
|
||||
|
||||
// Get dates from previous month
|
||||
for (let i = daysToAdd - 1; i >= 0; i--) {
|
||||
dates.push(prevMonthLastDay.subtract(i, 'day'));
|
||||
}
|
||||
|
||||
return { weekNumbers: [ weekNumber ], dates };
|
||||
return { weekNumbers: [ weekNumber ], weekYears: [ weekYear ], dates };
|
||||
}
|
||||
|
||||
function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
function getNextMonthDays(date: Dayjs, firstDayOfWeek: number): DateRangeInfo {
|
||||
const lastDayOfMonth = date.endOf('month');
|
||||
const lastDayISO = lastDayOfMonth.isoWeekday();
|
||||
const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1;
|
||||
const lastDayOfUserWeek = ((firstDayOfWeek + 6 - 1) % 7) + 1;
|
||||
const nextMonthFirstDay = date.add(1, 'month').startOf('month');
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
@@ -206,16 +210,5 @@ function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo
|
||||
dates.push(nextMonthFirstDay.add(i, 'day'));
|
||||
}
|
||||
}
|
||||
return { weekNumbers: [], dates };
|
||||
}
|
||||
|
||||
export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number {
|
||||
const weekStart = getWeekStartDate(date, firstDayOfWeekISO);
|
||||
return weekStart.isoWeek();
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs {
|
||||
const currentISO = date.isoWeekday();
|
||||
const diff = (currentISO - firstDayOfWeekISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
return { weekNumbers: [], weekYears: [], dates };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import "./Render.css";
|
||||
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import attributes from "../../services/attributes";
|
||||
import { t } from "../../services/i18n";
|
||||
import render from "../../services/render";
|
||||
import Alert from "../react/Alert";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import render from "../../services/render";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import Alert from "../react/Alert";
|
||||
import "./Render.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -34,13 +31,6 @@ export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Refresh on attribute change.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttributeRows().some(a => a.type === "relation" && a.name === "renderNote" && attributes.isAffecting(a, note))) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Integration with search.
|
||||
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
|
||||
@@ -20,9 +20,6 @@ export interface CanvasContent {
|
||||
appState: Partial<AppState>;
|
||||
}
|
||||
|
||||
/** Subset of the app state that should be persisted whenever they change. This explicitly excludes transient state like the current selection or zoom level. */
|
||||
type ImportantAppState = Pick<AppState, "gridModeEnabled" | "viewBackgroundColor">;
|
||||
|
||||
export default function useCanvasPersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"], isReadOnly: boolean): Partial<ExcalidrawProps> {
|
||||
const libraryChanged = useRef(false);
|
||||
|
||||
@@ -40,8 +37,6 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
const libraryCache = useRef<LibraryItem[]>([]);
|
||||
const attachmentMetadata = useRef<AttachmentMetadata[]>([]);
|
||||
|
||||
const appStateToCompare = useRef<Partial<ImportantAppState>>({});
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
@@ -52,6 +47,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
|
||||
libraryCache.current = [];
|
||||
attachmentMetadata.current = [];
|
||||
currentSceneVersion.current = -1;
|
||||
|
||||
// load saved content into excalidraw canvas
|
||||
let content: CanvasContent = {
|
||||
@@ -69,9 +65,6 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
|
||||
loadData(api, content, theme);
|
||||
|
||||
// Initialize tracking state after loading to prevent redundant updates from initial onChange events
|
||||
currentSceneVersion.current = getSceneVersion(api.getSceneElements());
|
||||
|
||||
// load the library state
|
||||
loadLibrary(note).then(({ libraryItems, metadata }) => {
|
||||
// Update the library and save to independent variables
|
||||
@@ -85,7 +78,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
async getData() {
|
||||
const api = apiRef.current;
|
||||
if (!api) return;
|
||||
const { content, svg } = await getData(api, appStateToCompare);
|
||||
const { content, svg } = await getData(api);
|
||||
const attachments: SavedData["attachments"] = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||
|
||||
// libraryChanged is unset in dataSaved()
|
||||
@@ -156,47 +149,21 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
const oldSceneVersion = currentSceneVersion.current;
|
||||
const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements());
|
||||
|
||||
let hasChanges = (newSceneVersion !== oldSceneVersion);
|
||||
|
||||
// There are cases where the scene version does not change, but appState did.
|
||||
if (!hasChanges) {
|
||||
const importantAppState = appStateToCompare.current;
|
||||
const currentAppState = apiRef.current.getAppState();
|
||||
for (const key in importantAppState) {
|
||||
if (importantAppState[key as keyof ImportantAppState] !== currentAppState[key as keyof ImportantAppState]) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
if (newSceneVersion !== oldSceneVersion) {
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
currentSceneVersion.current = newSceneVersion;
|
||||
}
|
||||
},
|
||||
onLibraryChange: (libraryItems) => {
|
||||
if (!apiRef.current || isReadOnly) return;
|
||||
|
||||
// Check if library actually changed by comparing with cached state
|
||||
const hasChanges =
|
||||
libraryItems.length !== libraryCache.current.length ||
|
||||
libraryItems.some(item => {
|
||||
const cachedItem = libraryCache.current.find(cached => cached.id === item.id);
|
||||
return !cachedItem || cachedItem.name !== item.name;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
libraryChanged.current = true;
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}
|
||||
onLibraryChange: () => {
|
||||
libraryChanged.current = true;
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObject<Partial<ImportantAppState>>) {
|
||||
async function getData(api: ExcalidrawImperativeAPI) {
|
||||
const elements = api.getSceneElements();
|
||||
const appState = api.getAppState();
|
||||
|
||||
@@ -221,12 +188,6 @@ async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObjec
|
||||
}
|
||||
});
|
||||
|
||||
const importantAppState: ImportantAppState = {
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
viewBackgroundColor: appState.viewBackgroundColor
|
||||
};
|
||||
appStateToCompare.current = importantAppState;
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
@@ -236,7 +197,7 @@ async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObjec
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom,
|
||||
...importantAppState
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
|
||||
import utils, { isMobile } from "../../../services/utils";
|
||||
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
|
||||
import Admonition from "../../react/Admonition";
|
||||
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
|
||||
import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
|
||||
import { EditableCode, EditableCodeProps } from "../code/Code";
|
||||
|
||||
export interface SplitEditorProps extends EditableCodeProps {
|
||||
@@ -30,22 +30,12 @@ export interface SplitEditorProps extends EditableCodeProps {
|
||||
* - Can display errors to the user via {@link setError}.
|
||||
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
|
||||
*/
|
||||
export default function SplitEditor(props: SplitEditorProps) {
|
||||
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
|
||||
|
||||
if (readOnly) {
|
||||
return <ReadOnlyView {...props} />;
|
||||
}
|
||||
|
||||
return <EditorWithSplit {...props} />;
|
||||
|
||||
}
|
||||
|
||||
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
|
||||
const splitEditorOrientation = useSplitOrientation(forceOrientation);
|
||||
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = (
|
||||
const editor = (!readOnly &&
|
||||
<div className="note-detail-split-editor-col">
|
||||
{editorBefore}
|
||||
<div className="note-detail-split-editor">
|
||||
@@ -63,14 +53,19 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
|
||||
</div>
|
||||
);
|
||||
|
||||
const preview = <PreviewContainer
|
||||
error={error}
|
||||
previewContent={previewContent}
|
||||
previewButtons={previewButtons}
|
||||
/>;
|
||||
const preview = (
|
||||
<div className="note-detail-split-preview-col">
|
||||
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
|
||||
{previewContent}
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
|
||||
{previewButtons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!utils.isDesktop() || !containerRef.current) return;
|
||||
if (!utils.isDesktop() || !containerRef.current || readOnly) return;
|
||||
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
|
||||
const splitInstance = Split(elements, {
|
||||
rtl: glob.isRtl,
|
||||
@@ -81,10 +76,10 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
|
||||
});
|
||||
|
||||
return () => splitInstance.destroy();
|
||||
}, [ splitEditorOrientation ]);
|
||||
}, [ readOnly, splitEditorOrientation ]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
|
||||
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${readOnly ? "split-read-only" : ""} ${className ?? ""}`}>
|
||||
{splitEditorOrientation === "horizontal"
|
||||
? <>{editor}{preview}</>
|
||||
: <>{preview}{editor}</>}
|
||||
@@ -92,43 +87,6 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyView({ ...props }: SplitEditorProps) {
|
||||
const { note, onContentChanged } = props;
|
||||
const content = useNoteBlob(note);
|
||||
const onContentChangedRef = useRef(onContentChanged);
|
||||
|
||||
useEffect(() => {
|
||||
onContentChangedRef.current = onContentChanged;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onContentChangedRef.current?.(content?.content ?? "");
|
||||
}, [ content ]);
|
||||
|
||||
return (
|
||||
<div className={`note-detail-split note-detail-printable ${props.className} split-read-only`}>
|
||||
<PreviewContainer {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewContainer({ error, previewContent, previewButtons }: {
|
||||
error?: string | null;
|
||||
previewContent: ComponentChildren;
|
||||
previewButtons?: ComponentChildren;
|
||||
}) {
|
||||
return (
|
||||
<div className="note-detail-split-preview-col">
|
||||
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
|
||||
{previewContent}
|
||||
</div>
|
||||
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
|
||||
{previewButtons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewButton(props: Omit<ActionButtonProps, "titlePosition">) {
|
||||
return <ActionButton
|
||||
{...props}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import svgPanZoom from "svg-pan-zoom";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import utils from "../../../services/utils";
|
||||
import { useElementSize, useTriliumEvent } from "../../react/hooks";
|
||||
import { RawHtmlBlock } from "../../react/RawHtml";
|
||||
import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor";
|
||||
import { RawHtmlBlock } from "../../react/RawHtml";
|
||||
import server from "../../../services/server";
|
||||
import svgPanZoom from "svg-pan-zoom";
|
||||
import { RefObject } from "preact";
|
||||
import { useElementSize, useTriliumEvent } from "../../react/hooks";
|
||||
import utils from "../../../services/utils";
|
||||
import toast from "../../../services/toast";
|
||||
|
||||
interface SvgSplitEditorProps extends Omit<SplitEditorProps, "previewContent"> {
|
||||
/**
|
||||
@@ -145,7 +144,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg: string | undefined) {
|
||||
@@ -182,7 +181,7 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
|
||||
lastPanZoom.current = {
|
||||
pan: zoomInstance.getPan(),
|
||||
zoom: zoomInstance.getZoom()
|
||||
};
|
||||
}
|
||||
zoomRef.current = undefined;
|
||||
zoomInstance.destroy();
|
||||
};
|
||||
|
||||
@@ -191,6 +191,7 @@ function ExperimentalOptions() {
|
||||
values={filteredExperimentalFeatures}
|
||||
keyProperty="id"
|
||||
titleProperty="name"
|
||||
descriptionProperty="description"
|
||||
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
|
||||
/>
|
||||
</OptionsSection>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import FormCheckbox from "../../../react/FormCheckbox";
|
||||
|
||||
interface CheckboxListProps<T> {
|
||||
values: T[];
|
||||
keyProperty: keyof T;
|
||||
titleProperty?: keyof T;
|
||||
disabledProperty?: keyof T;
|
||||
descriptionProperty?: keyof T;
|
||||
currentValue: string[];
|
||||
onChange: (newValues: string[]) => void;
|
||||
columnWidth?: string;
|
||||
}
|
||||
|
||||
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
|
||||
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, descriptionProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
|
||||
function toggleValue(value: string) {
|
||||
if (currentValue.includes(value)) {
|
||||
// Already there, needs removing.
|
||||
@@ -22,20 +25,17 @@ export default function CheckboxList<T>({ values, keyProperty, titleProperty, di
|
||||
return (
|
||||
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
|
||||
{values.map(value => (
|
||||
<li>
|
||||
<label className="tn-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
value={String(value[keyProperty])}
|
||||
checked={currentValue.includes(String(value[keyProperty]))}
|
||||
disabled={!!(disabledProperty && value[disabledProperty])}
|
||||
onChange={e => toggleValue((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
|
||||
</label>
|
||||
<li key={String(value[keyProperty])}>
|
||||
<FormCheckbox
|
||||
label={String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
|
||||
name={String(value[keyProperty])}
|
||||
currentValue={currentValue.includes(String(value[keyProperty]))}
|
||||
disabled={!!(disabledProperty && value[disabledProperty])}
|
||||
hint={value && (descriptionProperty ? String(value[descriptionProperty]) : undefined)}
|
||||
onChange={() => toggleValue(String(value[keyProperty]))}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,15 +93,7 @@ export default defineConfig(() => ({
|
||||
print: join(__dirname, "src", "print.tsx")
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunk) => {
|
||||
// We enforce a hash in the main index file to avoid caching issues, this only works because we have the HTML entry point.
|
||||
if (chunk.name === "index") {
|
||||
return "src/[name]-[hash].js";
|
||||
}
|
||||
|
||||
// For EJS-rendered pages (e.g. login) we need to have a stable name.
|
||||
return "src/[name].js";
|
||||
},
|
||||
entryFileNames: "src/[name].js",
|
||||
chunkFileNames: "src/[name]-[hash].js",
|
||||
assetFileNames: "src/[name]-[hash].[ext]",
|
||||
manualChunks: {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.1",
|
||||
"cheerio": "1.2.0",
|
||||
"cheerio": "1.1.2",
|
||||
"chokidar": "5.0.0",
|
||||
"cls-hooked": "4.2.2",
|
||||
"compression": "1.8.1",
|
||||
@@ -91,7 +91,7 @@
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-session": "1.19.0",
|
||||
"express-session": "1.18.2",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.3",
|
||||
"helmet": "8.1.0",
|
||||
@@ -99,7 +99,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.0",
|
||||
"i18next": "25.7.4",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -126,7 +126,7 @@
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"turnish": "1.7.1",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.3.1",
|
||||
"ws": "8.19.0",
|
||||
|
||||
@@ -4,34 +4,17 @@
|
||||
<p>Trilium Web Clipper is a web browser extension which allows user to clip
|
||||
text, screenshots, whole pages and short notes and save them directly to
|
||||
Trilium Notes.</p>
|
||||
<h2>Supported browsers</h2>
|
||||
<p>Trilium Web Clipper officially supports the following web browsers:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Mozilla Firefox, using Manifest v2.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Google Chrome, using Manifest v3. Theoretically the extension should work
|
||||
on other Chromium-based browsers as well, but they are not officially supported.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<h2>Obtaining the extension</h2>
|
||||
<aside class="admonition warning">
|
||||
<p>The extension is currently under development. A preview with unsigned
|
||||
extensions is available on <a href="https://github.com/TriliumNext/Trilium/actions/runs/21318809414">GitHub Actions</a>.</p>
|
||||
<p>We have already submitted the extension to both Chrome and Firefox web
|
||||
stores, but they are pending validation.</p>
|
||||
</aside>
|
||||
<p>Project is hosted <a href="https://github.com/TriliumNext/web-clipper">here</a>.</p>
|
||||
<p>Firefox and Chrome are supported browsers, but the chrome build should
|
||||
work on other chromium based browsers as well.</p>
|
||||
<h2>Functionality</h2>
|
||||
<ul>
|
||||
<li>select text and clip it with the right-click context menu</li>
|
||||
<li>click on an image or link and save it through context menu</li>
|
||||
<li>save whole page from the popup or context menu</li>
|
||||
<li>save screenshot (with crop tool) from either popup or context menu</li>
|
||||
<li
|
||||
>create short text note from popup</li>
|
||||
<li>create short text note from popup</li>
|
||||
</ul>
|
||||
<h2>Location of clippings</h2>
|
||||
<p>Trilium will save these clippings as a new child note under a "clipper
|
||||
inbox" note.</p>
|
||||
<p>By default, that's the <a href="#root/_help_l0tKav7yLHGF">day note</a> but you
|
||||
@@ -40,33 +23,21 @@
|
||||
spellcheck="false">clipperInbox</code>, on any other note.</p>
|
||||
<p>If there's multiple clippings from the same page (and on the same day),
|
||||
then they will be added to the same note.</p>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>Keyboard shortcuts are available for most functions:</p>
|
||||
<p><strong>Extension is available from:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Save selected text: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)</li>
|
||||
<li
|
||||
>Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌥</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)</li>
|
||||
<li
|
||||
>Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>E</kbd>)</li>
|
||||
</ul>
|
||||
<p>To set custom shortcuts, follow the directions for your browser.</p>
|
||||
<ul>
|
||||
<li><strong>Firefox</strong>: <code spellcheck="false">about:addons</code> →
|
||||
Gear icon ⚙️ → Manage extension shortcuts</li>
|
||||
<li><strong>Chrome</strong>: <code spellcheck="false">chrome://extensions/shortcuts</code>
|
||||
<li><a href="https://github.com/TriliumNext/web-clipper/releases">Project release page</a> -
|
||||
.xpi for Firefox and .zip for Chromium based browsers.</li>
|
||||
<li><a href="https://chromewebstore.google.com/detail/trilium-web-clipper/dfhgmnfclbebfobmblelddiejjcijbjm">Chrome Web Store</a>
|
||||
</li>
|
||||
</ul>
|
||||
<aside class="admonition note">
|
||||
<p>On Firefox, the default shortcuts interfere with some browser features.
|
||||
As such, the keyboard combinations will not trigger the Web Clipper action.
|
||||
To fix this, simply change the keyboard shortcut to something that works.
|
||||
The defaults will be adjusted in future versions.</p>
|
||||
</aside>
|
||||
<h2>Configuration</h2>
|
||||
<p>The extension needs to connect to a running Trilium instance. By default,
|
||||
it scans a port range on the local computer to find a desktop Trilium instance.</p>
|
||||
<p>It's also possible to configure the <a href="#root/_help_WOcw2SLH6tbX">server</a> address
|
||||
if you don't run the desktop application, or want it to work without the
|
||||
desktop application running.</p>
|
||||
<h2>Credits</h2>
|
||||
<p>Some parts of the code are based on the <a href="https://github.com/laurent22/joplin/tree/master/Clipper">Joplin Notes browser extension</a>.</p>
|
||||
<h2>Username</h2>
|
||||
<p>Older versions of Trilium (before 0.50) required username & password
|
||||
to authenticate, but this is no longer the case. You may enter anything
|
||||
in that field, it will not have any effect.</p>
|
||||
@@ -233,7 +233,7 @@
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронізація з ПК",
|
||||
"description": "Це налаштування потрібно ініціювати з екземпляра ПК:",
|
||||
"step1": "Відкрийте свій екземпляр Trilium Notes на ПК.",
|
||||
"step1": "Відкрийте екземпляр Trilium Notes на ПК.",
|
||||
"step2": "У меню Trilium натисніть Параметри.",
|
||||
"step3": "Натисніть на Категорія Синхронізації.",
|
||||
"step4": "Змініть адресу екземпляра сервера на: {{- host}} та натисніть кнопку Зберегти.",
|
||||
@@ -249,7 +249,7 @@
|
||||
"proxy-server": "Проксі-сервер (необов'язково)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Нотатка:",
|
||||
"proxy-instruction": "Якщо залишити налаштування проксі-сервера порожнім, використовуватиметься системний проксі-сервер (стосується лише програми на ПК)",
|
||||
"proxy-instruction": "Якщо залишити налаштування проксі-сервера порожнім, використовуватиметься системний проксі-сервер (стосується лише ПК програми)",
|
||||
"password": "Пароль",
|
||||
"password-placeholder": "Пароль",
|
||||
"back": "Назад",
|
||||
@@ -415,7 +415,7 @@
|
||||
"end-time": "Час завершення",
|
||||
"geolocation": "Геолокація",
|
||||
"built-in-templates": "Вбудовані шаблони",
|
||||
"board": "Kanban Дошка",
|
||||
"board": "Дошка",
|
||||
"status": "Статус",
|
||||
"board_note_first": "Перша нотатка",
|
||||
"board_note_second": "Друга нотатка",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type BNote from "../becca/entities/bnote.js";
|
||||
|
||||
import attributeService from "./attributes.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import { dayjs, Dayjs } from "@triliumnext/commons";
|
||||
import { dayjs, Dayjs, getFirstDayOfWeek1, getWeekInfo, WeekSettings } from "@triliumnext/commons";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import noteService from "./notes.js";
|
||||
import optionService from "./options.js";
|
||||
@@ -63,7 +63,8 @@ function getJournalNoteTitle(
|
||||
rootNote: BNote,
|
||||
timeUnit: TimeUnit,
|
||||
dateObj: Dayjs,
|
||||
number: number
|
||||
number: number,
|
||||
weekYear?: number // Optional: the week year for cross-year weeks
|
||||
) {
|
||||
const patterns = {
|
||||
year: rootNote.getOwnedLabelValue("yearPattern") || "{year}",
|
||||
@@ -79,9 +80,14 @@ function getJournalNoteTitle(
|
||||
const numberStr = number.toString();
|
||||
const ordinalStr = ordinal(dateObj);
|
||||
|
||||
// For week notes, use the weekYear if provided (handles cross-year weeks)
|
||||
const yearForDisplay = (timeUnit === "week" && weekYear !== undefined)
|
||||
? weekYear.toString()
|
||||
: dateObj.format("YYYY");
|
||||
|
||||
const allReplacements: Record<string, string> = {
|
||||
// Common date formats
|
||||
"{year}": dateObj.format("YYYY"),
|
||||
"{year}": yearForDisplay,
|
||||
|
||||
// Month related
|
||||
"{isoMonth}": dateObj.format("YYYY-MM"),
|
||||
@@ -286,6 +292,14 @@ function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
return monthNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getWeekSettings(): WeekSettings {
|
||||
return {
|
||||
firstDayOfWeek: parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10),
|
||||
firstWeekOfYear: parseInt(optionService.getOptionOrNull("firstWeekOfYear") ?? "0", 10),
|
||||
minDaysInFirstWeek: parseInt(optionService.getOptionOrNull("minDaysInFirstWeek") ?? "4", 10)
|
||||
};
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs): Dayjs {
|
||||
const firstDayISO = parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10);
|
||||
const day = date.isoWeekday();
|
||||
@@ -294,9 +308,8 @@ function getWeekStartDate(date: Dayjs): Dayjs {
|
||||
}
|
||||
|
||||
function getWeekNumberStr(date: Dayjs): string {
|
||||
const isoYear = date.isoWeekYear();
|
||||
const isoWeekNum = date.isoWeek();
|
||||
return `${isoYear}-W${isoWeekNum.toString().padStart(2, "0")}`;
|
||||
const { weekYear, weekNumber } = getWeekInfo(date, getWeekSettings());
|
||||
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) {
|
||||
@@ -329,17 +342,19 @@ function getWeekNote(weekStr: string, _rootNote: BNote | null = null): BNote | n
|
||||
|
||||
const [ yearStr, weekNumStr ] = weekStr.trim().split("-W");
|
||||
const weekNumber = parseInt(weekNumStr);
|
||||
const weekYear = parseInt(yearStr);
|
||||
|
||||
const firstDayOfYear = dayjs().year(parseInt(yearStr)).month(0).date(1);
|
||||
const weekStartDate = firstDayOfYear.add(weekNumber - 1, "week");
|
||||
const startDate = getWeekStartDate(weekStartDate);
|
||||
const endDate = dayjs(startDate).add(6, "day");
|
||||
// Calculate week start date based on user's first week of year settings.
|
||||
// This correctly handles cross-year weeks based on user preferences.
|
||||
const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, getWeekSettings());
|
||||
const startDate = firstDayOfWeek1.add(weekNumber - 1, "week");
|
||||
const endDate = startDate.add(6, "day");
|
||||
|
||||
const startMonth = startDate.month();
|
||||
const endMonth = endDate.month();
|
||||
|
||||
const monthNote = getMonthNote(startDate.format("YYYY-MM-DD"), rootNote);
|
||||
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber);
|
||||
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber, weekYear);
|
||||
|
||||
sql.transactional(() => {
|
||||
weekNote = createNote(monthNote, noteTitle);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AttributeRow, NoteType } from "@triliumnext/commons";
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
|
||||
export interface NoteParams {
|
||||
/** optionally can force specific noteId */
|
||||
@@ -24,6 +24,4 @@ export interface NoteParams {
|
||||
utcDateCreated?: string;
|
||||
ignoreForbiddenParents?: boolean;
|
||||
target?: "into";
|
||||
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
|
||||
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { type AttachmentRow, type AttributeRow, type BranchRow, dayjs, type NoteRow } from "@triliumnext/commons";
|
||||
import fs from "fs";
|
||||
import html2plaintext from "html2plaintext";
|
||||
import { t } from "i18next";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import BAttachment from "../becca/entities/battachment.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import ValidationError from "../errors/validation_error.js";
|
||||
import cls from "../services/cls.js";
|
||||
import log from "../services/log.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import { newEntityId, quoteRegex, toMap, unescapeHtml } from "../services/utils.js";
|
||||
import sql from "./sql.js";
|
||||
import optionService from "./options.js";
|
||||
import dateUtils from "./date_utils.js";
|
||||
import entityChangesService from "./entity_changes.js";
|
||||
import eventService from "./events.js";
|
||||
import htmlSanitizer from "./html_sanitizer.js";
|
||||
import imageService from "./image.js";
|
||||
import noteTypesService from "./note_types.js";
|
||||
import type { NoteParams } from "./note-interface.js";
|
||||
import optionService from "./options.js";
|
||||
import request from "./request.js";
|
||||
import cls from "../services/cls.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import log from "../services/log.js";
|
||||
import { newEntityId, unescapeHtml, quoteRegex, toMap } from "../services/utils.js";
|
||||
import revisionService from "./revisions.js";
|
||||
import sql from "./sql.js";
|
||||
import type TaskContext from "./task_context.js";
|
||||
import request from "./request.js";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
import becca from "../becca/becca.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import BAttachment from "../becca/entities/battachment.js";
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
import htmlSanitizer from "./html_sanitizer.js";
|
||||
import ValidationError from "../errors/validation_error.js";
|
||||
import noteTypesService from "./note_types.js";
|
||||
import fs from "fs";
|
||||
import ws from "./ws.js";
|
||||
import html2plaintext from "html2plaintext";
|
||||
import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
|
||||
import type TaskContext from "./task_context.js";
|
||||
import type { NoteParams } from "./note-interface.js";
|
||||
import imageService from "./image.js";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface FoundLink {
|
||||
name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink";
|
||||
@@ -47,13 +47,14 @@ function getNewNotePosition(parentNote: BNote) {
|
||||
.reduce((min, note) => Math.min(min, note?.notePosition || 0), 0);
|
||||
|
||||
return minNotePos - 10;
|
||||
}
|
||||
const maxNotePos = parentNote
|
||||
.getChildBranches()
|
||||
.filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position
|
||||
.reduce((max, note) => Math.max(max, note?.notePosition || 0), 0);
|
||||
} else {
|
||||
const maxNotePos = parentNote
|
||||
.getChildBranches()
|
||||
.filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position
|
||||
.reduce((max, note) => Math.max(max, note?.notePosition || 0), 0);
|
||||
|
||||
return maxNotePos + 10;
|
||||
return maxNotePos + 10;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNoteTitleChanged(note: BNote) {
|
||||
@@ -87,7 +88,7 @@ function copyChildAttributes(parentNote: BNote, childNote: BNote) {
|
||||
new BAttribute({
|
||||
noteId: childNote.noteId,
|
||||
type: attr.type,
|
||||
name,
|
||||
name: name,
|
||||
value: attr.value,
|
||||
position: attr.position,
|
||||
isInheritable: attr.isInheritable
|
||||
@@ -221,14 +222,6 @@ function createNewNote(params: NoteParams): {
|
||||
utcDateCreated: params.utcDateCreated
|
||||
}).save();
|
||||
|
||||
// Create attributes atomically.
|
||||
for (const attribute of params.attributes || []) {
|
||||
new BAttribute({
|
||||
...attribute,
|
||||
noteId: note.noteId
|
||||
}).save();
|
||||
}
|
||||
|
||||
note.setContent(params.content);
|
||||
|
||||
branch = new BBranch({
|
||||
@@ -267,7 +260,7 @@ function createNewNote(params: NoteParams): {
|
||||
eventService.emit(eventService.ENTITY_CHANGED, { entityName: "blobs", entity: note });
|
||||
eventService.emit(eventService.ENTITY_CREATED, { entityName: "branches", entity: branch });
|
||||
eventService.emit(eventService.ENTITY_CHANGED, { entityName: "branches", entity: branch });
|
||||
eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote });
|
||||
eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote: parentNote });
|
||||
|
||||
log.info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`);
|
||||
|
||||
@@ -315,8 +308,9 @@ function createNewNoteWithTarget(target: "into" | "after" | "before", targetBran
|
||||
entityChangesService.putNoteReorderingEntityChange(params.parentNoteId);
|
||||
|
||||
return retObject;
|
||||
} else {
|
||||
throw new Error(`Unknown target '${target}'`);
|
||||
}
|
||||
throw new Error(`Unknown target '${target}'`);
|
||||
}
|
||||
|
||||
function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) {
|
||||
@@ -494,7 +488,7 @@ function findRelationMapLinks(content: string, foundLinks: FoundLink[]) {
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(`Could not scan for relation map links: ${e.message}`);
|
||||
log.error("Could not scan for relation map links: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,8 +656,8 @@ function saveAttachments(note: BNote, content: string) {
|
||||
|
||||
const attachment = note.saveAttachment({
|
||||
role: "file",
|
||||
mime,
|
||||
title,
|
||||
mime: mime,
|
||||
title: title,
|
||||
content: buffer
|
||||
});
|
||||
|
||||
@@ -959,7 +953,7 @@ function duplicateSubtree(origNoteId: string, newParentNoteId: string) {
|
||||
const duplicateNoteSuffix = t("notes.duplicate-note-suffix");
|
||||
|
||||
if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) {
|
||||
res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix });
|
||||
res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix: duplicateNoteSuffix });
|
||||
}
|
||||
|
||||
res.note.save();
|
||||
@@ -1056,12 +1050,13 @@ function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | und
|
||||
note: existingNote,
|
||||
branch: createDuplicatedBranch()
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
// order here is important, note needs to be created first to not mess up the becca
|
||||
note: createDuplicatedNote(),
|
||||
branch: createDuplicatedBranch()
|
||||
};
|
||||
}
|
||||
return {
|
||||
// order here is important, note needs to be created first to not mess up the becca
|
||||
note: createDuplicatedNote(),
|
||||
branch: createDuplicatedBranch()
|
||||
};
|
||||
}
|
||||
|
||||
function getNoteIdMapping(origNote: BNote) {
|
||||
|
||||
3
apps/web-clipper/.gitignore
vendored
3
apps/web-clipper/.gitignore
vendored
@@ -1,2 +1 @@
|
||||
.output
|
||||
.wxt
|
||||
dist/
|
||||
@@ -1,31 +1,24 @@
|
||||
# Trilium Web Clipper
|
||||
|
||||
## Context
|
||||
## This repo is dead
|
||||
|
||||
The Web Clipper is an extension for the Trilium Notes application, an open-source note-taking application that can be used either in standalone mode via the desktop application or connected to a server.
|
||||
**Trilium is in maintenance mode and Web Clipper is not likely to get new releases.**
|
||||
|
||||
The source is extracted from the official monorepo, where it can be found under `apps/web-clipper`. The only change made to the provided source code is to have `tsconfig.base.json` in the same directory as the Web Clipper. The submitted source code is a snapshot of the following commit: [https://github.com/TriliumNext/Trilium/commit/1cf93ff0dec89ee1a80654934cb30fad74920043](https://github.com/TriliumNext/Trilium/commit/1cf93ff0dec89ee1a80654934cb30fad74920043)
|
||||
Trilium Web Clipper is a web browser extension which allows user to clip text, screenshots, whole pages and short notes and save them directly to [Trilium Notes](https://github.com/zadam/trilium).
|
||||
|
||||
There are some warnings regarding the use of `innerHTML` but they come from a third-party library (Readability). We plan to update to a newer version of that library soon, but we would like to publish the extension first (if possible).
|
||||
For more details, see the [wiki page](https://github.com/zadam/trilium/wiki/Web-clipper).
|
||||
|
||||
## Building from source
|
||||
## Keyboard shortcuts
|
||||
Keyboard shortcuts are available for most functions:
|
||||
* Save selected text: `Ctrl+Shift+S` (Mac: `Cmd+Shift+S`)
|
||||
* Save whole page: `Alt+Shift+S` (Mac: `Opt+Shift+S`)
|
||||
* Save screenshot: `Ctrl+Shift+E` (Mac: `Cmd+Shift+E`)
|
||||
|
||||
To build from the provided sources:
|
||||
To set custom shortcuts, follow the directions for your browser.
|
||||
|
||||
1. `pnpm i` to install the dependencies.
|
||||
2. `pnpm build:firefox` to trigger the Firefox build.
|
||||
3. The output will be available in `.output/firefox-mv2`.
|
||||
**Firefox**: `about:addons` > Gear icon ⚙️ > Manage extension shortcuts
|
||||
|
||||
> [!NOTE]
|
||||
> To generate the ZIP instead that can be imported into Firefox, run `pnpm zip;firefox` which will generate `.output\triliumnextweb-clipper-1.0.1-sources.zip`.
|
||||
**Chrome**: `chrome://extensions/shortcuts`
|
||||
|
||||
## Testing
|
||||
|
||||
To test it, a functional Trilium Notes desktop application is required:
|
||||
|
||||
1. Download the latest version of Trilium Notes from [https://triliumnotes.org/](https://triliumnotes.org/) (top-right bottom automatically detects the platform).
|
||||
2. During the first setup, create a new database.
|
||||
3. Allow the Firewall port if asked.
|
||||
4. Install the Web Clipper extension into the browser.
|
||||
5. The extension should be able to see the Trilium instance and become active.
|
||||
6. Web pages can now be clipped and they will appear in the local Trilium instance.
|
||||
## Credits
|
||||
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
451
apps/web-clipper/background.js
Normal file
451
apps/web-clipper/background.js
Normal file
@@ -0,0 +1,451 @@
|
||||
// Keyboard shortcuts
|
||||
chrome.commands.onCommand.addListener(async function (command) {
|
||||
if (command == "saveSelection") {
|
||||
await saveSelection();
|
||||
} else if (command == "saveWholePage") {
|
||||
await saveWholePage();
|
||||
} else if (command == "saveTabs") {
|
||||
await saveTabs();
|
||||
} else if (command == "saveCroppedScreenshot") {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
await saveCroppedScreenshot(activeTab.url);
|
||||
} else {
|
||||
console.log("Unrecognized command", command);
|
||||
}
|
||||
});
|
||||
|
||||
function cropImage(newArea, dataUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newArea.width;
|
||||
canvas.height = newArea.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
|
||||
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function takeCroppedScreenshot(cropRect) {
|
||||
const activeTab = await getActiveTab();
|
||||
const zoom = await browser.tabs.getZoom(activeTab.id) * window.devicePixelRatio;
|
||||
|
||||
const newArea = Object.assign({}, cropRect);
|
||||
newArea.x *= zoom;
|
||||
newArea.y *= zoom;
|
||||
newArea.width *= zoom;
|
||||
newArea.height *= zoom;
|
||||
|
||||
const dataUrl = await browser.tabs.captureVisibleTab(null, { format: 'png' });
|
||||
|
||||
return await cropImage(newArea, dataUrl);
|
||||
}
|
||||
|
||||
async function takeWholeScreenshot() {
|
||||
// this saves only visible portion of the page
|
||||
// workaround to save the whole page is to scroll & stitch
|
||||
// example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
|
||||
// see page.js and popup.js
|
||||
return await browser.tabs.captureVisibleTab(null, { format: 'png' });
|
||||
}
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
if (isDevEnv()) {
|
||||
browser.browserAction.setIcon({
|
||||
path: 'icons/32-dev.png',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-selection",
|
||||
title: "Save selection to Trilium",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-cropped-screenshot",
|
||||
title: "Clip screenshot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-cropped-screenshot",
|
||||
title: "Crop screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-whole-screenshot",
|
||||
title: "Save whole screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-page",
|
||||
title: "Save whole page to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-link",
|
||||
title: "Save link to Trilium",
|
||||
contexts: ["link"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-image",
|
||||
title: "Save image to Trilium",
|
||||
contexts: ["image"]
|
||||
});
|
||||
|
||||
async function getActiveTab() {
|
||||
const tabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs[0];
|
||||
}
|
||||
|
||||
async function getWindowTabs() {
|
||||
const tabs = await browser.tabs.query({
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
async function sendMessageToActiveTab(message) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error("No active tab.");
|
||||
}
|
||||
|
||||
try {
|
||||
return await browser.tabs.sendMessage(activeTab.id, message);
|
||||
}
|
||||
catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function toast(message, noteId = null, tabIds = null) {
|
||||
sendMessageToActiveTab({
|
||||
name: 'toast',
|
||||
message: message,
|
||||
noteId: noteId,
|
||||
tabIds: tabIds
|
||||
});
|
||||
}
|
||||
|
||||
function blob2base64(blob) {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchImage(url) {
|
||||
const resp = await fetch(url);
|
||||
const blob = await resp.blob();
|
||||
|
||||
return await blob2base64(blob);
|
||||
}
|
||||
|
||||
async function postProcessImage(image) {
|
||||
if (image.src.startsWith("data:image/")) {
|
||||
image.dataUrl = image.src;
|
||||
image.src = "inline." + image.src.substr(11, 3); // this should extract file type - png/jpg
|
||||
}
|
||||
else {
|
||||
try {
|
||||
image.dataUrl = await fetchImage(image.src, image);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`Cannot fetch image from ${image.src}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postProcessImages(resp) {
|
||||
if (resp.images) {
|
||||
for (const image of resp.images) {
|
||||
await postProcessImage(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelection() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Selection has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function getImagePayloadFromSrc(src, pageUrl) {
|
||||
const image = {
|
||||
imageId: randomString(20),
|
||||
src: src
|
||||
};
|
||||
|
||||
await postProcessImage(image);
|
||||
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return {
|
||||
title: activeTab.title,
|
||||
content: `<img src="${image.imageId}">`,
|
||||
images: [image],
|
||||
pageUrl: pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCroppedScreenshot(pageUrl) {
|
||||
const cropRect = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
|
||||
|
||||
const src = await takeCroppedScreenshot(cropRect);
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholeScreenshot(pageUrl) {
|
||||
const src = await takeWholeScreenshot();
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveImage(srcUrl, pageUrl) {
|
||||
const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Image has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholePage() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Page has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveLinkWithNote(title, content) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!title.trim()) {
|
||||
title = activeTab.title;
|
||||
}
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', {
|
||||
title: title,
|
||||
content: content,
|
||||
clipType: 'note',
|
||||
pageUrl: activeTab.url
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
toast("Link with note has been saved to Trilium.", resp.noteId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getTabsPayload(tabs) {
|
||||
let content = '<ul>';
|
||||
tabs.forEach(tab => {
|
||||
content += `<li><a href="${tab.url}">${tab.title}</a></li>`
|
||||
});
|
||||
content += '</ul>';
|
||||
|
||||
const domainsCount = tabs.map(tab => tab.url)
|
||||
.reduce((acc, url) => {
|
||||
const hostname = new URL(url).hostname
|
||||
return acc.set(hostname, (acc.get(hostname) || 0) + 1)
|
||||
}, new Map());
|
||||
|
||||
let topDomains = [...domainsCount]
|
||||
.sort((a, b) => {return b[1]-a[1]})
|
||||
.slice(0,3)
|
||||
.map(domain=>domain[0])
|
||||
.join(', ')
|
||||
|
||||
if (tabs.length > 3) { topDomains += '...' }
|
||||
|
||||
return {
|
||||
title: `${tabs.length} browser tabs: ${topDomains}`,
|
||||
content: content,
|
||||
clipType: 'tabs'
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTabs() {
|
||||
const tabs = await getWindowTabs();
|
||||
|
||||
const payload = await getTabsPayload(tabs);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabIds = tabs.map(tab=>{return tab.id});
|
||||
|
||||
toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
|
||||
}
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async function(info, tab) {
|
||||
if (info.menuItemId === 'trilium-save-selection') {
|
||||
await saveSelection();
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
|
||||
await saveCroppedScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-whole-screenshot') {
|
||||
await saveWholeScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-image') {
|
||||
await saveImage(info.srcUrl, info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-link') {
|
||||
const link = document.createElement("a");
|
||||
link.href = info.linkUrl;
|
||||
// linkText might be available only in firefox
|
||||
link.appendChild(document.createTextNode(info.linkText || info.linkUrl));
|
||||
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', {
|
||||
title: activeTab.title,
|
||||
content: link.outerHTML,
|
||||
pageUrl: info.pageUrl
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Link has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-page') {
|
||||
await saveWholePage();
|
||||
}
|
||||
else {
|
||||
console.log("Unrecognized menuItemId", info.menuItemId);
|
||||
}
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener(async request => {
|
||||
console.log("Received", request);
|
||||
|
||||
if (request.name === 'openNoteInTrilium') {
|
||||
const resp = await triliumServerFacade.callService('POST', 'open/' + request.noteId);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// desktop app is not available so we need to open in browser
|
||||
if (resp.result === 'open-in-browser') {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
|
||||
if (triliumServerUrl) {
|
||||
const noteUrl = triliumServerUrl + '/#' + request.noteId;
|
||||
|
||||
console.log("Opening new tab in browser", noteUrl);
|
||||
|
||||
browser.tabs.create({
|
||||
url: noteUrl
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error("triliumServerUrl not found in local storage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (request.name === 'closeTabs') {
|
||||
return await browser.tabs.remove(request.tabIds)
|
||||
}
|
||||
else if (request.name === 'load-script') {
|
||||
return await browser.tabs.executeScript({file: request.file});
|
||||
}
|
||||
else if (request.name === 'save-cropped-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveCroppedScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveWholeScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-page') {
|
||||
return await saveWholePage();
|
||||
}
|
||||
else if (request.name === 'save-link-with-note') {
|
||||
return await saveLinkWithNote(request.title, request.content);
|
||||
}
|
||||
else if (request.name === 'save-tabs') {
|
||||
return await saveTabs();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search') {
|
||||
triliumServerFacade.triggerSearchForTrilium();
|
||||
}
|
||||
else if (request.name === 'send-trilium-search-status') {
|
||||
triliumServerFacade.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search-note-url') {
|
||||
const activeTab = await getActiveTab();
|
||||
triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
|
||||
}
|
||||
});
|
||||
1
apps/web-clipper/build.js
Normal file
1
apps/web-clipper/build.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
|
||||
@@ -1 +0,0 @@
|
||||
export default { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
|
||||
351
apps/web-clipper/content.js
Normal file
351
apps/web-clipper/content.js
Normal file
@@ -0,0 +1,351 @@
|
||||
function absoluteUrl(url) {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const protocol = url.toLowerCase().split(':')[0];
|
||||
if (['http', 'https', 'file'].indexOf(protocol) >= 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.indexOf('//') === 0) {
|
||||
return location.protocol + url;
|
||||
} else if (url[0] === '/') {
|
||||
return location.protocol + '//' + location.host + url;
|
||||
} else {
|
||||
return getBaseUrl() + '/' + url;
|
||||
}
|
||||
}
|
||||
|
||||
function pageTitle() {
|
||||
const titleElements = document.getElementsByTagName("title");
|
||||
|
||||
return titleElements.length ? titleElements[0].text.trim() : document.title.trim();
|
||||
}
|
||||
|
||||
function getReadableDocument() {
|
||||
// Readability directly change the passed document, so clone to preserve the original web page.
|
||||
const documentCopy = document.cloneNode(true);
|
||||
const readability = new Readability(documentCopy, {
|
||||
serializer: el => el // so that .content is returned as DOM element instead of HTML
|
||||
});
|
||||
|
||||
const article = readability.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new Error('Could not parse HTML document with Readability');
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
body: article.content,
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentDates() {
|
||||
var dates = {
|
||||
publishedDate: null,
|
||||
modifiedDate: null,
|
||||
};
|
||||
|
||||
const articlePublishedTime = document.querySelector("meta[property='article:published_time']");
|
||||
if (articlePublishedTime && articlePublishedTime.getAttribute('content')) {
|
||||
dates.publishedDate = new Date(articlePublishedTime.getAttribute('content'));
|
||||
}
|
||||
|
||||
const articleModifiedTime = document.querySelector("meta[property='article:modified_time']");
|
||||
if (articleModifiedTime && articleModifiedTime.getAttribute('content')) {
|
||||
dates.modifiedDate = new Date(articleModifiedTime.getAttribute('content'));
|
||||
}
|
||||
|
||||
// TODO: if we didn't get dates from meta, then try to get them from JSON-LD
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function getRectangleArea() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.opacity = '0.6';
|
||||
overlay.style.background = 'black';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.zIndex = 99999999;
|
||||
overlay.style.top = 0;
|
||||
overlay.style.left = 0;
|
||||
overlay.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const messageComp = document.createElement('div');
|
||||
|
||||
const messageCompWidth = 300;
|
||||
messageComp.setAttribute("tabindex", "0"); // so that it can be focused
|
||||
messageComp.style.position = 'fixed';
|
||||
messageComp.style.opacity = '0.95';
|
||||
messageComp.style.fontSize = '14px';
|
||||
messageComp.style.width = messageCompWidth + 'px';
|
||||
messageComp.style.maxWidth = messageCompWidth + 'px';
|
||||
messageComp.style.border = '1px solid black';
|
||||
messageComp.style.background = 'white';
|
||||
messageComp.style.color = 'black';
|
||||
messageComp.style.top = '10px';
|
||||
messageComp.style.textAlign = 'center';
|
||||
messageComp.style.padding = '10px';
|
||||
messageComp.style.left = Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) + 'px';
|
||||
messageComp.style.zIndex = overlay.style.zIndex + 1;
|
||||
|
||||
messageComp.textContent = 'Drag and release to capture a screenshot';
|
||||
|
||||
document.body.appendChild(messageComp);
|
||||
|
||||
const selection = document.createElement('div');
|
||||
selection.style.opacity = '0.5';
|
||||
selection.style.border = '1px solid red';
|
||||
selection.style.background = 'white';
|
||||
selection.style.border = '2px solid black';
|
||||
selection.style.zIndex = overlay.style.zIndex - 1;
|
||||
selection.style.top = 0;
|
||||
selection.style.left = 0;
|
||||
selection.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(selection);
|
||||
|
||||
messageComp.focus(); // we listen on keypresses on this element to cancel on escape
|
||||
|
||||
let isDragging = false;
|
||||
let draggingStartPos = null;
|
||||
let selectionArea = {};
|
||||
|
||||
function updateSelection() {
|
||||
selection.style.left = selectionArea.x + 'px';
|
||||
selection.style.top = selectionArea.y + 'px';
|
||||
selection.style.width = selectionArea.width + 'px';
|
||||
selection.style.height = selectionArea.height + 'px';
|
||||
}
|
||||
|
||||
function setSelectionSizeFromMouse(event) {
|
||||
if (event.clientX < draggingStartPos.x) {
|
||||
selectionArea.x = event.clientX;
|
||||
}
|
||||
|
||||
if (event.clientY < draggingStartPos.y) {
|
||||
selectionArea.y = event.clientY;
|
||||
}
|
||||
|
||||
selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x));
|
||||
selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y));
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function selection_mouseDown(event) {
|
||||
selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0};
|
||||
draggingStartPos = {x: event.clientX, y: event.clientY};
|
||||
isDragging = true;
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function selection_mouseMove(event) {
|
||||
if (!isDragging) return;
|
||||
setSelectionSizeFromMouse(event);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
isDragging = false;
|
||||
|
||||
overlay.removeEventListener('mousedown', selection_mouseDown);
|
||||
overlay.removeEventListener('mousemove', selection_mouseMove);
|
||||
overlay.removeEventListener('mouseup', selection_mouseUp);
|
||||
|
||||
document.body.removeChild(overlay);
|
||||
document.body.removeChild(selection);
|
||||
document.body.removeChild(messageComp);
|
||||
}
|
||||
|
||||
function selection_mouseUp(event) {
|
||||
setSelectionSizeFromMouse(event);
|
||||
|
||||
removeOverlay();
|
||||
|
||||
console.info('selectionArea:', selectionArea);
|
||||
|
||||
if (!selectionArea || !selectionArea.width || !selectionArea.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to wait a bit before taking the screenshot to make sure
|
||||
// the overlays have been removed and don't appear in the
|
||||
// screenshot. 10ms is not enough.
|
||||
setTimeout(() => resolve(selectionArea), 100);
|
||||
}
|
||||
|
||||
function cancel(event) {
|
||||
if (event.key === "Escape") {
|
||||
removeOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener('mousedown', selection_mouseDown);
|
||||
overlay.addEventListener('mousemove', selection_mouseMove);
|
||||
overlay.addEventListener('mouseup', selection_mouseUp);
|
||||
overlay.addEventListener('mouseup', selection_mouseUp);
|
||||
messageComp.addEventListener('keydown', cancel);
|
||||
});
|
||||
}
|
||||
|
||||
function makeLinksAbsolute(container) {
|
||||
for (const link of container.getElementsByTagName('a')) {
|
||||
if (link.href) {
|
||||
link.href = absoluteUrl(link.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getImages(container) {
|
||||
const images = [];
|
||||
|
||||
for (const img of container.getElementsByTagName('img')) {
|
||||
if (!img.src) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingImage = images.find(image => image.src === img.src);
|
||||
|
||||
if (existingImage) {
|
||||
img.src = existingImage.imageId;
|
||||
}
|
||||
else {
|
||||
const imageId = randomString(20);
|
||||
|
||||
images.push({
|
||||
imageId: imageId,
|
||||
src: img.src
|
||||
});
|
||||
|
||||
img.src = imageId;
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
function createLink(clickAction, text, color = "lightskyblue") {
|
||||
const link = document.createElement('a');
|
||||
link.href = "javascript:";
|
||||
link.style.color = color;
|
||||
link.appendChild(document.createTextNode(text));
|
||||
link.addEventListener("click", () => {
|
||||
browser.runtime.sendMessage(null, clickAction)
|
||||
});
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
async function prepareMessageResponse(message) {
|
||||
console.info('Message: ' + message.name);
|
||||
|
||||
if (message.name === "toast") {
|
||||
let messageText;
|
||||
|
||||
if (message.noteId) {
|
||||
messageText = document.createElement('p');
|
||||
messageText.setAttribute("style", "padding: 0; margin: 0; font-size: larger;")
|
||||
messageText.appendChild(document.createTextNode(message.message + " "));
|
||||
messageText.appendChild(createLink(
|
||||
{name: 'openNoteInTrilium', noteId: message.noteId},
|
||||
"Open in Trilium."
|
||||
));
|
||||
|
||||
// only after saving tabs
|
||||
if (message.tabIds) {
|
||||
messageText.appendChild(document.createElement("br"));
|
||||
messageText.appendChild(createLink(
|
||||
{name: 'closeTabs', tabIds: message.tabIds},
|
||||
"Close saved tabs.",
|
||||
"tomato"
|
||||
));
|
||||
}
|
||||
}
|
||||
else {
|
||||
messageText = message.message;
|
||||
}
|
||||
|
||||
await requireLib('/lib/toast.js');
|
||||
|
||||
showToast(messageText, {
|
||||
settings: {
|
||||
duration: 7000
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (message.name === "trilium-save-selection") {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i);
|
||||
|
||||
container.appendChild(range.cloneContents());
|
||||
}
|
||||
|
||||
makeLinksAbsolute(container);
|
||||
|
||||
const images = getImages(container);
|
||||
|
||||
return {
|
||||
title: pageTitle(),
|
||||
content: container.innerHTML,
|
||||
images: images,
|
||||
pageUrl: getPageLocationOrigin() + location.pathname + location.search + location.hash
|
||||
};
|
||||
|
||||
}
|
||||
else if (message.name === 'trilium-get-rectangle-for-screenshot') {
|
||||
return getRectangleArea();
|
||||
}
|
||||
else if (message.name === "trilium-save-page") {
|
||||
await requireLib("/lib/JSDOMParser.js");
|
||||
await requireLib("/lib/Readability.js");
|
||||
await requireLib("/lib/Readability-readerable.js");
|
||||
|
||||
const {title, body} = getReadableDocument();
|
||||
|
||||
makeLinksAbsolute(body);
|
||||
|
||||
const images = getImages(body);
|
||||
|
||||
var labels = {};
|
||||
const dates = getDocumentDates();
|
||||
if (dates.publishedDate) {
|
||||
labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10);
|
||||
}
|
||||
if (dates.modifiedDate) {
|
||||
labels['modifiedDate'] = dates.publishedDate.toISOString().substring(0, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
title: title,
|
||||
content: body.innerHTML,
|
||||
images: images,
|
||||
pageUrl: getPageLocationOrigin() + location.pathname + location.search,
|
||||
clipType: 'page',
|
||||
labels: labels
|
||||
};
|
||||
}
|
||||
else {
|
||||
throw new Error('Unknown command: ' + JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(prepareMessageResponse);
|
||||
|
||||
const loadedLibs = [];
|
||||
|
||||
async function requireLib(libPath) {
|
||||
if (!loadedLibs.includes(libPath)) {
|
||||
loadedLibs.push(libPath);
|
||||
|
||||
await browser.runtime.sendMessage({name: 'load-script', file: libPath});
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
import { randomString, Rect } from "@/utils";
|
||||
|
||||
import TriliumServerFacade from "./trilium_server_facade";
|
||||
|
||||
type BackgroundMessage = {
|
||||
name: "toast";
|
||||
message: string;
|
||||
noteId: string | null;
|
||||
tabIds: number[] | null;
|
||||
} | {
|
||||
name: "trilium-save-selection";
|
||||
} | {
|
||||
name: "trilium-get-rectangle-for-screenshot";
|
||||
} | {
|
||||
name: "trilium-save-page";
|
||||
};
|
||||
|
||||
export default defineBackground(() => {
|
||||
const triliumServerFacade = new TriliumServerFacade();
|
||||
|
||||
// Keyboard shortcuts
|
||||
browser.commands.onCommand.addListener(async (command) => {
|
||||
switch (command) {
|
||||
case "saveSelection":
|
||||
await saveSelection();
|
||||
break;
|
||||
case "saveWholePage":
|
||||
await saveWholePage();
|
||||
break;
|
||||
case "saveTabs":
|
||||
await saveTabs();
|
||||
break;
|
||||
case "saveCroppedScreenshot": {
|
||||
const activeTab = await getActiveTab();
|
||||
await saveCroppedScreenshot(activeTab.url);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log("Unrecognized command", command);
|
||||
}
|
||||
});
|
||||
|
||||
function cropImageManifestV2(newArea: Rect, dataUrl: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newArea.width;
|
||||
canvas.height = newArea.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
img.onerror = reject;
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function cropImageManifestV3(newArea: Rect, dataUrl: string) {
|
||||
// Create offscreen document if it doesn't exist
|
||||
await ensureOffscreenDocument();
|
||||
|
||||
// Send cropping task to offscreen document
|
||||
return await browser.runtime.sendMessage({
|
||||
type: 'CROP_IMAGE',
|
||||
dataUrl,
|
||||
cropRect: newArea
|
||||
});
|
||||
}
|
||||
|
||||
async function takeCroppedScreenshot(cropRect: Rect, devicePixelRatio: number = 1) {
|
||||
const activeTab = await getActiveTab();
|
||||
const zoom = await browser.tabs.getZoom(activeTab.id) * devicePixelRatio;
|
||||
|
||||
const newArea: Rect = {
|
||||
x: cropRect.x * zoom,
|
||||
y: cropRect.y * zoom,
|
||||
width: cropRect.width * zoom,
|
||||
height: cropRect.height * zoom
|
||||
};
|
||||
|
||||
const dataUrl = await browser.tabs.captureVisibleTab({ format: 'png' });
|
||||
const cropImage = (import.meta.env.MANIFEST_VERSION === 3 ? cropImageManifestV3 : cropImageManifestV2);
|
||||
return await cropImage(newArea, dataUrl);
|
||||
}
|
||||
|
||||
async function ensureOffscreenDocument() {
|
||||
const existingContexts = await browser.runtime.getContexts({
|
||||
contextTypes: ['OFFSCREEN_DOCUMENT']
|
||||
});
|
||||
|
||||
if (existingContexts.length > 0) {
|
||||
return; // Already exists
|
||||
}
|
||||
|
||||
await browser.offscreen.createDocument({
|
||||
url: browser.runtime.getURL('/offscreen.html'),
|
||||
reasons: ['DOM_SCRAPING'], // or 'DISPLAY_MEDIA' depending on browser support
|
||||
justification: 'Image cropping requires canvas API'
|
||||
});
|
||||
}
|
||||
|
||||
async function takeWholeScreenshot() {
|
||||
// this saves only visible portion of the page
|
||||
// workaround to save the whole page is to scroll & stitch
|
||||
// example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
|
||||
// see page.js and popup.js
|
||||
return await browser.tabs.captureVisibleTab({ format: 'png' });
|
||||
}
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-selection",
|
||||
title: "Save selection to Trilium",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-cropped-screenshot",
|
||||
title: "Crop screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-whole-screenshot",
|
||||
title: "Save whole screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-page",
|
||||
title: "Save whole page to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-link",
|
||||
title: "Save link to Trilium",
|
||||
contexts: ["link"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-image",
|
||||
title: "Save image to Trilium",
|
||||
contexts: ["image"]
|
||||
});
|
||||
|
||||
async function getActiveTab() {
|
||||
const tabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs[0];
|
||||
}
|
||||
|
||||
async function getWindowTabs() {
|
||||
const tabs = await browser.tabs.query({
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
async function sendMessageToActiveTab(message: BackgroundMessage) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!activeTab?.id) {
|
||||
throw new Error("No active tab.");
|
||||
}
|
||||
|
||||
return await browser.tabs.sendMessage(activeTab.id, message);
|
||||
}
|
||||
|
||||
function toast(message: string, noteId: string | null = null, tabIds: number[] | null = null) {
|
||||
sendMessageToActiveTab({
|
||||
name: 'toast',
|
||||
message,
|
||||
noteId,
|
||||
tabIds
|
||||
});
|
||||
}
|
||||
|
||||
function blob2base64(blob: Blob) {
|
||||
return new Promise<string | null>(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
resolve(reader.result as string | null);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchImage(url: string) {
|
||||
const resp = await fetch(url);
|
||||
const blob = await resp.blob();
|
||||
|
||||
return await blob2base64(blob);
|
||||
}
|
||||
|
||||
async function postProcessImage(image: { src: string, dataUrl?: string | null }) {
|
||||
if (image.src.startsWith("data:image/")) {
|
||||
image.dataUrl = image.src;
|
||||
const mimeSubtype = image.src.match(/data:image\/(.*?);/)?.[1];
|
||||
if (!mimeSubtype) return;
|
||||
image.src = `inline.${mimeSubtype}`; // this should extract file type - png/jpg
|
||||
}
|
||||
else {
|
||||
try {
|
||||
image.dataUrl = await fetchImage(image.src);
|
||||
} catch (e) {
|
||||
console.error(`Cannot fetch image from ${image.src}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postProcessImages(resp: { images?: { src: string, dataUrl?: string }[] }) {
|
||||
if (resp.images) {
|
||||
for (const image of resp.images) {
|
||||
await postProcessImage(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelection() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Selection has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function getImagePayloadFromSrc(src: string, pageUrl: string | null | undefined) {
|
||||
const image = {
|
||||
imageId: randomString(20),
|
||||
src
|
||||
};
|
||||
|
||||
await postProcessImage(image);
|
||||
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return {
|
||||
title: activeTab.title,
|
||||
content: `<img src="${image.imageId}">`,
|
||||
images: [image],
|
||||
pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCroppedScreenshot(pageUrl: string | null | undefined) {
|
||||
const { rect, devicePixelRatio } = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
|
||||
|
||||
const src = await takeCroppedScreenshot(rect, devicePixelRatio);
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholeScreenshot(pageUrl: string | null | undefined) {
|
||||
const src = await takeWholeScreenshot();
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveImage(srcUrl: string, pageUrl: string | null | undefined) {
|
||||
const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Image has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholePage() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Page has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveLinkWithNote(title: string, content: string) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!title.trim()) {
|
||||
title = activeTab.title ?? "";
|
||||
}
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', {
|
||||
title,
|
||||
content,
|
||||
clipType: 'note',
|
||||
pageUrl: activeTab.url
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
toast("Link with note has been saved to Trilium.", resp.noteId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getTabsPayload(tabs: Browser.tabs.Tab[]) {
|
||||
let content = '<ul>';
|
||||
tabs.forEach(tab => {
|
||||
content += `<li><a href="${tab.url}">${tab.title}</a></li>`;
|
||||
});
|
||||
content += '</ul>';
|
||||
|
||||
const domainsCount = tabs.map(tab => tab.url)
|
||||
.reduce((acc, url) => {
|
||||
const hostname = new URL(url ?? "").hostname;
|
||||
return acc.set(hostname, (acc.get(hostname) || 0) + 1);
|
||||
}, new Map());
|
||||
|
||||
let topDomains = [...domainsCount]
|
||||
.sort((a, b) => {return b[1]-a[1];})
|
||||
.slice(0,3)
|
||||
.map(domain=>domain[0])
|
||||
.join(', ');
|
||||
|
||||
if (tabs.length > 3) { topDomains += '...'; }
|
||||
|
||||
return {
|
||||
title: `${tabs.length} browser tabs: ${topDomains}`,
|
||||
content,
|
||||
clipType: 'tabs'
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTabs() {
|
||||
const tabs = await getWindowTabs();
|
||||
|
||||
const payload = await getTabsPayload(tabs);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
if (!resp) return;
|
||||
|
||||
const tabIds = tabs.map(tab => tab.id).filter(id => id !== undefined) as number[];
|
||||
toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
|
||||
}
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async (info: globalThis.Browser.contextMenus.OnClickData & { linkText?: string; }) => {
|
||||
if (info.menuItemId === 'trilium-save-selection') {
|
||||
await saveSelection();
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
|
||||
await saveCroppedScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-whole-screenshot') {
|
||||
await saveWholeScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-image') {
|
||||
if (!info.srcUrl) return;
|
||||
await saveImage(info.srcUrl, info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-link') {
|
||||
if (!info.linkUrl) return;
|
||||
// Link text is only available on Firefox.
|
||||
const linkText = info.linkText || info.linkUrl;
|
||||
const content = `<a href="${info.linkUrl}">${linkText}</a>`;
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', {
|
||||
title: activeTab.title,
|
||||
content,
|
||||
pageUrl: info.pageUrl
|
||||
});
|
||||
|
||||
if (!resp) return;
|
||||
toast("Link has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-page') {
|
||||
await saveWholePage();
|
||||
}
|
||||
else {
|
||||
console.log("Unrecognized menuItemId", info.menuItemId);
|
||||
}
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener(async request => {
|
||||
console.log("Received", request);
|
||||
|
||||
if (request.name === 'openNoteInTrilium') {
|
||||
const resp = await triliumServerFacade.callService('POST', `open/${request.noteId}`);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// desktop app is not available so we need to open in browser
|
||||
if (resp.result === 'open-in-browser') {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
|
||||
if (triliumServerUrl) {
|
||||
const noteUrl = `${triliumServerUrl }/#${ request.noteId}`;
|
||||
|
||||
console.log("Opening new tab in browser", noteUrl);
|
||||
|
||||
browser.tabs.create({
|
||||
url: noteUrl
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error("triliumServerUrl not found in local storage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (request.name === 'closeTabs') {
|
||||
return await browser.tabs.remove(request.tabIds);
|
||||
}
|
||||
else if (request.name === 'save-cropped-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveCroppedScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveWholeScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-page') {
|
||||
return await saveWholePage();
|
||||
}
|
||||
else if (request.name === 'save-link-with-note') {
|
||||
return await saveLinkWithNote(request.title, request.content);
|
||||
}
|
||||
else if (request.name === 'save-tabs') {
|
||||
return await saveTabs();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search') {
|
||||
triliumServerFacade.triggerSearchForTrilium();
|
||||
}
|
||||
else if (request.name === 'send-trilium-search-status') {
|
||||
triliumServerFacade.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search-note-url') {
|
||||
const activeTab = await getActiveTab();
|
||||
if (activeTab.url) {
|
||||
triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
const PROTOCOL_VERSION_MAJOR = 1;
|
||||
|
||||
type TriliumSearchStatus = {
|
||||
status: "searching";
|
||||
} | {
|
||||
status: "not-found"
|
||||
} | {
|
||||
status: "found-desktop",
|
||||
port: number;
|
||||
url: string;
|
||||
} | {
|
||||
status: "found-server",
|
||||
url: string;
|
||||
token: string;
|
||||
} | {
|
||||
status: "version-mismatch";
|
||||
extensionMajor: number;
|
||||
triliumMajor: number;
|
||||
};
|
||||
|
||||
type TriliumSearchNoteStatus = {
|
||||
status: "not-found",
|
||||
noteId: null
|
||||
} | {
|
||||
status: "found",
|
||||
noteId: string
|
||||
};
|
||||
|
||||
export default class TriliumServerFacade {
|
||||
private triliumSearch?: TriliumSearchStatus;
|
||||
private triliumSearchNote?: TriliumSearchNoteStatus;
|
||||
|
||||
constructor() {
|
||||
this.triggerSearchForTrilium();
|
||||
|
||||
// continually scan for changes (if e.g. desktop app is started after browser)
|
||||
setInterval(() => this.triggerSearchForTrilium(), 60 * 1000);
|
||||
}
|
||||
|
||||
async sendTriliumSearchStatusToPopup() {
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-search-status",
|
||||
triliumSearch: this.triliumSearch
|
||||
});
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
async sendTriliumSearchNoteToPopup(){
|
||||
try{
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-previously-visited",
|
||||
searchNote: this.triliumSearchNote
|
||||
});
|
||||
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
|
||||
setTriliumSearchNote(st: TriliumSearchNoteStatus){
|
||||
this.triliumSearchNote = st;
|
||||
this.sendTriliumSearchNoteToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearch(ts: TriliumSearchStatus) {
|
||||
this.triliumSearch = ts;
|
||||
|
||||
this.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearchWithVersionCheck(json: { protocolVersion: string }, resp: TriliumSearchStatus) {
|
||||
const [ major ] = json.protocolVersion
|
||||
.split(".")
|
||||
.map(chunk => parseInt(chunk, 10));
|
||||
|
||||
// minor version is intended to be used to dynamically limit features provided by extension
|
||||
// if some specific Trilium API is not supported. So far not needed.
|
||||
|
||||
if (major !== PROTOCOL_VERSION_MAJOR) {
|
||||
this.setTriliumSearch({
|
||||
status: 'version-mismatch',
|
||||
extensionMajor: PROTOCOL_VERSION_MAJOR,
|
||||
triliumMajor: major
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.setTriliumSearch(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSearchForTrilium() {
|
||||
this.setTriliumSearch({ status: 'searching' });
|
||||
|
||||
try {
|
||||
const port = await this.getPort();
|
||||
|
||||
console.debug(`Trying port ${port}`);
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/clipper/handshake`);
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-desktop',
|
||||
port,
|
||||
url: `http://127.0.0.1:${port}`
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// continue
|
||||
}
|
||||
|
||||
const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
|
||||
|
||||
if (triliumServerUrl && authToken) {
|
||||
try {
|
||||
const resp = await fetch(`${triliumServerUrl }/api/clipper/handshake`, {
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-server',
|
||||
url: triliumServerUrl,
|
||||
token: authToken
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Request to the configured server instance failed with:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// if all above fails it's not found
|
||||
this.setTriliumSearch({ status: 'not-found' });
|
||||
}
|
||||
|
||||
async triggerSearchNoteByUrl(noteUrl: string) {
|
||||
const resp = await this.callService('GET', `notes-by-url/${encodeURIComponent(noteUrl)}`);
|
||||
let newStatus: TriliumSearchNoteStatus;
|
||||
if (resp && resp.noteId) {
|
||||
newStatus = {
|
||||
status: 'found',
|
||||
noteId: resp.noteId,
|
||||
};
|
||||
} else {
|
||||
newStatus = {
|
||||
status: 'not-found',
|
||||
noteId: null
|
||||
};
|
||||
}
|
||||
this.setTriliumSearchNote(newStatus);
|
||||
}
|
||||
async waitForTriliumSearch() {
|
||||
return new Promise<void>((res, rej) => {
|
||||
const checkStatus = () => {
|
||||
if (this.triliumSearch?.status === "searching") {
|
||||
setTimeout(checkStatus, 500);
|
||||
} else if (this.triliumSearch?.status === 'not-found') {
|
||||
rej(new Error("Trilium instance has not been found."));
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async getPort() {
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
|
||||
|
||||
if (triliumDesktopPort) {
|
||||
return parseInt(triliumDesktopPort, 10);
|
||||
}
|
||||
|
||||
return import.meta.env.DEV ? 37742 : 37840;
|
||||
}
|
||||
|
||||
async callService(method: string, path: string, body?: string | object) {
|
||||
await this.waitForTriliumSearch();
|
||||
if (!this.triliumSearch || (this.triliumSearch.status !== 'found-desktop' && this.triliumSearch.status !== 'found-server')) return;
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: "token" in this.triliumSearch ? this.triliumSearch.token ?? "" : "",
|
||||
'Content-Type': 'application/json',
|
||||
'trilium-local-now-datetime': this.localNowDateTime()
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const url = `${this.triliumSearch.url}/api/clipper/${path}`;
|
||||
|
||||
console.log(`Sending ${method} request to ${url}`);
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Sending request to trilium failed", e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
localNowDateTime() {
|
||||
const date = new Date();
|
||||
const off = date.getTimezoneOffset();
|
||||
const absoff = Math.abs(off);
|
||||
return (`${new Date(date.getTime() - off * 60 * 1000).toISOString().substr(0,23).replace("T", " ") +
|
||||
(off > 0 ? '-' : '+') +
|
||||
(absoff / 60).toFixed(0).padStart(2,'0') }:${
|
||||
(absoff % 60).toString().padStart(2,'0')}`);
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import Readability from "@/lib/Readability.js";
|
||||
import { createLink, getBaseUrl, getPageLocationOrigin, randomString, Rect } from "@/utils.js";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
"<all_urls>"
|
||||
],
|
||||
main: () => {
|
||||
function absoluteUrl(url: string | undefined) {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const protocol = url.toLowerCase().split(':')[0];
|
||||
if (['http', 'https', 'file'].indexOf(protocol) >= 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.indexOf('//') === 0) {
|
||||
return location.protocol + url;
|
||||
} else if (url[0] === '/') {
|
||||
return `${location.protocol}//${location.host}${url}`;
|
||||
}
|
||||
return `${getBaseUrl()}/${url}`;
|
||||
|
||||
}
|
||||
|
||||
function pageTitle() {
|
||||
const titleElements = document.getElementsByTagName("title");
|
||||
|
||||
return titleElements.length ? titleElements[0].text.trim() : document.title.trim();
|
||||
}
|
||||
|
||||
function getReadableDocument() {
|
||||
// Readability directly change the passed document, so clone to preserve the original web page.
|
||||
const documentCopy = document.cloneNode(true);
|
||||
const readability = new Readability(documentCopy, {
|
||||
serializer: el => el // so that .content is returned as DOM element instead of HTML
|
||||
});
|
||||
|
||||
const article = readability.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new Error('Could not parse HTML document with Readability');
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
body: article.content,
|
||||
};
|
||||
}
|
||||
|
||||
function getDocumentDates() {
|
||||
let publishedDate: Date | null = null;
|
||||
let modifiedDate: Date | null = null;
|
||||
|
||||
const articlePublishedTime = document.querySelector("meta[property='article:published_time']")?.getAttribute('content');
|
||||
if (articlePublishedTime) {
|
||||
publishedDate = new Date(articlePublishedTime);
|
||||
}
|
||||
|
||||
const articleModifiedTime = document.querySelector("meta[property='article:modified_time']")?.getAttribute('content');
|
||||
if (articleModifiedTime) {
|
||||
modifiedDate = new Date(articleModifiedTime);
|
||||
}
|
||||
|
||||
// TODO: if we didn't get dates from meta, then try to get them from JSON-LD
|
||||
return { publishedDate, modifiedDate };
|
||||
}
|
||||
|
||||
function getRectangleArea() {
|
||||
return new Promise<Rect>((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.opacity = '0.6';
|
||||
overlay.style.background = 'black';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.zIndex = "99999999";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const messageComp = document.createElement('div');
|
||||
|
||||
const messageCompWidth = 300;
|
||||
messageComp.setAttribute("tabindex", "0"); // so that it can be focused
|
||||
messageComp.style.position = 'fixed';
|
||||
messageComp.style.opacity = '0.95';
|
||||
messageComp.style.fontSize = '14px';
|
||||
messageComp.style.width = `${messageCompWidth}px`;
|
||||
messageComp.style.maxWidth = `${messageCompWidth}px`;
|
||||
messageComp.style.border = '1px solid black';
|
||||
messageComp.style.background = 'white';
|
||||
messageComp.style.color = 'black';
|
||||
messageComp.style.top = '10px';
|
||||
messageComp.style.textAlign = 'center';
|
||||
messageComp.style.padding = '10px';
|
||||
messageComp.style.left = `${Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) }px`;
|
||||
messageComp.style.zIndex = overlay.style.zIndex + 1;
|
||||
|
||||
messageComp.textContent = 'Drag and release to capture a screenshot';
|
||||
|
||||
document.body.appendChild(messageComp);
|
||||
|
||||
const selection = document.createElement('div');
|
||||
selection.style.opacity = '0.5';
|
||||
selection.style.border = '1px solid red';
|
||||
selection.style.background = 'white';
|
||||
selection.style.border = '2px solid black';
|
||||
selection.style.zIndex = String(parseInt(overlay.style.zIndex, 10) - 1);
|
||||
selection.style.top = "0";
|
||||
selection.style.left = "0";
|
||||
selection.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(selection);
|
||||
|
||||
messageComp.focus(); // we listen on keypresses on this element to cancel on escape
|
||||
|
||||
let isDragging = false;
|
||||
let draggingStartPos: {x: number, y: number} | null = null;
|
||||
let selectionArea: Rect;
|
||||
|
||||
function updateSelection() {
|
||||
selection.style.left = `${selectionArea.x}px`;
|
||||
selection.style.top = `${selectionArea.y}px`;
|
||||
selection.style.width = `${selectionArea.width}px`;
|
||||
selection.style.height = `${selectionArea.height}px`;
|
||||
}
|
||||
|
||||
function setSelectionSizeFromMouse(event: MouseEvent) {
|
||||
if (!draggingStartPos) return;
|
||||
|
||||
if (event.clientX < draggingStartPos.x) {
|
||||
selectionArea.x = event.clientX;
|
||||
}
|
||||
|
||||
if (event.clientY < draggingStartPos.y) {
|
||||
selectionArea.y = event.clientY;
|
||||
}
|
||||
|
||||
selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x));
|
||||
selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y));
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function selection_mouseDown(event: MouseEvent) {
|
||||
selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0};
|
||||
draggingStartPos = {x: event.clientX, y: event.clientY};
|
||||
isDragging = true;
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function selection_mouseMove(event: MouseEvent) {
|
||||
if (!isDragging) return;
|
||||
setSelectionSizeFromMouse(event);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
isDragging = false;
|
||||
|
||||
overlay.removeEventListener('mousedown', selection_mouseDown);
|
||||
overlay.removeEventListener('mousemove', selection_mouseMove);
|
||||
overlay.removeEventListener('mouseup', selection_mouseUp);
|
||||
|
||||
document.body.removeChild(overlay);
|
||||
document.body.removeChild(selection);
|
||||
document.body.removeChild(messageComp);
|
||||
}
|
||||
|
||||
function selection_mouseUp(event: MouseEvent) {
|
||||
setSelectionSizeFromMouse(event);
|
||||
|
||||
removeOverlay();
|
||||
|
||||
console.info('selectionArea:', selectionArea);
|
||||
|
||||
if (!selectionArea || !selectionArea.width || !selectionArea.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to wait a bit before taking the screenshot to make sure
|
||||
// the overlays have been removed and don't appear in the
|
||||
// screenshot. 10ms is not enough.
|
||||
setTimeout(() => resolve(selectionArea), 100);
|
||||
}
|
||||
|
||||
function cancel(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
removeOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
overlay.addEventListener('mousedown', selection_mouseDown);
|
||||
overlay.addEventListener('mousemove', selection_mouseMove);
|
||||
overlay.addEventListener('mouseup', selection_mouseUp);
|
||||
messageComp.addEventListener('keydown', cancel);
|
||||
});
|
||||
}
|
||||
|
||||
function makeLinksAbsolute(container: HTMLElement) {
|
||||
for (const link of container.getElementsByTagName('a')) {
|
||||
if (link.href) {
|
||||
const newUrl = absoluteUrl(link.href);
|
||||
if (!newUrl) continue;
|
||||
link.href = newUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getImages(container: HTMLElement) {
|
||||
const images: {imageId: string, src: string}[] = [];
|
||||
|
||||
for (const img of container.getElementsByTagName('img')) {
|
||||
if (!img.src) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingImage = images.find(image => image.src === img.src);
|
||||
|
||||
if (existingImage) {
|
||||
img.src = existingImage.imageId;
|
||||
}
|
||||
else {
|
||||
const imageId = randomString(20);
|
||||
|
||||
images.push({
|
||||
imageId,
|
||||
src: img.src
|
||||
});
|
||||
|
||||
img.src = imageId;
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
async function prepareMessageResponse(message: {name: string, noteId?: string, message?: string, tabIds?: string[]}) {
|
||||
console.info(`Message: ${ message.name}`);
|
||||
|
||||
if (message.name === "toast") {
|
||||
let messageText;
|
||||
|
||||
if (message.noteId) {
|
||||
messageText = document.createElement('p');
|
||||
messageText.setAttribute("style", "padding: 0; margin: 0; font-size: larger;");
|
||||
messageText.appendChild(document.createTextNode(`${message.message } `));
|
||||
messageText.appendChild(createLink(
|
||||
{name: 'openNoteInTrilium', noteId: message.noteId},
|
||||
"Open in Trilium."
|
||||
));
|
||||
|
||||
// only after saving tabs
|
||||
if (message.tabIds) {
|
||||
messageText.appendChild(document.createElement("br"));
|
||||
messageText.appendChild(createLink(
|
||||
{name: 'closeTabs', tabIds: message.tabIds},
|
||||
"Close saved tabs.",
|
||||
"tomato"
|
||||
));
|
||||
}
|
||||
}
|
||||
else {
|
||||
messageText = message.message;
|
||||
}
|
||||
|
||||
await import("@/lib/toast");
|
||||
|
||||
window.showToast(messageText, {
|
||||
settings: {
|
||||
duration: 7000
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (message.name === "trilium-save-selection") {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
throw new Error('No selection available to clip');
|
||||
}
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i);
|
||||
|
||||
container.appendChild(range.cloneContents());
|
||||
}
|
||||
|
||||
makeLinksAbsolute(container);
|
||||
|
||||
const images = getImages(container);
|
||||
|
||||
return {
|
||||
title: pageTitle(),
|
||||
content: container.innerHTML,
|
||||
images,
|
||||
pageUrl: getPageLocationOrigin() + location.pathname + location.search + location.hash
|
||||
};
|
||||
|
||||
}
|
||||
else if (message.name === 'trilium-get-rectangle-for-screenshot') {
|
||||
return {
|
||||
rect: await getRectangleArea(),
|
||||
devicePixelRatio: window.devicePixelRatio
|
||||
};
|
||||
}
|
||||
else if (message.name === "trilium-save-page") {
|
||||
const {title, body} = getReadableDocument();
|
||||
|
||||
makeLinksAbsolute(body);
|
||||
|
||||
const images = getImages(body);
|
||||
|
||||
const labels = {};
|
||||
const dates = getDocumentDates();
|
||||
if (dates.publishedDate) {
|
||||
labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10);
|
||||
}
|
||||
if (dates.modifiedDate) {
|
||||
labels['modifiedDate'] = dates.modifiedDate.toISOString().substring(0, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
content: body.innerHTML,
|
||||
images,
|
||||
pageUrl: getPageLocationOrigin() + location.pathname + location.search,
|
||||
clipType: 'page',
|
||||
labels
|
||||
};
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unknown command: ${ JSON.stringify(message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message) => {
|
||||
try {
|
||||
const response = await prepareMessageResponse(message);
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="manifest.exclude" content="['safari','firefox']" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,24 +0,0 @@
|
||||
browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.type === 'CROP_IMAGE') {
|
||||
cropImage(message.cropRect, message.dataUrl).then(sendResponse);
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
});
|
||||
|
||||
function cropImage(newArea: { x: number, y: number, width: number, height: number }, dataUrl: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newArea.width;
|
||||
canvas.height = newArea.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height,
|
||||
0, 0, newArea.width, newArea.height);
|
||||
}
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
BIN
apps/web-clipper/icons/32-dev.png
Normal file
BIN
apps/web-clipper/icons/32-dev.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/web-clipper/icons/32.png
Normal file
BIN
apps/web-clipper/icons/32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web-clipper/icons/48.png
Normal file
BIN
apps/web-clipper/icons/48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/web-clipper/icons/96.png
Normal file
BIN
apps/web-clipper/icons/96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1196
apps/web-clipper/lib/JSDOMParser.js
Normal file
1196
apps/web-clipper/lib/JSDOMParser.js
Normal file
File diff suppressed because it is too large
Load Diff
108
apps/web-clipper/lib/Readability-readerable.js
Normal file
108
apps/web-clipper/lib/Readability-readerable.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-env es6:false */
|
||||
/*
|
||||
* Copyright (c) 2010 Arc90 Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This code is heavily based on Arc90's readability.js (1.7.1) script
|
||||
* available at: http://code.google.com/p/arc90labs-readability
|
||||
*/
|
||||
|
||||
var REGEXPS = {
|
||||
// NOTE: These two regular expressions are duplicated in
|
||||
// Readability.js. Please keep both copies in sync.
|
||||
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,
|
||||
okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i,
|
||||
};
|
||||
|
||||
function isNodeVisible(node) {
|
||||
// Have to null-check node.style and node.className.indexOf to deal with SVG and MathML nodes.
|
||||
return (!node.style || node.style.display != "none")
|
||||
&& !node.hasAttribute("hidden")
|
||||
//check for "fallback-image" so that wikimedia math images are displayed
|
||||
&& (!node.hasAttribute("aria-hidden") || node.getAttribute("aria-hidden") != "true" || (node.className && node.className.indexOf && node.className.indexOf("fallback-image") !== -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether or not the document is reader-able without parsing the whole thing.
|
||||
* @param {Object} options Configuration object.
|
||||
* @param {number} [options.minContentLength=140] The minimum node content length used to decide if the document is readerable.
|
||||
* @param {number} [options.minScore=20] The minumum cumulated 'score' used to determine if the document is readerable.
|
||||
* @param {Function} [options.visibilityChecker=isNodeVisible] The function used to determine if a node is visible.
|
||||
* @return {boolean} Whether or not we suspect Readability.parse() will suceeed at returning an article object.
|
||||
*/
|
||||
function isProbablyReaderable(doc, options = {}) {
|
||||
// For backward compatibility reasons 'options' can either be a configuration object or the function used
|
||||
// to determine if a node is visible.
|
||||
if (typeof options == "function") {
|
||||
options = { visibilityChecker: options };
|
||||
}
|
||||
|
||||
var defaultOptions = { minScore: 20, minContentLength: 140, visibilityChecker: isNodeVisible };
|
||||
options = Object.assign(defaultOptions, options);
|
||||
|
||||
var nodes = doc.querySelectorAll("p, pre, article");
|
||||
|
||||
// Get <div> nodes which have <br> node(s) and append them into the `nodes` variable.
|
||||
// Some articles' DOM structures might look like
|
||||
// <div>
|
||||
// Sentences<br>
|
||||
// <br>
|
||||
// Sentences<br>
|
||||
// </div>
|
||||
var brNodes = doc.querySelectorAll("div > br");
|
||||
if (brNodes.length) {
|
||||
var set = new Set(nodes);
|
||||
[].forEach.call(brNodes, function (node) {
|
||||
set.add(node.parentNode);
|
||||
});
|
||||
nodes = Array.from(set);
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
// This is a little cheeky, we use the accumulator 'score' to decide what to return from
|
||||
// this callback:
|
||||
return [].some.call(nodes, function (node) {
|
||||
if (!options.visibilityChecker(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var matchString = node.className + " " + node.id;
|
||||
if (REGEXPS.unlikelyCandidates.test(matchString) &&
|
||||
!REGEXPS.okMaybeItsACandidate.test(matchString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.matches("li p")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var textContentLength = node.textContent.trim().length;
|
||||
if (textContentLength < options.minContentLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
score += Math.sqrt(textContentLength - options.minContentLength);
|
||||
|
||||
if (score > options.minScore) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module === "object") {
|
||||
module.exports = isProbablyReaderable;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
* @param {HTMLDocument} doc The document to parse.
|
||||
* @param {Object} options The options object.
|
||||
*/
|
||||
export default function Readability(doc, options) {
|
||||
function Readability(doc, options) {
|
||||
// In some older versions, people passed a URI as the first argument. Cope:
|
||||
if (options && options.documentElement) {
|
||||
doc = options;
|
||||
@@ -2277,3 +2277,7 @@ Readability.prototype = {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module === "object") {
|
||||
module.exports = Readability;
|
||||
}
|
||||
|
||||
1224
apps/web-clipper/lib/browser-polyfill.js
Normal file
1224
apps/web-clipper/lib/browser-polyfill.js
Normal file
File diff suppressed because it is too large
Load Diff
75
apps/web-clipper/manifest.json
Normal file
75
apps/web-clipper/manifest.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Trilium Web Clipper (dev)",
|
||||
"version": "1.0.1",
|
||||
"description": "Save web clippings to Trilium Notes.",
|
||||
"homepage_url": "https://github.com/zadam/trilium-web-clipper",
|
||||
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
|
||||
"icons": {
|
||||
"32": "icons/32.png",
|
||||
"48": "icons/48.png",
|
||||
"96": "icons/96.png"
|
||||
},
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"http://*/",
|
||||
"https://*/",
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"contextMenus"
|
||||
],
|
||||
"browser_action": {
|
||||
"default_icon": "icons/32.png",
|
||||
"default_title": "Trilium Web Clipper",
|
||||
"default_popup": "popup/popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"lib/browser-polyfill.js",
|
||||
"utils.js",
|
||||
"content.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"lib/browser-polyfill.js",
|
||||
"utils.js",
|
||||
"trilium_server_facade.js",
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options/options.html"
|
||||
},
|
||||
"commands": {
|
||||
"saveSelection": {
|
||||
"description": "Save the selected text into a note",
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+S"
|
||||
}
|
||||
},
|
||||
"saveWholePage": {
|
||||
"description": "Save the current page",
|
||||
"suggested_key": {
|
||||
"default": "Alt+Shift+S"
|
||||
}
|
||||
},
|
||||
"saveCroppedScreenshot": {
|
||||
"description": "Take a cropped screenshot of the current page",
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+E"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{1410742d-b377-40e7-a9db-63dc9c6ec99c}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,9 @@
|
||||
<p>Note that the entered password is not stored anywhere, it will be only used to retrieve an authorization token from the server instance which will be then used to send the clipped notes.</p>
|
||||
</form>
|
||||
|
||||
<script type="module" src="../../lib/cash.min.js"></script>
|
||||
<script type="module" src="index.ts"></script>
|
||||
<script src="../lib/cash.min.js"></script>
|
||||
<script src="../lib/browser-polyfill.js"></script>
|
||||
<script src="options.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -17,8 +17,8 @@ function showSuccess(message) {
|
||||
async function saveTriliumServerSetup(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (($triliumServerUrl.val() as string | undefined)?.trim().length === 0
|
||||
|| ($triliumServerPassword.val() as string | undefined)?.trim().length === 0) {
|
||||
if ($triliumServerUrl.val().trim().length === 0
|
||||
|| $triliumServerPassword.val().trim().length === 0) {
|
||||
showError("One or more mandatory inputs are missing. Please fill in server URL and password.");
|
||||
|
||||
return;
|
||||
@@ -27,7 +27,7 @@ async function saveTriliumServerSetup(e) {
|
||||
let resp;
|
||||
|
||||
try {
|
||||
resp = await fetch(`${$triliumServerUrl.val()}/api/login/token`, {
|
||||
resp = await fetch($triliumServerUrl.val() + '/api/login/token', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -39,8 +39,7 @@ async function saveTriliumServerSetup(e) {
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
showError(`Unknown error: ${message}`);
|
||||
showError("Unknown error: " + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +47,7 @@ async function saveTriliumServerSetup(e) {
|
||||
showError("Incorrect credentials.");
|
||||
}
|
||||
else if (resp.status !== 200) {
|
||||
showError(`Unrecognised response with status code ${ resp.status}`);
|
||||
showError("Unrecognised response with status code " + resp.status);
|
||||
}
|
||||
else {
|
||||
const json = await resp.json();
|
||||
@@ -90,8 +89,8 @@ const $triilumDesktopSetupForm = $("#trilium-desktop-setup-form");
|
||||
$triilumDesktopSetupForm.on("submit", e => {
|
||||
e.preventDefault();
|
||||
|
||||
const port = ($triliumDesktopPort.val() as string | undefined ?? "").trim();
|
||||
const portNum = parseInt(port, 10);
|
||||
const port = $triliumDesktopPort.val().trim();
|
||||
const portNum = parseInt(port);
|
||||
|
||||
if (port && (isNaN(portNum) || portNum <= 0 || portNum >= 65536)) {
|
||||
showError(`Please enter valid port number.`);
|
||||
@@ -106,8 +105,8 @@ $triilumDesktopSetupForm.on("submit", e => {
|
||||
});
|
||||
|
||||
async function restoreOptions() {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get("authToken");
|
||||
|
||||
$errorMessage.hide();
|
||||
$successMessage.hide();
|
||||
@@ -128,7 +127,8 @@ async function restoreOptions() {
|
||||
$triliumServerConfiguredDiv.hide();
|
||||
}
|
||||
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
|
||||
|
||||
$triliumDesktopPort.val(triliumDesktopPort);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@triliumnext/web-clipper",
|
||||
"version": "1.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"devDependencies": {
|
||||
"@wxt-dev/auto-icons": "1.1.0",
|
||||
"wxt": "0.20.13"
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,11 @@
|
||||
<div>Status: <span id="connection-status">unknown</span></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="../../lib/cash.min.js"></script>
|
||||
<script type="module" src="popup.ts"></script>
|
||||
<script src="../lib/browser-polyfill.js"></script>
|
||||
<script src="../lib/cash.min.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
<script src="../utils.js"></script>
|
||||
<script src="../content.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createLink } from "@/utils";
|
||||
|
||||
async function sendMessage(message: object) {
|
||||
async function sendMessage(message) {
|
||||
try {
|
||||
console.log("Sending message", message);
|
||||
return await browser.runtime.sendMessage(message);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -38,9 +35,9 @@ $saveTabsButton.on("click", () => sendMessage({name: 'save-tabs'}));
|
||||
|
||||
const $saveLinkWithNoteWrapper = $("#save-link-with-note-wrapper");
|
||||
const $textNote = $("#save-link-with-note-textarea");
|
||||
const $keepTitle = $<HTMLInputElement>("#keep-title-checkbox");
|
||||
const $keepTitle = $("#keep-title-checkbox");
|
||||
|
||||
$textNote.on('keypress', (event) => {
|
||||
$textNote.on('keypress', function (event) {
|
||||
if ((event.which === 10 || event.which === 13) && event.ctrlKey) {
|
||||
saveLinkWithNote();
|
||||
return false;
|
||||
@@ -63,7 +60,7 @@ $("#cancel-button").on("click", () => {
|
||||
});
|
||||
|
||||
async function saveLinkWithNote() {
|
||||
const textNoteVal = ($textNote.val() as string | undefined ?? "").trim();
|
||||
const textNoteVal = $textNote.val().trim();
|
||||
let title, content;
|
||||
|
||||
if (!textNoteVal) {
|
||||
@@ -101,7 +98,7 @@ async function saveLinkWithNote() {
|
||||
$("#save-button").on("click", saveLinkWithNote);
|
||||
|
||||
$("#show-help-button").on("click", () => {
|
||||
window.open("https://docs.triliumnotes.org/user-guide/setup/web-clipper", '_blank');
|
||||
window.open("https://github.com/zadam/trilium/wiki/Web-clipper", '_blank');
|
||||
});
|
||||
|
||||
function escapeHtml(string) {
|
||||
@@ -111,7 +108,7 @@ function escapeHtml(string) {
|
||||
|
||||
const htmlWithPars = pre.innerHTML.replace(/\n/g, "</p><p>");
|
||||
|
||||
return `<p>${htmlWithPars}</p>`;
|
||||
return '<p>' + htmlWithPars + '</p>';
|
||||
}
|
||||
|
||||
const $connectionStatus = $("#connection-status");
|
||||
@@ -160,13 +157,14 @@ browser.runtime.onMessage.addListener(request => {
|
||||
const {searchNote} = request;
|
||||
if (searchNote.status === 'found'){
|
||||
const a = createLink({name: 'openNoteInTrilium', noteId: searchNote.noteId},
|
||||
"Open in Trilium.");
|
||||
$alreadyVisited.text(`Already visited website!`);
|
||||
"Open in Trilium.")
|
||||
noteFound = `Already visited website!`;
|
||||
$alreadyVisited.html(noteFound);
|
||||
$alreadyVisited[0].appendChild(a);
|
||||
}else{
|
||||
$alreadyVisited.html('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
@@ -176,7 +174,7 @@ const $checkConnectionButton = $("#check-connection-button");
|
||||
$checkConnectionButton.on("click", () => {
|
||||
browser.runtime.sendMessage({
|
||||
name: "trigger-trilium-search"
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
$(() => browser.runtime.sendMessage({name: "send-trilium-search-status"}));
|
||||
11
apps/web-clipper/trilium-web-clipper.iml
Normal file
11
apps/web-clipper/trilium-web-clipper.iml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
225
apps/web-clipper/trilium_server_facade.js
Normal file
225
apps/web-clipper/trilium_server_facade.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const PROTOCOL_VERSION_MAJOR = 1;
|
||||
|
||||
function isDevEnv() {
|
||||
const manifest = browser.runtime.getManifest();
|
||||
|
||||
return manifest.name.endsWith('(dev)');
|
||||
}
|
||||
|
||||
class TriliumServerFacade {
|
||||
constructor() {
|
||||
this.triggerSearchForTrilium();
|
||||
|
||||
// continually scan for changes (if e.g. desktop app is started after browser)
|
||||
setInterval(() => this.triggerSearchForTrilium(), 60 * 1000);
|
||||
}
|
||||
|
||||
async sendTriliumSearchStatusToPopup() {
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-search-status",
|
||||
triliumSearch: this.triliumSearch
|
||||
});
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
async sendTriliumSearchNoteToPopup(){
|
||||
try{
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-previously-visited",
|
||||
searchNote: this.triliumSearchNote
|
||||
})
|
||||
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
|
||||
setTriliumSearchNote(st){
|
||||
this.triliumSearchNote = st;
|
||||
this.sendTriliumSearchNoteToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearch(ts) {
|
||||
this.triliumSearch = ts;
|
||||
|
||||
this.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearchWithVersionCheck(json, resp) {
|
||||
const [major, minor] = json.protocolVersion
|
||||
.split(".")
|
||||
.map(chunk => parseInt(chunk));
|
||||
|
||||
// minor version is intended to be used to dynamically limit features provided by extension
|
||||
// if some specific Trilium API is not supported. So far not needed.
|
||||
|
||||
if (major !== PROTOCOL_VERSION_MAJOR) {
|
||||
this.setTriliumSearch({
|
||||
status: 'version-mismatch',
|
||||
extensionMajor: PROTOCOL_VERSION_MAJOR,
|
||||
triliumMajor: major
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.setTriliumSearch(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSearchForTrilium() {
|
||||
this.setTriliumSearch({ status: 'searching' });
|
||||
|
||||
try {
|
||||
const port = await this.getPort();
|
||||
|
||||
console.debug('Trying port ' + port);
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/clipper/handshake`);
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-desktop',
|
||||
port: port,
|
||||
url: 'http://127.0.0.1:' + port
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// continue
|
||||
}
|
||||
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get("authToken");
|
||||
|
||||
if (triliumServerUrl && authToken) {
|
||||
try {
|
||||
const resp = await fetch(triliumServerUrl + '/api/clipper/handshake', {
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-server',
|
||||
url: triliumServerUrl,
|
||||
token: authToken
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Request to the configured server instance failed with:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// if all above fails it's not found
|
||||
this.setTriliumSearch({ status: 'not-found' });
|
||||
}
|
||||
|
||||
async triggerSearchNoteByUrl(noteUrl) {
|
||||
const resp = await triliumServerFacade.callService('GET', 'notes-by-url/' + encodeURIComponent(noteUrl))
|
||||
let newStatus = {
|
||||
status: 'not-found',
|
||||
noteId: null
|
||||
}
|
||||
if (resp && resp.noteId) {
|
||||
newStatus.noteId = resp.noteId;
|
||||
newStatus.status = 'found';
|
||||
}
|
||||
this.setTriliumSearchNote(newStatus);
|
||||
}
|
||||
async waitForTriliumSearch() {
|
||||
return new Promise((res, rej) => {
|
||||
const checkStatus = () => {
|
||||
if (this.triliumSearch.status === "searching") {
|
||||
setTimeout(checkStatus, 500);
|
||||
}
|
||||
else if (this.triliumSearch.status === 'not-found') {
|
||||
rej(new Error("Trilium instance has not been found."));
|
||||
}
|
||||
else {
|
||||
res();
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async getPort() {
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
|
||||
|
||||
if (triliumDesktopPort) {
|
||||
return parseInt(triliumDesktopPort);
|
||||
}
|
||||
else {
|
||||
return isDevEnv() ? 37740 : 37840;
|
||||
}
|
||||
}
|
||||
|
||||
async callService(method, path, body) {
|
||||
const fetchOptions = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.waitForTriliumSearch();
|
||||
|
||||
fetchOptions.headers.Authorization = this.triliumSearch.token || "";
|
||||
fetchOptions.headers['trilium-local-now-datetime'] = this.localNowDateTime();
|
||||
|
||||
const url = this.triliumSearch.url + "/api/clipper/" + path;
|
||||
|
||||
console.log(`Sending ${method} request to ${url}`);
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Sending request to trilium failed", e);
|
||||
|
||||
toast('Your request failed because we could not contact Trilium instance. Please make sure Trilium is running and is accessible.');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
localNowDateTime() {
|
||||
const date = new Date();
|
||||
const off = date.getTimezoneOffset();
|
||||
const absoff = Math.abs(off);
|
||||
return (new Date(date.getTime() - off * 60 * 1000).toISOString().substr(0,23).replace("T", " ") +
|
||||
(off > 0 ? '-' : '+') +
|
||||
(absoff / 60).toFixed(0).padStart(2,'0') + ':' +
|
||||
(absoff % 60).toString().padStart(2,'0'));
|
||||
}
|
||||
}
|
||||
|
||||
window.triliumServerFacade = new TriliumServerFacade();
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"../../tsconfig.base.json",
|
||||
"./.wxt/tsconfig.json"
|
||||
]
|
||||
}
|
||||
7
apps/web-clipper/types.d.ts
vendored
7
apps/web-clipper/types.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
interface Window {
|
||||
showToast(message: string, opts?: {
|
||||
settings?: {
|
||||
duration: number;
|
||||
}
|
||||
}): void;
|
||||
}
|
||||
28
apps/web-clipper/utils.js
Normal file
28
apps/web-clipper/utils.js
Normal file
@@ -0,0 +1,28 @@
|
||||
function randomString(len) {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
let output = getPageLocationOrigin() + location.pathname;
|
||||
|
||||
if (output[output.length - 1] !== '/') {
|
||||
output = output.split('/');
|
||||
output.pop();
|
||||
output = output.join('/');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function getPageLocationOrigin() {
|
||||
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
|
||||
// but for file:// protocol this is browser dependant and in particular Firefox returns "null" in this case.
|
||||
return location.protocol === 'file:' ? 'file://' : location.origin;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
export type Rect = { x: number, y: number, width: number, height: number };
|
||||
|
||||
export function randomString(len: number) {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function getBaseUrl() {
|
||||
let output = getPageLocationOrigin() + location.pathname;
|
||||
|
||||
if (output[output.length - 1] !== '/') {
|
||||
const outputArr = output.split('/');
|
||||
outputArr.pop();
|
||||
output = outputArr.join('/');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function getPageLocationOrigin() {
|
||||
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
|
||||
// but for file:// protocol this is browser dependant and in particular Firefox returns "null" in this case.
|
||||
return location.protocol === 'file:' ? 'file://' : location.origin;
|
||||
}
|
||||
|
||||
export function createLink(clickAction: object, text: string, color = "lightskyblue") {
|
||||
const link = document.createElement('a');
|
||||
link.href = "javascript:";
|
||||
link.style.color = color;
|
||||
link.appendChild(document.createTextNode(text));
|
||||
link.addEventListener("click", () => {
|
||||
browser.runtime.sendMessage(null, clickAction);
|
||||
});
|
||||
|
||||
return link;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { copyFile, readFile, rm, writeFile } from "fs/promises";
|
||||
import { defineConfig } from "wxt";
|
||||
|
||||
let originalTsConfig: object;
|
||||
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/auto-icons'],
|
||||
manifest: ({ manifestVersion }) => ({
|
||||
name: "Trilium Web Clipper",
|
||||
description: "Save web clippings to Trilium Notes.",
|
||||
homepage_url: "https://docs.triliumnotes.org/user-guide/setup/web-clipper",
|
||||
permissions: [
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"http://*/",
|
||||
"https://*/",
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
manifestVersion === 3 && "offscreen"
|
||||
].filter(Boolean),
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#id.
|
||||
id: "web-clipper@triliumnotes.org",
|
||||
// Firefox built-in data collection consent
|
||||
// See https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/
|
||||
// This extension only communicates with a user-configured Trilium instance
|
||||
// and does not collect telemetry or send data to remote servers.
|
||||
data_collection_permissions: {
|
||||
required: ["none"]
|
||||
}
|
||||
}
|
||||
},
|
||||
commands: {
|
||||
saveSelection: {
|
||||
description: "Save the selected text into a note",
|
||||
suggested_key: {
|
||||
default: "Ctrl+Shift+S"
|
||||
}
|
||||
},
|
||||
saveWholePage: {
|
||||
description: "Save the current page",
|
||||
suggested_key: {
|
||||
default: "Alt+Shift+S"
|
||||
}
|
||||
},
|
||||
saveCroppedScreenshot: {
|
||||
description: "Take a cropped screenshot of the current page",
|
||||
suggested_key: {
|
||||
default: "Ctrl+Shift+E"
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
zip: {
|
||||
includeSources: [
|
||||
"entrypoints/offscreen/index.html"
|
||||
]
|
||||
},
|
||||
hooks: {
|
||||
'zip:sources:start': async () => {
|
||||
// Rewrite tsconfig.base.json into the web-clipper app folder
|
||||
await copyFile("../../tsconfig.base.json", "./tsconfig.base.json");
|
||||
|
||||
originalTsConfig = JSON.parse(await readFile("./tsconfig.json", "utf-8"));
|
||||
const adjustedTsConfig = {
|
||||
...originalTsConfig,
|
||||
extends: ["./tsconfig.base.json", "./.wxt/tsconfig.json"]
|
||||
};
|
||||
|
||||
await writeFile("./tsconfig.json", JSON.stringify(adjustedTsConfig, null, 4), 'utf-8');
|
||||
},
|
||||
"zip:sources:done": async () => {
|
||||
// Restore original tsconfig.json
|
||||
await writeFile("./tsconfig.json", JSON.stringify(originalTsConfig, null, 4), 'utf-8');
|
||||
|
||||
// Remove the copied tsconfig.base.json
|
||||
await rm("./tsconfig.base.json");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.8.0",
|
||||
"i18next": "25.7.4",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.28.2",
|
||||
"preact-iso": "2.11.1",
|
||||
@@ -23,7 +23,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
"vite": "7.3.1",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.0.17"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
},
|
||||
"header": {
|
||||
"get-started": "시작하기",
|
||||
"documentation": "문서",
|
||||
"support-us": "후원하기"
|
||||
"documentation": "문서"
|
||||
},
|
||||
"support_us": {
|
||||
"financial_donations_title": "금전적 기부",
|
||||
@@ -57,8 +56,7 @@
|
||||
"financial_donations_cta": "애플리케이션의 주요 개발자 (<Link>eliandoran</Link>)을 다음 방법으로 후원하는 것을 고려해 주십시오.",
|
||||
"github_sponsors": "GitHub Sponsors",
|
||||
"paypal": "페이팔",
|
||||
"buy_me_a_coffee": "Buy Me A Coffee",
|
||||
"title": "후원하기"
|
||||
"buy_me_a_coffee": "Buy Me A Coffee"
|
||||
},
|
||||
"contribute": {
|
||||
"title": "기여할 수 있는 다른 방법",
|
||||
@@ -86,14 +84,7 @@
|
||||
"title_x64": "리눅스 64비트",
|
||||
"title_arm64": "ARM 기반 리눅스",
|
||||
"description_x64": "대부분의 리눅스 배포판에서 x86_64 아키텍처와 호환됩니다.",
|
||||
"description_arm64": "ARM 기반 리눅스 배포판에서 aarch64 아키텍처와 호환됩니다.",
|
||||
"quick_start": "사용하시는 배포판에 따라 적절한 패키지 형식을 선택해주세요:",
|
||||
"download_deb": ".deb",
|
||||
"download_rpm": ".rpm",
|
||||
"download_flatpak": ".flatpak",
|
||||
"download_zip": "포터블 (.zip)",
|
||||
"download_nixpkgs": "nixpkgs",
|
||||
"download_aur": "AUR"
|
||||
"description_arm64": "ARM 기반 리눅스 배포판에서 aarch64 아키텍처와 호환됩니다."
|
||||
},
|
||||
"note_types": {
|
||||
"text_title": "텍스트 노트",
|
||||
@@ -157,44 +148,5 @@
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "자세히 알아보기..."
|
||||
},
|
||||
"footer": {
|
||||
"copyright_and_the": " 그리고 ",
|
||||
"copyright_community": "커뮤니티"
|
||||
},
|
||||
"social_buttons": {
|
||||
"github": "GitHub",
|
||||
"github_discussions": "GitHub Discussions",
|
||||
"matrix": "Matrix",
|
||||
"reddit": "Reddit"
|
||||
},
|
||||
"download_helper_desktop_macos": {
|
||||
"title_x64": "macOS (Intel)",
|
||||
"title_arm64": "macOS (Apple Silicon)",
|
||||
"description_x64": "Intel Mac은 Monterey 버전 이상 macOS에서 지원합니다.",
|
||||
"description_arm64": "M1, M2 칩과 같은 Apple Silicon이 탑재된 Macs 전용.",
|
||||
"quick_start": "Homebrew로 설치하는 경우:",
|
||||
"download_dmg": "설치 프로그램 내려받기 (.dmg)",
|
||||
"download_homebrew_cask": "Homebrew Cask",
|
||||
"download_zip": "포터블 (.zip)"
|
||||
},
|
||||
"download_helper_server_docker": {
|
||||
"title": "Docker를 사용한 셀프 호스팅",
|
||||
"description": "Docker 컨테이너를 사용하여 Windows, Linux 또는 macOS에 간편하게 배포할 수 있습니다.",
|
||||
"download_dockerhub": "도커 허브",
|
||||
"download_ghcr": "ghcr.io"
|
||||
},
|
||||
"download_helper_server_linux": {
|
||||
"title": "리눅스에서 셀프 호스팅",
|
||||
"description": "Trilium Notes를 자체 서버 또는 VPS에 배포하세요. 대부분의 배포판과 호환됩니다.",
|
||||
"download_tar_x64": "x64 (.tar.xz)",
|
||||
"download_tar_arm64": "ARM (.tar.xz)",
|
||||
"download_nixos": "NixOS 모듈"
|
||||
},
|
||||
"download_helper_server_hosted": {
|
||||
"title": "유료 호스팅",
|
||||
"description": "Trilium Notes는 간편한 접근 및 관리를 위해 유료 서비스인 PikaPods에서 호스팅할 수 있습니다. Trilium 팀과 직접 제휴되어있지는 않습니다.",
|
||||
"download_pikapod": "PikaPods에서 설치하기",
|
||||
"download_triliumcc": "또는 trilium.cc를 참조하세요"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,113 +45,6 @@
|
||||
"code_description": "Великі зразки вихідного коду або скриптів використовують спеціальний редактор із підсвічуванням синтаксису для багатьох мов програмування та різними колірними темами.",
|
||||
"file_title": "Файлові нотатки",
|
||||
"file_description": "Вбудовуйте мультимедійні файли, такі як PDF-файли, зображення, відео, з попереднім переглядом у програмі.",
|
||||
"canvas_title": "Полотно",
|
||||
"title": "Кілька способів представлення вашої інформації",
|
||||
"canvas_description": "Розташовуйте фігури, зображення та текст на нескінченному полотні, використовуючи ту саму технологію, що й excalidraw.com. Ідеально підходить для діаграм, ескізів та візуального планування.",
|
||||
"mermaid_description": "Створюйте діаграми, такі як блок-схеми, діаграми класів та послідовностей, діаграми Ганта та багато іншого, використовуючи синтаксис Mermaid.",
|
||||
"others_list": "та інші: <0>карта нотаток</0>, <1>карта зв'язків</1>, <2>збережені пошуки</2>, <3>візуалізація нотаток</3> та <4>веб-перегляди</4>."
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"title": "Спільне використання та розширюваність",
|
||||
"import_export_title": "Імпорт/експорт",
|
||||
"import_export_description": "Легко взаємодійте з іншими програмами, використовуючи формати Markdown, ENEX, OML.",
|
||||
"share_title": "Діліться нотатками в Інтернеті"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Колекції",
|
||||
"calendar_title": "Календар",
|
||||
"calendar_description": "Організовуйте свої особисті або професійні події за допомогою календаря з підтримкою цілоденних та багатоденних подій. Переглядайте свої події з першого погляду завдяки тижневому, місячному та річному переглядам. Легке додавання або перетягування подій.",
|
||||
"table_title": "Таблиця",
|
||||
"table_description": "Відображайте та редагуйте інформацію про нотатки в табличній структурі з різними типами стовпців, такими як текст, число, прапорці, дата й час, посилання та кольори, а також підтримка зв'язків. За потреби відображайте нотатки в деревоподібній ієрархії всередині таблиці.",
|
||||
"board_title": "Дошка Kanban",
|
||||
"board_description": "Організуйте свої завдання або статуси проектів на дошці Kanbanза допомогою простого способу створення нових елементів і стовпців, а також простої зміни їх статусу шляхом перетягування по дошці.",
|
||||
"geomap_title": "Геокарта",
|
||||
"geomap_description": "Плануйте свою відпустку або позначайте цікаві місця безпосередньо на географічній карті за допомогою налаштовуваних маркерів. Відображайте записані GPX-треки для відстеження маршрутів.",
|
||||
"presentation_title": "Презентація",
|
||||
"presentation_description": "Упорядкуйте інформацію у слайди та презентуйте їх у повноекранному режимі з плавними переходами. Слайди також можна експортувати у PDF для зручного обміну."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Часті запитання",
|
||||
"mobile_question": "Чи є мобільний додаток?",
|
||||
"mobile_answer": "Наразі офіційного мобільного застосунку немає. Однак, якщо у вас є серверний екземпляр, ви можете отримати до нього доступ за допомогою веббраузера та навіть встановити його як PWA. Для Android існує неофіційний застосунок під назвою TriliumDroid, який працює навіть офлайн (так само, як і клієнт для настільних комп’ютерів).",
|
||||
"database_question": "Де зберігаються дані?",
|
||||
"database_answer": "Усі ваші нотатки зберігатимуться в базі даних SQLite в папці програми. Причина, чому Trilium використовує базу даних замість звичайних текстових файлів, пов'язана як з продуктивністю, так і з тим, що деякі функції було б набагато складніше реалізувати, такі як клони (одна й та сама нотатка в кількох місцях дерева). Щоб знайти папку програми, просто перейдіть до вікна «Про систему».",
|
||||
"server_question": "Чи потрібен мені сервер для використання Trilium?",
|
||||
"server_answer": "Ні, сервер дозволяє доступ через веббраузер і керує синхронізацією, якщо у вас кілька пристроїв. Щоб розпочати, достатньо завантажити десктопний застосунок і почати ним користуватися.",
|
||||
"scaling_question": "Наскільки добре масштабується застосунок з великою кількістю нотаток?",
|
||||
"scaling_answer": "Залежно від використання, програма має бути здатною без проблем обробляти щонайменше 100 000 нотаток. Зверніть увагу, що процес синхронізації іноді може завершитися невдачею, якщо завантажувати багато великих файлів (1 ГБ на файл), оскільки Trilium призначений радше як програма бази знань, ніж як сховище файлів (наприклад, NextCloud).",
|
||||
"network_share_question": "Чи можна надати спільний доступ до бази даних через мережевий диск?",
|
||||
"network_share_answer": "Ні, зазвичай не рекомендується використовувати спільний доступ до бази даних SQLite через мережевий диск. Хоча іноді це може спрацювати, існує ймовірність пошкодження бази даних через недосконале блокування файлів у мережі.",
|
||||
"security_question": "Як захищені мої дані?",
|
||||
"security_answer": "За замовчуванням нотатки не шифруються та їх можна зчитувати безпосередньо з бази даних. Після того, як нотатку позначено як зашифровану, вона шифрується за допомогою AES-128-CBC."
|
||||
},
|
||||
"final_cta": {
|
||||
"title": "Готові розпочати роботу з Trilium Notes?",
|
||||
"description": "Створіть свою особисту базу знань за допомогою потужних функцій та повної конфіденційності.",
|
||||
"get_started": "Почати"
|
||||
},
|
||||
"components": {
|
||||
"link_learn_more": "Дізнатися більше..."
|
||||
},
|
||||
"download_now": {
|
||||
"text": "Завантажити зараз ",
|
||||
"platform_big": "v{{version}} для {{platform}}",
|
||||
"platform_small": "для {{platform}}",
|
||||
"linux_big": "v{{version}} для Linux",
|
||||
"linux_small": "для Linux",
|
||||
"more_platforms": "Більше платформ та налаштування серверів"
|
||||
},
|
||||
"header": {
|
||||
"get-started": "Почати",
|
||||
"documentation": "Документація",
|
||||
"support-us": "Підтримайте нас"
|
||||
},
|
||||
"footer": {
|
||||
"copyright_and_the": " і ",
|
||||
"copyright_community": "спільнота"
|
||||
},
|
||||
"social_buttons": {
|
||||
"github": "GitHub",
|
||||
"github_discussions": "Обговорення на GitHub",
|
||||
"matrix": "Матриця",
|
||||
"reddit": "Reddit"
|
||||
},
|
||||
"support_us": {
|
||||
"title": "Підтримайте нас",
|
||||
"financial_donations_title": "Фінансова підтримка",
|
||||
"financial_donations_description": "Trilium створено та підтримується завдяки <Link>сотням годин роботи</Link>. Ваша підтримка забезпечує його відкритий вихідний код, покращує функції та покриває витрати, такі як хостинг.",
|
||||
"financial_donations_cta": "Розгляньте можливість підтримки головного розробника (<Link>eliandoran</Link>) програми через:",
|
||||
"github_sponsors": "GitHub Спонсори",
|
||||
"paypal": "PayPal",
|
||||
"buy_me_a_coffee": "Купи мені кави"
|
||||
},
|
||||
"contribute": {
|
||||
"title": "Інші способи зробити внесок",
|
||||
"way_translate": "Перекладіть програму вашою рідною мовою за допомогою <Link>Weblate</Link>.",
|
||||
"way_community": "Взаємодійте зі спільнотою на <Discussions>GitHub Обговорення</Discussions> або на <Matrix>Matrix</Matrix>.",
|
||||
"way_reports": "Повідомляйте про помилки через <Link>Проблеми GitHub</Link>.",
|
||||
"way_document": "Покращуйте документацію, повідомляючи нас про прогалини в ній або надаючи посібники, відповіді на поширені запитання чи навчальні посібники.",
|
||||
"way_market": "Поширте інформацію: поділіться нотатками Trilium з друзями або в блогах і соціальних мережах."
|
||||
},
|
||||
"404": {
|
||||
"title": "404: Не знайдено",
|
||||
"description": "Сторінку, яку ви шукали, не знайдено. Можливо, її видалили або URL-адреса неправильна."
|
||||
},
|
||||
"download_helper_desktop_windows": {
|
||||
"title_x64": "Windows 64-bit",
|
||||
"title_arm64": "Windows на ARM",
|
||||
"description_x64": "Сумісний з пристроями Intel або AMD під управлінням Windows 10 та 11.",
|
||||
"description_arm64": "Сумісний з пристроями ARM (наприклад, з Qualcomm Snapdragon).",
|
||||
"quick_start": "Щоб встановити через Winget:",
|
||||
"download_exe": "Завантажити інсталятор (.exe)",
|
||||
"download_zip": "Портативний (.zip)"
|
||||
},
|
||||
"download_helper_desktop_linux": {
|
||||
"title_x64": "Linux 64-bit",
|
||||
"title_arm64": "Linux на ARM",
|
||||
"description_x64": "Для більшості дистрибутивів Linux, сумісних з архітектурою x86_64.",
|
||||
"description_arm64": "Для дистрибутивів Linux на базі ARM, сумісних з архітектурою aarch64.",
|
||||
"quick_start": "Виберіть відповідний формат пакета, залежно від вашого дистрибутива:",
|
||||
"download_deb": ".deb"
|
||||
"canvas_title": "Полотно"
|
||||
}
|
||||
}
|
||||
|
||||
34
docs/Developer Guide/!!!meta.json
vendored
34
docs/Developer Guide/!!!meta.json
vendored
@@ -2839,40 +2839,6 @@
|
||||
"format": "markdown",
|
||||
"dataFileName": "Themes.md",
|
||||
"attachments": []
|
||||
},
|
||||
{
|
||||
"isClone": false,
|
||||
"noteId": "YTAxJMA3uWwn",
|
||||
"notePath": [
|
||||
"jdjRLhLV3TtI",
|
||||
"yeqU0zo0ZQ83",
|
||||
"YTAxJMA3uWwn"
|
||||
],
|
||||
"title": "Web Clipper",
|
||||
"notePosition": 210,
|
||||
"prefix": null,
|
||||
"isExpanded": false,
|
||||
"type": "text",
|
||||
"mime": "text/html",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "label",
|
||||
"name": "shareAlias",
|
||||
"value": "web-clipper",
|
||||
"isInheritable": false,
|
||||
"position": 20
|
||||
},
|
||||
{
|
||||
"type": "label",
|
||||
"name": "iconClass",
|
||||
"value": "bx bx-paperclip",
|
||||
"isInheritable": false,
|
||||
"position": 30
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
"dataFileName": "Web Clipper.md",
|
||||
"attachments": []
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Web Clipper
|
||||
The Web Clipper is present in the monorepo in `apps/web-clipper`. It's based on [WXT](https://wxt.dev/guide/introduction.html), a framework for building web extensions that allows very easy development and publishing.
|
||||
|
||||
## Manifest version
|
||||
|
||||
Originally the Web Clipper supported only Manifest v2, which made the extension incompatible with Google Chrome. [#8494](https://github.com/TriliumNext/Trilium/pull/8494) introduces Manifest v3 support for Google Chrome, alongside with Manifest v2 for Firefox.
|
||||
|
||||
Although Firefox does support Manifest v3, we are still using Manifest v2 for it because WXT dev mode doesn't work for the Firefox / Manifest v3 combination and there were some mentions about Manifest v3 not being well supported on Firefox Mobile (and we plan to have support for it).
|
||||
|
||||
## Dev mode
|
||||
|
||||
WXT allows easy development of the plugin, with full TypeScript support and live reload. To enter dev mode:
|
||||
|
||||
* Run `pnpm --filter web-clipper dev` to enter dev mode for Chrome (with manifest v3).
|
||||
* Run `pnpm --filter web-clipper dev:firefox` to enter dev mode for Firefox (with manifest v2).
|
||||
|
||||
This will open a separate browser instance in which the extension is automatically injected.
|
||||
|
||||
## Port
|
||||
|
||||
The default port is:
|
||||
|
||||
* `37742` if in development mode. This makes it possible to use `pnpm desktop:start` to spin up a desktop instance to use the Clipper with.
|
||||
* `37840` in production, the default Trilium port.
|
||||
|
||||
## Building
|
||||
|
||||
* Run `build` (Chrome) or `build:firefox` to generate the output files, which will be in `.output/[browser]`.
|
||||
* Run `zip` or `zip:firefox` to generate the ZIP files.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/web-clipper.yml` handles the building of the web clipper. Whenever the web clipper is modified, it generates the ZIPs and uploads them as artifacts.
|
||||
|
||||
There is currently no automatic publishing to the app stores.
|
||||
@@ -1,5 +1,5 @@
|
||||
# Documentation
|
||||
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/rFcOjCdtKSRx/Documentation_image.png" width="205" height="162">
|
||||
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/TzNwfb67k5Dh/Documentation_image.png" width="205" height="162">
|
||||
|
||||
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
|
||||
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.
|
||||
|
||||
@@ -9,7 +9,6 @@ The mono-repo is mainly structured in:
|
||||
* `client`, representing the front-end that is used both by the server and the desktop application.
|
||||
* `server`, representing the Node.js / server version of the application.
|
||||
* `desktop`, representing the Electron-based desktop application.
|
||||
* `web-clipper`, representing the browser extension to easily clip web pages into Trilium, with support for both Firefox and Chrome (manifest V3).
|
||||
* `packages`, containing dependencies used by one or more `apps`.
|
||||
* `commons`, containing shared code for all the apps.
|
||||
|
||||
|
||||
4
docs/README-hi.md
vendored
4
docs/README-hi.md
vendored
@@ -114,8 +114,8 @@ application with focus on building large personal knowledge bases.
|
||||
location pins and GPX tracks
|
||||
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced
|
||||
showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* ऑटोमेशन के लिए [REST
|
||||
API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for
|
||||
automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile
|
||||
frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for
|
||||
|
||||
264
docs/README-uk.md
vendored
264
docs/README-uk.md
vendored
@@ -112,41 +112,38 @@ Trilium Notes — це безкоштовний кросплатформний
|
||||
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
|
||||
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
|
||||
географічними позначками та GPX-треками
|
||||
* [Сценарії](https://docs.triliumnotes.org/user-guide/scripts) – див. [Розширені
|
||||
демонстрації](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) для
|
||||
автоматизації
|
||||
* Добре масштабується як за зручністю використання, так і за продуктивністю до
|
||||
100 000 нотаток
|
||||
* Оптимізовано для сенсорного керування [мобільний
|
||||
інтерфейс](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) для
|
||||
смартфонів і планшетів
|
||||
* Вбудована [темна
|
||||
тема](https://docs.triliumnotes.org/user-guide/concepts/themes), підтримка тем
|
||||
користувача
|
||||
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced
|
||||
showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for
|
||||
automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile
|
||||
frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for
|
||||
smartphones and tablets
|
||||
* Built-in [dark
|
||||
theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for
|
||||
user themes
|
||||
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote)
|
||||
and [Markdown import &
|
||||
export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) для
|
||||
легкого збереження веб-контенту
|
||||
* Настроюваний інтерфейс користувача (кнопки бічної панелі, віджети, що
|
||||
визначаються користувачем, ...)
|
||||
* [Метрики](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), а
|
||||
також панель інструментів Grafana.
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for
|
||||
easy saving of web content
|
||||
* Customizable UI (sidebar buttons, user-defined widgets, ...)
|
||||
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics),
|
||||
along with a Grafana Dashboard.
|
||||
|
||||
✨ Перегляньте наступні сторонні ресурси/спільноти, щоб дізнатися більше про
|
||||
TriliumNext:
|
||||
✨ Check out the following third-party resources/communities for more TriliumNext
|
||||
related goodies:
|
||||
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) для тем,
|
||||
скриптів, плагінів тощо від сторонніх розробників.
|
||||
- [TriliumRocks!](https://trilium.rocks/) для навчальних посібників, інструкцій
|
||||
та багато іншого.
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party
|
||||
themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
|
||||
## ❓Чому TriliumNext?
|
||||
## ❓Why TriliumNext?
|
||||
|
||||
Оригінальний розробник Trilium ([Zadam](https://github.com/zadam)) люб'язно
|
||||
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
|
||||
https://github.com/TriliumNext
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has
|
||||
graciously given the Trilium repository to the community project which resides
|
||||
at https://github.com/TriliumNext
|
||||
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
|
||||
@@ -164,90 +161,86 @@ prevents direct migration.
|
||||
|
||||
## 💬 Discuss with us
|
||||
|
||||
Не соромтеся приєднуватися до наших офіційних обговорень. Ми будемо раді почути
|
||||
про ваші функції, пропозиції чи проблеми!
|
||||
Feel free to join our official conversations. We would love to hear what
|
||||
features, suggestions, or issues you may have!
|
||||
|
||||
- [Матриця](https://matrix.to/#/#triliumnext:matrix.org) (Для синхронних
|
||||
обговорень.)
|
||||
- Кімната матриці `Загальні` також підключена до
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
|
||||
discussions.)
|
||||
- The `General` Matrix room is also bridged to
|
||||
[XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Обговорення на Github](https://github.com/TriliumNext/Trilium/discussions)
|
||||
(Для асинхронних обговорень.)
|
||||
- [Проблеми Github](https://github.com/TriliumNext/Trilium/issues) (Для звітів
|
||||
про помилки та запитів на нові функції.)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For
|
||||
asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug
|
||||
reports and feature requests.)
|
||||
|
||||
## 🏗 Встановлення
|
||||
## 🏗 Installation
|
||||
|
||||
### Windows / MacOS
|
||||
|
||||
Завантажте бінарний реліз для вашої платформи зі сторінки [останнього
|
||||
релізу](https://github.com/TriliumNext/Trilium/releases/latest), розпакуйте
|
||||
пакет і запустіть виконуваний файл `trilium`.
|
||||
Download the binary release for your platform from the [latest release
|
||||
page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package
|
||||
and run the `trilium` executable.
|
||||
|
||||
### Linux
|
||||
|
||||
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
|
||||
дистрибутива.
|
||||
If your distribution is listed in the table below, use your distribution's
|
||||
package.
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
|
||||
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
|
||||
розпакувати пакет і запустити виконуваний файл `trilium`.
|
||||
You may also download the binary release for your platform from the [latest
|
||||
release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the
|
||||
package and run the `trilium` executable.
|
||||
|
||||
TriliumNext також доступний у форматі Flatpak, але ще не опублікований на
|
||||
FlatHub.
|
||||
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
|
||||
|
||||
### Браузер (будь-яка ОС)
|
||||
### Browser (any OS)
|
||||
|
||||
Якщо ви використовуєте серверну інсталяцію (див. нижче), ви можете отримати
|
||||
безпосередній доступ до веб-інтерфейсу (який майже ідентичний десктопному
|
||||
додатку).
|
||||
If you use a server installation (see below), you can directly access the web
|
||||
interface (which is almost identical to the desktop app).
|
||||
|
||||
Наразі підтримуються (і протестовані) лише найновіші версії Chrome та Firefox.
|
||||
Currently only the latest versions of Chrome & Firefox are supported (and
|
||||
tested).
|
||||
|
||||
### Мобільний
|
||||
### Mobile
|
||||
|
||||
Щоб використовувати TriliumNext на мобільному пристрої, ви можете скористатися
|
||||
мобільним веб-браузером для доступу до мобільного інтерфейсу серверної
|
||||
інсталяції (див. нижче).
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to
|
||||
access the mobile interface of a server installation (see below).
|
||||
|
||||
Див. випуск https://github.com/TriliumNext/Trilium/issues/4962 для отримання
|
||||
додаткової інформації про підтримку мобільних додатків.
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more
|
||||
information on mobile app support.
|
||||
|
||||
Якщо ви надаєте перевагу рідному додатку для Android, ви можете скористатися
|
||||
If you prefer a native Android app, you can use
|
||||
[TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Повідомляйте про помилки та відсутні функції на [їхньому
|
||||
репозиторії](https://github.com/FliegendeWurst/TriliumDroid). Примітка: Найкраще
|
||||
вимкнути автоматичні оновлення на вашому сервері (див. нижче) під час
|
||||
використання TriliumDroid, оскільки версія синхронізації має збігатися між
|
||||
Trilium та TriliumDroid.
|
||||
Report bugs and missing features at [their
|
||||
repository](https://github.com/FliegendeWurst/TriliumDroid). Note: It is best to
|
||||
disable automatic updates on your server installation (see below) when using
|
||||
TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
|
||||
### Сервер
|
||||
### Server
|
||||
|
||||
Щоб встановити TriliumNext на власний сервер (зокрема через Docker з
|
||||
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)), дотримуйтесь
|
||||
інструкцій [документації щодо встановлення
|
||||
сервера](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
To install TriliumNext on your own server (including via Docker from
|
||||
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server
|
||||
installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
|
||||
|
||||
## 💻 Зробіть свій внесок
|
||||
## 💻 Contribute
|
||||
|
||||
### Переклади
|
||||
### Translations
|
||||
|
||||
Якщо ви носій мови, допоможіть нам перекласти Trilium, перейшовши на нашу
|
||||
[сторінку Weblate](https://hosted.weblate.org/engage/trilium/).
|
||||
If you are a native speaker, help us translate Trilium by heading over to our
|
||||
[Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Ось мовне висвітлення, яке ми маємо наразі:
|
||||
Here's the language coverage we have so far:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Код
|
||||
### Code
|
||||
|
||||
Завантажте репозиторій, встановіть залежності за допомогою `pnpm`, а потім
|
||||
запустіть сервер (доступний за адресою http://localhost:8080):
|
||||
Download the repository, install dependencies using `pnpm` and then run the
|
||||
server (available at http://localhost:8080):
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -255,10 +248,10 @@ pnpm install
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
### Документація
|
||||
### Documentation
|
||||
|
||||
Завантажте репозиторій, встановіть залежності за допомогою `pnpm`, а потім
|
||||
запустіть середовище, необхідне для редагування документації:
|
||||
Download the repository, install dependencies using `pnpm` and then run the
|
||||
environment required to edit the documentation:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -266,9 +259,9 @@ pnpm install
|
||||
pnpm edit-docs:edit-docs
|
||||
```
|
||||
|
||||
### Створення виконуваного файлу
|
||||
Завантажте репозиторій, встановіть залежності за допомогою `pnpm`, а потім
|
||||
зберіть настільний додаток для Windows:
|
||||
### Building the Executable
|
||||
Download the repository, install dependencies using `pnpm` and then build the
|
||||
desktop app for Windows:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -276,72 +269,71 @@ pnpm install
|
||||
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
Для отримання додаткової інформації див. [документацію
|
||||
розробника](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
For more details, see the [development
|
||||
docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
|
||||
### Документація розробника
|
||||
### Developer Documentation
|
||||
|
||||
Please view the [documentation
|
||||
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
||||
for details. If you have more questions, feel free to reach out via the links
|
||||
described in the "Discuss with us" section above.
|
||||
|
||||
## 👏 Привітання
|
||||
## 👏 Shoutouts
|
||||
|
||||
* [zadam](https://github.com/zadam) за оригінальну концепцію та реалізацію
|
||||
застосунку.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) за розробку піктограми
|
||||
програми.
|
||||
* [nriver](https://github.com/nriver) за його роботу з інтернаціоналізації.
|
||||
* [Thomas Frei](https://github.com/thfrei) за його оригінальну роботу на Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) для оригінального віджета
|
||||
підсвічування синтаксису.
|
||||
* [Dosu](https://dosu.dev/) за надання нам автоматичних відповідей на проблеми
|
||||
та обговорення GitHub.
|
||||
* [Tabler Icons](https://tabler.io/icons) для значків у системному треї.
|
||||
* [zadam](https://github.com/zadam) for the original concept and implementation
|
||||
of the application.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
|
||||
application icon.
|
||||
* [nriver](https://github.com/nriver) for his work on internationalization.
|
||||
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) for the original syntax highlight
|
||||
widget.
|
||||
* [Dosu](https://dosu.dev/) for providing us with the automated responses to
|
||||
GitHub issues and discussions.
|
||||
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
|
||||
|
||||
Trilium був би неможливим без технологій, що лежать в його основі:
|
||||
Trilium would not be possible without the technologies behind it:
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) – візуальний редактор
|
||||
текстових нотаток. Ми вдячні за те, що нам запропонували набір
|
||||
преміум-функцій.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) – редактор коду з
|
||||
підтримкою величезної кількості мов програмування.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) – нескінченна дошка, що
|
||||
використовується в нотатках Canvas.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) – забезпечує
|
||||
функціональність карти розуму.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) – для візуалізації географічних
|
||||
карт.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) – для інтерактивної
|
||||
таблиці, що використовується в колекціях.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) – багатофункціональна
|
||||
бібліотека дерев без реальної конкуренції.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) – бібліотека візуальної
|
||||
зв’язності. Використовується в [картах
|
||||
зв’язків](https://docs.triliumnotes.org/user-guide/note-types/relation-map) та
|
||||
[картах
|
||||
посилань](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind
|
||||
text notes. We are grateful for being offered a set of the premium features.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with
|
||||
support for huge amount of languages.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
|
||||
whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
|
||||
mind map functionality.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
|
||||
maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
|
||||
table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
|
||||
without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
|
||||
Used in [relation
|
||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
||||
[link
|
||||
maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
|
||||
## 🤝 Підтримка
|
||||
## 🤝 Support
|
||||
|
||||
Trilium створено та підтримується [сотнями годин
|
||||
роботи](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Ваша
|
||||
підтримка забезпечує його відкритий вихідний код, покращує функції та покриває
|
||||
витрати, такі як хостинг.
|
||||
Trilium is built and maintained with [hundreds of hours of
|
||||
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
|
||||
support keeps it open-source, improves features, and covers costs such as
|
||||
hosting.
|
||||
|
||||
Розгляньте можливість підтримки головного розробника
|
||||
([eliandoran](https://github.com/eliandoran)) програми через:
|
||||
Consider supporting the main developer
|
||||
([eliandoran](https://github.com/eliandoran)) of the application via:
|
||||
|
||||
- [Спонсори GitHub](https://github.com/sponsors/eliandoran)
|
||||
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
|
||||
- [PayPal](https://paypal.me/eliandoran)
|
||||
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
|
||||
|
||||
## 🔑 Ліцензія
|
||||
## 🔑 License
|
||||
|
||||
Авторське право 2017-2025 належить zadam, Elian Doran та іншим авторам
|
||||
Copyright 2017-2025 zadam, Elian Doran, and other contributors
|
||||
|
||||
Ця програма є вільним програмним забезпеченням: ви можете розповсюджувати її
|
||||
та/або змінювати відповідно до умов Загальної публічної ліцензії GNU Affero,
|
||||
опублікованої Фондом вільного програмного забезпечення, або версії 3 Ліцензії,
|
||||
або (на ваш вибір) будь-якої пізнішої версії.
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version.
|
||||
|
||||
@@ -3,19 +3,9 @@
|
||||
|
||||
Trilium Web Clipper is a web browser extension which allows user to clip text, screenshots, whole pages and short notes and save them directly to Trilium Notes.
|
||||
|
||||
## Supported browsers
|
||||
Project is hosted [here](https://github.com/TriliumNext/web-clipper).
|
||||
|
||||
Trilium Web Clipper officially supports the following web browsers:
|
||||
|
||||
* Mozilla Firefox, using Manifest v2.
|
||||
* Google Chrome, using Manifest v3. Theoretically the extension should work on other Chromium-based browsers as well, but they are not officially supported.
|
||||
|
||||
## Obtaining the extension
|
||||
|
||||
> [!WARNING]
|
||||
> The extension is currently under development. A preview with unsigned extensions is available on [GitHub Actions](https://github.com/TriliumNext/Trilium/actions/runs/21318809414).
|
||||
>
|
||||
> We have already submitted the extension to both Chrome and Firefox web stores, but they are pending validation.
|
||||
Firefox and Chrome are supported browsers, but the chrome build should work on other chromium based browsers as well.
|
||||
|
||||
## Functionality
|
||||
|
||||
@@ -25,29 +15,16 @@ Trilium Web Clipper officially supports the following web browsers:
|
||||
* save screenshot (with crop tool) from either popup or context menu
|
||||
* create short text note from popup
|
||||
|
||||
## Location of clippings
|
||||
|
||||
Trilium will save these clippings as a new child note under a "clipper inbox" note.
|
||||
|
||||
By default, that's the [day note](../Advanced%20Usage/Advanced%20Showcases/Day%20Notes.md) but you can override that by setting the [label](../Advanced%20Usage/Attributes.md) `clipperInbox`, on any other note.
|
||||
|
||||
If there's multiple clippings from the same page (and on the same day), then they will be added to the same note.
|
||||
|
||||
## Keyboard shortcuts
|
||||
**Extension is available from:**
|
||||
|
||||
Keyboard shortcuts are available for most functions:
|
||||
|
||||
* Save selected text: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)
|
||||
* Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌥</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)
|
||||
* Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>E</kbd>)
|
||||
|
||||
To set custom shortcuts, follow the directions for your browser.
|
||||
|
||||
* **Firefox**: `about:addons` → Gear icon ⚙️ → Manage extension shortcuts
|
||||
* **Chrome**: `chrome://extensions/shortcuts`
|
||||
|
||||
> [!NOTE]
|
||||
> On Firefox, the default shortcuts interfere with some browser features. As such, the keyboard combinations will not trigger the Web Clipper action. To fix this, simply change the keyboard shortcut to something that works. The defaults will be adjusted in future versions.
|
||||
* [Project release page](https://github.com/TriliumNext/web-clipper/releases) - .xpi for Firefox and .zip for Chromium based browsers.
|
||||
* [Chrome Web Store](https://chromewebstore.google.com/detail/trilium-web-clipper/dfhgmnfclbebfobmblelddiejjcijbjm)
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -55,6 +32,6 @@ The extension needs to connect to a running Trilium instance. By default, it sca
|
||||
|
||||
It's also possible to configure the [server](Server%20Installation.md) address if you don't run the desktop application, or want it to work without the desktop application running.
|
||||
|
||||
## Credits
|
||||
## Username
|
||||
|
||||
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).
|
||||
Older versions of Trilium (before 0.50) required username & password to authenticate, but this is no longer the case. You may enter anything in that field, it will not have any effect.
|
||||
52
flake.lock
generated
52
flake.lock
generated
@@ -18,26 +18,43 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769184885,
|
||||
"narHash": "sha256-wVX5Cqpz66SINNsmt3Bv/Ijzzfl8EPUISq5rKK129K0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "12689597ba7a6d776c3c979f393896be095269d4",
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765701828,
|
||||
"narHash": "sha256-bUqeCi+mdqXt6Ag0n+9QAqyvFiQPZdSCzTI70Nn3HhA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "62996354316ef041b26c36e998a9ef193ede2864",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pnpm2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
@@ -77,6 +94,21 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
description = "Trilium Notes (experimental flake)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/master";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
pnpm2nix = {
|
||||
url = "github:FliegendeWurst/pnpm2nix-nzbr";
|
||||
inputs = {
|
||||
flake-utils.follows = "flake-utils";
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -40,7 +40,7 @@
|
||||
"dev:linter-check": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint .",
|
||||
"dev:linter-fix": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint . --fix",
|
||||
"postinstall": "tsx scripts/electron-rebuild.mts && pnpm prepare",
|
||||
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall"
|
||||
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
@@ -51,9 +51,9 @@
|
||||
"@types/express": "5.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.10.9",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.17",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"@vitest/ui": "4.0.17",
|
||||
"chalk": "5.6.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dpdm": "3.14.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.5.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"happy-dom": "20.3.7",
|
||||
"happy-dom": "20.3.3",
|
||||
"http-server": "14.1.1",
|
||||
"jiti": "2.6.1",
|
||||
"js-yaml": "4.1.1",
|
||||
@@ -77,7 +77,7 @@
|
||||
"upath": "2.0.1",
|
||||
"vite": "7.3.1",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.0.17"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
@@ -93,7 +93,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
@@ -115,7 +115,7 @@
|
||||
"on-headers@<1.1.0": ">=1.1.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"form-data@>=3.0.0 <3.0.4": ">=3.0.4",
|
||||
"node-abi": "4.26.0"
|
||||
"node-abi": "4.25.0"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"sqlite3"
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@vitest/browser": "4.0.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.0.17",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@vitest/browser": "4.0.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
@@ -39,7 +39,7 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.0.17",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@vitest/browser": "4.0.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.0.17",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@vitest/browser": "4.0.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.0.17",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@vitest/browser": "4.0.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"ckeditor5": "47.4.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.0.17",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -71,6 +71,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"lodash-es": "4.17.23"
|
||||
"lodash-es": "4.17.22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.27",
|
||||
"@smithy/middleware-retry": "4.4.24",
|
||||
"@types/jquery": "3.5.33"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
"@fsegurai/codemirror-theme-tokyo-night-day": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-tokyo-night-storm": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-volcano": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-vscode-dark": "6.2.4",
|
||||
"@fsegurai/codemirror-theme-vscode-light": "6.2.4",
|
||||
"@fsegurai/codemirror-theme-vscode-dark": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-vscode-light": "6.2.3",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@replit/codemirror-lang-nix": "6.0.1",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig, KeyBinding } from "@codemirror/view";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
|
||||
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
|
||||
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
@@ -12,17 +12,6 @@ import { createSearchHighlighter, SearchHighlighter, searchMatchHighlightTheme }
|
||||
|
||||
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
|
||||
|
||||
// Custom keymap to prevent Ctrl+Enter from inserting a newline
|
||||
// This allows the parent application to handle the shortcut (e.g., for "Run Active Note")
|
||||
const preventCtrlEnterKeymap: readonly KeyBinding[] = [
|
||||
{
|
||||
key: "Ctrl-Enter",
|
||||
mac: "Cmd-Enter",
|
||||
run: () => true, // Return true to mark event as handled, preventing default newline insertion
|
||||
preventDefault: true
|
||||
}
|
||||
];
|
||||
|
||||
type ContentChangedListener = () => void;
|
||||
|
||||
export interface EditorConfig {
|
||||
@@ -70,7 +59,6 @@ export default class CodeMirror extends EditorView {
|
||||
lineNumbers(),
|
||||
indentUnit.of(" ".repeat(4)),
|
||||
keymap.of([
|
||||
...preventCtrlEnterKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...smartIndentWithTab
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./lib/attribute_names.js";
|
||||
export * from "./lib/utils.js";
|
||||
export * from "./lib/dayjs.js";
|
||||
export * from "./lib/notes.js";
|
||||
export * from "./lib/week_utils.js";
|
||||
|
||||
@@ -26,12 +26,6 @@ export const NOTE_TYPE_ICONS = {
|
||||
|
||||
const FILE_MIME_MAPPINGS = {
|
||||
"application/pdf": "bx bxs-file-pdf",
|
||||
"application/vnd.oasis.opendocument.text": "bx bxs-file-doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "bx bxs-file-doc",
|
||||
};
|
||||
|
||||
const IMAGE_MIME_MAPPINGS = {
|
||||
"image/gif": "bx bxs-file-gif",
|
||||
};
|
||||
|
||||
export function getNoteIcon({ noteId, type, mime, iconClass, workspaceIconClass, isFolder }: {
|
||||
@@ -61,9 +55,6 @@ export function getNoteIcon({ noteId, type, mime, iconClass, workspaceIconClass,
|
||||
return correspondingMimeType?.icon ?? NOTE_TYPE_ICONS.code;
|
||||
} else if (type === "file") {
|
||||
return FILE_MIME_MAPPINGS[mime] ?? NOTE_TYPE_ICONS.file;
|
||||
} else if (type === "image") {
|
||||
return IMAGE_MIME_MAPPINGS[mime] ?? NOTE_TYPE_ICONS.image;
|
||||
}
|
||||
|
||||
return NOTE_TYPE_ICONS[type];
|
||||
}
|
||||
|
||||
165
packages/commons/src/lib/week_utils.spec.ts
Normal file
165
packages/commons/src/lib/week_utils.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dayjs } from "./dayjs.js";
|
||||
import { getWeekInfo, getFirstDayOfWeek1, getWeekString, WeekSettings, DEFAULT_WEEK_SETTINGS } from "./week_utils.js";
|
||||
|
||||
describe("week_utils", () => {
|
||||
describe("getWeekInfo", () => {
|
||||
describe("with firstWeekOfYear=0 (first week contains first day of year)", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 0,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
|
||||
it("2025-12-29 should be 2026-W01 (cross-year week)", () => {
|
||||
// 2026-01-01 is Thursday, so the week containing it starts on 2025-12-29 (Monday)
|
||||
// This week should be 2026-W01 because it contains 2026-01-01
|
||||
const result = getWeekInfo(dayjs("2025-12-29"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("2026-01-01 should be 2026-W01", () => {
|
||||
const result = getWeekInfo(dayjs("2026-01-01"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("2025-12-28 should be 2025-W52", () => {
|
||||
// 2025-12-28 is Sunday, which is the last day of the week starting 2025-12-22
|
||||
const result = getWeekInfo(dayjs("2025-12-28"), settings);
|
||||
expect(result.weekYear).toBe(2025);
|
||||
expect(result.weekNumber).toBe(52);
|
||||
});
|
||||
|
||||
it("2026-01-05 should be 2026-W02", () => {
|
||||
// 2026-01-05 is Monday, start of second week
|
||||
const result = getWeekInfo(dayjs("2026-01-05"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with firstWeekOfYear=1 (ISO standard, first week contains first Thursday)", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 1,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
|
||||
it("2023-01-01 should be 2022-W52 (Jan 1 is Sunday)", () => {
|
||||
// 2023-01-01 is Sunday, so the week starts on 2022-12-26
|
||||
// Since this week doesn't contain Jan 4, it's 2022-W52
|
||||
const result = getWeekInfo(dayjs("2023-01-01"), settings);
|
||||
expect(result.weekYear).toBe(2022);
|
||||
expect(result.weekNumber).toBe(52);
|
||||
});
|
||||
|
||||
it("2023-01-02 should be 2023-W01 (first Monday)", () => {
|
||||
const result = getWeekInfo(dayjs("2023-01-02"), settings);
|
||||
expect(result.weekYear).toBe(2023);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with firstWeekOfYear=2 (minimum days in first week)", () => {
|
||||
// 2026-01-01 is Thursday
|
||||
// The week containing Jan 1 starts on 2025-12-29 (Monday)
|
||||
// This week has 4 days in 2026 (Thu, Fri, Sat, Sun = Jan 1-4)
|
||||
|
||||
describe("with minDaysInFirstWeek=1", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 2,
|
||||
minDaysInFirstWeek: 1
|
||||
};
|
||||
|
||||
it("2025-12-29 should be 2026-W01 (4 days >= 1 minimum)", () => {
|
||||
// Week has 4 days in 2026, which is >= 1
|
||||
const result = getWeekInfo(dayjs("2025-12-29"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("2026-01-01 should be 2026-W01", () => {
|
||||
const result = getWeekInfo(dayjs("2026-01-01"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with minDaysInFirstWeek=7", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 2,
|
||||
minDaysInFirstWeek: 7
|
||||
};
|
||||
|
||||
it("2025-12-29 should be 2025-W52 (4 days < 7 minimum, so this is last week of 2025)", () => {
|
||||
// Week has only 4 days in 2026, which is < 7
|
||||
// So this week belongs to 2025
|
||||
const result = getWeekInfo(dayjs("2025-12-29"), settings);
|
||||
expect(result.weekYear).toBe(2025);
|
||||
expect(result.weekNumber).toBe(52);
|
||||
});
|
||||
|
||||
it("2026-01-01 should be 2025-W52 (still last week of 2025)", () => {
|
||||
const result = getWeekInfo(dayjs("2026-01-01"), settings);
|
||||
expect(result.weekYear).toBe(2025);
|
||||
expect(result.weekNumber).toBe(52);
|
||||
});
|
||||
|
||||
it("2026-01-05 should be 2026-W01 (first full week of 2026)", () => {
|
||||
// 2026-01-05 is Monday, start of the first full week
|
||||
const result = getWeekInfo(dayjs("2026-01-05"), settings);
|
||||
expect(result.weekYear).toBe(2026);
|
||||
expect(result.weekNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstDayOfWeek1", () => {
|
||||
it("with firstWeekOfYear=0, returns the first day of the week containing Jan 1", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 0,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
// 2026-01-01 is Thursday, so week starts on 2025-12-29
|
||||
const result = getFirstDayOfWeek1(2026, settings);
|
||||
expect(result.format("YYYY-MM-DD")).toBe("2025-12-29");
|
||||
});
|
||||
|
||||
it("with firstWeekOfYear=1, returns the first day of the week containing Jan 4", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 1,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
// 2023-01-04 is Wednesday, so week starts on 2023-01-02
|
||||
const result = getFirstDayOfWeek1(2023, settings);
|
||||
expect(result.format("YYYY-MM-DD")).toBe("2023-01-02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWeekString", () => {
|
||||
it("generates correct week string for cross-year week", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 0,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
expect(getWeekString(dayjs("2025-12-29"), settings)).toBe("2026-W01");
|
||||
});
|
||||
|
||||
it("generates correct week string with padded week number", () => {
|
||||
const settings: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 0,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
expect(getWeekString(dayjs("2026-01-05"), settings)).toBe("2026-W02");
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/commons/src/lib/week_utils.ts
Normal file
143
packages/commons/src/lib/week_utils.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { dayjs, Dayjs } from "./dayjs.js";
|
||||
|
||||
/**
|
||||
* Week settings for calculating week numbers.
|
||||
*/
|
||||
export interface WeekSettings {
|
||||
/** First day of the week (1=Monday to 7=Sunday) */
|
||||
firstDayOfWeek: number;
|
||||
/**
|
||||
* How to determine the first week of the year:
|
||||
* - 0: First week contains first day of the year
|
||||
* - 1: First week contains first Thursday (ISO 8601 standard)
|
||||
* - 2: First week has minimum days
|
||||
*/
|
||||
firstWeekOfYear: number;
|
||||
/** Minimum days in first week (used when firstWeekOfYear=2) */
|
||||
minDaysInFirstWeek: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default week settings (first week contains first day of year, week starts on Monday).
|
||||
*/
|
||||
export const DEFAULT_WEEK_SETTINGS: WeekSettings = {
|
||||
firstDayOfWeek: 1,
|
||||
firstWeekOfYear: 0,
|
||||
minDaysInFirstWeek: 4
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the first day of week 1 for a given year, based on user settings.
|
||||
*
|
||||
* @param year The year to calculate for
|
||||
* @param settings Week calculation settings
|
||||
* @returns The first day of week 1
|
||||
*/
|
||||
export function getFirstDayOfWeek1(year: number, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs {
|
||||
const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = settings;
|
||||
|
||||
const jan1 = dayjs(`${year}-01-01`);
|
||||
const jan1Weekday = jan1.isoWeekday(); // 1=Monday, 7=Sunday
|
||||
|
||||
// Calculate the first day of the week containing Jan 1
|
||||
const daysToSubtract = (jan1Weekday - firstDayOfWeek + 7) % 7;
|
||||
const weekContainingJan1Start = jan1.subtract(daysToSubtract, "day");
|
||||
|
||||
if (firstWeekOfYear === 0) {
|
||||
// First week contains first day of the year
|
||||
return weekContainingJan1Start;
|
||||
} else if (firstWeekOfYear === 1) {
|
||||
// First week contains first Thursday (ISO 8601 standard)
|
||||
const jan4 = dayjs(`${year}-01-04`);
|
||||
const jan4Weekday = jan4.isoWeekday();
|
||||
const daysToSubtractFromJan4 = (jan4Weekday - firstDayOfWeek + 7) % 7;
|
||||
return jan4.subtract(daysToSubtractFromJan4, "day");
|
||||
} else {
|
||||
// First week has minimum days
|
||||
const daysInFirstWeek = 7 - daysToSubtract;
|
||||
if (daysInFirstWeek >= minDaysInFirstWeek) {
|
||||
return weekContainingJan1Start;
|
||||
} else {
|
||||
return weekContainingJan1Start.add(1, "week");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the week year and week number for a given date based on user settings.
|
||||
*
|
||||
* @param date The date to calculate week info for
|
||||
* @param settings Week calculation settings
|
||||
* @returns Object with weekYear and weekNumber
|
||||
*/
|
||||
export function getWeekInfo(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): { weekYear: number; weekNumber: number } {
|
||||
const { firstDayOfWeek } = settings;
|
||||
|
||||
// Get the start of the week containing this date
|
||||
const dateWeekday = date.isoWeekday();
|
||||
const daysToSubtract = (dateWeekday - firstDayOfWeek + 7) % 7;
|
||||
const weekStart = date.subtract(daysToSubtract, "day");
|
||||
|
||||
// Try current year first
|
||||
let year = date.year();
|
||||
let firstDayOfWeek1 = getFirstDayOfWeek1(year, settings);
|
||||
|
||||
// If the week start is before week 1 of current year, it belongs to previous year
|
||||
if (weekStart.isBefore(firstDayOfWeek1)) {
|
||||
year--;
|
||||
firstDayOfWeek1 = getFirstDayOfWeek1(year, settings);
|
||||
} else {
|
||||
// Check if this might belong to next year's week 1
|
||||
const nextYearFirstDayOfWeek1 = getFirstDayOfWeek1(year + 1, settings);
|
||||
if (!weekStart.isBefore(nextYearFirstDayOfWeek1)) {
|
||||
year++;
|
||||
firstDayOfWeek1 = nextYearFirstDayOfWeek1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate week number
|
||||
const weekNumber = weekStart.diff(firstDayOfWeek1, "week") + 1;
|
||||
|
||||
return { weekYear: year, weekNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a week string in the format "YYYY-Www" (e.g., "2026-W01").
|
||||
*
|
||||
* @param date The date to generate the week string for
|
||||
* @param settings Week calculation settings
|
||||
* @returns Week string in format "YYYY-Www"
|
||||
*/
|
||||
export function getWeekString(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): string {
|
||||
const { weekYear, weekNumber } = getWeekInfo(date, settings);
|
||||
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the start date of the week containing the given date.
|
||||
*
|
||||
* @param date The date to find the week start for
|
||||
* @param firstDayOfWeek First day of the week (1=Monday to 7=Sunday)
|
||||
* @returns The start of the week
|
||||
*/
|
||||
export function getWeekStartDate(date: Dayjs, firstDayOfWeek: number = 1): Dayjs {
|
||||
const dateWeekday = date.isoWeekday();
|
||||
const diff = (dateWeekday - firstDayOfWeek + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a week string and returns the start date of that week.
|
||||
*
|
||||
* @param weekStr Week string in format "YYYY-Www" (e.g., "2026-W01")
|
||||
* @param settings Week calculation settings
|
||||
* @returns The start date of the week
|
||||
*/
|
||||
export function parseWeekString(weekStr: string, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs {
|
||||
const [yearStr, weekNumStr] = weekStr.trim().split("-W");
|
||||
const weekNumber = parseInt(weekNumStr);
|
||||
const weekYear = parseInt(yearStr);
|
||||
|
||||
const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, settings);
|
||||
return firstDayOfWeek1.add(weekNumber - 1, "week");
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registeredMimeTypes.add(mime);
|
||||
const loader = syntaxDefinitions[mime];
|
||||
if (!loader) {
|
||||
unsupportedMimeTypes.add(mime);
|
||||
@@ -30,7 +31,6 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
|
||||
|
||||
const language = (await loader()).default;
|
||||
hljs.registerLanguage(mime, language);
|
||||
registeredMimeTypes.add(mime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2445
pnpm-lock.yaml
generated
2445
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,6 @@
|
||||
{
|
||||
"path": "./apps/website"
|
||||
},
|
||||
{
|
||||
"path": "./apps/web-clipper"
|
||||
},
|
||||
{
|
||||
"path": "./apps/dump-db"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user