initial import

This commit is contained in:
zadam
2019-07-19 20:35:53 +02:00
commit 8d28c14133
28 changed files with 6037 additions and 0 deletions

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/trilium-web-clipper.iml" filepath="$PROJECT_DIR$/trilium-web-clipper.iml" />
</modules>
</component>
</project>

10
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectId" id="1OF6mJDrJJ99biD9ZeM2EJmkKPy" />
<component name="PropertiesComponent">
<property name="WebServerToolWindowFactoryState" value="false" />
<property name="aspect.path.notification.shown" value="true" />
<property name="nodejs_interpreter_path.stuck_in_default_project" value="undefined stuck path" />
<property name="nodejs_npm_path_reset_for_default_project" value="true" />
</component>
</project>

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Trilium Web Clipper

313
background.js Normal file
View File

@@ -0,0 +1,313 @@
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 takeScreenshot(cropRect) {
const activeTab = await getActiveTab();
const zoom = await browser.tabs.getZoom(activeTab.id);
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);
}
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-screenshot",
title: "Clip screenshot 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 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) {
console.error("Sending message to active tab failed, you might need to refresh the page after updating the extension.", e);
}
}
function toast(message, noteId = null) {
sendMessageToActiveTab({
name: 'toast',
message: message,
noteId: noteId
});
}
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
return;
}
image.dataUrl = await fetchImage(image.src, image);
}
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 saveScreenshot(pageUrl) {
const cropRect = await sendMessageToActiveTab({name: 'trilium-save-screenshot'});
const src = await takeScreenshot(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 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 saveNote(title, content) {
const resp = await triliumServerFacade.callService('POST', 'notes', {
title: title,
content: content
});
if (!resp) {
return false;
}
toast("Note has been saved to Trilium.", resp.noteId);
return true;
}
browser.contextMenus.onClicked.addListener(async function(info, tab) {
if (info.menuItemId === 'trilium-save-selection') {
await saveSelection();
}
else if (info.menuItemId === 'trilium-save-screenshot') {
await saveScreenshot(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 => {
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 === 'load-script') {
return await browser.tabs.executeScript({file: request.file});
}
else if (request.name === 'save-screenshot') {
return await saveScreenshot();
}
else if (request.name === 'save-whole-page') {
return await saveWholePage();
}
else if (request.name === 'save-note') {
return await saveNote(request.title, request.content);
}
else if (request.name === 'trigger-trilium-search') {
triliumServerFacade.triggerSearchForTrilium();
}
else if (request.name === 'send-trilium-search-status') {
triliumServerFacade.sendTriliumSearchStatusToPopup();
}
});

20
bin/build.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
VERSION=$(jq -r ".version" manifest.json)
ARTIFACT_NAME=trilium-web-clipper-${VERSION}
BUILD_DIR=dist/$ARTIFACT_NAME
rm -rf dist
mkdir -p "$BUILD_DIR"
cp -r icons lib options popup *.js manifest.json "$BUILD_DIR"
cd dist/"${ARTIFACT_NAME}" || exit
jq '.name = "Trilium Web Clipper"' manifest.json | sponge manifest.json
zip -r ../"${ARTIFACT_NAME}".zip *
cd ..
rm -r "${ARTIFACT_NAME}"

68
bin/release.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
export GITHUB_REPO=trilium-webclipper
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
VERSION=$1
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
then
echo "Version ${VERSION} isn't in format X.Y.Z"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo "There are uncommitted changes"
exit 1
fi
echo "Releasing Trilium Web Clipper $VERSION"
jq '.version = "'"$VERSION"'"' manifest.json | sponge manifest.json
git add manifest.json
echo 'module.exports = { buildDate:"'$(date --iso-8601=seconds)'", buildRevision: "'$(git log -1 --format="%H")'" };' > build.js
git add build.js
TAG=v$VERSION
echo "Committing package.json version change"
git commit -m "release $VERSION"
git push
echo "Tagging commit with $TAG"
git tag "$TAG"
git push origin "$TAG"
bin/build.sh
BUILD=trilium-web-clipper-$VERSION.zip
echo "Creating release in GitHub"
EXTRA=
if [[ $TAG == *"beta"* ]]; then
EXTRA=--pre-release
fi
github-release release \
--tag "$TAG" \
--name "$TAG release" $EXTRA
echo "Uploading build package"
github-release upload \
--tag "$TAG" \
--name "$BUILD" \
--file "dist/$BUILD"
echo "Release finished!"

1
build.js Normal file
View File

@@ -0,0 +1 @@
module.exports = { buildDate:"2019-07-19T19:49:14+02:00", buildRevision: "a047e83b8c8746a20018644c3660ad52d79c09a1" };

299
content.js Normal file
View File

@@ -0,0 +1,299 @@
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 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;
}
function getBaseUrl() {
let output = getPageLocationOrigin() + location.pathname;
if (output[output.length - 1] !== '/') {
output = output.split('/');
output.pop();
output = output.join('/');
}
return output;
}
function getReadableDocument() {
// Readability directly change the passed document so clone it so as
// to preserve the original web page.
const documentCopy = document.cloneNode(true);
const readability = new Readability(documentCopy);
const article = readability.parse();
if (!article) {
throw new Error('Could not parse HTML document with Readability');
}
return {
title: article.title,
body: article.articleContent,
}
}
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.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);
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 selection_mouseUp(event) {
setSelectionSizeFromMouse(event);
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);
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);
}
overlay.addEventListener('mousedown', selection_mouseDown);
overlay.addEventListener('mousemove', selection_mouseMove);
overlay.addEventListener('mouseup', selection_mouseUp);
});
}
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 imageId = randomString(20);
images.push({
imageId: imageId,
src: img.src
});
img.src = imageId;
}
return images;
}
async function prepareMessageResponse(message) {
console.info('Message: ' + message.name);
if (message.name === "toast") {
let messageText;
if (message.noteId) {
messageText = document.createElement('span');
messageText.appendChild(document.createTextNode(message.message + " "));
const link = document.createElement('a');
link.href = "javascript:";
link.style.color = "lightskyblue";
link.appendChild(document.createTextNode("Open in Trilium."));
link.addEventListener("click", () => {
browser.runtime.sendMessage(null, {
name: 'openNoteInTrilium',
noteId: message.noteId
})
});
messageText.appendChild(link);
}
else {
messageText = message.message;
}
await requireLib('/lib/toast.js');
showToast(messageText, {
settings: {
duration: 5000
}
});
}
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
};
}
else if (message.name === 'trilium-save-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);
return {
title: title,
content: body.innerHTML,
images: images,
pageUrl: getPageLocationOrigin() + location.pathname + location.search
};
}
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});
}
}

