Compare commits

..

92 Commits

Author SHA1 Message Date
Elian Doran
b7b367b5a3 chore(webclipper): address requested changes 2026-01-24 23:54:26 +02:00
Elian Doran
4927b01d96 chore(webclipper): fix typecheck 2026-01-24 23:13:27 +02:00
Elian Doran
e2e5d485d7 docs(dev): add some information on web clipper 2026-01-24 21:35:54 +02:00
Elian Doran
ada22e4966 docs(user): update web clipper 2026-01-24 21:24:14 +02:00
Elian Doran
1cf93ff0de chore(web-clipper): add data_collection_permissions for Firefox 2026-01-24 19:25:16 +02:00
Elian Doran
199962233b chore(web-clipper): use friendly ID for Firefox 2026-01-24 19:04:38 +02:00
Elian Doran
743a6f3466 ci(web-clipper): disable compression level for artifact 2026-01-24 18:12:02 +02:00
Elian Doran
625062a268 ci(web-clipper): no files uploaded 2026-01-24 17:57:30 +02:00
Elian Doran
cb0fabf273 ci(web-clipper): fail if no files found 2026-01-24 17:43:11 +02:00
Elian Doran
4c978d8622 ci(web-clipper): fail if no files found 2026-01-24 17:41:51 +02:00
Elian Doran
d0f441ec74 ci(web-clipper): generate .zip files on change 2026-01-24 17:40:18 +02:00
Elian Doran
9d347ff3d9 chore(web-clipper): update help URL 2026-01-24 17:25:09 +02:00
Elian Doran
c2a758dd4a chore(web-clipper): address requested changes 2026-01-24 17:23:06 +02:00
Elian Doran
bba69e98ae fix(web-clipper): warning about offscreen permission in MV2 2026-01-24 17:07:53 +02:00
Elian Doran
53e3d65c52 fix(web-clipper): handling of dev port 2026-01-24 17:01:03 +02:00
Elian Doran
a2a37a0b54 chore(web-clipper): integrate old manifest 2026-01-24 16:46:04 +02:00
Elian Doran
1fb360e34f chore(web-clipper): remove polyfill 2026-01-24 16:40:00 +02:00
Elian Doran
680817d81c fix(web-clipper): duplicate context menu entry 2026-01-24 16:39:00 +02:00
Elian Doran
bf736977ab chore(web-clipper): don't render offscreen for MV2 2026-01-24 16:26:16 +02:00
Elian Doran
28ed93dcdc refactor(web-clipper): use @-imports 2026-01-24 16:22:43 +02:00
Elian Doran
785ace64ad feat(web-clipper): use new Trilium icon for now 2026-01-24 16:20:36 +02:00
Elian Doran
ac109c2ece feat(web-clipper): support manifest V2 for Firefox 2026-01-24 16:07:15 +02:00
Elian Doran
1e82043999 fix(web-clipper): saving links not working under MV3 2026-01-24 15:42:04 +02:00
Elian Doran
e37487a1cf feat(web-clipper): handle manifest V3 2026-01-24 15:28:47 +02:00
Elian Doran
a9b8ffd94c chore(web-clipper): fix package lock 2026-01-24 14:26:05 +02:00
Elian Doran
4011771b64 chore(web-clipper): port the remaining files to TypeScript 2026-01-24 14:01:58 +02:00
Elian Doran
266494ba8c chore(web-clipper): port most files to TypeScript 2026-01-24 13:20:27 +02:00
Elian Doran
2e144fac5e chore(web-clipper): set up for TypeScript 2026-01-24 12:35:31 +02:00
Elian Doran
423038100e fix(web-clipper): undefined variable in popup 2026-01-24 12:12:51 +02:00
Elian Doran
75e88c69bd fix(web-clipper): createLink not defined in popup 2026-01-24 12:12:07 +02:00
Elian Doran
f0b1319f95 refactor(web-clipper): remove unnecessary libraries 2026-01-24 11:54:00 +02:00
Elian Doran
59f2fc8d03 fix(web-clipper): clipping whole page not working 2026-01-24 11:53:26 +02:00
Elian Doran
5d07a079ef feat(web-clipper): improve error handling for content entrypoint 2026-01-24 11:46:07 +02:00
Elian Doran
b5ff71b1a0 fix(web-clipper): missing utils import 2026-01-24 11:25:32 +02:00
Elian Doran
c0a2ae99cf fix(web-clipper): toast not working 2026-01-24 11:13:17 +02:00
Elian Doran
5600a707d3 chore(web-clipper): reintegrate name and description 2026-01-24 11:00:54 +02:00
Elian Doran
17f906fb65 chore(web-clipper): reintegrate icon 2026-01-24 10:58:20 +02:00
Elian Doran
276b3f834b fix(web-clipper): triliumServerFacade is not defined 2026-01-24 10:47:41 +02:00
Elian Doran
a9218960e9 fix(web-clipper): the storage API will not work with a temporary addon ID 2026-01-24 10:40:16 +02:00
Elian Doran
957590523c fix(web-clipper): integrate trilium server facade 2026-01-24 10:29:53 +02:00
Elian Doran
22308a101e fix(web-clipper): missing permissions 2026-01-24 10:18:59 +02:00
Elian Doran
ab95f6dcc2 fix(web-clipper): script imports 2026-01-24 10:08:29 +02:00
Elian Doran
cb8b968637 chore(web-clipper): make entrypoints actually run 2026-01-24 09:59:46 +02:00
Elian Doran
e4d319c7a1 chore(web-clipper): define entrypoints 2026-01-24 09:50:25 +02:00
Elian Doran
f8e5f31970 chore(web-clipper): install WXT 2026-01-24 09:45:05 +02:00
Elian Doran
5113e2ab97 chore(web-clipper): create package JSON 2026-01-24 09:42:02 +02:00
Elian Doran
6ae1cc18e2 chore(web-clipper): remove IDEA file 2026-01-24 09:37:03 +02:00
Elian Doran
256ad05d2d Merge branch 'main' of https://github.com/TriliumNext/Trilium 2026-01-24 09:36:18 +02:00
Elian Doran
1b0a53a441 feat(notes): add default icon for doc files 2026-01-24 09:29:11 +02:00
Elian Doran
430ef62a2d feat(notes): add default icon for GIF 2026-01-24 09:24:35 +02:00
Elian Doran
50aeda8ee8 chore(deps): update dependency cheerio to v1.2.0 (#8488) 2026-01-24 08:28:34 +02:00
renovate[bot]
1520c696a3 chore(deps): update dependency cheerio to v1.2.0 2026-01-24 01:39:45 +00:00
Elian Doran
f63f6244a1 fix: update nixpkgs to grab elector v40 (#8483) 2026-01-23 22:20:43 +02:00
Wael Nasreddine
8611d4a67a fix: update nixpkgs to grab elector v40 2026-01-23 08:31:30 -08:00
Elian Doran
c48bd9a5c3 fix(canvas): saving on start due to library change 2026-01-23 18:12:11 +02:00
Elian Doran
dba985b308 fix(canvas): saving on start due to mismatch in version number 2026-01-23 18:07:11 +02:00
Elian Doran
a51a831fe8 fix(canvas): background color not saved (closes #8325) 2026-01-23 17:56:04 +02:00
Elian Doran
44142e980d fix(quick_edit): not working when content is centered (closes #8371) 2026-01-23 17:17:41 +02:00
Elian Doran
7f83226f84 Calendar improvements (#8478) 2026-01-23 16:25:18 +02:00
Elian Doran
e3fdae8932 chore: address requested changes 2026-01-23 15:02:33 +02:00
Elian Doran
78c62be823 test(client): fix broken tests after change in attributes 2026-01-23 14:57:33 +02:00
Elian Doran
e51cea88bf feat(calendar): don't trigger refresh on delete 2026-01-23 12:47:02 +02:00
Elian Doran
d7409bec49 feat(calendar): don't trigger refresh on rename 2026-01-23 12:35:08 +02:00
Elian Doran
17b1f599ff feat(calendar): don't trigger refresh on event change 2026-01-23 12:24:35 +02:00
Elian Doran
81c85d712e chore(calendar): create note with attributes atomically 2026-01-23 12:11:06 +02:00
Elian Doran
2eae8bbb64 Revert "chore(calendar): remove automatic fetching on note creation"
This reverts commit 2a61f51e06.
2026-01-23 12:05:12 +02:00
Elian Doran
2a61f51e06 chore(calendar): remove automatic fetching on note creation 2026-01-23 11:58:57 +02:00
Elian Doran
3e3c3e3bb4 fix(calendar): redundant refresh when adding new item 2026-01-23 11:38:18 +02:00
Elian Doran
7b41a89b8e chore(deps): update dependency happy-dom to v20.3.7 (#8470) 2026-01-23 09:39:54 +02:00
renovate[bot]
36429da6da chore(deps): update dependency happy-dom to v20.3.7 2026-01-23 07:09:50 +00:00
Elian Doran
30f6ab5976 chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v54.3.2 (#8441) 2026-01-23 09:09:16 +02:00
Elian Doran
99a46f2a85 fix(deps): update dependency lodash-es to v4.17.23 [security] (#8454) 2026-01-23 09:08:39 +02:00
Elian Doran
6754b1f2e1 fix(deps): update dependency @preact/signals to v2.6.1 (#8457) 2026-01-23 09:08:25 +02:00
Elian Doran
122ad2b771 chore(deps): update dependency @redocly/cli to v2.14.7 (#8468) 2026-01-23 09:08:01 +02:00
Elian Doran
714e8ade1a chore(deps): update vitest monorepo to v4.0.18 (#8471) 2026-01-23 09:07:30 +02:00
Elian Doran
4a6ea38be0 chore(deps): update dependency express-session to v1.19.0 (#8472) 2026-01-23 09:06:31 +02:00
Elian Doran
8bc7f0b71f Translations update from Hosted Weblate (#8476) 2026-01-23 09:05:39 +02:00
Hosted Weblate
9a912c16ad Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2026-01-23 07:51:59 +01:00
Elian Doran
10a84a1356 chore(deps): update dependency @smithy/middleware-retry to v4.4.27 (#8469) 2026-01-23 08:51:48 +02:00
Wael Nasreddine
901201a7af chore: pnpm2nix to use our copy of flake-utils 2026-01-22 18:59:03 -08:00
renovate[bot]
a57a1dfc47 chore(deps): update dependency express-session to v1.19.0 2026-01-23 00:42:22 +00:00
renovate[bot]
577780cb90 chore(deps): update vitest monorepo to v4.0.18 2026-01-23 00:41:33 +00:00
renovate[bot]
b45eef9140 chore(deps): update dependency @smithy/middleware-retry to v4.4.27 2026-01-23 00:40:10 +00:00
renovate[bot]
907853bbba chore(deps): update dependency @redocly/cli to v2.14.7 2026-01-23 00:39:32 +00:00
Elian Doran
17f3ffd00c fix(mermaid): preview not rendering when read-only (closes #8419) 2026-01-22 20:56:58 +02:00
Elian Doran
8b86e17ac8 fix(client): race condition in syntax highlight (closes #8464) 2026-01-22 20:03:46 +02:00
Elian Doran
d6b6832a1d fix(promoted_attributes): checkbox not displaying initial value properly (closes #8062) 2026-01-22 08:15:40 +02:00
Elian Doran
9dfc1cdc4c fix(render): not refreshing on attribute change (closes #8321) 2026-01-22 08:10:29 +02:00
Elian Doran
673c39d798 Revert "feat(options/advanced): add description for experimental"
This reverts commit fc2ab91280.
2026-01-22 08:05:49 +02:00
renovate[bot]
8ca84d183c fix(deps): update dependency lodash-es to v4.17.23 [security] 2026-01-22 02:03:34 +00:00
renovate[bot]
9577aa2abe fix(deps): update dependency @preact/signals to v2.6.1 2026-01-22 01:45:37 +00:00
renovate[bot]
917e881faa chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v54.3.2 2026-01-21 10:43:23 +00:00
87 changed files with 3677 additions and 5175 deletions

47
.github/workflows/web-clipper.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Deploy web clipper extension
on:
push:
branches:
- main
paths:
- "apps/web-clipper/**"
pull_request:
paths:
- "apps/web-clipper/**"
jobs:
build:
runs-on: ubuntu-latest
name: Build web clipper extension
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --filter web-clipper --frozen-lockfile --ignore-scripts
- name: Build the web clipper extension
run: |
pnpm --filter web-clipper zip
pnpm --filter web-clipper zip:firefox
- name: Upload build artifacts
uses: actions/upload-artifact@v6
with:
name: web-clipper-extension
path: apps/web-clipper/.output/*.zip
include-hidden-files: true
if-no-files-found: error
compression-level: 0

View File

@@ -11,7 +11,7 @@
"license": "AGPL-3.0-only",
"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",

View File

@@ -27,7 +27,7 @@
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.6.0",
"@preact/signals": "2.6.1",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
@@ -78,7 +78,7 @@
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.3.4",
"happy-dom": "20.3.7",
"lightningcss": "1.31.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.5"

View File

@@ -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);
});
});

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
/**

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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),
})
});
}

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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
}
};

View File

@@ -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}

View File

@@ -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();
};

View File

@@ -191,7 +191,6 @@ function ExperimentalOptions() {
values={filteredExperimentalFeatures}
keyProperty="id"
titleProperty="name"
descriptionProperty="description"
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
/>
</OptionsSection>

View File

@@ -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>
);
}
)
}

View File

@@ -337,130 +337,6 @@ paths:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/revisions:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns all revisions for a note identified by its ID
operationId: getNoteRevisions
responses:
"200":
description: list of revisions
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/undelete:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
post:
description: Restore a deleted note. The note must be deleted and must have at least one undeleted parent.
operationId: undeleteNote
responses:
"200":
description: note restored successfully
content:
application/json; charset=utf-8:
schema:
type: object
properties:
success:
type: boolean
example: true
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/history:
get:
description: Returns recent changes including note creations, modifications, and deletions
operationId: getNoteHistory
parameters:
- name: ancestorNoteId
in: query
required: false
description: Limit changes to a subtree identified by this note ID. Defaults to "root" (all notes).
schema:
$ref: "#/components/schemas/EntityId"
responses:
"200":
description: list of recent changes
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/RecentChange"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns a revision identified by its ID
operationId: getRevisionById
responses:
"200":
description: revision response
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}/content:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns revision content identified by its ID
operationId: getRevisionContent
responses:
"200":
description: revision content response
content:
text/html:
schema:
type: string
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/branches:
post:
description: >
@@ -1310,93 +1186,3 @@ components:
type: string
description: Human readable error, potentially with more details,
example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI
Revision:
type: object
description: Revision represents a snapshot of note's title and content at some point in the past.
properties:
revisionId:
$ref: "#/components/schemas/EntityId"
readOnly: true
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
type:
type: string
enum:
[
text,
code,
render,
file,
image,
search,
relationMap,
book,
noteMap,
mermaid,
webView,
shortcut,
doc,
contentWidget,
launcher,
]
mime:
type: string
isProtected:
type: boolean
readOnly: true
title:
type: string
blobId:
type: string
description: ID of the blob object which effectively serves as a content hash
dateLastEdited:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
dateCreated:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
utcDateLastEdited:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateCreated:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateModified:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
contentLength:
type: integer
format: int32
readOnly: true
RecentChange:
type: object
description: Represents a recent change event (creation, modification, or deletion).
properties:
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
title:
type: string
description: Title at the time of the change (may be "[protected]" for protected notes)
current_title:
type: string
description: Current title of the note (may be "[protected]" for protected notes)
current_isDeleted:
type: boolean
description: Whether the note is currently deleted
current_deleteId:
type: string
description: Delete ID if the note is deleted
current_isProtected:
type: boolean
description: Whether the note is protected
utcDate:
$ref: "#/components/schemas/UtcDateTime"
description: UTC timestamp of the change
date:
$ref: "#/components/schemas/LocalDateTime"
description: Local timestamp of the change
canBeUndeleted:
type: boolean
description: Whether the note can be undeleted (only present for deleted notes)

View File

@@ -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",

View File

@@ -1,77 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/get-note-revisions", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create a revision by updating the note content
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content for revision")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets revisions for a note", async () => {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const revision = response.body[0];
expect(revision).toHaveProperty("revisionId");
expect(revision).toHaveProperty("noteId", createdNoteId);
expect(revision).toHaveProperty("type");
expect(revision).toHaveProperty("mime");
expect(revision).toHaveProperty("title");
expect(revision).toHaveProperty("isProtected");
expect(revision).toHaveProperty("blobId");
expect(revision).toHaveProperty("utcDateCreated");
});
it("returns empty array for note with no revisions", async () => {
// Create a new note without any revisions
const newNoteId = await createNote(app, token, "Brand new content");
const response = await supertest(app)
.get(`/etapi/notes/${newNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// New notes may or may not have revisions depending on settings
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.get("/etapi/notes/nonexistentnote/revisions")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
});

View File

@@ -1,71 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/get-revision", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial content");
// Update content to create a revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision metadata by ID", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("revisionId", revisionId);
expect(response.body).toHaveProperty("noteId", createdNoteId);
expect(response.body).toHaveProperty("type", "text");
expect(response.body).toHaveProperty("mime", "text/html");
expect(response.body).toHaveProperty("title", "Hello");
expect(response.body).toHaveProperty("isProtected", false);
expect(response.body).toHaveProperty("blobId");
expect(response.body).toHaveProperty("utcDateCreated");
expect(response.body).toHaveProperty("utcDateModified");
});
it("returns 404 for non-existent revision", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@@ -1,94 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/note-history", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
// Create a note to ensure there's some history
createdNoteId = await createNote(app, token, "History test content");
// Create a revision to ensure history has entries
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets recent changes history", async () => {
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
// Check that history entries have expected properties
const entry = response.body[0];
expect(entry).toHaveProperty("noteId");
expect(entry).toHaveProperty("title");
expect(entry).toHaveProperty("utcDate");
expect(entry).toHaveProperty("date");
expect(entry).toHaveProperty("current_isDeleted");
expect(entry).toHaveProperty("current_isProtected");
});
it("filters history by ancestor note", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=root")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// All results should be descendants of root (which is everything)
});
it("returns empty array for non-existent ancestor", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=nonexistentancestor")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// Should be empty since no notes are descendants of a non-existent note
expect(response.body.length).toBe(0);
});
it("includes canBeUndeleted for deleted notes", async () => {
// Create and delete a note
const noteToDeleteId = await createNote(app, token, "Note to delete for history test");
await supertest(app)
.delete(`/etapi/notes/${noteToDeleteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Check history - deleted note should appear with canBeUndeleted property
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
const deletedEntry = response.body.find(
(entry: any) => entry.noteId === noteToDeleteId && entry.current_isDeleted === true
);
// Deleted entries should have canBeUndeleted property
if (deletedEntry) {
expect(deletedEntry).toHaveProperty("canBeUndeleted");
}
});
});

View File

@@ -1,64 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/revision-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial revision content");
// Update content to ensure we have content in the revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Content after first update")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision content", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}/content`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toBeTruthy();
});
it("returns 404 for non-existent revision content", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision/content")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@@ -1,103 +0,0 @@
import { Application } from "express";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomInt } from "crypto";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/undelete-note", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("undeletes a deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note to delete and restore",
"type": "text",
"content": "Content to restore"
})
.expect(201);
// Verify note exists
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
// Delete the note
await supertest(app)
.delete(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Verify note is deleted (should return 404)
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(404);
// Undelete the note
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("success", true);
// Verify note is restored
const restoredResponse = await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(restoredResponse.body.title).toStrictEqual("Note to delete and restore");
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.post("/etapi/notes/nonexistentnote/undelete")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
it("returns 400 when trying to undelete a non-deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note not deleted",
"type": "text",
"content": "Content"
})
.expect(201);
// Try to undelete a note that isn't deleted
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(400);
expect(response.body.code).toStrictEqual("NOTE_NOT_DELETED");
});
});

View File

@@ -4,17 +4,34 @@
<p>Trilium Web Clipper is a web browser extension which allows user to clip
text, screenshots, whole pages and short notes and save them directly to
Trilium Notes.</p>
<p>Project is hosted <a href="https://github.com/TriliumNext/web-clipper">here</a>.</p>
<p>Firefox and Chrome are supported browsers, but the chrome build should
work on other chromium based browsers as well.</p>
<h2>Supported browsers</h2>
<p>Trilium Web Clipper officially supports the following web browsers:</p>
<ul>
<li>
<p>Mozilla Firefox, using Manifest v2.</p>
</li>
<li>
<p>Google Chrome, using Manifest v3. Theoretically the extension should work
on other Chromium-based browsers as well, but they are not officially supported.</p>
</li>
</ul>
<h2>Obtaining the extension</h2>
<aside class="admonition warning">
<p>The extension is currently under development. A preview with unsigned
extensions is available on <a href="https://github.com/TriliumNext/Trilium/actions/runs/21318809414">GitHub Actions</a>.</p>
<p>We have already submitted the extension to both Chrome and Firefox web
stores, but they are pending validation.</p>
</aside>
<h2>Functionality</h2>
<ul>
<li>select text and clip it with the right-click context menu</li>
<li>click on an image or link and save it through context menu</li>
<li>save whole page from the popup or context menu</li>
<li>save screenshot (with crop tool) from either popup or context menu</li>
<li>create short text note from popup</li>
<li
>create short text note from popup</li>
</ul>
<h2>Location of clippings</h2>
<p>Trilium will save these clippings as a new child note under a "clipper
inbox" note.</p>
<p>By default, that's the <a href="#root/_help_l0tKav7yLHGF">day note</a> but you
@@ -23,21 +40,33 @@
spellcheck="false">clipperInbox</code>, on any other note.</p>
<p>If there's multiple clippings from the same page (and on the same day),
then they will be added to the same note.</p>
<p><strong>Extension is available from:</strong>
</p>
<h2>Keyboard shortcuts</h2>
<p>Keyboard shortcuts are available for most functions:</p>
<ul>
<li><a href="https://github.com/TriliumNext/web-clipper/releases">Project release page</a> -
.xpi for Firefox and .zip for Chromium based browsers.</li>
<li><a href="https://chromewebstore.google.com/detail/trilium-web-clipper/dfhgmnfclbebfobmblelddiejjcijbjm">Chrome Web Store</a>
<li>Save selected text: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>S</kbd>)</li>
<li
>Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>S</kbd>)</li>
<li
>Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd></kbd>+<kbd></kbd>+<kbd>E</kbd>)</li>
</ul>
<p>To set custom shortcuts, follow the directions for your browser.</p>
<ul>
<li><strong>Firefox</strong>: <code spellcheck="false">about:addons</code>
Gear icon ⚙️ → Manage extension shortcuts</li>
<li><strong>Chrome</strong>: <code spellcheck="false">chrome://extensions/shortcuts</code>
</li>
</ul>
<aside class="admonition note">
<p>On Firefox, the default shortcuts interfere with some browser features.
As such, the keyboard combinations will not trigger the Web Clipper action.
To fix this, simply change the keyboard shortcut to something that works.
The defaults will be adjusted in future versions.</p>
</aside>
<h2>Configuration</h2>
<p>The extension needs to connect to a running Trilium instance. By default,
it scans a port range on the local computer to find a desktop Trilium instance.</p>
<p>It's also possible to configure the <a href="#root/_help_WOcw2SLH6tbX">server</a> address
if you don't run the desktop application, or want it to work without the
desktop application running.</p>
<h2>Username</h2>
<p>Older versions of Trilium (before 0.50) required username &amp; password
to authenticate, but this is no longer the case. You may enter anything
in that field, it will not have any effect.</p>
<h2>Credits</h2>
<p>Some parts of the code are based on the <a href="https://github.com/laurent22/joplin/tree/master/Clipper">Joplin Notes browser extension</a>.</p>

View File

@@ -121,16 +121,6 @@ function getAndCheckAttribute(attributeId: string) {
}
}
function getAndCheckRevision(revisionId: string) {
const revision = becca.getRevision(revisionId);
if (revision) {
return revision;
} else {
throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`);
}
}
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
for (const key of Object.keys(source)) {
if (!(key in allowedProperties)) {
@@ -162,6 +152,5 @@ export default {
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute,
getAndCheckAttachment,
getAndCheckRevision
getAndCheckAttachment
};

View File

@@ -2,7 +2,6 @@ import type BAttachment from "../becca/entities/battachment.js";
import type BAttribute from "../becca/entities/battribute.js";
import type BBranch from "../becca/entities/bbranch.js";
import type BNote from "../becca/entities/bnote.js";
import type BRevision from "../becca/entities/brevision.js";
function mapNoteToPojo(note: BNote) {
return {
@@ -65,28 +64,9 @@ function mapAttachmentToPojo(attachment: BAttachment) {
};
}
function mapRevisionToPojo(revision: BRevision) {
return {
revisionId: revision.revisionId,
noteId: revision.noteId,
type: revision.type,
mime: revision.mime,
isProtected: revision.isProtected,
title: revision.title,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,
utcDateLastEdited: revision.utcDateLastEdited,
utcDateCreated: revision.utcDateCreated,
utcDateModified: revision.utcDateModified,
contentLength: revision.contentLength
};
}
export default {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo,
mapAttachmentToPojo,
mapRevisionToPojo
mapAttachmentToPojo
};

View File

@@ -1,205 +0,0 @@
import becca from "../becca/becca.js";
import sql from "../services/sql.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import noteService from "../services/notes.js";
import TaskContext from "../services/task_context.js";
import protectedSessionService from "../services/protected_session.js";
import utils from "../services/utils.js";
import type { Router } from "express";
import type { NoteRow, RecentChangeRow } from "@triliumnext/commons";
function register(router: Router) {
// GET /etapi/notes/history - must be registered before /etapi/notes/:noteId routes
eu.route(router, "get", "/etapi/notes/history", (req, res, next) => {
const ancestorNoteId = (req.query.ancestorNoteId as string) || "root";
let recentChanges: RecentChangeRow[];
if (ancestorNoteId === "root") {
// Optimized path: no ancestor filtering needed, fetch directly from DB
recentChanges = sql.getRows<RecentChangeRow>(`
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1
ORDER BY utcDate DESC
LIMIT 500`);
} else {
// Use recursive CTE to find all descendants, then filter at DB level
// This pushes filtering to the database for much better performance
recentChanges = sql.getRows<RecentChangeRow>(`
WITH RECURSIVE descendants(noteId) AS (
SELECT ?
UNION
SELECT branches.noteId
FROM branches
JOIN descendants ON branches.parentNoteId = descendants.noteId
)
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1 AND notes.noteId IN (SELECT noteId FROM descendants)
ORDER BY utcDate DESC
LIMIT 500`, [ancestorNoteId]);
}
for (const change of recentChanges) {
if (change.current_isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
change.title = protectedSessionService.decryptString(change.title) || "[protected]";
change.current_title = protectedSessionService.decryptString(change.current_title) || "[protected]";
} else {
change.title = change.current_title = "[protected]";
}
}
if (change.current_isDeleted) {
const deleteId = change.current_deleteId;
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(change.noteId, deleteId);
// note (and the subtree) can be undeleted if there's at least one undeleted parent (whose branch would be undeleted by this op)
change.canBeUndeleted = undeletedParentBranchIds.length > 0;
}
}
res.json(recentChanges);
});
// GET /etapi/notes/:noteId/revisions - List all revisions for a note
eu.route(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const revisions = becca.getRevisionsFromQuery(
`SELECT revisions.*, LENGTH(blobs.content) AS contentLength
FROM revisions
JOIN blobs USING (blobId)
WHERE noteId = ?
ORDER BY utcDateCreated DESC`,
[note.noteId]
);
res.json(revisions.map((revision) => mappers.mapRevisionToPojo(revision)));
});
// POST /etapi/notes/:noteId/undelete - Restore a deleted note
eu.route(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => {
const { noteId } = req.params;
const noteRow = sql.getRow<NoteRow | null>("SELECT * FROM notes WHERE noteId = ?", [noteId]);
if (!noteRow) {
throw new eu.EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
}
if (!noteRow.isDeleted || !noteRow.deleteId) {
throw new eu.EtapiError(400, "NOTE_NOT_DELETED", `Note '${noteId}' is not deleted.`);
}
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(noteId, noteRow.deleteId);
if (undeletedParentBranchIds.length === 0) {
throw new eu.EtapiError(400, "CANNOT_UNDELETE", `Cannot undelete note '${noteId}' - no undeleted parent found.`);
}
const taskContext = new TaskContext("no-progress-reporting", "undeleteNotes", null);
noteService.undeleteNote(noteId, taskContext);
res.json({ success: true });
});
// GET /etapi/revisions/:revisionId - Get revision metadata
eu.route(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and cannot be read through ETAPI.`);
}
res.json(mappers.mapRevisionToPojo(revision));
});
// GET /etapi/revisions/:revisionId/content - Get revision content
eu.route(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and content cannot be read through ETAPI.`);
}
const filename = utils.formatDownloadTitle(revision.title, revision.type, revision.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", revision.mime);
res.send(revision.getContent());
});
}
export default {
register
};

View File

@@ -12,7 +12,6 @@ import etapiMetricsRoute from "../etapi/metrics.js";
import etapiNoteRoutes from "../etapi/notes.js";
import etapiSpecRoute from "../etapi/spec.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiRevisionsRoutes from "../etapi/revisions.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
@@ -362,8 +361,6 @@ function register(app: express.Application) {
etapiAttachmentRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBranchRoutes.register(router);
// Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId
etapiRevisionsRoutes.register(router);
etapiNoteRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
etapiSpecRoute.register(router);

View File

@@ -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">[];
}

View File

@@ -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) {

View File

@@ -1 +1,2 @@
dist/
.output
.wxt

View File

@@ -1,24 +0,0 @@
# Trilium Web Clipper
## This repo is dead
**Trilium is in maintenance mode and Web Clipper is not likely to get new releases.**
Trilium Web Clipper is a web browser extension which allows user to clip text, screenshots, whole pages and short notes and save them directly to [Trilium Notes](https://github.com/zadam/trilium).
For more details, see the [wiki page](https://github.com/zadam/trilium/wiki/Web-clipper).
## Keyboard shortcuts
Keyboard shortcuts are available for most functions:
* Save selected text: `Ctrl+Shift+S` (Mac: `Cmd+Shift+S`)
* Save whole page: `Alt+Shift+S` (Mac: `Opt+Shift+S`)
* Save screenshot: `Ctrl+Shift+E` (Mac: `Cmd+Shift+E`)
To set custom shortcuts, follow the directions for your browser.
**Firefox**: `about:addons` > Gear icon ⚙️ > Manage extension shortcuts
**Chrome**: `chrome://extensions/shortcuts`
## Credits
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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);
}
});

View File

@@ -1 +0,0 @@
module.exports = { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };

View File

@@ -0,0 +1 @@
export default { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };

View File

@@ -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});
}
}

View File

@@ -0,0 +1,483 @@
import { randomString, Rect } from "@/utils";
import TriliumServerFacade from "./trilium_server_facade";
type BackgroundMessage = {
name: "toast";
message: string;
noteId: string | null;
tabIds: number[] | null;
} | {
name: "trilium-save-selection";
} | {
name: "trilium-get-rectangle-for-screenshot";
} | {
name: "trilium-save-page";
};
export default defineBackground(() => {
const triliumServerFacade = new TriliumServerFacade();
// Keyboard shortcuts
browser.commands.onCommand.addListener(async (command) => {
switch (command) {
case "saveSelection":
await saveSelection();
break;
case "saveWholePage":
await saveWholePage();
break;
case "saveTabs":
await saveTabs();
break;
case "saveCroppedScreenshot": {
const activeTab = await getActiveTab();
await saveCroppedScreenshot(activeTab.url);
break;
}
default:
console.log("Unrecognized command", command);
}
});
function cropImageManifestV2(newArea: Rect, dataUrl: string) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = newArea.width;
canvas.height = newArea.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject();
return;
}
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
resolve(canvas.toDataURL());
};
img.onerror = reject;
img.src = dataUrl;
});
}
async function cropImageManifestV3(newArea: Rect, dataUrl: string) {
// Create offscreen document if it doesn't exist
await ensureOffscreenDocument();
// Send cropping task to offscreen document
return await browser.runtime.sendMessage({
type: 'CROP_IMAGE',
dataUrl,
cropRect: newArea
});
}
async function takeCroppedScreenshot(cropRect: Rect, devicePixelRatio: number = 1) {
const activeTab = await getActiveTab();
const zoom = await browser.tabs.getZoom(activeTab.id) * devicePixelRatio;
const newArea: Rect = {
x: cropRect.x * zoom,
y: cropRect.y * zoom,
width: cropRect.width * zoom,
height: cropRect.height * zoom
};
const dataUrl = await browser.tabs.captureVisibleTab({ format: 'png' });
const cropImage = (import.meta.env.MANIFEST_VERSION === 3 ? cropImageManifestV3 : cropImageManifestV2);
return await cropImage(newArea, dataUrl);
}
async function ensureOffscreenDocument() {
const existingContexts = await browser.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT']
});
if (existingContexts.length > 0) {
return; // Already exists
}
await browser.offscreen.createDocument({
url: browser.runtime.getURL('/offscreen.html'),
reasons: ['DOM_SCRAPING'], // or 'DISPLAY_MEDIA' depending on browser support
justification: 'Image cropping requires canvas API'
});
}
async function takeWholeScreenshot() {
// this saves only visible portion of the page
// workaround to save the whole page is to scroll & stitch
// example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
// see page.js and popup.js
return await browser.tabs.captureVisibleTab({ format: 'png' });
}
browser.contextMenus.create({
id: "trilium-save-selection",
title: "Save selection to Trilium",
contexts: ["selection"]
});
browser.contextMenus.create({
id: "trilium-save-cropped-screenshot",
title: "Crop screen shot to Trilium",
contexts: ["page"]
});
browser.contextMenus.create({
id: "trilium-save-whole-screenshot",
title: "Save whole screen shot to Trilium",
contexts: ["page"]
});
browser.contextMenus.create({
id: "trilium-save-page",
title: "Save whole page to Trilium",
contexts: ["page"]
});
browser.contextMenus.create({
id: "trilium-save-link",
title: "Save link to Trilium",
contexts: ["link"]
});
browser.contextMenus.create({
id: "trilium-save-image",
title: "Save image to Trilium",
contexts: ["image"]
});
async function getActiveTab() {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true
});
return tabs[0];
}
async function getWindowTabs() {
const tabs = await browser.tabs.query({
currentWindow: true
});
return tabs;
}
async function sendMessageToActiveTab(message: BackgroundMessage) {
const activeTab = await getActiveTab();
if (!activeTab?.id) {
throw new Error("No active tab.");
}
return await browser.tabs.sendMessage(activeTab.id, message);
}
function toast(message: string, noteId: string | null = null, tabIds: number[] | null = null) {
sendMessageToActiveTab({
name: 'toast',
message,
noteId,
tabIds
});
}
function blob2base64(blob: Blob) {
return new Promise<string | null>(resolve => {
const reader = new FileReader();
reader.onloadend = function() {
resolve(reader.result as string | null);
};
reader.readAsDataURL(blob);
});
}
async function fetchImage(url: string) {
const resp = await fetch(url);
const blob = await resp.blob();
return await blob2base64(blob);
}
async function postProcessImage(image: { src: string, dataUrl?: string | null }) {
if (image.src.startsWith("data:image/")) {
image.dataUrl = image.src;
const mimeSubtype = image.src.match(/data:image\/(.*?);/)?.[1];
if (!mimeSubtype) return;
image.src = `inline.${mimeSubtype}`; // this should extract file type - png/jpg
}
else {
try {
image.dataUrl = await fetchImage(image.src);
} catch (e) {
console.error(`Cannot fetch image from ${image.src}`, e);
}
}
}
async function postProcessImages(resp: { images?: { src: string, dataUrl?: string }[] }) {
if (resp.images) {
for (const image of resp.images) {
await postProcessImage(image);
}
}
}
async function saveSelection() {
const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
await postProcessImages(payload);
const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
if (!resp) {
return;
}
toast("Selection has been saved to Trilium.", resp.noteId);
}
async function getImagePayloadFromSrc(src: string, pageUrl: string | null | undefined) {
const image = {
imageId: randomString(20),
src
};
await postProcessImage(image);
const activeTab = await getActiveTab();
return {
title: activeTab.title,
content: `<img src="${image.imageId}">`,
images: [image],
pageUrl
};
}
async function saveCroppedScreenshot(pageUrl: string | null | undefined) {
const { rect, devicePixelRatio } = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
const src = await takeCroppedScreenshot(rect, devicePixelRatio);
const payload = await getImagePayloadFromSrc(src, pageUrl);
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
if (!resp) {
return;
}
toast("Screenshot has been saved to Trilium.", resp.noteId);
}
async function saveWholeScreenshot(pageUrl: string | null | undefined) {
const src = await takeWholeScreenshot();
const payload = await getImagePayloadFromSrc(src, pageUrl);
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
if (!resp) {
return;
}
toast("Screenshot has been saved to Trilium.", resp.noteId);
}
async function saveImage(srcUrl: string, pageUrl: string | null | undefined) {
const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
if (!resp) {
return;
}
toast("Image has been saved to Trilium.", resp.noteId);
}
async function saveWholePage() {
const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
await postProcessImages(payload);
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
if (!resp) {
return;
}
toast("Page has been saved to Trilium.", resp.noteId);
}
async function saveLinkWithNote(title: string, content: string) {
const activeTab = await getActiveTab();
if (!title.trim()) {
title = activeTab.title ?? "";
}
const resp = await triliumServerFacade.callService('POST', 'notes', {
title,
content,
clipType: 'note',
pageUrl: activeTab.url
});
if (!resp) {
return false;
}
toast("Link with note has been saved to Trilium.", resp.noteId);
return true;
}
async function getTabsPayload(tabs: Browser.tabs.Tab[]) {
let content = '<ul>';
tabs.forEach(tab => {
content += `<li><a href="${tab.url}">${tab.title}</a></li>`;
});
content += '</ul>';
const domainsCount = tabs.map(tab => tab.url)
.reduce((acc, url) => {
const hostname = new URL(url ?? "").hostname;
return acc.set(hostname, (acc.get(hostname) || 0) + 1);
}, new Map());
let topDomains = [...domainsCount]
.sort((a, b) => {return b[1]-a[1];})
.slice(0,3)
.map(domain=>domain[0])
.join(', ');
if (tabs.length > 3) { topDomains += '...'; }
return {
title: `${tabs.length} browser tabs: ${topDomains}`,
content,
clipType: 'tabs'
};
}
async function saveTabs() {
const tabs = await getWindowTabs();
const payload = await getTabsPayload(tabs);
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
if (!resp) return;
const tabIds = tabs.map(tab => tab.id).filter(id => id !== undefined) as number[];
toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
}
browser.contextMenus.onClicked.addListener(async (info: globalThis.Browser.contextMenus.OnClickData & { linkText?: string; }) => {
if (info.menuItemId === 'trilium-save-selection') {
await saveSelection();
}
else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
await saveCroppedScreenshot(info.pageUrl);
}
else if (info.menuItemId === 'trilium-save-whole-screenshot') {
await saveWholeScreenshot(info.pageUrl);
}
else if (info.menuItemId === 'trilium-save-image') {
if (!info.srcUrl) return;
await saveImage(info.srcUrl, info.pageUrl);
}
else if (info.menuItemId === 'trilium-save-link') {
if (!info.linkUrl) return;
// Link text is only available on Firefox.
const linkText = info.linkText || info.linkUrl;
const content = `<a href="${info.linkUrl}">${linkText}</a>`;
const activeTab = await getActiveTab();
const resp = await triliumServerFacade.callService('POST', 'clippings', {
title: activeTab.title,
content,
pageUrl: info.pageUrl
});
if (!resp) return;
toast("Link has been saved to Trilium.", resp.noteId);
}
else if (info.menuItemId === 'trilium-save-page') {
await saveWholePage();
}
else {
console.log("Unrecognized menuItemId", info.menuItemId);
}
});
browser.runtime.onMessage.addListener(async request => {
console.log("Received", request);
if (request.name === 'openNoteInTrilium') {
const resp = await triliumServerFacade.callService('POST', `open/${request.noteId}`);
if (!resp) {
return;
}
// desktop app is not available so we need to open in browser
if (resp.result === 'open-in-browser') {
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
if (triliumServerUrl) {
const noteUrl = `${triliumServerUrl }/#${ request.noteId}`;
console.log("Opening new tab in browser", noteUrl);
browser.tabs.create({
url: noteUrl
});
}
else {
console.error("triliumServerUrl not found in local storage.");
}
}
}
else if (request.name === 'closeTabs') {
return await browser.tabs.remove(request.tabIds);
}
else if (request.name === 'save-cropped-screenshot') {
const activeTab = await getActiveTab();
return await saveCroppedScreenshot(activeTab.url);
}
else if (request.name === 'save-whole-screenshot') {
const activeTab = await getActiveTab();
return await saveWholeScreenshot(activeTab.url);
}
else if (request.name === 'save-whole-page') {
return await saveWholePage();
}
else if (request.name === 'save-link-with-note') {
return await saveLinkWithNote(request.title, request.content);
}
else if (request.name === 'save-tabs') {
return await saveTabs();
}
else if (request.name === 'trigger-trilium-search') {
triliumServerFacade.triggerSearchForTrilium();
}
else if (request.name === 'send-trilium-search-status') {
triliumServerFacade.sendTriliumSearchStatusToPopup();
}
else if (request.name === 'trigger-trilium-search-note-url') {
const activeTab = await getActiveTab();
if (activeTab.url) {
triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
}
}
});
});

View File

@@ -0,0 +1,245 @@
const PROTOCOL_VERSION_MAJOR = 1;
type TriliumSearchStatus = {
status: "searching";
} | {
status: "not-found"
} | {
status: "found-desktop",
port: number;
url: string;
} | {
status: "found-server",
url: string;
token: string;
} | {
status: "version-mismatch";
extensionMajor: number;
triliumMajor: number;
};
type TriliumSearchNoteStatus = {
status: "not-found",
noteId: null
} | {
status: "found",
noteId: string
};
export default class TriliumServerFacade {
private triliumSearch?: TriliumSearchStatus;
private triliumSearchNote?: TriliumSearchNoteStatus;
constructor() {
this.triggerSearchForTrilium();
// continually scan for changes (if e.g. desktop app is started after browser)
setInterval(() => this.triggerSearchForTrilium(), 60 * 1000);
}
async sendTriliumSearchStatusToPopup() {
try {
await browser.runtime.sendMessage({
name: "trilium-search-status",
triliumSearch: this.triliumSearch
});
}
catch (e) {} // nothing might be listening
}
async sendTriliumSearchNoteToPopup(){
try{
await browser.runtime.sendMessage({
name: "trilium-previously-visited",
searchNote: this.triliumSearchNote
});
}
catch (e) {} // nothing might be listening
}
setTriliumSearchNote(st: TriliumSearchNoteStatus){
this.triliumSearchNote = st;
this.sendTriliumSearchNoteToPopup();
}
setTriliumSearch(ts: TriliumSearchStatus) {
this.triliumSearch = ts;
this.sendTriliumSearchStatusToPopup();
}
setTriliumSearchWithVersionCheck(json: { protocolVersion: string }, resp: TriliumSearchStatus) {
const [ major ] = json.protocolVersion
.split(".")
.map(chunk => parseInt(chunk, 10));
// minor version is intended to be used to dynamically limit features provided by extension
// if some specific Trilium API is not supported. So far not needed.
if (major !== PROTOCOL_VERSION_MAJOR) {
this.setTriliumSearch({
status: 'version-mismatch',
extensionMajor: PROTOCOL_VERSION_MAJOR,
triliumMajor: major
});
}
else {
this.setTriliumSearch(resp);
}
}
async triggerSearchForTrilium() {
this.setTriliumSearch({ status: 'searching' });
try {
const port = await this.getPort();
console.debug(`Trying port ${port}`);
const resp = await fetch(`http://127.0.0.1:${port}/api/clipper/handshake`);
const text = await resp.text();
console.log("Received response:", text);
const json = JSON.parse(text);
if (json.appName === 'trilium') {
this.setTriliumSearchWithVersionCheck(json, {
status: 'found-desktop',
port,
url: `http://127.0.0.1:${port}`
});
return;
}
}
catch (error) {
// continue
}
const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
if (triliumServerUrl && authToken) {
try {
const resp = await fetch(`${triliumServerUrl }/api/clipper/handshake`, {
headers: {
Authorization: authToken
}
});
const text = await resp.text();
console.log("Received response:", text);
const json = JSON.parse(text);
if (json.appName === 'trilium') {
this.setTriliumSearchWithVersionCheck(json, {
status: 'found-server',
url: triliumServerUrl,
token: authToken
});
return;
}
}
catch (e) {
console.log("Request to the configured server instance failed with:", e);
}
}
// if all above fails it's not found
this.setTriliumSearch({ status: 'not-found' });
}
async triggerSearchNoteByUrl(noteUrl: string) {
const resp = await this.callService('GET', `notes-by-url/${encodeURIComponent(noteUrl)}`);
let newStatus: TriliumSearchNoteStatus;
if (resp && resp.noteId) {
newStatus = {
status: 'found',
noteId: resp.noteId,
};
} else {
newStatus = {
status: 'not-found',
noteId: null
};
}
this.setTriliumSearchNote(newStatus);
}
async waitForTriliumSearch() {
return new Promise<void>((res, rej) => {
const checkStatus = () => {
if (this.triliumSearch?.status === "searching") {
setTimeout(checkStatus, 500);
} else if (this.triliumSearch?.status === 'not-found') {
rej(new Error("Trilium instance has not been found."));
} else {
res();
}
};
checkStatus();
});
}
async getPort() {
const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
if (triliumDesktopPort) {
return parseInt(triliumDesktopPort, 10);
}
return import.meta.env.DEV ? 37742 : 37840;
}
async callService(method: string, path: string, body?: string | object) {
await this.waitForTriliumSearch();
if (!this.triliumSearch || (this.triliumSearch.status !== 'found-desktop' && this.triliumSearch.status !== 'found-server')) return;
try {
const fetchOptions: RequestInit = {
method,
headers: {
Authorization: "token" in this.triliumSearch ? this.triliumSearch.token ?? "" : "",
'Content-Type': 'application/json',
'trilium-local-now-datetime': this.localNowDateTime()
},
};
if (body) {
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const url = `${this.triliumSearch.url}/api/clipper/${path}`;
console.log(`Sending ${method} request to ${url}`);
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(await response.text());
}
return await response.json();
}
catch (e) {
console.log("Sending request to trilium failed", e);
return null;
}
}
localNowDateTime() {
const date = new Date();
const off = date.getTimezoneOffset();
const absoff = Math.abs(off);
return (`${new Date(date.getTime() - off * 60 * 1000).toISOString().substr(0,23).replace("T", " ") +
(off > 0 ? '-' : '+') +
(absoff / 60).toFixed(0).padStart(2,'0') }:${
(absoff % 60).toString().padStart(2,'0')}`);
}
}

View File

@@ -0,0 +1,349 @@
import Readability from "@/lib/Readability.js";
import { createLink, getBaseUrl, getPageLocationOrigin, randomString, Rect } from "@/utils.js";
export default defineContentScript({
matches: [
"<all_urls>"
],
main: () => {
function absoluteUrl(url: string | undefined) {
if (!url) {
return url;
}
const protocol = url.toLowerCase().split(':')[0];
if (['http', 'https', 'file'].indexOf(protocol) >= 0) {
return url;
}
if (url.indexOf('//') === 0) {
return location.protocol + url;
} else if (url[0] === '/') {
return `${location.protocol}//${location.host}${url}`;
}
return `${getBaseUrl()}/${url}`;
}
function pageTitle() {
const titleElements = document.getElementsByTagName("title");
return titleElements.length ? titleElements[0].text.trim() : document.title.trim();
}
function getReadableDocument() {
// Readability directly change the passed document, so clone to preserve the original web page.
const documentCopy = document.cloneNode(true);
const readability = new Readability(documentCopy, {
serializer: el => el // so that .content is returned as DOM element instead of HTML
});
const article = readability.parse();
if (!article) {
throw new Error('Could not parse HTML document with Readability');
}
return {
title: article.title,
body: article.content,
};
}
function getDocumentDates() {
let publishedDate: Date | null = null;
let modifiedDate: Date | null = null;
const articlePublishedTime = document.querySelector("meta[property='article:published_time']")?.getAttribute('content');
if (articlePublishedTime) {
publishedDate = new Date(articlePublishedTime);
}
const articleModifiedTime = document.querySelector("meta[property='article:modified_time']")?.getAttribute('content');
if (articleModifiedTime) {
modifiedDate = new Date(articleModifiedTime);
}
// TODO: if we didn't get dates from meta, then try to get them from JSON-LD
return { publishedDate, modifiedDate };
}
function getRectangleArea() {
return new Promise<Rect>((resolve) => {
const overlay = document.createElement('div');
overlay.style.opacity = '0.6';
overlay.style.background = 'black';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.zIndex = "99999999";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.position = 'fixed';
document.body.appendChild(overlay);
const messageComp = document.createElement('div');
const messageCompWidth = 300;
messageComp.setAttribute("tabindex", "0"); // so that it can be focused
messageComp.style.position = 'fixed';
messageComp.style.opacity = '0.95';
messageComp.style.fontSize = '14px';
messageComp.style.width = `${messageCompWidth}px`;
messageComp.style.maxWidth = `${messageCompWidth}px`;
messageComp.style.border = '1px solid black';
messageComp.style.background = 'white';
messageComp.style.color = 'black';
messageComp.style.top = '10px';
messageComp.style.textAlign = 'center';
messageComp.style.padding = '10px';
messageComp.style.left = `${Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) }px`;
messageComp.style.zIndex = overlay.style.zIndex + 1;
messageComp.textContent = 'Drag and release to capture a screenshot';
document.body.appendChild(messageComp);
const selection = document.createElement('div');
selection.style.opacity = '0.5';
selection.style.border = '1px solid red';
selection.style.background = 'white';
selection.style.border = '2px solid black';
selection.style.zIndex = String(parseInt(overlay.style.zIndex, 10) - 1);
selection.style.top = "0";
selection.style.left = "0";
selection.style.position = 'fixed';
document.body.appendChild(selection);
messageComp.focus(); // we listen on keypresses on this element to cancel on escape
let isDragging = false;
let draggingStartPos: {x: number, y: number} | null = null;
let selectionArea: Rect;
function updateSelection() {
selection.style.left = `${selectionArea.x}px`;
selection.style.top = `${selectionArea.y}px`;
selection.style.width = `${selectionArea.width}px`;
selection.style.height = `${selectionArea.height}px`;
}
function setSelectionSizeFromMouse(event: MouseEvent) {
if (!draggingStartPos) return;
if (event.clientX < draggingStartPos.x) {
selectionArea.x = event.clientX;
}
if (event.clientY < draggingStartPos.y) {
selectionArea.y = event.clientY;
}
selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x));
selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y));
updateSelection();
}
function selection_mouseDown(event: MouseEvent) {
selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0};
draggingStartPos = {x: event.clientX, y: event.clientY};
isDragging = true;
updateSelection();
}
function selection_mouseMove(event: MouseEvent) {
if (!isDragging) return;
setSelectionSizeFromMouse(event);
}
function removeOverlay() {
isDragging = false;
overlay.removeEventListener('mousedown', selection_mouseDown);
overlay.removeEventListener('mousemove', selection_mouseMove);
overlay.removeEventListener('mouseup', selection_mouseUp);
document.body.removeChild(overlay);
document.body.removeChild(selection);
document.body.removeChild(messageComp);
}
function selection_mouseUp(event: MouseEvent) {
setSelectionSizeFromMouse(event);
removeOverlay();
console.info('selectionArea:', selectionArea);
if (!selectionArea || !selectionArea.width || !selectionArea.height) {
return;
}
// Need to wait a bit before taking the screenshot to make sure
// the overlays have been removed and don't appear in the
// screenshot. 10ms is not enough.
setTimeout(() => resolve(selectionArea), 100);
}
function cancel(event: KeyboardEvent) {
if (event.key === "Escape") {
removeOverlay();
}
}
overlay.addEventListener('mousedown', selection_mouseDown);
overlay.addEventListener('mousemove', selection_mouseMove);
overlay.addEventListener('mouseup', selection_mouseUp);
messageComp.addEventListener('keydown', cancel);
});
}
function makeLinksAbsolute(container: HTMLElement) {
for (const link of container.getElementsByTagName('a')) {
if (link.href) {
const newUrl = absoluteUrl(link.href);
if (!newUrl) continue;
link.href = newUrl;
}
}
}
function getImages(container: HTMLElement) {
const images: {imageId: string, src: string}[] = [];
for (const img of container.getElementsByTagName('img')) {
if (!img.src) {
continue;
}
const existingImage = images.find(image => image.src === img.src);
if (existingImage) {
img.src = existingImage.imageId;
}
else {
const imageId = randomString(20);
images.push({
imageId,
src: img.src
});
img.src = imageId;
}
}
return images;
}
async function prepareMessageResponse(message: {name: string, noteId?: string, message?: string, tabIds?: string[]}) {
console.info(`Message: ${ message.name}`);
if (message.name === "toast") {
let messageText;
if (message.noteId) {
messageText = document.createElement('p');
messageText.setAttribute("style", "padding: 0; margin: 0; font-size: larger;");
messageText.appendChild(document.createTextNode(`${message.message } `));
messageText.appendChild(createLink(
{name: 'openNoteInTrilium', noteId: message.noteId},
"Open in Trilium."
));
// only after saving tabs
if (message.tabIds) {
messageText.appendChild(document.createElement("br"));
messageText.appendChild(createLink(
{name: 'closeTabs', tabIds: message.tabIds},
"Close saved tabs.",
"tomato"
));
}
}
else {
messageText = message.message;
}
await import("@/lib/toast");
window.showToast(messageText, {
settings: {
duration: 7000
}
});
}
else if (message.name === "trilium-save-selection") {
const container = document.createElement('div');
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
throw new Error('No selection available to clip');
}
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i);
container.appendChild(range.cloneContents());
}
makeLinksAbsolute(container);
const images = getImages(container);
return {
title: pageTitle(),
content: container.innerHTML,
images,
pageUrl: getPageLocationOrigin() + location.pathname + location.search + location.hash
};
}
else if (message.name === 'trilium-get-rectangle-for-screenshot') {
return {
rect: await getRectangleArea(),
devicePixelRatio: window.devicePixelRatio
};
}
else if (message.name === "trilium-save-page") {
const {title, body} = getReadableDocument();
makeLinksAbsolute(body);
const images = getImages(body);
const labels = {};
const dates = getDocumentDates();
if (dates.publishedDate) {
labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10);
}
if (dates.modifiedDate) {
labels['modifiedDate'] = dates.modifiedDate.toISOString().substring(0, 10);
}
return {
title,
content: body.innerHTML,
images,
pageUrl: getPageLocationOrigin() + location.pathname + location.search,
clipType: 'page',
labels
};
}
else {
throw new Error(`Unknown command: ${ JSON.stringify(message)}`);
}
}
browser.runtime.onMessage.addListener(async (message) => {
try {
const response = await prepareMessageResponse(message);
return response;
} catch (err) {
console.error(err);
throw err;
}
});
}
});

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="manifest.exclude" content="['safari','firefox']" />
</head>
<body>
<script type="module" src="./index.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'CROP_IMAGE') {
cropImage(message.cropRect, message.dataUrl).then(sendResponse);
return true; // Keep channel open for async response
}
});
function cropImage(newArea: { x: number, y: number, width: number, height: number }, dataUrl: string) {
return new Promise<string>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = newArea.width;
canvas.height = newArea.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height,
0, 0, newArea.width, newArea.height);
}
resolve(canvas.toDataURL());
};
img.src = dataUrl;
});
}

View File

@@ -54,9 +54,8 @@
<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="index.ts"></script>
</body>

View File

@@ -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);
}

View File

@@ -46,11 +46,8 @@
<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/cash.min.js"></script>
<script type="module" src="popup.ts"></script>
</body>
</html>

View File

@@ -1,5 +1,8 @@
async function sendMessage(message) {
import { createLink } from "@/utils";
async function sendMessage(message: object) {
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) {
@@ -98,7 +101,7 @@ async function saveLinkWithNote() {
$("#save-button").on("click", saveLinkWithNote);
$("#show-help-button").on("click", () => {
window.open("https://github.com/zadam/trilium/wiki/Web-clipper", '_blank');
window.open("https://docs.triliumnotes.org/user-guide/setup/web-clipper", '_blank');
});
function escapeHtml(string) {
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,75 +0,0 @@
{
"manifest_version": 2,
"name": "Trilium Web Clipper (dev)",
"version": "1.0.1",
"description": "Save web clippings to Trilium Notes.",
"homepage_url": "https://github.com/zadam/trilium-web-clipper",
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"icons": {
"32": "icons/32.png",
"48": "icons/48.png",
"96": "icons/96.png"
},
"permissions": [
"activeTab",
"tabs",
"http://*/",
"https://*/",
"<all_urls>",
"storage",
"contextMenus"
],
"browser_action": {
"default_icon": "icons/32.png",
"default_title": "Trilium Web Clipper",
"default_popup": "popup/popup.html"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"lib/browser-polyfill.js",
"utils.js",
"content.js"
]
}
],
"background": {
"scripts": [
"lib/browser-polyfill.js",
"utils.js",
"trilium_server_facade.js",
"background.js"
]
},
"options_ui": {
"page": "options/options.html"
},
"commands": {
"saveSelection": {
"description": "Save the selected text into a note",
"suggested_key": {
"default": "Ctrl+Shift+S"
}
},
"saveWholePage": {
"description": "Save the current page",
"suggested_key": {
"default": "Alt+Shift+S"
}
},
"saveCroppedScreenshot": {
"description": "Take a cropped screenshot of the current page",
"suggested_key": {
"default": "Ctrl+Shift+E"
}
}
},
"browser_specific_settings": {
"gecko": {
"id": "{1410742d-b377-40e7-a9db-63dc9c6ec99c}"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "@triliumnext/web-clipper",
"version": "1.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"postinstall": "wxt prepare"
},
"keywords": [],
"packageManager": "pnpm@10.28.1",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.0",
"wxt": "0.20.13"
}
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -0,0 +1,6 @@
{
"extends": [
"../../tsconfig.base.json",
"./.wxt/tsconfig.json"
]
}

7
apps/web-clipper/types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface Window {
showToast(message: string, opts?: {
settings?: {
duration: number;
}
}): void;
}

View File

@@ -1,28 +0,0 @@
function randomString(len) {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < len; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function getBaseUrl() {
let output = getPageLocationOrigin() + location.pathname;
if (output[output.length - 1] !== '/') {
output = output.split('/');
output.pop();
output = output.join('/');
}
return output;
}
function getPageLocationOrigin() {
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
// but for file:// protocol this is browser dependant and in particular Firefox returns "null" in this case.
return location.protocol === 'file:' ? 'file://' : location.origin;
}

42
apps/web-clipper/utils.ts Normal file
View File

@@ -0,0 +1,42 @@
export type Rect = { x: number, y: number, width: number, height: number };
export function randomString(len: number) {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < len; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
export function getBaseUrl() {
let output = getPageLocationOrigin() + location.pathname;
if (output[output.length - 1] !== '/') {
const outputArr = output.split('/');
outputArr.pop();
output = outputArr.join('/');
}
return output;
}
export function getPageLocationOrigin() {
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
// but for file:// protocol this is browser dependant and in particular Firefox returns "null" in this case.
return location.protocol === 'file:' ? 'file://' : location.origin;
}
export function createLink(clickAction: object, text: string, color = "lightskyblue") {
const link = document.createElement('a');
link.href = "javascript:";
link.style.color = color;
link.appendChild(document.createTextNode(text));
link.addEventListener("click", () => {
browser.runtime.sendMessage(null, clickAction);
});
return link;
}

View File

@@ -0,0 +1,53 @@
import { defineConfig } from "wxt";
export default defineConfig({
modules: ['@wxt-dev/auto-icons'],
manifest: ({ manifestVersion }) => ({
name: "Trilium Web Clipper",
description: "Save web clippings to Trilium Notes.",
homepage_url: "https://docs.triliumnotes.org/user-guide/setup/web-clipper",
permissions: [
"activeTab",
"tabs",
"http://*/",
"https://*/",
"<all_urls>",
"storage",
"contextMenus",
manifestVersion === 3 && "offscreen"
].filter(Boolean),
browser_specific_settings: {
gecko: {
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#id.
id: "web-clipper@triliumnotes.org",
// Firefox built-in data collection consent
// See https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/
// This extension only communicates with a user-configured Trilium instance
// and does not collect telemetry or send data to remote servers.
data_collection_permissions: {
required: ["none"]
}
}
},
commands: {
saveSelection: {
description: "Save the selected text into a note",
suggested_key: {
default: "Ctrl+Shift+S"
}
},
saveWholePage: {
description: "Save the current page",
suggested_key: {
default: "Alt+Shift+S"
}
},
saveCroppedScreenshot: {
description: "Take a cropped screenshot of the current page",
suggested_key: {
default: "Ctrl+Shift+E"
}
}
}
})
});

View File

@@ -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"

View File

@@ -2839,6 +2839,40 @@
"format": "markdown",
"dataFileName": "Themes.md",
"attachments": []
},
{
"isClone": false,
"noteId": "YTAxJMA3uWwn",
"notePath": [
"jdjRLhLV3TtI",
"yeqU0zo0ZQ83",
"YTAxJMA3uWwn"
],
"title": "Web Clipper",
"notePosition": 210,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "label",
"name": "shareAlias",
"value": "web-clipper",
"isInheritable": false,
"position": 20
},
{
"type": "label",
"name": "iconClass",
"value": "bx bx-paperclip",
"isInheritable": false,
"position": 30
}
],
"format": "markdown",
"dataFileName": "Web Clipper.md",
"attachments": []
}
]
},

View File

@@ -0,0 +1,35 @@
# Web Clipper
The Web Clipper is present in the monorepo in `apps/web-clipper`. It's based on [WXT](https://wxt.dev/guide/introduction.html), a framework for building web extensions that allows very easy development and publishing.
## Manifest version
Originally the Web Clipper supported only Manifest v2, which made the extension incompatible with Google Chrome. [#8494](https://github.com/TriliumNext/Trilium/pull/8494) introduces Manifest v3 support for Google Chrome, alongside with Manifest v2 for Firefox.
Although Firefox does support Manifest v3, we are still using Manifest v2 for it because WXT dev mode doesn't work for the Firefox / Manifest v3 combination and there were some mentions about Manifest v3 not being well supported on Firefox Mobile (and we plan to have support for it).
## Dev mode
WXT allows easy development of the plugin, with full TypeScript support and live reload. To enter dev mode:
* Run `pnpm --filter web-clipper dev` to enter dev mode for Chrome (with manifest v3).
* Run `pnpm --filter web-clipper dev:firefox` to enter dev mode for Firefox (with manifest v2).
This will open a separate browser instance in which the extension is automatically injected.
## Port
The default port is:
* `37742` if in development mode. This makes it possible to use `pnpm desktop:start` to spin up a desktop instance to use the Clipper with.
* `37840` in production, the default Trilium port.
## Building
* Run `build` (Chrome) or `build:firefox` to generate the output files, which will be in `.output/[browser]`.
* Run `zip` or `zip:firefox` to generate the ZIP files.
## CI
`.github/workflows/web-clipper.yml` handles the building of the web clipper. Whenever the web clipper is modified, it generates the ZIPs and uploads them as artifacts.
There is currently no automatic publishing to the app stores.

View File

@@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/TzNwfb67k5Dh/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/rFcOjCdtKSRx/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@@ -9,6 +9,7 @@ The mono-repo is mainly structured in:
* `client`, representing the front-end that is used both by the server and the desktop application.
* `server`, representing the Node.js / server version of the application.
* `desktop`, representing the Electron-based desktop application.
* `web-clipper`, representing the browser extension to easily clip web pages into Trilium, with support for both Firefox and Chrome (manifest V3).
* `packages`, containing dependencies used by one or more `apps`.
* `commons`, containing shared code for all the apps.

4
docs/README-hi.md vendored
View File

@@ -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

View File

@@ -3,9 +3,19 @@
Trilium Web Clipper is a web browser extension which allows user to clip text, screenshots, whole pages and short notes and save them directly to Trilium Notes.
Project is hosted [here](https://github.com/TriliumNext/web-clipper).
## Supported browsers
Firefox and Chrome are supported browsers, but the chrome build should work on other chromium based browsers as well.
Trilium Web Clipper officially supports the following web browsers:
* Mozilla Firefox, using Manifest v2.
* Google Chrome, using Manifest v3. Theoretically the extension should work on other Chromium-based browsers as well, but they are not officially supported.
## Obtaining the extension
> [!WARNING]
> The extension is currently under development. A preview with unsigned extensions is available on [GitHub Actions](https://github.com/TriliumNext/Trilium/actions/runs/21318809414).
>
> We have already submitted the extension to both Chrome and Firefox web stores, but they are pending validation.
## Functionality
@@ -15,16 +25,29 @@ Firefox and Chrome are supported browsers, but the chrome build should work on o
* save screenshot (with crop tool) from either popup or context menu
* create short text note from popup
## Location of clippings
Trilium will save these clippings as a new child note under a "clipper inbox" note.
By default, that's the [day note](../Advanced%20Usage/Advanced%20Showcases/Day%20Notes.md) but you can override that by setting the [label](../Advanced%20Usage/Attributes.md) `clipperInbox`, on any other note.
If there's multiple clippings from the same page (and on the same day), then they will be added to the same note.
**Extension is available from:**
## Keyboard shortcuts
* [Project release page](https://github.com/TriliumNext/web-clipper/releases) - .xpi for Firefox and .zip for Chromium based browsers.
* [Chrome Web Store](https://chromewebstore.google.com/detail/trilium-web-clipper/dfhgmnfclbebfobmblelddiejjcijbjm)
Keyboard shortcuts are available for most functions:
* Save selected text: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)
* Save whole page: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (Mac: <kbd>⌥</kbd>+<kbd>⇧</kbd>+<kbd>S</kbd>)
* Save screenshot: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (Mac: <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>E</kbd>)
To set custom shortcuts, follow the directions for your browser.
* **Firefox**: `about:addons` → Gear icon ⚙️ → Manage extension shortcuts
* **Chrome**: `chrome://extensions/shortcuts`
> [!NOTE]
> On Firefox, the default shortcuts interfere with some browser features. As such, the keyboard combinations will not trigger the Web Clipper action. To fix this, simply change the keyboard shortcut to something that works. The defaults will be adjusted in future versions.
## Configuration
@@ -32,6 +55,6 @@ The extension needs to connect to a running Trilium instance. By default, it sca
It's also possible to configure the [server](Server%20Installation.md) address if you don't run the desktop application, or want it to work without the desktop application running.
## Username
## Credits
Older versions of Trilium (before 0.50) required username & password to authenticate, but this is no longer the case. You may enter anything in that field, it will not have any effect.
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).

48
flake.lock generated
View File

@@ -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",

View File

@@ -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";
};
};
};

View File

@@ -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.4",
"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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.4.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.26",
"@smithy/middleware-retry": "4.4.27",
"@types/jquery": "3.5.33"
}
}

View File

@@ -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];
}

View File

@@ -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);
}
}

2131
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,9 @@
{
"path": "./apps/website"
},
{
"path": "./apps/web-clipper"
},
{
"path": "./apps/dump-db"
},