mirror of
https://github.com/zadam/trilium.git
synced 2026-01-24 16:19:14 +01:00
Compare commits
91 Commits
week-note
...
webclipper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266494ba8c | ||
|
|
2e144fac5e | ||
|
|
423038100e | ||
|
|
75e88c69bd | ||
|
|
f0b1319f95 | ||
|
|
59f2fc8d03 | ||
|
|
5d07a079ef | ||
|
|
b5ff71b1a0 | ||
|
|
c0a2ae99cf | ||
|
|
5600a707d3 | ||
|
|
17f906fb65 | ||
|
|
276b3f834b | ||
|
|
a9218960e9 | ||
|
|
957590523c | ||
|
|
22308a101e | ||
|
|
ab95f6dcc2 | ||
|
|
cb8b968637 | ||
|
|
e4d319c7a1 | ||
|
|
f8e5f31970 | ||
|
|
5113e2ab97 | ||
|
|
6ae1cc18e2 | ||
|
|
256ad05d2d | ||
|
|
1b0a53a441 | ||
|
|
430ef62a2d | ||
|
|
50aeda8ee8 | ||
|
|
1520c696a3 | ||
|
|
f63f6244a1 | ||
|
|
8611d4a67a | ||
|
|
c48bd9a5c3 | ||
|
|
dba985b308 | ||
|
|
a51a831fe8 | ||
|
|
44142e980d | ||
|
|
7f83226f84 | ||
|
|
e3fdae8932 | ||
|
|
78c62be823 | ||
|
|
e51cea88bf | ||
|
|
d7409bec49 | ||
|
|
17b1f599ff | ||
|
|
81c85d712e | ||
|
|
2eae8bbb64 | ||
|
|
2a61f51e06 | ||
|
|
3e3c3e3bb4 | ||
|
|
7b41a89b8e | ||
|
|
36429da6da | ||
|
|
30f6ab5976 | ||
|
|
99a46f2a85 | ||
|
|
6754b1f2e1 | ||
|
|
122ad2b771 | ||
|
|
714e8ade1a | ||
|
|
4a6ea38be0 | ||
|
|
8bc7f0b71f | ||
|
|
9a912c16ad | ||
|
|
10a84a1356 | ||
|
|
901201a7af | ||
|
|
a57a1dfc47 | ||
|
|
577780cb90 | ||
|
|
b45eef9140 | ||
|
|
907853bbba | ||
|
|
17f3ffd00c | ||
|
|
8b86e17ac8 | ||
|
|
d6b6832a1d | ||
|
|
9dfc1cdc4c | ||
|
|
673c39d798 | ||
|
|
8ca84d183c | ||
|
|
9577aa2abe | ||
|
|
227be184ac | ||
|
|
d677f65eeb | ||
|
|
92f86bcca2 | ||
|
|
2015068d9e | ||
|
|
8a280c2f9d | ||
|
|
34fd6f9502 | ||
|
|
02acb36e47 | ||
|
|
280c0e0348 | ||
|
|
8528f0d848 | ||
|
|
917e881faa | ||
|
|
96b1efcfdc | ||
|
|
794e03b2cb | ||
|
|
a285c46b97 | ||
|
|
4da6294ef2 | ||
|
|
16ed9a7e8e | ||
|
|
798efbc22f | ||
|
|
60c789b6c7 | ||
|
|
f83d95136d | ||
|
|
f96ed0af26 | ||
|
|
1539664026 | ||
|
|
8aff775d0e | ||
|
|
94248eafe9 | ||
|
|
02335bba3f | ||
|
|
dad9578b83 | ||
|
|
c043788b09 | ||
|
|
e5bc416b46 |
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.5",
|
||||
"@redocly/cli": "2.14.7",
|
||||
"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.5.1",
|
||||
"@preact/signals": "2.6.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.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"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.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"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.3",
|
||||
"lightningcss": "1.30.2",
|
||||
"happy-dom": "20.3.7",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
"vite-plugin-static-copy": "3.1.5"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false, componentId?: string) {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name,
|
||||
value,
|
||||
isInheritable
|
||||
});
|
||||
isInheritable,
|
||||
}, componentId);
|
||||
}
|
||||
|
||||
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) {
|
||||
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined, componentId?: string) {
|
||||
if (value !== null && value !== undefined) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }, componentId);
|
||||
} 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}`);
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true, componentId?: string) {
|
||||
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}`);
|
||||
await server.remove(`notes/${branch.noteId}${query}`, componentId);
|
||||
} else {
|
||||
await server.remove(`branches/${branchIdToDelete}${query}`);
|
||||
await server.remove(`branches/${branchIdToDelete}${query}`, componentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +62,10 @@ describe("shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
const createKeyboardEvent = (key: string, code?: string, extraProps: Partial<KeyboardEvent> = {}) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
code: code || `Key${key.toUpperCase()}`,
|
||||
...extraProps
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
@@ -102,17 +103,23 @@ 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");
|
||||
const macOSAltAEvent = createKeyboardEvent("å", "KeyA", { altKey: true });
|
||||
expect(keyMatches(macOSAltAEvent, "a")).toBe(true);
|
||||
|
||||
// Option + H produces '˙'
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH");
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH", { altKey: true });
|
||||
expect(keyMatches(macOSAltHEvent, "h")).toBe(true);
|
||||
|
||||
// Option + S produces 'ß'
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS");
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS", { altKey: true });
|
||||
expect(keyMatches(macOSAltSEvent, "s")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +223,15 @@ 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({
|
||||
@@ -249,7 +265,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
@@ -280,7 +296,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
@@ -290,7 +306,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,7 +317,7 @@ describe("shortcuts", () => {
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,9 +110,8 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener in capture phase to intercept events before they reach
|
||||
// child elements like CodeMirror
|
||||
element.addEventListener('keydown', listener, true);
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
@@ -139,16 +138,15 @@ export function removeIndividualBinding(binding: ShortcutBinding) {
|
||||
if (activeBindingsInNamespace) {
|
||||
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
|
||||
}
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener("keydown", binding.listener, true);
|
||||
binding.element.removeEventListener("keydown", binding.listener);
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener('keydown', binding.listener, true);
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
@@ -217,9 +215,12 @@ 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') {
|
||||
// e.code is like "KeyA", "KeyB", etc.
|
||||
const expectedCode = `Key${key.toUpperCase()}`;
|
||||
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
|
||||
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();
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type AutoHighlightResult, type HighlightResult, type Theme } from "@triliumnext/highlightjs";
|
||||
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 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;
|
||||
|
||||
@@ -76,13 +77,15 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
|
||||
}
|
||||
|
||||
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
|
||||
if (highlightingLoaded) {
|
||||
if (!mimeTypeHint && highlightingLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load theme.
|
||||
const currentThemeName = String(options.get("codeBlockTheme"));
|
||||
await loadHighlightingTheme(currentThemeName);
|
||||
if (!highlightingLoaded) {
|
||||
const currentThemeName = String(options.get("codeBlockTheme"));
|
||||
await loadHighlightingTheme(currentThemeName);
|
||||
}
|
||||
|
||||
// Load mime types.
|
||||
let mimeTypes: MimeType[];
|
||||
@@ -94,7 +97,7 @@ export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
|
||||
enabled: true,
|
||||
mime: mimeTypeHint.replace("-", "/")
|
||||
}
|
||||
]
|
||||
];
|
||||
} else {
|
||||
mimeTypes = mime_types.getMimeTypes();
|
||||
}
|
||||
@@ -124,9 +127,9 @@ export function isSyntaxHighlightEnabled() {
|
||||
if (!isShare) {
|
||||
const theme = options.get("codeBlockTheme");
|
||||
return !!theme && theme !== "none";
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -217,6 +217,7 @@ 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 { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import froca from "../../../services/froca";
|
||||
import server from "../../../services/server";
|
||||
|
||||
interface NewEventOpts {
|
||||
title: string;
|
||||
@@ -10,6 +10,7 @@ interface NewEventOpts {
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
interface ChangeEventOpts {
|
||||
@@ -17,30 +18,48 @@ interface ChangeEventOpts {
|
||||
endDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime }: NewEventOpts) {
|
||||
// Create the note.
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text"
|
||||
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
|
||||
});
|
||||
|
||||
// Set the attributes.
|
||||
setLabel(note.noteId, "startDate", startDate);
|
||||
if (endDate) {
|
||||
setLabel(note.noteId, "endDate", endDate);
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "endDate",
|
||||
value: endDate
|
||||
});
|
||||
}
|
||||
if (startTime) {
|
||||
setLabel(note.noteId, "startTime", startTime);
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "startTime",
|
||||
value: startTime
|
||||
});
|
||||
}
|
||||
if (endTime) {
|
||||
setLabel(note.noteId, "endTime", endTime);
|
||||
attributes.push({
|
||||
type: "label",
|
||||
name: "endTime",
|
||||
value: endTime
|
||||
});
|
||||
}
|
||||
|
||||
// Create the note.
|
||||
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text",
|
||||
attributes
|
||||
}, componentId);
|
||||
}
|
||||
|
||||
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime }: ChangeEventOpts) {
|
||||
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime, componentId }: ChangeEventOpts) {
|
||||
// Don't store the end date if it's empty.
|
||||
if (endDate === startDate) {
|
||||
endDate = undefined;
|
||||
@@ -52,12 +71,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);
|
||||
setAttribute(note, "label", endAttribute, endDate);
|
||||
setLabel(noteId, startAttribute, startDate, false, componentId);
|
||||
setAttribute(note, "label", endAttribute, endDate, componentId);
|
||||
|
||||
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);
|
||||
setAttribute(note, "label", endAttribute, endTime);
|
||||
setAttribute(note, "label", startAttribute, startTime, componentId);
|
||||
setAttribute(note, "label", endAttribute, endTime, componentId);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote, componentId?: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -30,16 +30,16 @@ export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parent
|
||||
}
|
||||
|
||||
if (branchIdToDelete) {
|
||||
await branches.deleteNotes([ branchIdToDelete ], false, false);
|
||||
await branches.deleteNotes([ branchIdToDelete ], false, false, componentId);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "custom",
|
||||
componentFn: () => NoteColorPicker({note: note})
|
||||
componentFn: () => NoteColorPicker({note})
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
|
||||
import froca from "../../../services/froca";
|
||||
import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import server from "../../../services/server";
|
||||
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";
|
||||
|
||||
interface Event {
|
||||
startDate: string,
|
||||
endDate?: string | null,
|
||||
@@ -105,7 +106,8 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
|
||||
|
||||
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
|
||||
const eventData: EventInput = {
|
||||
title: title,
|
||||
id: note.noteId,
|
||||
title,
|
||||
start: startDate,
|
||||
url: `#${note.noteId}?popup`,
|
||||
noteId: note.noteId,
|
||||
@@ -148,12 +150,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, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
@@ -87,6 +88,7 @@ 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);
|
||||
|
||||
@@ -105,26 +107,34 @@ 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);
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot);
|
||||
const { eventDidMount } = useEventDisplayCustomization(note, parentComponent?.componentId);
|
||||
const editingProps = useEditing(note, isEditable, isCalendarRoot, parentComponent?.componentId);
|
||||
|
||||
// React to changes.
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|
||||
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
|
||||
{
|
||||
const api = calendarRef.current;
|
||||
if (!api) return;
|
||||
|
||||
// Subnote attribute change.
|
||||
if (loadResults.getAttributeRows(parentComponent?.componentId).some((a) => noteIds.includes(a.noteId ?? ""))) {
|
||||
// Defer execution after the load results are processed so that the event builder has the updated data to work with.
|
||||
setTimeout(() => {
|
||||
calendarRef.current?.refetchEvents();
|
||||
}, 0);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,7 +232,7 @@ function useLocale() {
|
||||
return calendarLocale;
|
||||
}
|
||||
|
||||
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, componentId: string | undefined) {
|
||||
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e);
|
||||
if (!startDate) return;
|
||||
@@ -234,8 +244,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
newEvent(note, { title, startDate, endDate, startTime, endTime });
|
||||
}, [ note ]);
|
||||
newEvent(note, { title, startDate, endDate, startTime, endTime, componentId });
|
||||
}, [ note, componentId ]);
|
||||
|
||||
const onEventChange = useCallback(async (e: EventChangeArg) => {
|
||||
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
|
||||
@@ -244,8 +254,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
|
||||
const note = await froca.getNote(e.event.extendedProps.noteId);
|
||||
if (!note) return;
|
||||
changeEvent(note, { startDate, endDate, startTime, endTime });
|
||||
}, []);
|
||||
changeEvent(note, { startDate, endDate, startTime, endTime, componentId });
|
||||
}, [ componentId ]);
|
||||
|
||||
// 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) => {
|
||||
@@ -264,7 +274,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
function useEventDisplayCustomization(parentNote: FNote) {
|
||||
function useEventDisplayCustomization(parentNote: FNote, componentId: string | undefined) {
|
||||
const eventDidMount = useCallback((e: EventMountArg) => {
|
||||
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
||||
|
||||
@@ -321,7 +331,7 @@ function useEventDisplayCustomization(parentNote: FNote) {
|
||||
const note = await froca.getNote(e.event.extendedProps.noteId);
|
||||
if (!note) return;
|
||||
|
||||
openCalendarContextMenu(contextMenuEvent, note, parentNote);
|
||||
openCalendarContextMenu(contextMenuEvent, note, parentNote, componentId);
|
||||
}
|
||||
|
||||
if (isMobile()) {
|
||||
|
||||
@@ -82,6 +82,10 @@ 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%;
|
||||
@@ -106,4 +110,4 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
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 { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import attributes from "../../services/attributes";
|
||||
import { t } from "../../services/i18n";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
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";
|
||||
|
||||
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -31,6 +34,13 @@ 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,6 +20,9 @@ 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);
|
||||
|
||||
@@ -37,6 +40,8 @@ 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,
|
||||
@@ -47,7 +52,6 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
|
||||
libraryCache.current = [];
|
||||
attachmentMetadata.current = [];
|
||||
currentSceneVersion.current = -1;
|
||||
|
||||
// load saved content into excalidraw canvas
|
||||
let content: CanvasContent = {
|
||||
@@ -65,6 +69,9 @@ 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
|
||||
@@ -78,7 +85,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
async getData() {
|
||||
const api = apiRef.current;
|
||||
if (!api) return;
|
||||
const { content, svg } = await getData(api);
|
||||
const { content, svg } = await getData(api, appStateToCompare);
|
||||
const attachments: SavedData["attachments"] = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||
|
||||
// libraryChanged is unset in dataSaved()
|
||||
@@ -149,21 +156,47 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
|
||||
const oldSceneVersion = currentSceneVersion.current;
|
||||
const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements());
|
||||
|
||||
if (newSceneVersion !== oldSceneVersion) {
|
||||
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) {
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
currentSceneVersion.current = newSceneVersion;
|
||||
}
|
||||
},
|
||||
onLibraryChange: () => {
|
||||
libraryChanged.current = true;
|
||||
spacedUpdate.resetUpdateTimer();
|
||||
spacedUpdate.scheduleUpdate();
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getData(api: ExcalidrawImperativeAPI) {
|
||||
async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObject<Partial<ImportantAppState>>) {
|
||||
const elements = api.getSceneElements();
|
||||
const appState = api.getAppState();
|
||||
|
||||
@@ -188,6 +221,12 @@ async function getData(api: ExcalidrawImperativeAPI) {
|
||||
}
|
||||
});
|
||||
|
||||
const importantAppState: ImportantAppState = {
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
viewBackgroundColor: appState.viewBackgroundColor
|
||||
};
|
||||
appStateToCompare.current = importantAppState;
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
@@ -197,7 +236,7 @@ async function getData(api: ExcalidrawImperativeAPI) {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom,
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
...importantAppState
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
|
||||
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
|
||||
import { EditableCode, EditableCodeProps } from "../code/Code";
|
||||
|
||||
export interface SplitEditorProps extends EditableCodeProps {
|
||||
@@ -30,12 +30,22 @@ 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({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
|
||||
const splitEditorOrientation = useSplitOrientation(forceOrientation);
|
||||
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export default function SplitEditor(props: SplitEditorProps) {
|
||||
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
|
||||
|
||||
const editor = (!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);
|
||||
const splitEditorOrientation = useSplitOrientation(forceOrientation);
|
||||
|
||||
const editor = (
|
||||
<div className="note-detail-split-editor-col">
|
||||
{editorBefore}
|
||||
<div className="note-detail-split-editor">
|
||||
@@ -53,19 +63,14 @@ export default function SplitEditor({ note, error, splitOptions, previewContent,
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
const preview = <PreviewContainer
|
||||
error={error}
|
||||
previewContent={previewContent}
|
||||
previewButtons={previewButtons}
|
||||
/>;
|
||||
|
||||
useEffect(() => {
|
||||
if (!utils.isDesktop() || !containerRef.current || readOnly) return;
|
||||
if (!utils.isDesktop() || !containerRef.current) return;
|
||||
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
|
||||
const splitInstance = Split(elements, {
|
||||
rtl: glob.isRtl,
|
||||
@@ -76,10 +81,10 @@ export default function SplitEditor({ note, error, splitOptions, previewContent,
|
||||
});
|
||||
|
||||
return () => splitInstance.destroy();
|
||||
}, [ readOnly, splitEditorOrientation ]);
|
||||
}, [ splitEditorOrientation ]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${readOnly ? "split-read-only" : ""} ${className ?? ""}`}>
|
||||
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
|
||||
{splitEditorOrientation === "horizontal"
|
||||
? <>{editor}{preview}</>
|
||||
: <>{preview}{editor}</>}
|
||||
@@ -87,6 +92,43 @@ export default function SplitEditor({ note, error, splitOptions, previewContent,
|
||||
);
|
||||
}
|
||||
|
||||
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,13 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
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 { 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";
|
||||
|
||||
interface SvgSplitEditorProps extends Omit<SplitEditorProps, "previewContent"> {
|
||||
/**
|
||||
@@ -144,7 +145,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg: string | undefined) {
|
||||
@@ -181,7 +182,7 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
|
||||
lastPanZoom.current = {
|
||||
pan: zoomInstance.getPan(),
|
||||
zoom: zoomInstance.getZoom()
|
||||
}
|
||||
};
|
||||
zoomRef.current = undefined;
|
||||
zoomInstance.destroy();
|
||||
};
|
||||
|
||||
@@ -191,7 +191,6 @@ function ExperimentalOptions() {
|
||||
values={filteredExperimentalFeatures}
|
||||
keyProperty="id"
|
||||
titleProperty="name"
|
||||
descriptionProperty="description"
|
||||
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
|
||||
/>
|
||||
</OptionsSection>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
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, descriptionProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
|
||||
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
|
||||
function toggleValue(value: string) {
|
||||
if (currentValue.includes(value)) {
|
||||
// Already there, needs removing.
|
||||
@@ -25,17 +22,20 @@ export default function CheckboxList<T>({ values, keyProperty, titleProperty, di
|
||||
return (
|
||||
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
|
||||
{values.map(value => (
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -93,7 +93,15 @@ export default defineConfig(() => ({
|
||||
print: join(__dirname, "src", "print.tsx")
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "src/[name].js",
|
||||
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";
|
||||
},
|
||||
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.1.2",
|
||||
"cheerio": "1.2.0",
|
||||
"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.18.2",
|
||||
"express-session": "1.19.0",
|
||||
"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.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"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.7.1",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.3.1",
|
||||
"ws": "8.19.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
import type { AttributeRow, NoteType } from "@triliumnext/commons";
|
||||
|
||||
export interface NoteParams {
|
||||
/** optionally can force specific noteId */
|
||||
@@ -24,4 +24,6 @@ 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 sql from "./sql.js";
|
||||
import optionService from "./options.js";
|
||||
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 dateUtils from "./date_utils.js";
|
||||
import entityChangesService from "./entity_changes.js";
|
||||
import eventService from "./events.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 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";
|
||||
import noteTypesService from "./note_types.js";
|
||||
import type { NoteParams } from "./note-interface.js";
|
||||
import optionService from "./options.js";
|
||||
import request from "./request.js";
|
||||
import revisionService from "./revisions.js";
|
||||
import sql from "./sql.js";
|
||||
import type TaskContext from "./task_context.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
interface FoundLink {
|
||||
name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink";
|
||||
@@ -47,14 +47,13 @@ function getNewNotePosition(parentNote: BNote) {
|
||||
.reduce((min, note) => Math.min(min, note?.notePosition || 0), 0);
|
||||
|
||||
return minNotePos - 10;
|
||||
} 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function triggerNoteTitleChanged(note: BNote) {
|
||||
@@ -88,7 +87,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
|
||||
@@ -222,6 +221,14 @@ 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({
|
||||
@@ -260,7 +267,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: parentNote });
|
||||
eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote });
|
||||
|
||||
log.info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`);
|
||||
|
||||
@@ -308,9 +315,8 @@ 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">) {
|
||||
@@ -488,7 +494,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,8 +662,8 @@ function saveAttachments(note: BNote, content: string) {
|
||||
|
||||
const attachment = note.saveAttachment({
|
||||
role: "file",
|
||||
mime: mime,
|
||||
title: title,
|
||||
mime,
|
||||
title,
|
||||
content: buffer
|
||||
});
|
||||
|
||||
@@ -953,7 +959,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: duplicateNoteSuffix });
|
||||
res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix });
|
||||
}
|
||||
|
||||
res.note.save();
|
||||
@@ -1050,13 +1056,12 @@ 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 +1,2 @@
|
||||
dist/
|
||||
.output
|
||||
.wxt
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,451 +0,0 @@
|
||||
// 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,351 +0,0 @@
|
||||
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});
|
||||
}
|
||||
}
|
||||
455
apps/web-clipper/entrypoints/background/index.js
Normal file
455
apps/web-clipper/entrypoints/background/index.js
Normal file
@@ -0,0 +1,455 @@
|
||||
import { randomString } from "../../utils";
|
||||
import TriliumServerFacade, { isDevEnv } from "./trilium_server_facade";
|
||||
|
||||
export default defineBackground(() => {
|
||||
const triliumServerFacade = new TriliumServerFacade();
|
||||
|
||||
// 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 === '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);
|
||||
}
|
||||
});
|
||||
});
|
||||
253
apps/web-clipper/entrypoints/background/trilium_server_facade.ts
Normal file
253
apps/web-clipper/entrypoints/background/trilium_server_facade.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
const PROTOCOL_VERSION_MAJOR = 1;
|
||||
|
||||
export function isDevEnv() {
|
||||
const manifest = browser.runtime.getManifest();
|
||||
|
||||
return manifest.name.endsWith('(dev)');
|
||||
}
|
||||
|
||||
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){
|
||||
this.triliumSearchNote = st;
|
||||
this.sendTriliumSearchNoteToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearch(ts: TriliumSearchStatus) {
|
||||
this.triliumSearch = ts;
|
||||
|
||||
this.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearchWithVersionCheck(json: { protocolVersion: string }, resp: TriliumSearchStatus) {
|
||||
const [major, minor] = 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) {
|
||||
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 isDevEnv() ? 37740 : 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);
|
||||
|
||||
window.showToast('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')}`);
|
||||
}
|
||||
}
|
||||
345
apps/web-clipper/entrypoints/content/index.ts
Normal file
345
apps/web-clipper/entrypoints/content/index.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import Readability from "../../lib/Readability.js";
|
||||
import { createLink, getBaseUrl, getPageLocationOrigin, randomString } from "../../utils.js";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: [
|
||||
"<all_urls>"
|
||||
],
|
||||
main: () => {
|
||||
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}`;
|
||||
}
|
||||
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 && articlePublishedTime) {
|
||||
publishedDate = new Date(articlePublishedTime);
|
||||
}
|
||||
|
||||
const articleModifiedTime = document.querySelector("meta[property='article:modified_time']")?.getAttribute('content');
|
||||
if (articleModifiedTime && 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((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: {x?: number, y?: number, width?: number, height?: number} = {};
|
||||
|
||||
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 (!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) {
|
||||
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: 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 getRectangleArea();
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -54,9 +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 src="../lib/cash.min.js"></script>
|
||||
<script src="../lib/browser-polyfill.js"></script>
|
||||
<script src="options.js"></script>
|
||||
<script type="module" src="../../lib/cash.min.js"></script>
|
||||
<script type="module" src="../../lib/browser-polyfill.js"></script>
|
||||
<script type="module" src="index.ts"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -17,8 +17,8 @@ function showSuccess(message) {
|
||||
async function saveTriliumServerSetup(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if ($triliumServerUrl.val().trim().length === 0
|
||||
|| $triliumServerPassword.val().trim().length === 0) {
|
||||
if (($triliumServerUrl.val() as string | undefined)?.trim().length === 0
|
||||
|| ($triliumServerPassword.val() as string | undefined)?.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,7 +39,8 @@ async function saveTriliumServerSetup(e) {
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
showError("Unknown error: " + e.message);
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
showError(`Unknown error: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,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();
|
||||
@@ -89,8 +90,8 @@ const $triilumDesktopSetupForm = $("#trilium-desktop-setup-form");
|
||||
$triilumDesktopSetupForm.on("submit", e => {
|
||||
e.preventDefault();
|
||||
|
||||
const port = $triliumDesktopPort.val().trim();
|
||||
const portNum = parseInt(port);
|
||||
const port = ($triliumDesktopPort.val() as string | undefined ?? "").trim();
|
||||
const portNum = parseInt(port, 10);
|
||||
|
||||
if (port && (isNaN(portNum) || portNum <= 0 || portNum >= 65536)) {
|
||||
showError(`Please enter valid port number.`);
|
||||
@@ -105,8 +106,8 @@ $triilumDesktopSetupForm.on("submit", e => {
|
||||
});
|
||||
|
||||
async function restoreOptions() {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get("authToken");
|
||||
const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
|
||||
|
||||
$errorMessage.hide();
|
||||
$successMessage.hide();
|
||||
@@ -127,8 +128,7 @@ async function restoreOptions() {
|
||||
$triliumServerConfiguredDiv.hide();
|
||||
}
|
||||
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
|
||||
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
|
||||
$triliumDesktopPort.val(triliumDesktopPort);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,10 @@
|
||||
<div>Status: <span id="connection-status">unknown</span></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script type="module" src="../../lib/browser-polyfill.js"></script>
|
||||
<script type="module" src="../../lib/cash.min.js"></script>
|
||||
<script type="module" src="popup.ts"></script>
|
||||
<script type="module" src="../../utils.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createLink } from "../../utils";
|
||||
|
||||
async function sendMessage(message) {
|
||||
try {
|
||||
console.log("Sending message", message);
|
||||
return await browser.runtime.sendMessage(message);
|
||||
}
|
||||
catch (e) {
|
||||
@@ -35,9 +38,9 @@ $saveTabsButton.on("click", () => sendMessage({name: 'save-tabs'}));
|
||||
|
||||
const $saveLinkWithNoteWrapper = $("#save-link-with-note-wrapper");
|
||||
const $textNote = $("#save-link-with-note-textarea");
|
||||
const $keepTitle = $("#keep-title-checkbox");
|
||||
const $keepTitle = $<HTMLInputElement>("#keep-title-checkbox");
|
||||
|
||||
$textNote.on('keypress', function (event) {
|
||||
$textNote.on('keypress', (event) => {
|
||||
if ((event.which === 10 || event.which === 13) && event.ctrlKey) {
|
||||
saveLinkWithNote();
|
||||
return false;
|
||||
@@ -60,7 +63,7 @@ $("#cancel-button").on("click", () => {
|
||||
});
|
||||
|
||||
async function saveLinkWithNote() {
|
||||
const textNoteVal = $textNote.val().trim();
|
||||
const textNoteVal = ($textNote.val() as string | undefined ?? "").trim();
|
||||
let title, content;
|
||||
|
||||
if (!textNoteVal) {
|
||||
@@ -108,7 +111,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");
|
||||
@@ -157,14 +160,13 @@ browser.runtime.onMessage.addListener(request => {
|
||||
const {searchNote} = request;
|
||||
if (searchNote.status === 'found'){
|
||||
const a = createLink({name: 'openNoteInTrilium', noteId: searchNote.noteId},
|
||||
"Open in Trilium.")
|
||||
noteFound = `Already visited website!`;
|
||||
$alreadyVisited.html(noteFound);
|
||||
"Open in Trilium.");
|
||||
$alreadyVisited.text(`Already visited website!`);
|
||||
$alreadyVisited[0].appendChild(a);
|
||||
}else{
|
||||
$alreadyVisited.html('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
@@ -174,7 +176,7 @@ const $checkConnectionButton = $("#check-connection-button");
|
||||
$checkConnectionButton.on("click", () => {
|
||||
browser.runtime.sendMessage({
|
||||
name: "trigger-trilium-search"
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
$(() => browser.runtime.sendMessage({name: "send-trilium-search-status"}));
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
/* 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.
|
||||
*/
|
||||
function Readability(doc, options) {
|
||||
export default function Readability(doc, options) {
|
||||
// In some older versions, people passed a URI as the first argument. Cope:
|
||||
if (options && options.documentElement) {
|
||||
doc = options;
|
||||
@@ -2277,7 +2277,3 @@ Readability.prototype = {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module === "object") {
|
||||
module.exports = Readability;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
@@ -10,15 +8,6 @@
|
||||
"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",
|
||||
@@ -26,27 +15,16 @@
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"lib/browser-polyfill.js",
|
||||
"utils.js",
|
||||
"content.js"
|
||||
"lib/browser-polyfill.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": [
|
||||
"lib/browser-polyfill.js",
|
||||
"utils.js",
|
||||
"trilium_server_facade.js",
|
||||
"background.js"
|
||||
"lib/browser-polyfill.js"
|
||||
]
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options/options.html"
|
||||
},
|
||||
"commands": {
|
||||
"saveSelection": {
|
||||
"description": "Save the selected text into a note",
|
||||
@@ -66,10 +44,5 @@
|
||||
"default": "Ctrl+Shift+E"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{1410742d-b377-40e7-a9db-63dc9c6ec99c}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/web-clipper/package.json
Normal file
20
apps/web-clipper/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@triliumnext/web-clipper",
|
||||
"version": "1.0.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": "0.20.13"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,225 +0,0 @@
|
||||
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();
|
||||
6
apps/web-clipper/tsconfig.json
Normal file
6
apps/web-clipper/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"../../tsconfig.base.json",
|
||||
"./.wxt/tsconfig.json"
|
||||
]
|
||||
}
|
||||
7
apps/web-clipper/types.d.ts
vendored
Normal file
7
apps/web-clipper/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
interface Window {
|
||||
showToast(message: string, opts?: {
|
||||
settings?: {
|
||||
duration: number;
|
||||
}
|
||||
}): void;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
function randomString(len) {
|
||||
export function randomString(len) {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
@@ -9,7 +9,7 @@ function randomString(len) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
export function getBaseUrl() {
|
||||
let output = getPageLocationOrigin() + location.pathname;
|
||||
|
||||
if (output[output.length - 1] !== '/') {
|
||||
@@ -21,8 +21,20 @@ function getBaseUrl() {
|
||||
return output;
|
||||
}
|
||||
|
||||
function getPageLocationOrigin() {
|
||||
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, 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
|
||||
}
|
||||
|
||||
23
apps/web-clipper/wxt.config.ts
Normal file
23
apps/web-clipper/wxt.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "wxt";
|
||||
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/auto-icons'],
|
||||
manifest: {
|
||||
name: "Trilium Web Clipper",
|
||||
description: "Save web clippings to Trilium Notes.",
|
||||
permissions: [
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"http://*/",
|
||||
"https://*/",
|
||||
"<all_urls>",
|
||||
"storage",
|
||||
"contextMenus"
|
||||
],
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
id: "{1410742d-b377-40e7-a9db-63dc9c6ec99c}"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"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.17"
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
|
||||
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) for
|
||||
automation
|
||||
* ऑटोमेशन के लिए [REST
|
||||
API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi)
|
||||
* 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
|
||||
|
||||
48
flake.lock
generated
48
flake.lock
generated
@@ -18,43 +18,26 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765701828,
|
||||
"narHash": "sha256-bUqeCi+mdqXt6Ag0n+9QAqyvFiQPZdSCzTI70Nn3HhA=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1769184885,
|
||||
"narHash": "sha256-wVX5Cqpz66SINNsmt3Bv/Ijzzfl8EPUISq5rKK129K0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "62996354316ef041b26c36e998a9ef193ede2864",
|
||||
"rev": "12689597ba7a6d776c3c979f393896be095269d4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pnpm2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
@@ -94,21 +77,6 @@
|
||||
"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,11 +2,14 @@
|
||||
description = "Trilium Notes (experimental flake)";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/master";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
pnpm2nix = {
|
||||
url = "github:FliegendeWurst/pnpm2nix-nzbr";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs = {
|
||||
flake-utils.follows = "flake-utils";
|
||||
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"
|
||||
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall"
|
||||
},
|
||||
"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.17",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"@vitest/ui": "4.0.17",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"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.3",
|
||||
"happy-dom": "20.3.7",
|
||||
"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.17"
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
"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.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"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.25.0"
|
||||
"node-abi": "4.26.0"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"sqlite3"
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@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.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"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.17",
|
||||
"vitest": "4.0.18",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@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.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"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.17",
|
||||
"vitest": "4.0.18",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@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.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"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.17",
|
||||
"vitest": "4.0.18",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@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.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"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.17",
|
||||
"vitest": "4.0.18",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
|
||||
"@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.17",
|
||||
"@vitest/coverage-istanbul": "4.0.17",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"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.17",
|
||||
"vitest": "4.0.18",
|
||||
"webdriverio": "9.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -71,6 +71,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"lodash-es": "4.17.22"
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.24",
|
||||
"@smithy/middleware-retry": "4.4.27",
|
||||
"@types/jquery": "3.5.33"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig, KeyBinding } 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,6 +12,17 @@ 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 {
|
||||
@@ -59,6 +70,7 @@ export default class CodeMirror extends EditorView {
|
||||
lineNumbers(),
|
||||
indentUnit.of(" ".repeat(4)),
|
||||
keymap.of([
|
||||
...preventCtrlEnterKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...smartIndentWithTab
|
||||
|
||||
@@ -26,6 +26,12 @@ 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 }: {
|
||||
@@ -55,6 +61,9 @@ 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];
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registeredMimeTypes.add(mime);
|
||||
const loader = syntaxDefinitions[mime];
|
||||
if (!loader) {
|
||||
unsupportedMimeTypes.add(mime);
|
||||
@@ -31,6 +30,7 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
|
||||
|
||||
const language = (await loader()).default;
|
||||
hljs.registerLanguage(mime, language);
|
||||
registeredMimeTypes.add(mime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2397
pnpm-lock.yaml
generated
2397
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@
|
||||
{
|
||||
"path": "./apps/website"
|
||||
},
|
||||
{
|
||||
"path": "./apps/web-clipper"
|
||||
},
|
||||
{
|
||||
"path": "./apps/dump-db"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user