BIN
icons/32-dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
icons/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
icons/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
icons/96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

1190
lib/JSDOMParser.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
// https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605
/* eslint-env es6:false */
/* globals exports */
/*
* 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|foot|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|main|shadow/i,
};
function isNodeVisible(node) {
// Have to null-check node.style to deal with SVG and MathML nodes.
return (!node.style || node.style.display != "none") && !node.hasAttribute("hidden");
}
/**
* Decides whether or not the document is reader-able without parsing the whole thing.
*
* @return boolean Whether or not we suspect Readability.parse() will suceeed at returning an article object.
*/
function isProbablyReaderable(doc, isVisible) {
if (!isVisible) {
isVisible = isNodeVisible;
}
var nodes = doc.querySelectorAll("p, pre");
// 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 (!isVisible(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 < 140) {
return false;
}
score += Math.sqrt(textContentLength - 140);
if (score > 20) {
return true;
}
return false;
});
}
if (typeof exports === "object") {
exports.isProbablyReaderable = isProbablyReaderable;
}

1854
lib/Readability.js Normal file

File diff suppressed because it is too large Load Diff

1187
lib/browser-polyfill.js Normal file

File diff suppressed because it is too large Load Diff

37
lib/cash.min.js vendored Normal file
View File

@@ -0,0 +1,37 @@
/* MIT https://github.com/kenwheeler/cash */
(function(){
'use strict';var e=document,g=window,k=e.createElement("div"),l=Array.prototype,m=l.filter,n=l.indexOf,aa=l.map,q=l.push,r=l.reverse,u=l.slice,v=l.some,ba=l.splice,ca=/^#[\w-]*$/,da=/^\.[\w-]*$/,ea=/<.+>/,fa=/^\w+$/;function x(a,b){void 0===b&&(b=e);return b&&9===b.nodeType||b&&1===b.nodeType?da.test(a)?b.getElementsByClassName(a.slice(1)):fa.test(a)?b.getElementsByTagName(a):b.querySelectorAll(a):[]}
var y=function(){function a(a,c){void 0===c&&(c=e);if(a){if(a instanceof y)return a;var b=a;if(z(a)){if(b=c instanceof y?c[0]:c,b=ca.test(a)?b.getElementById(a.slice(1)):ea.test(a)?A(a):x(a,b),!b)return}else if(B(a))return this.ready(a);if(b.nodeType||b===g)b=[b];this.length=b.length;a=0;for(c=this.length;a<c;a++)this[a]=b[a]}}a.prototype.init=function(b,c){return new a(b,c)};return a}(),C=y.prototype.init;C.fn=C.prototype=y.prototype;y.prototype.length=0;y.prototype.splice=ba;
"function"===typeof Symbol&&(y.prototype[Symbol.iterator]=Array.prototype[Symbol.iterator]);y.prototype.get=function(a){return void 0===a?u.call(this):this[0>a?a+this.length:a]};y.prototype.eq=function(a){return C(this.get(a))};y.prototype.first=function(){return this.eq(0)};y.prototype.last=function(){return this.eq(-1)};y.prototype.map=function(a){return C(aa.call(this,function(b,c){return a.call(b,c,b)}))};y.prototype.slice=function(){return C(u.apply(this,arguments))};var ha=/-([a-z])/g;
function ia(a,b){return b.toUpperCase()}function D(a){return a.replace(ha,ia)}C.camelCase=D;function E(a,b){for(var c=0,d=a.length;c<d&&!1!==b.call(a[c],c,a[c]);c++);}C.each=E;y.prototype.each=function(a){E(this,a);return this};y.prototype.removeProp=function(a){return this.each(function(b,c){delete c[a]})};function F(a){for(var b=1;b<arguments.length;b++);b=arguments;for(var c=b.length,d=2>c?0:1;d<c;d++)for(var f in b[d])a[f]=b[d][f];return a}y.prototype.extend=function(a){return F(C.fn,a)};
C.extend=F;C.guid=1;function G(a,b){var c=a&&(a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.msMatchesSelector||a.oMatchesSelector);return!!c&&c.call(a,b)}C.matches=G;function H(a,b,c){for(var d=[],f=0,h=a.length;f<h;f++)for(var p=a[f][b];null!=p;){d.push(p);if(!c)break;p=p[b]}return d}function I(a){return!!a&&a===a.window}function B(a){return"function"===typeof a}function z(a){return"string"===typeof a}function J(a){return!isNaN(parseFloat(a))&&isFinite(a)}var K=Array.isArray;
C.isWindow=I;C.isFunction=B;C.isString=z;C.isNumeric=J;C.isArray=K;y.prototype.prop=function(a,b){if(a){if(z(a))return 2>arguments.length?this[0]&&this[0][a]:this.each(function(c,f){f[a]=b});for(var c in a)this.prop(c,a[c]);return this}};function L(a){return z(a)?function(b,c){return G(c,a)}:B(a)?a:a instanceof y?function(b,c){return a.is(c)}:function(b,c){return c===a}}y.prototype.filter=function(a){if(!a)return C();var b=L(a);return C(m.call(this,function(a,d){return b.call(a,d,a)}))};
function M(a,b){return b&&a.length?a.filter(b):a}var ja=/\S+/g;function N(a){return z(a)?a.match(ja)||[]:[]}y.prototype.hasClass=function(a){return a&&v.call(this,function(b){return b.classList.contains(a)})};y.prototype.removeAttr=function(a){var b=N(a);return b.length?this.each(function(a,d){E(b,function(a,b){d.removeAttribute(b)})}):this};
y.prototype.attr=function(a,b){if(a){if(z(a)){if(2>arguments.length){if(!this[0])return;var c=this[0].getAttribute(a);return null===c?void 0:c}return void 0===b?this:null===b?this.removeAttr(a):this.each(function(c,f){f.setAttribute(a,b)})}for(c in a)this.attr(c,a[c]);return this}};y.prototype.toggleClass=function(a,b){var c=N(a),d=void 0!==b;return c.length?this.each(function(a,h){E(c,function(a,c){d?b?h.classList.add(c):h.classList.remove(c):h.classList.toggle(c)})}):this};
y.prototype.addClass=function(a){return this.toggleClass(a,!0)};y.prototype.removeClass=function(a){return arguments.length?this.toggleClass(a,!1):this.attr("class","")};function O(a){return 1<a.length?m.call(a,function(a,c,d){return n.call(d,a)===c}):a}C.unique=O;y.prototype.add=function(a,b){return C(O(this.get().concat(C(a,b).get())))};function P(a,b,c){if(a&&1===a.nodeType&&b)return a=g.getComputedStyle(a,null),b?c?a.getPropertyValue(b)||void 0:a[b]:a}
function Q(a,b){return parseInt(P(a,b),10)||0}var R=/^--/,S={},ka=k.style,la=["webkit","moz","ms","o"];function ma(a,b){void 0===b&&(b=R.test(a));if(b)return a;if(!S[a]){b=D(a);var c=""+b.charAt(0).toUpperCase()+b.slice(1);b=(b+" "+la.join(c+" ")+c).split(" ");E(b,function(b,c){if(c in ka)return S[a]=c,!1})}return S[a]}C.prefixedProp=ma;var na={animationIterationCount:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0};
function oa(a,b,c){void 0===c&&(c=R.test(a));return c||na[a]||!J(b)?b:b+"px"}y.prototype.css=function(a,b){if(z(a)){var c=R.test(a);a=ma(a,c);if(2>arguments.length)return this[0]&&P(this[0],a,c);if(!a)return this;b=oa(a,b,c);return this.each(function(d,h){h&&1===h.nodeType&&(c?h.style.setProperty(a,b):h.style[a]=b)})}for(var d in a)this.css(d,a[d]);return this};function pa(a,b){a=a.dataset?a.dataset[b]||a.dataset[D(b)]:a.getAttribute("data-"+b);try{return JSON.parse(a)}catch(c){}return a}var qa=/^data-(.+)/;
y.prototype.data=function(a,b){var c=this;if(!a){if(!this[0])return;var d={};E(this[0].attributes,function(a,b){(a=b.name.match(qa))&&(d[a[1]]=c.data(a[1]))});return d}if(z(a))return void 0===b?this[0]&&pa(this[0],a):this.each(function(c,d){c=b;try{c=JSON.stringify(c)}catch(w){}d.dataset?d.dataset[D(a)]=c:d.setAttribute("data-"+a,c)});for(var f in a)this.data(f,a[f]);return this};
function ra(a,b){return Q(a,"border"+(b?"Left":"Top")+"Width")+Q(a,"padding"+(b?"Left":"Top"))+Q(a,"padding"+(b?"Right":"Bottom"))+Q(a,"border"+(b?"Right":"Bottom")+"Width")}E(["Width","Height"],function(a,b){y.prototype["inner"+b]=function(){if(this[0])return I(this[0])?g["inner"+b]:this[0]["client"+b]}});
E(["width","height"],function(a,b){y.prototype[b]=function(c){if(!this[0])return void 0===c?void 0:this;if(!arguments.length)return I(this[0])?this[0][D("outer-"+b)]:this[0].getBoundingClientRect()[b]-ra(this[0],!a);var d=parseInt(c,10);return this.each(function(c,h){h&&1===h.nodeType&&(c=P(h,"boxSizing"),h.style[b]=oa(b,d+("border-box"===c?ra(h,!a):0)))})}});
E(["Width","Height"],function(a,b){y.prototype["outer"+b]=function(c){if(this[0])return I(this[0])?g["outer"+b]:this[0]["offset"+b]+(c?Q(this[0],"margin"+(a?"Top":"Left"))+Q(this[0],"margin"+(a?"Bottom":"Right")):0)}});var T={};
y.prototype.toggle=function(a){return this.each(function(b,c){if(a=void 0!==a?a:"none"===P(c,"display")){if(c.style.display="","none"===P(c,"display")){b=c.style;c=c.tagName;if(T[c])c=T[c];else{var d=e.createElement(c);e.body.appendChild(d);var f=P(d,"display");e.body.removeChild(d);c=T[c]="none"!==f?f:"block"}b.display=c}}else c.style.display="none"})};y.prototype.hide=function(){return this.toggle(!1)};y.prototype.show=function(){return this.toggle(!0)};
function sa(a,b){return!b||!v.call(b,function(b){return 0>a.indexOf(b)})}var U={focus:"focusin",blur:"focusout"},ta={mouseenter:"mouseover",mouseleave:"mouseout"},ua=/^(?:mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function va(a,b,c,d,f){f.guid=f.guid||C.guid++;var h=a.__cashEvents=a.__cashEvents||{};h[b]=h[b]||[];h[b].push([c,d,f]);a.addEventListener(b,f)}function V(a){a=a.split(".");return[a[0],a.slice(1).sort()]}
function W(a,b,c,d,f){var h=a.__cashEvents=a.__cashEvents||{};if(b)h[b]&&(h[b]=h[b].filter(function(h){var p=h[0],ya=h[1];h=h[2];if(f&&h.guid!==f.guid||!sa(p,c)||d&&d!==ya)return!0;a.removeEventListener(b,h)}));else{for(b in h)W(a,b,c,d,f);delete a.__cashEvents}}y.prototype.off=function(a,b,c){var d=this;void 0===a?this.each(function(a,b){return W(b)}):(B(b)&&(c=b,b=""),E(N(a),function(a,h){a=V(ta[h]||U[h]||h);var f=a[0],w=a[1];d.each(function(a,d){return W(d,f,w,b,c)})}));return this};
y.prototype.on=function(a,b,c,d){var f=this;if(!z(a)){for(var h in a)this.on(h,b,a[h]);return this}B(b)&&(c=b,b="");E(N(a),function(a,h){a=V(ta[h]||U[h]||h);var p=a[0],w=a[1];f.each(function(a,h){a=function za(a){if(!a.namespace||sa(w,a.namespace.split("."))){var f=h;if(b){for(var t=a.target;!G(t,b);){if(t===h)return;t=t.parentNode;if(!t)return}f=t;a.__delegate=!0}a.__delegate&&Object.defineProperty(a,"currentTarget",{configurable:!0,get:function(){return f}});t=c.call(f,a,a.data);d&&W(h,p,w,b,za);
!1===t&&(a.preventDefault(),a.stopPropagation())}};a.guid=c.guid=c.guid||C.guid++;va(h,p,w,b,a)})});return this};y.prototype.one=function(a,b,c){return this.on(a,b,c,!0)};y.prototype.ready=function(a){function b(){return a(C)}"loading"!==e.readyState?setTimeout(b):e.addEventListener("DOMContentLoaded",b);return this};
y.prototype.trigger=function(a,b){if(z(a)){var c=V(a);a=c[0];c=c[1];var d=ua.test(a)?"MouseEvents":"HTMLEvents";var f=e.createEvent(d);f.initEvent(a,!0,!0);f.namespace=c.join(".")}else f=a;f.data=b;var h=f.type in U;return this.each(function(a,b){if(h&&B(b[f.type]))b[f.type]();else b.dispatchEvent(f)})};function wa(a){return a.multiple?H(m.call(a.options,function(a){return a.selected&&!a.disabled&&!a.parentNode.disabled}),"value"):a.value||""}
var xa=/%20/g,Aa=/file|reset|submit|button|image/i,Ba=/radio|checkbox/i;y.prototype.serialize=function(){var a="";this.each(function(b,c){E(c.elements||[c],function(b,c){c.disabled||!c.name||"FIELDSET"===c.tagName||Aa.test(c.type)||Ba.test(c.type)&&!c.checked||(b=wa(c),void 0!==b&&(b=K(b)?b:[b],E(b,function(b,d){b=a;d="&"+encodeURIComponent(c.name)+"="+encodeURIComponent(d).replace(xa,"+");a=b+d})))})});return a.substr(1)};
y.prototype.val=function(a){return void 0===a?this[0]&&wa(this[0]):this.each(function(b,c){if("SELECT"===c.tagName){var d=K(a)?a:null===a?[]:[a];E(c.options,function(a,b){b.selected=0<=d.indexOf(b.value)})}else c.value=null===a?"":a})};y.prototype.clone=function(){return this.map(function(a,b){return b.cloneNode(!0)})};y.prototype.detach=function(){return this.each(function(a,b){b.parentNode&&b.parentNode.removeChild(b)})};var Ca=/^\s*<(\w+)[^>]*>/,Da=/^\s*<(\w+)\s*\/?>(?:<\/\1>)?\s*$/,X;
function A(a){if(!X){var b=e.createElement("table"),c=e.createElement("tr");X={"*":k,tr:e.createElement("tbody"),td:c,th:c,thead:b,tbody:b,tfoot:b}}if(!z(a))return[];if(Da.test(a))return[e.createElement(RegExp.$1)];b=Ca.test(a)&&RegExp.$1;b=X[b]||X["*"];b.innerHTML=a;return C(b.childNodes).detach().get()}C.parseHTML=A;y.prototype.empty=function(){return this.each(function(a,b){for(;b.firstChild;)b.removeChild(b.firstChild)})};
y.prototype.html=function(a){return void 0===a?this[0]&&this[0].innerHTML:this.each(function(b,c){c.innerHTML=a})};y.prototype.remove=function(){return this.detach().off()};y.prototype.text=function(a){return void 0===a?this[0]?this[0].textContent:"":this.each(function(b,c){c.textContent=a})};y.prototype.unwrap=function(){this.parent().each(function(a,b){a=C(b);a.replaceWith(a.children())});return this};var Ea=e.documentElement;
y.prototype.offset=function(){var a=this[0];if(a)return a=a.getBoundingClientRect(),{top:a.top+g.pageYOffset-Ea.clientTop,left:a.left+g.pageXOffset-Ea.clientLeft}};y.prototype.offsetParent=function(){return C(this[0]&&this[0].offsetParent)};y.prototype.position=function(){var a=this[0];if(a)return{left:a.offsetLeft,top:a.offsetTop}};y.prototype.children=function(a){var b=[];this.each(function(a,d){q.apply(b,d.children)});return M(C(O(b)),a)};
y.prototype.contents=function(){var a=[];this.each(function(b,c){q.apply(a,"IFRAME"===c.tagName?[c.contentDocument]:c.childNodes)});return C(O(a))};y.prototype.find=function(a){for(var b=[],c=0,d=this.length;c<d;c++){var f=x(a,this[c]);f.length&&q.apply(b,f)}return C(O(b))};var Fa=/^$|^module$|\/(?:java|ecma)script/i,Ga=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g;
function Y(a){a=C(a);a.filter("script").add(a.find("script")).each(function(a,c){!c.src&&Fa.test(c.type)&&c.ownerDocument.documentElement.contains(c)&&eval(c.textContent.replace(Ga,""))})}function Z(a,b,c){E(a,function(a,f){E(b,function(b,d){b=a?d.cloneNode(!0):d;c?f.insertBefore(b,c&&f.firstChild):f.appendChild(b);Y(b)})})}y.prototype.append=function(){var a=this;E(arguments,function(b,c){Z(a,C(c))});return this};y.prototype.appendTo=function(a){Z(C(a),this);return this};
y.prototype.insertAfter=function(a){var b=this;C(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d.nextSibling);Y(b)})});return this};y.prototype.after=function(){var a=this;E(r.apply(arguments),function(b,c){r.apply(C(c).slice()).insertAfter(a)});return this};y.prototype.insertBefore=function(a){var b=this;C(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d);Y(b)})});return this};
y.prototype.before=function(){var a=this;E(arguments,function(b,c){C(c).insertBefore(a)});return this};y.prototype.prepend=function(){var a=this;E(arguments,function(b,c){Z(a,C(c),!0)});return this};y.prototype.prependTo=function(a){Z(C(a),r.apply(this.slice()),!0);return this};y.prototype.replaceWith=function(a){return this.before(a).remove()};y.prototype.replaceAll=function(a){C(a).replaceWith(this);return this};
y.prototype.wrapAll=function(a){if(this[0]){a=C(a);this.first().before(a);for(a=a[0];a.children.length;)a=a.firstElementChild;this.appendTo(a)}return this};y.prototype.wrap=function(a){return this.each(function(b,c){var d=C(a)[0];C(c).wrapAll(b?d.cloneNode(!0):d)})};y.prototype.wrapInner=function(a){return this.each(function(b,c){b=C(c);c=b.contents();c.length?c.wrapAll(a):b.append(a)})};
y.prototype.has=function(a){var b=z(a)?function(b,d){return!!x(a,d).length}:function(b,d){return d.contains(a)};return this.filter(b)};y.prototype.is=function(a){if(!a||!this[0])return!1;var b=L(a),c=!1;this.each(function(a,f){c=b.call(f,a,f);return!c});return c};y.prototype.next=function(a,b){return M(C(O(H(this,"nextElementSibling",b))),a)};y.prototype.nextAll=function(a){return this.next(a,!0)};
y.prototype.not=function(a){if(!a||!this[0])return this;var b=L(a);return this.filter(function(a,d){return!b.call(d,a,d)})};y.prototype.parent=function(a){return M(C(O(H(this,"parentNode"))),a)};y.prototype.index=function(a){var b=a?C(a)[0]:this[0];a=a?this:C(b).parent().children();return n.call(a,b)};y.prototype.closest=function(a){if(!a||!this[0])return C();var b=this.filter(a);return b.length?b:this.parent().closest(a)};
y.prototype.parents=function(a){return M(C(O(H(this,"parentElement",!0))),a)};y.prototype.prev=function(a,b){return M(C(O(H(this,"previousElementSibling",b))),a)};y.prototype.prevAll=function(a){return this.prev(a,!0)};y.prototype.siblings=function(a){var b=[];this.each(function(a,d){q.apply(b,C(d).parent().children(function(a,b){return b!==d}))});return M(C(O(b)),a)};"undefined"!==typeof exports?module.exports=C:g.cash=g.$=C;
})();

