Last active
February 1, 2023 02:51
-
-
Save hannesfrank/d241e10d3fff32068bc9b60253d58371 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Smart Rem Alpha | |
// @version 1.2.5 | |
// @match https://www.remnote.io/* | |
// @description User Script of Smart Rem alpha version | |
// @license MIT | |
// @author Hannes Frank | |
// @namespace https://github.com/hannesfrank | |
// @run-at document-idle | |
// ==/UserScript== | |
function getSettings() { | |
return { | |
// Legacy web app | |
DATABASE_NAME: `lnotes`, | |
// Desktop app and sometimes web app | |
// DATABASE_NAME: `remnote-${currentKnowledgeBaseId()}`, | |
DATABASE_VERSION: 28, | |
SMART_REM_PREFIX: ">>>" | |
}; | |
} | |
// =============== Query explorations ================ | |
function remContent(rem) { | |
return [...rem.key, ...(rem.value || [])]; | |
} | |
async function getRemText(remId, exploredRem = []) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElementsText = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return richTextElement; | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if ( | |
richTextElement.i == "q" && | |
!exploredRem.includes(richTextElement._id) | |
) { | |
return await getRemText( | |
richTextElement._id, | |
exploredRem.concat([richTextElement._id]) | |
); | |
} else { | |
// If the Rem is some other rich text element, just take its .text property. | |
return richTextElement.text; | |
} | |
}) | |
); | |
return richTextElementsText.join(""); | |
} | |
async function makeRemContainer(rem) { | |
//const template = document.createElement("template"); | |
//template.innerHTML = `<div class="sr-rem-container">${await getRemText(rem._id)}</div>`; | |
//return template.content.firstChild; | |
const text = await getRemText(rem._id); | |
return `<div class="sr-rem-container">${text}</div>`; | |
} | |
function hasReference(rem, refId) { | |
for (let part of remContent(rem)) { | |
if (typeof part === "object" && part.i === "q" && part._id === refId) { | |
return true; | |
} | |
} | |
return false; | |
} | |
function hasTag(rem, tagId) { | |
/* TODO: Not implemented */ | |
} | |
/** | |
* returns query AST as a json object representing the postorder | |
*/ | |
async function parseQuery(rawExpression) { | |
// TODO: I can be even more explicit with hasReference, hasTag and maybe date operators. | |
// This would play more nicely with not | |
const resolvedReferences = await Promise.all( | |
rawExpression.map(async (part) => { | |
if (typeof part === "string") { | |
return part; | |
} | |
if (typeof part === "object" && part.i === "q") { | |
return `"${part._id}"`; | |
} | |
return "??"; | |
}) | |
); | |
const queryStr = resolvedReferences.join(""); | |
console.info("Query STR", queryStr); | |
const query = JSON.parse(queryStr); | |
console.info("Query AST", query); | |
return query; | |
} | |
async function resolveQuery(queryAST) { | |
if (queryAST.and) { | |
const remIds = queryAST.and; | |
console.info("and", remIds); | |
const result = []; | |
let cursor = await db.transaction("quanta").store.openCursor(); | |
// There is an async iterator version which might make this more responsive | |
// for await (const cursor of tx.store) { | |
// const rem = cursor.value; | |
// if (remIds.every(remId => hasReference(rem, remId))) { | |
// result.push(rem) | |
// } | |
// } | |
while (cursor) { | |
const rem = cursor.value; | |
if (remIds.every((remId) => hasReference(rem, remId))) { | |
result.push(rem); | |
} | |
cursor = await cursor.continue(); | |
} | |
console.warn("query result", result); | |
return result; | |
} else if (queryAST.or) { | |
const remIds = queryAST.or; | |
console.info("or", remIds); | |
const result = []; | |
let cursor = await db.transaction("quanta").store.openCursor(); | |
while (cursor) { | |
const rem = cursor.value; | |
if (remIds.some((remId) => hasReference(rem, remId))) { | |
result.push(rem); | |
} | |
cursor = await cursor.continue(); | |
} | |
console.warn("query result", result); | |
return result; | |
} else if (false && queryAST.not) { | |
const remIds = queryAST.not; | |
console.info("or", remIds); | |
const result = []; | |
let cursor = await db.transaction("quanta").store.openCursor(); | |
while (cursor) { | |
const rem = cursor.value; | |
if (remIds.some((remId) => hasReference(rem, remId))) { | |
result.push(rem); | |
} | |
cursor = await cursor.continue(); | |
} | |
console.warn("query result", result); | |
return result; | |
} | |
return []; | |
} | |
// ============= Common functions ========== | |
async function getRemMarkdown(remId) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElements = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return richTextElement; | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if (richTextElement.i == "q") { | |
return await getRemText(richTextElement._id); | |
} else if (richTextElement.i == "m") { | |
// TODO: There is much missing here: images, code blocks, latex | |
let md = richTextElement.text; | |
if (richTextElement.b) { | |
md = `**${md}**`; | |
} | |
if (richTextElement.l) { | |
md = `_${md}_`; | |
} | |
if (richTextElement.x) { | |
md = `$${md}$`; | |
} | |
if (richTextElement.q) { | |
md = `\`${md}\``; | |
} | |
return md; | |
} else { | |
return richTextElement.text; | |
} | |
}) | |
); | |
return richTextElements.join(""); | |
} | |
async function getRemTex(remId) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElementsTex = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return richTextElement; | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if (richTextElement.i == "q") { | |
return await getRemText(richTextElement._id); | |
} else if (richTextElement.i == "m") { | |
// TODO: There is much missing here: images, code blocks, latex | |
let tex = richTextElement.text; | |
if (richTextElement.b) { | |
tex = `\\textbf{${tex}}`; | |
} | |
if (richTextElement.l) { | |
tex = `\\textit{${tex}}`; | |
} | |
if (richTextElement.x) { | |
tex = `\\(${tex}\\)`; | |
} | |
if (richTextElement.q) { | |
tex = `\\texttt{${tex}}`; | |
} | |
return tex; | |
} else { | |
return richTextElement.text; | |
} | |
}) | |
); | |
return richTextElementsTex.join(""); | |
} | |
async function getRemText(remId, exploredRem = []) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElementsText = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return richTextElement; | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if ( | |
richTextElement.i == "q" && | |
!exploredRem.includes(richTextElement._id) | |
) { | |
return await getRemText( | |
richTextElement._id, | |
exploredRem.concat([richTextElement._id]) | |
); | |
} else { | |
// If the Rem is some other rich text element, just take its .text property. | |
return richTextElement.text; | |
} | |
}) | |
); | |
return richTextElementsText.join(""); | |
} | |
async function getRemHTML(remId, exploredRem = []) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElementsHTML = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return richTextElement; | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if (richTextElement.i == "q") { | |
return await getRemText(richTextElement._id); | |
} else if (richTextElement.i == "m") { | |
// TODO: There is much missing here: images, code blocks, latex | |
let html = richTextElement.text; | |
if (richTextElement.b) { | |
html = `<strong>${html}</strong>`; | |
} | |
if (richTextElement.l) { | |
html = `<em>${html}</em>`; | |
} | |
return html; | |
} else { | |
return richTextElement.text; | |
} | |
}) | |
); | |
return richTextElementsHTML.join(""); | |
} | |
// ============= Smart Rem ================= | |
// ---------- Smart Rem Util --------------- | |
// Return promises for each dependency | |
// Check if link elements also have an onload event | |
function addDependency(url) { | |
if (url.endsWith(".css")) { | |
return addCSSDependency(url); | |
} else if (url.endsWith(".js")) { | |
return addJsDependency(url); | |
} else { | |
// TODO: do error handling here | |
return new Promise((_, reject) => { | |
console.error("Could not load dependency", url); | |
reject(url); | |
}); | |
} | |
} | |
function addCSSDependency(url) { | |
return new Promise((resolve, reject) => { | |
const link = document.createElement("link"); | |
link.rel = "stylesheet"; | |
link.type = "text/css"; | |
link.href = url; | |
document.getElementsByTagName("head")[0].appendChild(link); | |
// For now I do not wait on loading them since it is not that | |
// important for CSS to arive first. | |
resolve(); | |
}); | |
} | |
function addJsDependency(url) { | |
return new Promise((resolve, reject) => { | |
const script = document.createElement("script"); | |
script.src = url; | |
script.addEventListener("load", resolve); | |
script.addEventListener("error", (e) => reject(e.error)); | |
document.getElementsByTagName("head")[0].appendChild(script); | |
}); | |
// https://stackoverflow.com/questions/8578617/inject-a-script-tag-with-remote-src-and-wait-for-it-to-execute | |
// (function(d, s, id){ | |
// var js, fjs = d.getElementsByTagName(s)[0]; | |
// if (d.getElementById(id)){ return; } | |
// js = d.createElement(s); js.id = id; | |
// js.onload = function(){ | |
// // remote script has loaded | |
// }; | |
// js.src = "//connect.facebook.net/en_US/sdk.js"; | |
// fjs.parentNode.insertBefore(js, fjs); | |
// }(document, 'script', 'facebook-jssdk')); | |
} | |
// ------- Smart Rem Definitions ----------- | |
async function f() { | |
const settings = getSettings(); | |
const REM_ID_LENGTH = 17; | |
const SMART_REM_PREFIX = settings.SMART_REM_PREFIX; | |
function matchRegex(regex) { | |
return (el) => { | |
const smartCommandText = el.remData.key[0] | |
.slice(SMART_REM_PREFIX.length) | |
.trim(); | |
return regex.exec(smartCommandText); | |
}; | |
} | |
function matchTag(remId) { | |
return (el) => { | |
const tags = el.remData.typeParents; | |
return tags.includes(remId); | |
} | |
} | |
let enabledSmartCommands = [ | |
{ | |
_dependencies: [ | |
"https://cdn.jsdelivr.net/npm/@argdown/web-components/dist/argdown-map.js", | |
"https://cdn.jsdelivr.net/npm/@argdown/web-components/dist/argdown-map.css", | |
"https://cdn.jsdelivr.net/npm/markdown-it@12.0.4/dist/markdown-it.min.js", | |
], | |
matcher: matchRegex(/^\s*argdown:/), | |
handler: async (match, el) => { | |
var md = window.markdownit(); | |
var result = md.render('# markdown-it rulezz!'); | |
// user/repo | |
// title | |
// content | |
// content | |
// Adapted from https://github.com/sindresorhus/new-github-issue-url | |
function createIssueURL(userRepo, title, body) { | |
const repoUrl = `https://github.com/${userRepo}`; | |
const url = new URL(`${repoUrl}/issues/new`); | |
const params = [ | |
"body", | |
"title", | |
"labels", | |
"template", | |
"milestone", | |
"assignee", | |
"projects", | |
]; | |
url.searchParams.set("title", title); | |
url.searchParams.set("body", body); | |
return url.toString(); | |
} | |
const children = await Promise.all( | |
el.remData.children.map(async (childId) => await getRemText(childId)) | |
); | |
if (children.length < 3) { | |
return `<p>Specify at least user/repo, title and content as children of this rem.</p>`; | |
} | |
const [userRepo, title, ...body] = children; | |
console.info(body); | |
const url = createIssueURL( | |
userRepo.trim(), | |
title.trim(), | |
body.join("\n") | |
); | |
// TODO: Fragments should be applied with tags | |
return `<p>Report issue for github.com/${userRepo}: <a href="${url}" target="_blank"><button>Create Issue</button></a></p>`; | |
}, | |
}, | |
{ | |
// TODO: Listen to changes of child rems | |
// TODO: Can an API be used to allow screenshots? | |
matcher: matchRegex(/^\s*github-issue/), | |
handler: async (match, el) => { | |
// user/repo | |
// title | |
// content | |
// content | |
// Adapted from https://github.com/sindresorhus/new-github-issue-url | |
function createIssueURL(userRepo, title, body) { | |
const repoUrl = `https://github.com/${userRepo}`; | |
const url = new URL(`${repoUrl}/issues/new`); | |
const params = [ | |
"body", | |
"title", | |
"labels", | |
"template", | |
"milestone", | |
"assignee", | |
"projects", | |
]; | |
url.searchParams.set("title", title); | |
url.searchParams.set("body", body); | |
return url.toString(); | |
} | |
const children = await Promise.all( | |
el.remData.children.map(async (childId) => await getRemText(childId)) | |
); | |
if (children.length < 3) { | |
return `<p>Specify at least user/repo, title and content as children of this rem.</p>`; | |
} | |
const [userRepo, title, ...body] = children; | |
console.info(body); | |
const url = createIssueURL( | |
userRepo.trim(), | |
title.trim(), | |
body.join("\n") | |
); | |
// TODO: Fragments should be applied with tags | |
return `<p>Report issue for github.com/${userRepo}: <a href="${url}" target="_blank"><button>Create Issue</button></a></p>`; | |
}, | |
}, | |
{ | |
matcher: matchTag('hw6ouZym8yCrPNYif'), // Add your progress bar tag id here | |
handler: async (match, el) => { | |
async function* walkDescendants(rem) { | |
const children = await Promise.all( | |
rem.children.map(async (childId) => await db.get("quanta", childId)) | |
); | |
for (let child of children) { | |
if (child && !child.rcrp) { | |
yield child; | |
yield* walkDescendants(child); | |
} | |
} | |
} | |
let limit = 10; | |
const descendants = [] | |
for await (let d of walkDescendants(el.remData)) { | |
descendants.push(d); | |
} | |
const [done, total] = descendants.reduce(([d, t], rem) => { | |
if (rem.crt && rem.crt.t && rem.crt.t.s && rem.crt.t.s.s === 'Unfinished') { return [d, t+1]} | |
if (rem.crt && rem.crt.t && rem.crt.t.s && rem.crt.t.s.s === 'Finished') { return [d+1, t+1]} | |
return [d, t]; | |
}, [0,0]); | |
const progress = Math.round(done/total * 100); | |
return `<div data-progress="${progress}" style="--progress-bar-progress: ${progress};">${done}/${total}</div>`; | |
}, | |
}, | |
{ | |
// Requires non-standard file api (only available in Chrome and not enabled in the Electron App) | |
dependencies: [ | |
"https://unpkg.com/docx@6.0.0/build/index.js", | |
"https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.js" | |
], | |
matcher: matchRegex(/^export/), | |
init: function () { | |
if (window.remnoteExportAutosaveInterval) { | |
clearInterval(window.remnoteExportAutosaveInterval); | |
window.remnoteExportAutosaveInterval = null; | |
} | |
window.remnoteExportAutosave = false; | |
}, | |
handler: async (match, el) => { | |
/* Run this before | |
[fileHandle] = await window.showOpenFilePicker(); | |
window.mdfile = fileHandle; | |
[fileHandle] = await window.showOpenFilePicker(); | |
window.texfile = fileHandle; | |
or: | |
window.mdfile = await window.showSaveFilePicker(); | |
window.texfile = await window.showSaveFilePicker(); | |
*/ | |
const remId = el.remData._id; | |
const exportButtons = document.createElement("div"); | |
const exportMarkdown = document.createElement("button"); | |
exportMarkdown.textContent = "Sync Markdown"; | |
const exportTex = document.createElement("button"); | |
exportTex.textContent = "Sync Tex"; | |
const exportDocx = document.createElement("button"); | |
exportDocx.textContent = "Save Docx"; | |
const autoSave = document.createElement("button"); | |
autoSave.textContent = "Auto Save: Off"; | |
exportButtons.append(exportMarkdown); | |
exportButtons.append(exportTex); | |
exportButtons.append(autoSave); | |
exportButtons.append(exportDocx); | |
exportMarkdown.onclick = async () => await writeFile(window.mdfile, await buildMd(remId)); | |
exportTex.onclick = async () => await writeFile(window.texfile, await buildTex(remId)); | |
exportDocx.onclick = async () => await buildDocx(remId); | |
function updateAutoSave(evt) { | |
let autoSave = evt.target; | |
if (window.remnoteExportAutosaveInterval) { | |
clearInterval(window.remnoteExportAutosaveInterval); | |
window.remnoteExportAutosaveInterval = null; | |
} | |
if (window.remnoteExportAutosave) { | |
window.remnoteExportAutosave = false; | |
autoSave.textContent = 'Auto Save: Off'; | |
} else { | |
window.remnoteExportAutosave = true; | |
window.remnoteExportAutosaveInterval = setInterval(() => { | |
exportMarkdown.click(); | |
exportTex.click(); | |
console.info("Autosaving tex and markdown"); | |
}, 2000); | |
autoSave.textContent = 'Auto Save: On' | |
} | |
} | |
autoSave.onclick = updateAutoSave; | |
//exportTex.onclick = async () => console.info(await buildTex(remId)); | |
async function writeFile(fileHandle, contents) { | |
// Create a FileSystemWritableFileStream to write to. | |
const writable = await fileHandle.createWritable(); | |
// Write the contents of the file to the stream. | |
await writable.write(contents); | |
// Close the file and write the contents to disk. | |
await writable.close(); | |
} | |
async function buildMd(remId) { | |
const rem = await db.get("quanta", remId); | |
const children = await Promise.all( | |
rem.children.map(async (childId) => await db.get("quanta", childId)) | |
); | |
async function remToMd(rem) { | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H1') { | |
return `# ${await getRemMarkdown(rem._id)}` | |
} | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H2') { | |
return `## ${await getRemMarkdown(rem._id)}` | |
} | |
if (rem.crt && rem.crt.t && rem.crt.t.s && rem.crt.t.s.s === 'Unfinished') { | |
return `- [ ] ${await getRemMarkdown(rem._id)}` | |
} | |
if (rem.crt && rem.crt.t && rem.crt.t.s && rem.crt.t.s.s === 'Finished') { | |
return `- [x] ${await getRemMarkdown(rem._id)}` | |
} | |
return await getRemMarkdown(rem._id); | |
} | |
const mdParts = await Promise.all( | |
children.map(remToMd) | |
); | |
const md = mdParts.join('\n\n'); | |
return md; | |
} | |
async function buildTex(remId) { | |
const rem = await db.get("quanta", remId); | |
const children = await Promise.all( | |
rem.children.map(async (childId) => await db.get("quanta", childId)) | |
); | |
async function remToTex(rem) { | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H1') { | |
return `\\section{${await getRemTex(rem._id)}}` | |
} | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H2') { | |
return `\\subsection{${await getRemTex(rem._id)}}` | |
} | |
if (rem.crt && rem.crt.t && rem.crt.t.s && rem.crt.t.s.s === 'Unfinished') { | |
return `\\todo{${await getRemTex(rem._id)}}` | |
} | |
return await getRemTex(rem._id); | |
} | |
const texParts = await Promise.all( | |
children.map(remToTex) | |
); | |
const tex = texParts.join('\n\n'); | |
return `% References | |
% List of must-have packages | |
% https://tex.stackexchange.com/questions/553/what-packages-do-people-load-by-default-in-latex | |
% Template libraries: | |
% https://de.sharelatex.com/templates | |
% https://www.overleaf.com/gallery | |
% Warn about deprecated packages | |
\\RequirePackage[l2tabu, orthodox]{nag} | |
\\documentclass[a4paper, draft]{article} | |
% TODO: Use different packages for pdflatex, xetex | |
% Font and encoding | |
\\usepackage[T1]{fontenc} | |
\\usepackage{lmodern} | |
\\usepackage[utf8]{inputenc} | |
% Language specific | |
% ----------------- | |
\\usepackage[ngerman]{babel} % pdflatex | |
% \\usepackage{polyglossia} % xetex/luatex | |
% \\setdefaultlanguage[spelling=new]{german} | |
% Quotes | |
% UTF: AltGr+v/b | |
% babel shorthands "\`quoted"' | |
% \\usepackage[autostyle=true,german=quotes]{csquotes} | |
% \\MakeAutoQuote{»}{«} % use AltGr+x/y to insert »quoted« | |
% \\MakeOuterQuote{"} % "quoted" | |
% Quoted text is correctly quoted based on language config, or use command | |
% \\enquote{text in quotes} | |
% Layout | |
\\usepackage{microtype} % Better word wrap | |
\\usepackage[left=2.5cm, right=3cm, top=2cm, bottom=2cm]{geometry} % margins | |
\\usepackage{hyperref} | |
\\usepackage[parfill]{parskip} | |
% Math and science packages | |
\\usepackage{mathtools} % includes amsmath | |
\\usepackage{siunitx} % \\SI{3.1415}{\\degree} | |
\\usepackage{graphicx} % \\includegraphics[width=0.8\\textwidth]{path} | |
% Misc | |
\\usepackage[textsize=footnotesize,obeyDraft]{todonotes} % \\todo{note}, \\missingfigure{alt-text} | |
\\usepackage{xcolor} | |
\\title{Title} | |
%\\subtitle{Generate \\LaTeX{} documents and pdfs directly from RemNote} | |
\\author{Hannes Frank} | |
\\begin{document} | |
\\maketitle | |
\\listoftodos | |
\\tableofcontents | |
${tex} | |
\\end{document}` | |
} | |
async function buildDocx(remId) { | |
console.info("Building Docx"); | |
const rem = await db.get("quanta", remId); | |
const children = await Promise.all( | |
rem.children.map(async (childId) => await db.get("quanta", childId)) | |
); | |
async function getRemDocx(remId) { | |
let rem = await db.get("quanta", remId); | |
if (!rem) return; | |
const richTextElements = await Promise.all( | |
rem.key.map(async (richTextElement) => { | |
// If the element is a string, juts return it | |
if (typeof richTextElement == "string") { | |
return new docx.TextRun(richTextElement); | |
// If the element is a Rem Reference (i == "q"), then recursively get that Rem Reference's text. | |
} else if (richTextElement.i == "q") { | |
return new docx.TextRun(await getRemText(richTextElement._id)); | |
} else if (richTextElement.i == "m") { | |
// TODO: There is much missing here: images, code blocks, latex | |
let text = richTextElement.text; | |
// https://docx.js.org/#/usage/styling-with-js?id=available-options | |
let options = {}; | |
if (richTextElement.b) { | |
options.bold = true; | |
} | |
if (richTextElement.l) { | |
options.italics = true; | |
} | |
if (richTextElement.x) { | |
// TODO: Formulas in Docx | |
} | |
if (richTextElement.q) { | |
options.font = "Courier New"; | |
} | |
return new docx.TextRun({text, ...options}); | |
} else { | |
return new docx.TextRun(richTextElement.text); | |
} | |
}) | |
); | |
return richTextElements; | |
} | |
async function remToDocx(rem) { | |
const textRuns = await getRemDocx(rem._id); | |
const options = {}; | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H1') { | |
options.heading = docx.HeadingLevel.HEADING_1; | |
} | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H2') { | |
options.heading = docx.HeadingLevel.HEADING_2; | |
} | |
if (rem.crt && rem.crt.r && rem.crt.r.s && rem.crt.r.s.s === 'H3') { | |
options.heading = docx.HeadingLevel.HEADING_3; | |
} | |
return new docx.Paragraph({ | |
children: textRuns, | |
...options | |
}); | |
} | |
const paragraphs = await Promise.all( | |
children.map(remToDocx) | |
); | |
const doc = new docx.Document({ | |
creator: "You", | |
description: "Generated from RemNote", | |
title: "My RemNote Document", | |
sections: [ | |
{ | |
properties: {}, | |
children: paragraphs | |
} | |
] | |
}); | |
console.info(doc); | |
docx.Packer.toBlob(doc).then((blob) => { | |
console.info(blob); | |
saveAs(blob, "example.docx"); | |
console.info("Document created successfully"); | |
}); | |
} | |
return exportButtons; | |
}, | |
}, | |
]; | |
// TODO: Each smart rem should also get a `name` which is added as class to the result node. | |
const allSmartCommands = [ | |
{ | |
matcher: matchRegex(/^\s*query-rem-json:(.*)/), | |
handler: async (match, el) => { | |
const rawExpression = [...el.remData.key]; | |
rawExpression[0] = match[1]; | |
const query = await parseQuery(rawExpression); | |
const resultIncludingSelf = await resolveQuery(query); | |
const result = resultIncludingSelf.filter( | |
(rem) => rem._id !== el.remData._id | |
); | |
const remContainers = await Promise.all(result.map(makeRemContainer)); | |
return `<p>${remContainers.join("\n")}</p>`; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^\s*=(.*)/), | |
handler: async (match, el) => { | |
const rawExpression = [...el.remData.key]; | |
rawExpression[0] = match[1]; | |
const resolvedVariables = await Promise.all( | |
rawExpression.map(async (part) => { | |
if (typeof part === "string") { | |
return part; | |
} | |
if (typeof part === "object" && part.i === "q") { | |
const rem = await db.get("quanta", part._id); | |
return rem.value; | |
} | |
return "??"; | |
}) | |
); | |
const expression = resolvedVariables.join(""); | |
const result = eval(expression); | |
console.info("Calc: ", match, " = ", result); | |
return `<p>${expression} = ${result}</p>`; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^spotify:\s*(.+)/), | |
handler: async (match) => | |
`<iframe src="https://open.spotify.com/embed/${match[1]}" width="300" height="380"></iframe>`, | |
}, | |
{ | |
matcher: matchRegex(/^embed:\s*(.+)/), | |
handler: async (match) => | |
`<iframe src="https://${match[1]}" width="750" height="500"></iframe>`, | |
}, | |
{ | |
// More embeds than simple tweets are possible, like showing the timeline of someone | |
// https://developer.twitter.com/en/docs/twitter-for-websites/javascript-api/guides/scripting-factory-functions | |
init: function () { | |
window.twttr = (function (d, s, id) { | |
var js, | |
fjs = d.getElementsByTagName(s)[0], | |
t = window.twttr || {}; | |
if (d.getElementById(id)) return t; | |
js = d.createElement(s); | |
js.id = id; | |
js.src = "https://platform.twitter.com/widgets.js"; | |
fjs.parentNode.insertBefore(js, fjs); | |
t._e = []; | |
t.ready = function (f) { | |
t._e.push(f); | |
}; | |
console.info("initialized twitter"); | |
return t; | |
})(document, "script", "twitter-wjs"); | |
}, | |
matcher: matchRegex(/^twitter:\s*(.+)/), | |
handler: async (match) => { | |
console.info("rendering twitter"); | |
twttr.widgets | |
.createTweet( | |
"511181794914627584", | |
document.getElementById("twitter-tweet"), | |
{ | |
align: "left", | |
} | |
) | |
.then(function (el) { | |
console.info("Tweet displayed."); | |
}) | |
.catch(console.error); | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^weather:\s*(.+)/), | |
handler: async (match) => | |
`<img src="https://wttr.in/${match[1]}_tpq0.png" />`, | |
/* handler: async match => { | |
const resp = await fetch("https://wttr.in/Dresden"); | |
const text = await resp.text(); | |
console.warn(text); | |
return `${text}`; | |
}*/ | |
}, | |
{ | |
dependencies: [ | |
"https://cdn.jsdelivr.net/npm/reveal.js/dist/reveal.js", | |
"https://cdn.jsdelivr.net/npm/reveal.js/dist/reveal.css", | |
"https://cdn.jsdelivr.net/npm/reveal.js/dist/theme/white.css", | |
//"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css", | |
], | |
matcher: matchRegex(/^presentation/), | |
handler: async (match, el) => { | |
const remId = el.remData._id; | |
const presentationId = `deck-${remId}`; | |
async function buildSlide(slideRemId) { | |
const slideRem = await db.get("quanta", slideRemId); | |
const bulletPoints = await Promise.all( | |
slideRem.children.map(async (childId) => await getRemHTML(childId)) | |
); | |
// TODO: Animation Fragments should be configurable with tags | |
return `<h2>${await getRemText( | |
slideRemId | |
)}</h2><ul>${bulletPoints | |
.map((p) => `<li class="fragment">${p}</li>`) | |
.join("")}</ul>`; | |
} | |
async function buildPresentationContent(rootRem) { | |
// console.info(rootRem.children); | |
const slides = await Promise.all( | |
rootRem.children.map(async (childId) => await buildSlide(childId)) | |
); | |
console.info("slides", slides); | |
return `<div class="slides">${slides | |
.map((slide) => `<section>${slide}</section>`) | |
.join("\n")}</div>`; | |
} | |
const presentationTemplate = document.createElement("template"); | |
presentationTemplate.innerHTML = `<div class="reveal"> | |
${await buildPresentationContent(el.remData)} | |
</div>`; | |
const presentation = presentationTemplate.content.firstChild; | |
presentation.id = presentationId; | |
window.presentation = presentation; | |
const deck = new Reveal(presentation, { | |
embedded: true, | |
keyboardCondition: "focused", | |
}); | |
deck.initialize(); | |
return presentation; | |
}, | |
}, | |
{ | |
dependencies: [ | |
"https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js", | |
], | |
init: function () { | |
mermaid.initialize({ | |
securityLevel: "loose", | |
}); | |
}, | |
matcher: matchRegex(/^mermaid/), | |
handler: async (match, el) => { | |
const codeBlocks = el.remData.key.filter( | |
(part) => part.i && part.i === "o" | |
); | |
if (codeBlocks.length < 1) { | |
return `<p>No code block found!</p>`; | |
} | |
const graphDefinition = codeBlocks[0].text; | |
const mermaidId = `mermaid-${el.remData._id}`; | |
const mermaidNode = | |
document.getElementById(mermaidId) || document.createElement("div"); | |
console.info(mermaidNode); | |
mermaidNode.id = mermaidId; | |
function insertSvg(svgCode, bindFunctions) { | |
mermaidNode.innerHTML = svgCode; | |
} | |
mermaid.render(mermaidId + "-graph", graphDefinition, insertSvg); | |
return mermaidNode; | |
}, | |
}, | |
{ | |
dependencies: [ | |
"https://d3js.org/d3.v3.min.js", | |
"https://cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.js", | |
"https://cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.css", | |
], | |
init: function () { | |
mermaid.initialize({ | |
securityLevel: "loose", | |
}); | |
}, | |
matcher: matchRegex(/^heatmap/), | |
handler: async (match, el) => { | |
const cards = await db.getAll("cards"); | |
let practice_sessions = {}; | |
for (let card of cards) { | |
if (!card.h) continue; | |
for (let hi of card.h) { | |
let t = typeof hi.date === "number" ? hi.date : Date.parse(hi.date); | |
t /= 1000; | |
practice_sessions[t] = practice_sessions[t] | |
? practice_sessions[t] + 1 | |
: 1; | |
} | |
} | |
const heatmapNode = document.createElement("div"); | |
heatmapNode.className = "heatmap"; | |
const today = new Date(); | |
let start = new Date(); | |
start.setMonth(start.getMonth() - 11); | |
start.setDate(1); | |
const cal = new CalHeatMap(); | |
cal.init({ | |
// itemSelector: '#' + targetContainerId, | |
itemSelector: heatmapNode, | |
domain: "month", | |
subDomain: "day", | |
data: practice_sessions, | |
start: start, | |
cellSize: 10, | |
range: 12, | |
legend: [20, 40, 60, 80], | |
legendVerticalPosition: "center", | |
legendHorizontalPosition: "right", | |
legendOrientation: "vertical", | |
tooltip: true, | |
highlight: ["now", today], | |
}); | |
return heatmapNode; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^chucknorris/), | |
handler: async (match) => { | |
const resp = await fetch("https://api.chucknorris.io/jokes/random"); | |
const json = await resp.json(); | |
return `<p>🧔 ${json.value}</p>`; | |
}, | |
}, | |
// TODO: Dependency injection like this does not work. Use `dependencies` key for this. | |
// { | |
// matcher: matchRegex(/^weatherwidget/), | |
// handler: async (match) => { | |
// return `<a class="weatherwidget-io" href="https://forecast7.com/de/51d0513d74/dresden/" data-label_1="DRESDEN" data-label_2="Wetter" data-font="Fira Sans" data-icons="Climacons Animated" data-mode="Forecast" data-days="5" data-theme="dark" >DRESDEN Wetter</a> | |
// <script> | |
// !function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src='https://weatherwidget.io/js/widget.min.js';fjs.parentNode.insertBefore(js,fjs);}}(document,'script','weatherwidget-io-js'); | |
// </script>`; | |
// }, | |
// }, | |
{ | |
matcher: matchRegex(/^current ip/), | |
handler: async (match) => { | |
const resp = await fetch("https://api.ipify.org/?format=json"); | |
const json = await resp.json(); | |
return `<p>💻 ${json.ip}</p>`; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^zip code:\s*(.+)/), | |
handler: async (match) => { | |
const resp = await fetch(`https://api.zippopotam.us/${match[1]}`); | |
const json = await resp.json(); | |
if (!json.places) { | |
return `<p>Not found!</p>`; | |
} | |
return `<p>${json.places[0]["place name"]}, ${json.country}</p>`; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^xkcd/), | |
handler: async (match) => { | |
const resp = await fetch("https://xkcd.now.sh/?comic=latest"); | |
const json = await resp.json(); | |
return `<img src="${json.img}" />`; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^html:\s*(.+)/), | |
handler: async (match) => { | |
return `${match[1]}`; | |
}, | |
}, | |
{ | |
dependencies: [ | |
"https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js", | |
], | |
matcher: matchRegex(/^markdown:\s*(.+)/s), | |
handler: async (match) => { | |
const markdown = match[1]; | |
console.warn("markdown", markdown); | |
const html = new showdown.Converter({ tables: true }).makeHtml( | |
markdown | |
); | |
return html; | |
}, | |
}, | |
{ | |
matcher: matchRegex(/^regex matching the smart command/), | |
handler: | |
"async function taking the smart block content and returning result markup", | |
}, | |
]; | |
function findAllRem() { | |
return [...document.querySelectorAll("[id^=Pane]")]; | |
} | |
function remId(remEl) { | |
return remEl.id.slice( | |
remEl.id.length - 1 - REM_ID_LENGTH, | |
remEl.id.length - 1 | |
); | |
} | |
function isSmartRem(el) { | |
// TODO: I disable this for now to enable tags. This means larger resource use. | |
// There could be a prefilter still for a set of registered tags. | |
// TODO: This works only for plain text, not bold formatting etc. | |
const rem = el.remData; | |
return rem && (( | |
rem.key.length && | |
rem.key[0].startsWith && | |
rem.key[0].startsWith(">>>") | |
) || (rem.typeParents && rem.typeParents.includes('hw6ouZym8yCrPNYif'))); | |
} | |
async function updateRemData(el) { | |
el.remData = await db.get("quanta", remId(el)); | |
} | |
// Life Cycle Polyfill | |
// Rem is created | |
function onCreate(el) { | |
console.info("onCreate", el.id); | |
} | |
// The rem is discovered, e.g. after reloading the page or navigating | |
async function onInsert(el) { | |
await updateRemData(el); | |
if (isSmartRem(el)) { | |
prepareSmartRem(el); | |
await evaluateSmartRem(el); | |
} | |
} | |
// Rem is deleted | |
function onDelete(el) { | |
// For this I need to store a set of references to elements and check if the an element of the new collection was already present | |
// I don't need this yet, so I will not other. | |
console.info("onDelete", el.id); | |
} | |
// Rem is focused for editing | |
function onFocusIn(event) { | |
console.info("onFocusIn", event); | |
} | |
function parentRem(el) { | |
const p = el.parentElement && el.parentElement.parentElement && el.parentElement.parentElement.parentElement && | |
el.parentElement.parentElement.parentElement.firstChild; | |
if (p && p.remData) { | |
return p; | |
} | |
} | |
// Rem is unfocused | |
async function onFocusOut(el) { | |
await updateRemData(el); | |
console.info("onFocusOut", el, el.remData, el.remSmartResult); | |
if (isSmartRem(el)) { | |
await evaluateSmartRem(el); | |
} else { | |
if (el.remSmartResult) { | |
el.remSmartResult.remove(); | |
delete el.remSmartResult; | |
} | |
} | |
// Bubble up | |
const parent = parentRem(el) | |
if (parent) { | |
onFocusOut(parent); | |
} | |
} | |
// Rem content changed | |
function onChange(el) { | |
// I assume this can only happen if a rem is focused for now (no plugin etc.) | |
// Here I need to check periodically if the new content is the old content | |
// For that the old and new content has to be compared | |
// I will moch this for now as running periodically. | |
console.info("onChange", el.id); | |
} | |
function prepareSmartRem(el) { | |
// add evaluate button | |
// add result container | |
// add events to watch or recalculate rem | |
// TODO: These hooks should be provided by the API | |
setResult(el, "?"); | |
} | |
// This function is run periodically on all rem. | |
async function prepareLifeCycle(el) { | |
if (el.remLifeCycleHooks !== undefined) { | |
// Old rem: hooks already installed | |
return; | |
} | |
el.remLifeCycleHooks = {}; | |
await onInsert(el); | |
// TODO: Focus in should run change handler | |
el.remLifeCycleHooks.focusin = (event) => onFocusIn(el, event); | |
el.addEventListener("focusin", el.remLifeCycleHooks.focusin); | |
el.remLifeCycleHooks.focusout = (event) => onFocusOut(el, event); | |
el.addEventListener("focusout", el.remLifeCycleHooks.focusout); | |
// TODO: Change handler or focusOut should check if it is a smartRem or not | |
// TODO: Monitor new rems or when a rem is turned into a smart rem | |
} | |
function setResult(el, resultMarkup) { | |
if (el.remSmartResult) { | |
el.remSmartResult.remove(); | |
delete el.remSmartResult; | |
} | |
const result = makeResult(resultMarkup); | |
el.remSmartResult = result; | |
el.append(result); | |
} | |
function makeResult(resultMarkup) { | |
const smartRemResult = document.createElement("div"); | |
smartRemResult.classList.add("smart-rem-result"); | |
// TODO: Add custom result classes here, e.g. to float right. | |
// TODO: Maybe I should bundle <styles> with the smart rems as well. (Research CSS modules) | |
if (typeof resultMarkup === "string") { | |
const resultTemplate = document.createElement("template"); | |
resultTemplate.innerHTML = resultMarkup; | |
smartRemResult.append(...resultTemplate.content.children); | |
} else { | |
// HTMLNode | |
smartRemResult.appendChild(resultMarkup); | |
} | |
return smartRemResult; | |
} | |
async function evaluateSmartRem(el) { | |
console.log('eval', el); | |
for (const smartCommand of enabledSmartCommands) { | |
const match = smartCommand.matcher(el); | |
if (match) { | |
const result = await smartCommand.handler(match, el); | |
setResult(el, result); | |
return; | |
} | |
} | |
setResult(el, "? Command not found!"); | |
} | |
// Comment this when developing smart rem and remove after there are options which smart rems to enable | |
// This makes it faster to load since not as many dependencies have to be downloaded | |
// | |
enabledSmartCommands = [...enabledSmartCommands, ...allSmartCommands]; | |
// TODO: Prevent reloading the dependencies when rerunning the script. | |
// E.g. generate a unique id for each script url and check if it is already there. | |
const dependencies = Array.prototype.concat( | |
...enabledSmartCommands.map((sr) => sr.dependencies).filter((d) => d) | |
); | |
console.info("Loading dependencies: ", dependencies); | |
const dependencyLoaders = dependencies.map(addDependency); | |
await dependencyLoaders.reduce(async (previousPromise, nextPromise) => { | |
await previousPromise; | |
return nextPromise; | |
}, Promise.resolve()); | |
Promise.all([ | |
import("https://unpkg.com/idb?module"), | |
//...dependencies.map(addDependency), | |
// Somehow this import does not work... I'll use builtin eval for now | |
//fetch("https://unpkg.com/bigeval").then(response=>response.text()).then(text=>Function(text)) | |
]).then(async ([idb]) => { | |
db = await idb.openDB(settings.DATABASE_NAME, settings.DATABASE_VERSION); | |
// Init dependencies | |
// TODO: Make sure init blocks are reentrant while development | |
for (const smartCommand of enabledSmartCommands) { | |
if (smartCommand.init) { | |
smartCommand.init(); | |
} | |
} | |
let rems = await findAllRem(); | |
rems.map(resetRem); | |
await Promise.all(rems.map(prepareLifeCycle)); | |
// FIXME: This is run to detect new rem. I have not hooked up the created hook yet. | |
setInterval(async function () { | |
const rems = await findAllRem(); | |
await Promise.all(rems.map(prepareLifeCycle)); | |
}, 2000); | |
}); | |
} | |
function resetRem(el) { | |
delete el.remData; | |
if (el.remLifeCycleHooks) { | |
for (const [event, hook] of Object.entries(el.remLifeCycleHooks)) { | |
el.removeEventListener(event, hook); | |
} | |
} | |
delete el.remLifeCycleHooks; | |
if (el.remSmartResult) el.remSmartResult.remove(); | |
} | |
f(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment