mirror of
https://github.com/zadam/trilium.git
synced 2026-01-25 00:29:13 +01:00
Compare commits
47 Commits
main
...
webclipper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b367b5a3 | ||
|
|
4927b01d96 | ||
|
|
e2e5d485d7 | ||
|
|
ada22e4966 | ||
|
|
1cf93ff0de | ||
|
|
199962233b | ||
|
|
743a6f3466 | ||
|
|
625062a268 | ||
|
|
cb0fabf273 | ||
|
|
4c978d8622 | ||
|
|
d0f441ec74 | ||
|
|
9d347ff3d9 | ||
|
|
c2a758dd4a | ||
|
|
bba69e98ae | ||
|
|
53e3d65c52 | ||
|
|
a2a37a0b54 | ||
|
|
1fb360e34f | ||
|
|
680817d81c | ||
|
|
bf736977ab | ||
|
|
28ed93dcdc | ||
|
|
785ace64ad | ||
|
|
ac109c2ece | ||
|
|
1e82043999 | ||
|
|
e37487a1cf | ||
|
|
a9b8ffd94c | ||
|
|
4011771b64 | ||
|
|
266494ba8c | ||
|
|
2e144fac5e | ||
|
|
423038100e | ||
|
|
75e88c69bd | ||
|
|
f0b1319f95 | ||
|
|
59f2fc8d03 | ||
|
|
5d07a079ef | ||
|
|
b5ff71b1a0 | ||
|
|
c0a2ae99cf | ||
|
|
5600a707d3 | ||
|
|
17f906fb65 | ||
|
|
276b3f834b | ||
|
|
a9218960e9 | ||
|
|
957590523c | ||
|
|
22308a101e | ||
|
|
ab95f6dcc2 | ||
|
|
cb8b968637 | ||
|
|
e4d319c7a1 | ||
|
|
f8e5f31970 | ||
|
|
5113e2ab97 | ||
|
|
6ae1cc18e2 |
47
.github/workflows/web-clipper.yml
vendored
Normal file
47
.github/workflows/web-clipper.yml
vendored
Normal 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
|
||||
@@ -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 & 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>
|
||||
3
apps/web-clipper/.gitignore
vendored
3
apps/web-clipper/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
dist/
|
||||
.output
|
||||
.wxt
|
||||
@@ -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).
|
||||
BIN
apps/web-clipper/assets/icon.png
Normal file
BIN
apps/web-clipper/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -1,451 +0,0 @@
|
||||
// Keyboard shortcuts
|
||||
chrome.commands.onCommand.addListener(async function (command) {
|
||||
if (command == "saveSelection") {
|
||||
await saveSelection();
|
||||
} else if (command == "saveWholePage") {
|
||||
await saveWholePage();
|
||||
} else if (command == "saveTabs") {
|
||||
await saveTabs();
|
||||
} else if (command == "saveCroppedScreenshot") {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
await saveCroppedScreenshot(activeTab.url);
|
||||
} else {
|
||||
console.log("Unrecognized command", command);
|
||||
}
|
||||
});
|
||||
|
||||
function cropImage(newArea, dataUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = newArea.width;
|
||||
canvas.height = newArea.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height);
|
||||
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function takeCroppedScreenshot(cropRect) {
|
||||
const activeTab = await getActiveTab();
|
||||
const zoom = await browser.tabs.getZoom(activeTab.id) * window.devicePixelRatio;
|
||||
|
||||
const newArea = Object.assign({}, cropRect);
|
||||
newArea.x *= zoom;
|
||||
newArea.y *= zoom;
|
||||
newArea.width *= zoom;
|
||||
newArea.height *= zoom;
|
||||
|
||||
const dataUrl = await browser.tabs.captureVisibleTab(null, { format: 'png' });
|
||||
|
||||
return await cropImage(newArea, dataUrl);
|
||||
}
|
||||
|
||||
async function takeWholeScreenshot() {
|
||||
// this saves only visible portion of the page
|
||||
// workaround to save the whole page is to scroll & stitch
|
||||
// example in https://github.com/mrcoles/full-page-screen-capture-chrome-extension
|
||||
// see page.js and popup.js
|
||||
return await browser.tabs.captureVisibleTab(null, { format: 'png' });
|
||||
}
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
if (isDevEnv()) {
|
||||
browser.browserAction.setIcon({
|
||||
path: 'icons/32-dev.png',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-selection",
|
||||
title: "Save selection to Trilium",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-cropped-screenshot",
|
||||
title: "Clip screenshot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-cropped-screenshot",
|
||||
title: "Crop screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-whole-screenshot",
|
||||
title: "Save whole screen shot to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-page",
|
||||
title: "Save whole page to Trilium",
|
||||
contexts: ["page"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-link",
|
||||
title: "Save link to Trilium",
|
||||
contexts: ["link"]
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "trilium-save-image",
|
||||
title: "Save image to Trilium",
|
||||
contexts: ["image"]
|
||||
});
|
||||
|
||||
async function getActiveTab() {
|
||||
const tabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs[0];
|
||||
}
|
||||
|
||||
async function getWindowTabs() {
|
||||
const tabs = await browser.tabs.query({
|
||||
currentWindow: true
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
async function sendMessageToActiveTab(message) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error("No active tab.");
|
||||
}
|
||||
|
||||
try {
|
||||
return await browser.tabs.sendMessage(activeTab.id, message);
|
||||
}
|
||||
catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function toast(message, noteId = null, tabIds = null) {
|
||||
sendMessageToActiveTab({
|
||||
name: 'toast',
|
||||
message: message,
|
||||
noteId: noteId,
|
||||
tabIds: tabIds
|
||||
});
|
||||
}
|
||||
|
||||
function blob2base64(blob) {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchImage(url) {
|
||||
const resp = await fetch(url);
|
||||
const blob = await resp.blob();
|
||||
|
||||
return await blob2base64(blob);
|
||||
}
|
||||
|
||||
async function postProcessImage(image) {
|
||||
if (image.src.startsWith("data:image/")) {
|
||||
image.dataUrl = image.src;
|
||||
image.src = "inline." + image.src.substr(11, 3); // this should extract file type - png/jpg
|
||||
}
|
||||
else {
|
||||
try {
|
||||
image.dataUrl = await fetchImage(image.src, image);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`Cannot fetch image from ${image.src}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postProcessImages(resp) {
|
||||
if (resp.images) {
|
||||
for (const image of resp.images) {
|
||||
await postProcessImage(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelection() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Selection has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function getImagePayloadFromSrc(src, pageUrl) {
|
||||
const image = {
|
||||
imageId: randomString(20),
|
||||
src: src
|
||||
};
|
||||
|
||||
await postProcessImage(image);
|
||||
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return {
|
||||
title: activeTab.title,
|
||||
content: `<img src="${image.imageId}">`,
|
||||
images: [image],
|
||||
pageUrl: pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCroppedScreenshot(pageUrl) {
|
||||
const cropRect = await sendMessageToActiveTab({name: 'trilium-get-rectangle-for-screenshot'});
|
||||
|
||||
const src = await takeCroppedScreenshot(cropRect);
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholeScreenshot(pageUrl) {
|
||||
const src = await takeWholeScreenshot();
|
||||
|
||||
const payload = await getImagePayloadFromSrc(src, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Screenshot has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveImage(srcUrl, pageUrl) {
|
||||
const payload = await getImagePayloadFromSrc(srcUrl, pageUrl);
|
||||
|
||||
const resp = await triliumServerFacade.callService("POST", "clippings", payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Image has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveWholePage() {
|
||||
const payload = await sendMessageToActiveTab({name: 'trilium-save-page'});
|
||||
|
||||
await postProcessImages(payload);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Page has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
|
||||
async function saveLinkWithNote(title, content) {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
if (!title.trim()) {
|
||||
title = activeTab.title;
|
||||
}
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', {
|
||||
title: title,
|
||||
content: content,
|
||||
clipType: 'note',
|
||||
pageUrl: activeTab.url
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
toast("Link with note has been saved to Trilium.", resp.noteId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getTabsPayload(tabs) {
|
||||
let content = '<ul>';
|
||||
tabs.forEach(tab => {
|
||||
content += `<li><a href="${tab.url}">${tab.title}</a></li>`
|
||||
});
|
||||
content += '</ul>';
|
||||
|
||||
const domainsCount = tabs.map(tab => tab.url)
|
||||
.reduce((acc, url) => {
|
||||
const hostname = new URL(url).hostname
|
||||
return acc.set(hostname, (acc.get(hostname) || 0) + 1)
|
||||
}, new Map());
|
||||
|
||||
let topDomains = [...domainsCount]
|
||||
.sort((a, b) => {return b[1]-a[1]})
|
||||
.slice(0,3)
|
||||
.map(domain=>domain[0])
|
||||
.join(', ')
|
||||
|
||||
if (tabs.length > 3) { topDomains += '...' }
|
||||
|
||||
return {
|
||||
title: `${tabs.length} browser tabs: ${topDomains}`,
|
||||
content: content,
|
||||
clipType: 'tabs'
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTabs() {
|
||||
const tabs = await getWindowTabs();
|
||||
|
||||
const payload = await getTabsPayload(tabs);
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'notes', payload);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabIds = tabs.map(tab=>{return tab.id});
|
||||
|
||||
toast(`${tabs.length} links have been saved to Trilium.`, resp.noteId, tabIds);
|
||||
}
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async function(info, tab) {
|
||||
if (info.menuItemId === 'trilium-save-selection') {
|
||||
await saveSelection();
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-cropped-screenshot') {
|
||||
await saveCroppedScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-whole-screenshot') {
|
||||
await saveWholeScreenshot(info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-image') {
|
||||
await saveImage(info.srcUrl, info.pageUrl);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-link') {
|
||||
const link = document.createElement("a");
|
||||
link.href = info.linkUrl;
|
||||
// linkText might be available only in firefox
|
||||
link.appendChild(document.createTextNode(info.linkText || info.linkUrl));
|
||||
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
const resp = await triliumServerFacade.callService('POST', 'clippings', {
|
||||
title: activeTab.title,
|
||||
content: link.outerHTML,
|
||||
pageUrl: info.pageUrl
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast("Link has been saved to Trilium.", resp.noteId);
|
||||
}
|
||||
else if (info.menuItemId === 'trilium-save-page') {
|
||||
await saveWholePage();
|
||||
}
|
||||
else {
|
||||
console.log("Unrecognized menuItemId", info.menuItemId);
|
||||
}
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener(async request => {
|
||||
console.log("Received", request);
|
||||
|
||||
if (request.name === 'openNoteInTrilium') {
|
||||
const resp = await triliumServerFacade.callService('POST', 'open/' + request.noteId);
|
||||
|
||||
if (!resp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// desktop app is not available so we need to open in browser
|
||||
if (resp.result === 'open-in-browser') {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
|
||||
if (triliumServerUrl) {
|
||||
const noteUrl = triliumServerUrl + '/#' + request.noteId;
|
||||
|
||||
console.log("Opening new tab in browser", noteUrl);
|
||||
|
||||
browser.tabs.create({
|
||||
url: noteUrl
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error("triliumServerUrl not found in local storage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (request.name === 'closeTabs') {
|
||||
return await browser.tabs.remove(request.tabIds)
|
||||
}
|
||||
else if (request.name === 'load-script') {
|
||||
return await browser.tabs.executeScript({file: request.file});
|
||||
}
|
||||
else if (request.name === 'save-cropped-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveCroppedScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-screenshot') {
|
||||
const activeTab = await getActiveTab();
|
||||
|
||||
return await saveWholeScreenshot(activeTab.url);
|
||||
}
|
||||
else if (request.name === 'save-whole-page') {
|
||||
return await saveWholePage();
|
||||
}
|
||||
else if (request.name === 'save-link-with-note') {
|
||||
return await saveLinkWithNote(request.title, request.content);
|
||||
}
|
||||
else if (request.name === 'save-tabs') {
|
||||
return await saveTabs();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search') {
|
||||
triliumServerFacade.triggerSearchForTrilium();
|
||||
}
|
||||
else if (request.name === 'send-trilium-search-status') {
|
||||
triliumServerFacade.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
else if (request.name === 'trigger-trilium-search-note-url') {
|
||||
const activeTab = await getActiveTab();
|
||||
triliumServerFacade.triggerSearchNoteByUrl(activeTab.url);
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
|
||||
1
apps/web-clipper/build.ts
Normal file
1
apps/web-clipper/build.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default { buildDate:"2022-10-29T15:25:37+02:00", buildRevision: "c9c10a90aa9b94efdf150b0b2fd57f9df5bf2d0a" };
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
483
apps/web-clipper/entrypoints/background/index.ts
Normal file
483
apps/web-clipper/entrypoints/background/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
245
apps/web-clipper/entrypoints/background/trilium_server_facade.ts
Normal file
245
apps/web-clipper/entrypoints/background/trilium_server_facade.ts
Normal 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')}`);
|
||||
}
|
||||
}
|
||||
349
apps/web-clipper/entrypoints/content/index.ts
Normal file
349
apps/web-clipper/entrypoints/content/index.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
10
apps/web-clipper/entrypoints/offscreen/index.html
Normal file
10
apps/web-clipper/entrypoints/offscreen/index.html
Normal 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>
|
||||
24
apps/web-clipper/entrypoints/offscreen/index.ts
Normal file
24
apps/web-clipper/entrypoints/offscreen/index.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -17,8 +17,8 @@ function showSuccess(message) {
|
||||
async function saveTriliumServerSetup(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if ($triliumServerUrl.val().trim().length === 0
|
||||
|| $triliumServerPassword.val().trim().length === 0) {
|
||||
if (($triliumServerUrl.val() as string | undefined)?.trim().length === 0
|
||||
|| ($triliumServerPassword.val() as string | undefined)?.trim().length === 0) {
|
||||
showError("One or more mandatory inputs are missing. Please fill in server URL and password.");
|
||||
|
||||
return;
|
||||
@@ -27,7 +27,7 @@ async function saveTriliumServerSetup(e) {
|
||||
let resp;
|
||||
|
||||
try {
|
||||
resp = await fetch($triliumServerUrl.val() + '/api/login/token', {
|
||||
resp = await fetch(`${$triliumServerUrl.val()}/api/login/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -39,7 +39,8 @@ async function saveTriliumServerSetup(e) {
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
showError("Unknown error: " + e.message);
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
showError(`Unknown error: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ async function saveTriliumServerSetup(e) {
|
||||
showError("Incorrect credentials.");
|
||||
}
|
||||
else if (resp.status !== 200) {
|
||||
showError("Unrecognised response with status code " + resp.status);
|
||||
showError(`Unrecognised response with status code ${ resp.status}`);
|
||||
}
|
||||
else {
|
||||
const json = await resp.json();
|
||||
@@ -89,8 +90,8 @@ const $triilumDesktopSetupForm = $("#trilium-desktop-setup-form");
|
||||
$triilumDesktopSetupForm.on("submit", e => {
|
||||
e.preventDefault();
|
||||
|
||||
const port = $triliumDesktopPort.val().trim();
|
||||
const portNum = parseInt(port);
|
||||
const port = ($triliumDesktopPort.val() as string | undefined ?? "").trim();
|
||||
const portNum = parseInt(port, 10);
|
||||
|
||||
if (port && (isNaN(portNum) || portNum <= 0 || portNum >= 65536)) {
|
||||
showError(`Please enter valid port number.`);
|
||||
@@ -105,8 +106,8 @@ $triilumDesktopSetupForm.on("submit", e => {
|
||||
});
|
||||
|
||||
async function restoreOptions() {
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get("authToken");
|
||||
const {triliumServerUrl} = await browser.storage.sync.get<{ triliumServerUrl: string }>("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get<{ authToken: string }>("authToken");
|
||||
|
||||
$errorMessage.hide();
|
||||
$successMessage.hide();
|
||||
@@ -127,8 +128,7 @@ async function restoreOptions() {
|
||||
$triliumServerConfiguredDiv.hide();
|
||||
}
|
||||
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
|
||||
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get<{ triliumDesktopPort: string }>("triliumDesktopPort");
|
||||
$triliumDesktopPort.val(triliumDesktopPort);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,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>
|
||||
@@ -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
@@ -1,108 +0,0 @@
|
||||
/* eslint-env es6:false */
|
||||
/*
|
||||
* Copyright (c) 2010 Arc90 Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This code is heavily based on Arc90's readability.js (1.7.1) script
|
||||
* available at: http://code.google.com/p/arc90labs-readability
|
||||
*/
|
||||
|
||||
var REGEXPS = {
|
||||
// NOTE: These two regular expressions are duplicated in
|
||||
// Readability.js. Please keep both copies in sync.
|
||||
unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,
|
||||
okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i,
|
||||
};
|
||||
|
||||
function isNodeVisible(node) {
|
||||
// Have to null-check node.style and node.className.indexOf to deal with SVG and MathML nodes.
|
||||
return (!node.style || node.style.display != "none")
|
||||
&& !node.hasAttribute("hidden")
|
||||
//check for "fallback-image" so that wikimedia math images are displayed
|
||||
&& (!node.hasAttribute("aria-hidden") || node.getAttribute("aria-hidden") != "true" || (node.className && node.className.indexOf && node.className.indexOf("fallback-image") !== -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether or not the document is reader-able without parsing the whole thing.
|
||||
* @param {Object} options Configuration object.
|
||||
* @param {number} [options.minContentLength=140] The minimum node content length used to decide if the document is readerable.
|
||||
* @param {number} [options.minScore=20] The minumum cumulated 'score' used to determine if the document is readerable.
|
||||
* @param {Function} [options.visibilityChecker=isNodeVisible] The function used to determine if a node is visible.
|
||||
* @return {boolean} Whether or not we suspect Readability.parse() will suceeed at returning an article object.
|
||||
*/
|
||||
function isProbablyReaderable(doc, options = {}) {
|
||||
// For backward compatibility reasons 'options' can either be a configuration object or the function used
|
||||
// to determine if a node is visible.
|
||||
if (typeof options == "function") {
|
||||
options = { visibilityChecker: options };
|
||||
}
|
||||
|
||||
var defaultOptions = { minScore: 20, minContentLength: 140, visibilityChecker: isNodeVisible };
|
||||
options = Object.assign(defaultOptions, options);
|
||||
|
||||
var nodes = doc.querySelectorAll("p, pre, article");
|
||||
|
||||
// Get <div> nodes which have <br> node(s) and append them into the `nodes` variable.
|
||||
// Some articles' DOM structures might look like
|
||||
// <div>
|
||||
// Sentences<br>
|
||||
// <br>
|
||||
// Sentences<br>
|
||||
// </div>
|
||||
var brNodes = doc.querySelectorAll("div > br");
|
||||
if (brNodes.length) {
|
||||
var set = new Set(nodes);
|
||||
[].forEach.call(brNodes, function (node) {
|
||||
set.add(node.parentNode);
|
||||
});
|
||||
nodes = Array.from(set);
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
// This is a little cheeky, we use the accumulator 'score' to decide what to return from
|
||||
// this callback:
|
||||
return [].some.call(nodes, function (node) {
|
||||
if (!options.visibilityChecker(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var matchString = node.className + " " + node.id;
|
||||
if (REGEXPS.unlikelyCandidates.test(matchString) &&
|
||||
!REGEXPS.okMaybeItsACandidate.test(matchString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.matches("li p")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var textContentLength = node.textContent.trim().length;
|
||||
if (textContentLength < options.minContentLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
score += Math.sqrt(textContentLength - options.minContentLength);
|
||||
|
||||
if (score > options.minScore) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module === "object") {
|
||||
module.exports = isProbablyReaderable;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
* @param {HTMLDocument} doc The document to parse.
|
||||
* @param {Object} options The options object.
|
||||
*/
|
||||
function Readability(doc, options) {
|
||||
export default function Readability(doc, options) {
|
||||
// In some older versions, people passed a URI as the first argument. Cope:
|
||||
if (options && options.documentElement) {
|
||||
doc = options;
|
||||
@@ -2277,7 +2277,3 @@ Readability.prototype = {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof module === "object") {
|
||||
module.exports = Readability;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/web-clipper/package.json
Normal file
21
apps/web-clipper/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,225 +0,0 @@
|
||||
const PROTOCOL_VERSION_MAJOR = 1;
|
||||
|
||||
function isDevEnv() {
|
||||
const manifest = browser.runtime.getManifest();
|
||||
|
||||
return manifest.name.endsWith('(dev)');
|
||||
}
|
||||
|
||||
class TriliumServerFacade {
|
||||
constructor() {
|
||||
this.triggerSearchForTrilium();
|
||||
|
||||
// continually scan for changes (if e.g. desktop app is started after browser)
|
||||
setInterval(() => this.triggerSearchForTrilium(), 60 * 1000);
|
||||
}
|
||||
|
||||
async sendTriliumSearchStatusToPopup() {
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-search-status",
|
||||
triliumSearch: this.triliumSearch
|
||||
});
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
async sendTriliumSearchNoteToPopup(){
|
||||
try{
|
||||
await browser.runtime.sendMessage({
|
||||
name: "trilium-previously-visited",
|
||||
searchNote: this.triliumSearchNote
|
||||
})
|
||||
|
||||
}
|
||||
catch (e) {} // nothing might be listening
|
||||
}
|
||||
|
||||
setTriliumSearchNote(st){
|
||||
this.triliumSearchNote = st;
|
||||
this.sendTriliumSearchNoteToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearch(ts) {
|
||||
this.triliumSearch = ts;
|
||||
|
||||
this.sendTriliumSearchStatusToPopup();
|
||||
}
|
||||
|
||||
setTriliumSearchWithVersionCheck(json, resp) {
|
||||
const [major, minor] = json.protocolVersion
|
||||
.split(".")
|
||||
.map(chunk => parseInt(chunk));
|
||||
|
||||
// minor version is intended to be used to dynamically limit features provided by extension
|
||||
// if some specific Trilium API is not supported. So far not needed.
|
||||
|
||||
if (major !== PROTOCOL_VERSION_MAJOR) {
|
||||
this.setTriliumSearch({
|
||||
status: 'version-mismatch',
|
||||
extensionMajor: PROTOCOL_VERSION_MAJOR,
|
||||
triliumMajor: major
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.setTriliumSearch(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSearchForTrilium() {
|
||||
this.setTriliumSearch({ status: 'searching' });
|
||||
|
||||
try {
|
||||
const port = await this.getPort();
|
||||
|
||||
console.debug('Trying port ' + port);
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/clipper/handshake`);
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-desktop',
|
||||
port: port,
|
||||
url: 'http://127.0.0.1:' + port
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// continue
|
||||
}
|
||||
|
||||
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
|
||||
const {authToken} = await browser.storage.sync.get("authToken");
|
||||
|
||||
if (triliumServerUrl && authToken) {
|
||||
try {
|
||||
const resp = await fetch(triliumServerUrl + '/api/clipper/handshake', {
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
console.log("Received response:", text);
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (json.appName === 'trilium') {
|
||||
this.setTriliumSearchWithVersionCheck(json, {
|
||||
status: 'found-server',
|
||||
url: triliumServerUrl,
|
||||
token: authToken
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Request to the configured server instance failed with:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// if all above fails it's not found
|
||||
this.setTriliumSearch({ status: 'not-found' });
|
||||
}
|
||||
|
||||
async triggerSearchNoteByUrl(noteUrl) {
|
||||
const resp = await triliumServerFacade.callService('GET', 'notes-by-url/' + encodeURIComponent(noteUrl))
|
||||
let newStatus = {
|
||||
status: 'not-found',
|
||||
noteId: null
|
||||
}
|
||||
if (resp && resp.noteId) {
|
||||
newStatus.noteId = resp.noteId;
|
||||
newStatus.status = 'found';
|
||||
}
|
||||
this.setTriliumSearchNote(newStatus);
|
||||
}
|
||||
async waitForTriliumSearch() {
|
||||
return new Promise((res, rej) => {
|
||||
const checkStatus = () => {
|
||||
if (this.triliumSearch.status === "searching") {
|
||||
setTimeout(checkStatus, 500);
|
||||
}
|
||||
else if (this.triliumSearch.status === 'not-found') {
|
||||
rej(new Error("Trilium instance has not been found."));
|
||||
}
|
||||
else {
|
||||
res();
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
});
|
||||
}
|
||||
|
||||
async getPort() {
|
||||
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
|
||||
|
||||
if (triliumDesktopPort) {
|
||||
return parseInt(triliumDesktopPort);
|
||||
}
|
||||
else {
|
||||
return isDevEnv() ? 37740 : 37840;
|
||||
}
|
||||
}
|
||||
|
||||
async callService(method, path, body) {
|
||||
const fetchOptions = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.waitForTriliumSearch();
|
||||
|
||||
fetchOptions.headers.Authorization = this.triliumSearch.token || "";
|
||||
fetchOptions.headers['trilium-local-now-datetime'] = this.localNowDateTime();
|
||||
|
||||
const url = this.triliumSearch.url + "/api/clipper/" + path;
|
||||
|
||||
console.log(`Sending ${method} request to ${url}`);
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Sending request to trilium failed", e);
|
||||
|
||||
toast('Your request failed because we could not contact Trilium instance. Please make sure Trilium is running and is accessible.');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
localNowDateTime() {
|
||||
const date = new Date();
|
||||
const off = date.getTimezoneOffset();
|
||||
const absoff = Math.abs(off);
|
||||
return (new Date(date.getTime() - off * 60 * 1000).toISOString().substr(0,23).replace("T", " ") +
|
||||
(off > 0 ? '-' : '+') +
|
||||
(absoff / 60).toFixed(0).padStart(2,'0') + ':' +
|
||||
(absoff % 60).toString().padStart(2,'0'));
|
||||
}
|
||||
}
|
||||
|
||||
window.triliumServerFacade = new TriliumServerFacade();
|
||||
6
apps/web-clipper/tsconfig.json
Normal file
6
apps/web-clipper/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"../../tsconfig.base.json",
|
||||
"./.wxt/tsconfig.json"
|
||||
]
|
||||
}
|
||||
7
apps/web-clipper/types.d.ts
vendored
Normal file
7
apps/web-clipper/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
interface Window {
|
||||
showToast(message: string, opts?: {
|
||||
settings?: {
|
||||
duration: number;
|
||||
}
|
||||
}): void;
|
||||
}
|
||||
@@ -1,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
42
apps/web-clipper/utils.ts
Normal 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;
|
||||
}
|
||||
53
apps/web-clipper/wxt.config.ts
Normal file
53
apps/web-clipper/wxt.config.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
34
docs/Developer Guide/!!!meta.json
vendored
34
docs/Developer Guide/!!!meta.json
vendored
@@ -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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
35
docs/Developer Guide/Developer Guide/Concepts/Web Clipper.md
vendored
Normal file
35
docs/Developer Guide/Developer Guide/Concepts/Web Clipper.md
vendored
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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": {
|
||||
|
||||
1623
pnpm-lock.yaml
generated
1623
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@
|
||||
{
|
||||
"path": "./apps/website"
|
||||
},
|
||||
{
|
||||
"path": "./apps/web-clipper"
|
||||
},
|
||||
{
|
||||
"path": "./apps/dump-db"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user