266
lib/toast.js Normal file
View File

@@ -0,0 +1,266 @@
/***********************************************
"toast.js"
Created by Michael Cheng on 05/31/2015 22:34
http://michaelcheng.us/
michael@michaelcheng.us
--All Rights Reserved--
***********************************************/
'use strict';
/**
* The Toast animation speed; how long the Toast takes to move to and from the screen
* @type {Number}
*/
const TOAST_ANIMATION_SPEED = 400;
const Transitions = {
SHOW: {
'-webkit-transition': 'opacity ' + TOAST_ANIMATION_SPEED + 'ms, -webkit-transform ' + TOAST_ANIMATION_SPEED + 'ms',
'transition': 'opacity ' + TOAST_ANIMATION_SPEED + 'ms, transform ' + TOAST_ANIMATION_SPEED + 'ms',
'opacity': '1',
'-webkit-transform': 'translateY(-100%) translateZ(0)',
'transform': 'translateY(-100%) translateZ(0)'
},
HIDE: {
'opacity': '0',
'-webkit-transform': 'translateY(150%) translateZ(0)',
'transform': 'translateY(150%) translateZ(0)'
}
};
/**
* The main Toast object
* @param {String} text The text to put inside the Toast
* @param {Object} options Optional; the Toast options. See Toast.prototype.DEFAULT_SETTINGS for more information
* @param {Object} transitions Optional; the Transitions object. This should not be used unless you know what you're doing
*/
function Toast(text, options, transitions) {
if(getToastStage() !== null) {
// If there is already a Toast being shown, put this Toast in the queue to show later
Toast.prototype.toastQueue.push({
text: text,
options: options,
transitions: transitions
});
} else {
Toast.prototype.Transitions = transitions || Transitions;
var _options = options || {};
_options = Toast.prototype.mergeOptions(Toast.prototype.DEFAULT_SETTINGS, _options);
Toast.prototype.show(text, _options);
_options = null;
}
}
/**
* The toastStage. This is the HTML element in which the toast resides
* Getter and setter methods are available privately
* @type {Element}
*/
var _toastStage = null;
function getToastStage() {
return _toastStage;
}
function setToastStage(toastStage) {
_toastStage = toastStage;
}
// define some Toast constants
/**
* The default Toast settings
* @type {Object}
*/
Toast.prototype.DEFAULT_SETTINGS = {
style: {
main: {
'background': 'rgba(0, 0, 0, .8)',
'box-shadow': '0 0 10px rgba(0, 0, 0, .8)',
'border-radius': '3px',
'z-index': '99999',
'color': 'rgba(255, 255, 255, .9)',
'padding': '10px 15px',
'max-width': '60%',
'width': '100%',
'word-break': 'keep-all',
'margin': '0 auto',
'text-align': 'center',
'position': 'fixed',
'left': '0',
'right': '0',
'bottom': '0',
'-webkit-transform': 'translateY(150%) translateZ(0)',
'transform': 'translateY(150%) translateZ(0)',
'-webkit-filter': 'blur(0)',
'opacity': '0'
}
},
settings: {
duration: 4000
}
};
Toast.prototype.Transitions = {};
/**
* The queue of Toasts waiting to be shown
* @type {Array}
*/
Toast.prototype.toastQueue = [];
/**
* The Timeout object for animations.
* This should be shared among the Toasts, because timeouts may be cancelled e.g. on explicit call of hide()
* @type {Object}
*/
Toast.prototype.timeout = null;
/**
* Merge the DEFAULT_SETTINGS with the user defined options if specified
* @param {Object} options The user defined options
*/
Toast.prototype.mergeOptions = function(initialOptions, customOptions) {
var merged = customOptions;
for(var prop in initialOptions) {
if(merged.hasOwnProperty(prop)) {
if(initialOptions[prop] !== null && initialOptions[prop].constructor === Object) {
merged[prop] = Toast.prototype.mergeOptions(initialOptions[prop], merged[prop]);
}
} else {
merged[prop] = initialOptions[prop];
}
}
return merged;
};
/**
* Generate the Toast with the specified text.
* @param {String|Object} text The text to show inside the Toast, can be an HTML element or plain text
* @param {Object} style The style to set for the Toast
*/
Toast.prototype.generate = function(text, style) {
var toastStage = document.createElement('div');
/**
* If the text is a String, create a textNode for appending
*/
if(typeof text === 'string') {
text = document.createTextNode(text);
}
toastStage.appendChild(text);
setToastStage(toastStage);
toastStage = null;
Toast.prototype.stylize(getToastStage(), style);
};
/**
* Stylize the Toast.
* @param {Element} element The HTML element to stylize
* @param {Object} styles An object containing the style to apply
* @return Returns nothing
*/
Toast.prototype.stylize = function(element, styles) {
Object.keys(styles).forEach(function(style) {
element.style[style] = styles[style];
});
};
/**
* Show the Toast
* @param {String} text The text to show inside the Toast
* @param {Object} options The object containing the options for the Toast
*/
Toast.prototype.show = function(text, options) {
this.generate(text, options.style.main);
var toastStage = getToastStage();
document.body.insertBefore(toastStage, document.body.firstChild);
// This is a hack to get animations started. Apparently without explicitly redrawing, it'll just attach the class and no animations would be done
toastStage.offsetHeight;
Toast.prototype.stylize(toastStage, Toast.prototype.Transitions.SHOW);
toastStage = null;
// Hide the Toast after the specified time
clearTimeout(Toast.prototype.timeout);
Toast.prototype.timeout = setTimeout(Toast.prototype.hide, options.settings.duration);
};
/**
* Hide the Toast that's currently shown
*/
Toast.prototype.hide = function() {
var toastStage = getToastStage();
Toast.prototype.stylize(toastStage, Toast.prototype.Transitions.HIDE);
// Destroy the Toast element after animations end
clearTimeout(Toast.prototype.timeout);
toastStage.addEventListener('transitionend', Toast.prototype.animationListener);
toastStage = null;
};
Toast.prototype.animationListener = function() {
getToastStage().removeEventListener('transitionend', Toast.prototype.animationListener);
Toast.prototype.destroy.call(this);
};
/**
* Clean up after the Toast slides away. Namely, removing the Toast from the DOM. After the Toast is cleaned up, display the next Toast in the queue if any exists
*/
Toast.prototype.destroy = function() {
var toastStage = getToastStage();
document.body.removeChild(toastStage);
toastStage = null;
setToastStage(null);
if(Toast.prototype.toastQueue.length > 0) {
// Show the rest of the Toasts in the queue if they exist
var toast = Toast.prototype.toastQueue.shift();
Toast(toast.text, toast.options, toast.transitions);
// clean up
toast = null;
}
};
window.showToast = Toast;
"END OF FILE"; // to avoid "result is non-structured-clonable data"

55
manifest.json Normal file
View File

@@ -0,0 +1,55 @@
{
"manifest_version": 2,
"name": "Trilium Web Clipper (dev)",
"version": "0.1.5",
"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"
},
"browser_specific_settings": {
"gecko": {
"id": "{1410742d-b377-40e7-a9db-63dc9c6ec99c}"
}
}
}

67
options/options.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
</head>
<body>
<div id="error-message" style="font-weight: bold; color: red; display: none;"></div>
<div id="success-message" style="font-weight: bold; color: green; display: none;"></div>
<h2>Trilium desktop instance</h2>
<p>Web clipper by default tries to find a running desktop instance on ports 37740 - 37749. If you configured your Trilium desktop app to run on a different port, you can specify it here (otherwise keep it empty).</p>
<form id="trilium-desktop-setup-form">
<p>
<label for="trilium-desktop-port" style="font-weight: bold;">Trilium desktop port: </label>
<input type="text" id="trilium-desktop-port" size="6" style="text-align: right;"/> (normally keep this empty)
</p>
<input type="submit" value="Save">
</form>
<h2>Trilium server instance</h2>
<p>If you have a server instance set up, you can optionally configure it as a fail over target for the clipped notes. Desktop instance will still be given priority, but in cases that the desktop instance is not available (e.g. it's not running), web clipper will send the notes to the server instance instead.</p>
<div id="trilium-server-configured" style="display: none;">
<strong>Trilium server instance has been already configured to <a id="trilium-server-link" href=""></a>.</strong>
<p>You can also <a id="reset-trilium-server-setup" href="javascript:">remove the current setup</a> and configure it again.</p>
</div>
<form id="trilium-server-setup-form" style="display: none;">
<table>
<tr>
<th>Trilium server URL:</th>
<td><input type="text" id="trilium-server-url"/></td>
</tr>
<tr>
<th>Username:</th>
<td><input type="text" id="trilium-server-username"/></td>
</tr>
<tr>
<th>Password:</th>
<td><input type="password" id="trilium-server-password"/></td>
</tr>
<tr>
<th></th>
<td><input type="submit" value="Login to the server instance"/></td>
</tr>
</table>
<p>Note that the entered credentials are not stored anywhere, they 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>
</body>
</html>

140
options/options.js Normal file
View File

@@ -0,0 +1,140 @@
const $triliumServerUrl = $("#trilium-server-url");
const $triliumServerUsername = $("#trilium-server-username");
const $triliumServerPassword = $("#trilium-server-password");
const $errorMessage = $("#error-message");
const $successMessage = $("#success-message");
function showError(message) {
$errorMessage.html(message).show();
$successMessage.hide();
}
function showSuccess(message) {
$successMessage.html(message).show();
$errorMessage.hide();
}
async function saveTriliumServerSetup(e) {
e.preventDefault();
if ($triliumServerUrl.val().trim().length === 0
|| $triliumServerUsername.val().trim().length === 0
|| $triliumServerPassword.val().trim().length === 0) {
showError("One or more mandatory inputs are missing. Please fill in server URL, username and password.");
return;
}
let resp;
try {
resp = await fetch($triliumServerUrl.val() + '/api/login/token', {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: $triliumServerUsername.val(),
password: $triliumServerPassword.val()
})
});
}
catch (e) {
showError("Unknown error: " + e.message);
return;
}
if (resp.status === 401) {
showError("Incorrect credentials.");
}
else if (resp.status !== 200) {
showError("Unrecognised response with status code " + resp.status);
}
else {
const json = await resp.json();
showSuccess("Authentication against Trilium server has been successful.");
$triliumServerUsername.val('');
$triliumServerPassword.val('');
browser.storage.sync.set({
triliumServerUrl: $triliumServerUrl.val(),
authToken: json.token
});
await restoreOptions();
}
}
const $triliumServerSetupForm = $("#trilium-server-setup-form");
const $triliumServerConfiguredDiv = $("#trilium-server-configured");
const $triliumServerLink = $("#trilium-server-link");
const $resetTriliumServerSetupLink = $("#reset-trilium-server-setup");
$resetTriliumServerSetupLink.on("click", e => {
e.preventDefault();
browser.storage.sync.set({
triliumServerUrl: '',
authToken: ''
});
restoreOptions();
});
$triliumServerSetupForm.on("submit", saveTriliumServerSetup);
const $triliumDesktopPort = $("#trilium-desktop-port");
const $triilumDesktopSetupForm = $("#trilium-desktop-setup-form");
$triilumDesktopSetupForm.on("submit", e => {
e.preventDefault();
const port = $triliumDesktopPort.val().trim();
const portNum = parseInt(port);
if (port && (isNaN(portNum) || portNum <= 0 || portNum >= 65536)) {
showError(`Please enter valid port number.`);
return;
}
browser.storage.sync.set({
triliumDesktopPort: port
});
showSuccess(`Port number has been saved.`);
});
async function restoreOptions() {
const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl");
const {authToken} = await browser.storage.sync.get("authToken");
$errorMessage.hide();
$successMessage.hide();
$triliumServerUrl.val('');
$triliumServerUsername.val('');
$triliumServerPassword.val('');
if (triliumServerUrl && authToken) {
$triliumServerSetupForm.hide();
$triliumServerConfiguredDiv.show();
$triliumServerLink
.attr("href", triliumServerUrl)
.text(triliumServerUrl);
}
else {
$triliumServerSetupForm.show();
$triliumServerConfiguredDiv.hide();
}
const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort");
$triliumDesktopPort.val(triliumDesktopPort);
}
$(restoreOptions);

47
popup/popup.css Normal file
View File

@@ -0,0 +1,47 @@
html, body {
width: 300px;
font-size: 12px;
}
.button {
margin: 3% auto;
padding: 4px;
text-align: center;
border: 1px solid #ccc;
border-radius: 3px;
background-color: #eee;
cursor: pointer;
color: black;
}
.wide {
min-width: 8em;
}
.full {
display: block;
width: 100%;
}
#create-text-note-wrapper {
display: none;
}
#create-text-note-textarea {
width: 100%;
}
#save-button {
border-color: #0062cc;
background-color: #0069d9;
color: white;
}
#check-connection-button {
float: right;
margin-top: -6px;
}
button[disabled] {
color: #aaa;
}

46
popup/popup.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<link rel="stylesheet" href="popup.css"/>
</head>
<body>
<div style="display: flex; justify-content: space-between; vertical-align: middle;">
<h3>Trilium Web Clipper</h3>
<div style="position: relative; top: 6px;">
<button class="button" id="show-options-button">Options</button>
<button class="button" id="show-help-button">Help</button>
</div>
</div>
<button class="button full needs-connection" id="clip-screenshot-button">Clip screenshot</button>
<button class="button full needs-connection" id="save-whole-page-button">Save whole page</button>
<button class="button full needs-connection" id="create-text-note-button">Create text note</button>
<div id="create-text-note-wrapper">
<textarea id="create-text-note-textarea" rows="5"></textarea>
<div style="display: flex;">
<button type="submit" class="button wide" id="save-button">Save</button>
<button type="submit" class="button wide" id="cancel-button">Cancel</button>
</div>
</div>
<div style="margin-top: 15px;">
<button class="button" id="check-connection-button">check</button>
<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>
</body>
</html>

138
popup/popup.js Normal file
View File

@@ -0,0 +1,138 @@
async function sendMessage(message) {
try {
return await browser.runtime.sendMessage(message);
}
catch (e) {
console.log("Calling browser runtime failed:", e);
alert("Calling browser runtime failed. Refreshing page might help.");
}
}
const $showOptionsButton = $("#show-options-button");
const $clipScreenShotButton = $("#clip-screenshot-button");
const $saveWholePageButton = $("#save-whole-page-button");
$showOptionsButton.on("click", () => browser.runtime.openOptionsPage());
$clipScreenShotButton.on("click", () => sendMessage({name: 'save-screenshot'}));
$saveWholePageButton.on("click", () => sendMessage({name: 'save-whole-page'}));
const $createTextNoteWrapper = $("#create-text-note-wrapper");
const $textNote = $("#create-text-note-textarea");
$textNote.on('keypress', function (event) {
if (event.which === 10 || event.which === 13 && event.ctrlKey) {
saveNote();
return false;
}
return true;
});
$("#create-text-note-button").on("click", () => {
$createTextNoteWrapper.show();
$textNote[0].focus();
});
$("#cancel-button").on("click", () => {
$createTextNoteWrapper.hide();
$textNote.val("");
window.close();
});
async function saveNote() {
const textNoteVal = $textNote.val().trim();
if (textNoteVal.length === 0) {
alert("Note is empty. Please enter some text");
return;
}
const match = /^(.*?)([.?!]\s|\n)/.exec(textNoteVal);
let title, content;
if (match) {
title = match[0].trim();
content = textNoteVal.substr(title.length).trim();
}
else {
title = textNoteVal;
content = '';
}
content = escapeHtml(content);
const result = await sendMessage({name: 'save-note', title, content});
if (result) {
$textNote.val('');
window.close();
}
}
$("#save-button").on("click", saveNote);
$("#show-help-button").on("click", () => {
window.open("https://github.com/zadam/trilium/wiki/Web-clipper", '_blank');
});
function escapeHtml(string) {
const pre = document.createElement('pre');
const text = document.createTextNode(string);
pre.appendChild(text);
const htmlWithPars = pre.innerHTML.replace(/\n/g, "</p><p>");
return '<p>' + htmlWithPars + '</p>';
}
const $connectionStatus = $("#connection-status");
const $needsConnection = $(".needs-connection");
browser.runtime.onMessage.addListener(request => {
if (request.name === 'trilium-search-status') {
const {triliumSearch} = request;
let statusText = triliumSearch.status;
let isConnected;
if (triliumSearch.status === 'not-found') {
statusText = `<span style="color: red">Not found</span>`;
isConnected = false;
}
else if (triliumSearch.status === 'found-desktop') {
statusText = `<span style="color: green">Connected on port ${triliumSearch.port}</span>`;
isConnected = true;
}
else if (triliumSearch.status === 'found-server') {
statusText = `<span style="color: green" title="Connected to ${triliumSearch.url}">Connected to the server</span>`;
isConnected = true;
}
$connectionStatus.html(statusText);
if (isConnected) {
$needsConnection.removeAttr("disabled");
$needsConnection.removeAttr("title");
}
else {
$needsConnection.attr("disabled", "disabled");
$needsConnection.attr("title", "This action can't be performed without active connection to Trilium.");
}
}
});
const $checkConnectionButton = $("#check-connection-button");
$checkConnectionButton.on("click", () => {
browser.runtime.sendMessage({
name: "trigger-trilium-search"
})
});
$(() => browser.runtime.sendMessage({name: "send-trilium-search-status"}));

9
trilium-web-clipper.iml Normal file
View File

@@ -0,0 +1,9 @@
<?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$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

166
trilium_server_facade.js Normal file
View File

@@ -0,0 +1,166 @@
function isDevEnv() {
const manifest = browser.runtime.getManifest();
return manifest.name.endsWith('(dev)') >= 0;
}
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
}
setTriliumSearch(ts) {
this.triliumSearch = ts;
this.sendTriliumSearchStatusToPopup();
}
async triggerSearchForTrilium() {
this.setTriliumSearch({ status: 'searching' });
const startingPort = await this.getStartingPort();
for (let testedPort = startingPort; testedPort < startingPort + 10; testedPort++) {
try {
console.debug('Trying port ' + testedPort);
const resp = await fetch(`http://127.0.0.1:${testedPort}/api/clipper/handshake`);
const text = await resp.text();
console.log("Received response:", text);
const json = JSON.parse(text);
if (json.appName === 'trilium') {
this.setTriliumSearch({
status: 'found-desktop',
port: testedPort,
url: 'http://127.0.0.1:' + testedPort
});
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.setTriliumSearch({
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 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 getStartingPort() {
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 || "";
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;
}
}
}
window.triliumServerFacade = new TriliumServerFacade();

10
utils.js Normal file
View File

@@ -0,0 +1,10 @@
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;
}