I'm writing this post in Trilium Notes. It's a personal knowledge base like TiddlyWiki or Notion, but with powerful scripting. I can make a note, set the type to code (backend JS) and click the run button, then it will run that code on the server!
My implementation looks like this:
This is the code that I have for making a new post (always up to date, embedded from real script):
Manage
// Run this script to create a new post. const POSTS_FOLDER = 'E0sJWbschXAm' ;(async () => { const note = await api.createNewNote({ parentNoteId: POSTS_FOLDER, type: 'text', title: 'Untitled Post', content: '' }) await note.note.setAttribute('label', 'draft', 'true') })()
The External API is a folder with some HTTP APIs. The site you're on calls these APIs. The first one is Posts:
Posts
// This script is the API for listing posts const FOLDER_CONTAINING_POSTS_ID = 'E0sJWbschXAm' // express.js API provided by Trilium const { req, res } = api // Modules // thenby: my favorite micro-library, helps sorting things const firstBy = require('thenby') ;(async () => { if (req.method == 'GET') { const root = await api.getNote(FOLDER_CONTAINING_POSTS_ID) const children = await root.getChildNotes() // Simultaneously process every note, deciding if it should be in the post list const filtered = ( await Promise.all( children.map(async (note) => { // Drafts are filtered from the post list, but stil accessible from the Post endpoint const draft = await note.getAttribute('label', 'draft') if (draft && draft.value === 'true') return null // The date of the post should be included in the response note.date = await note.getAttribute('label', 'date') note.date = (note.date && note.date.value) || note.utcDateCreated // The tag attribute can be specified multiple times note.tags = await note.getAttributes('label', 'tag') if (!note.tags) note.tags = [] note.tags = note.tags.map((tag) => tag.value) // The script for creating a new post (Manage) should not be included in the post index // This is a more generic way to allow children in the root to not be listed const article = await note.getAttribute('label', 'article') if (!article) return note if (article.value === 'false') return null return note }) ) ) .filter((noteOrNull) => noteOrNull) .sort(firstBy('date', -1)) // This header is required to tell browsers that my website is allowed to access the response. // Documentation on CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS res.header('Access-Control-Allow-Origin', '*') res.send( filtered.map((note) => { return { id: note.noteId, title: note.title, date: note.date, tags: note.tags } }) ) } else { // This route should only be accessible by GET. res.sendStatus(400) } })()
It returns a list of posts that are available to read (except drafts).
The second one is Post, and that returns the content of a post. The code is:
Post
const { req, res } = api ;(async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) const result = await getContent(target) const public_fileserve = await result.note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) result.note.date = await result.note.getAttribute('label', 'date') result.note.tags = await result.note.getAttributes('label', 'tag') result.note.fullwidth = await result.note.getAttribute('label', 'fullwidth') console.log('note fullwidth', result.note.fullwidth) if (!result.note.tags) result.note.tags = [] result.note.tags = result.note.tags.map((tag) => tag.value) res.header('Access-Control-Allow-Origin', '*') res.send({ title: result.note.title, type: result.note.type, date: (result.note.date && result.note.date.value) || result.note.utcDateCreated, fullwidth: result.note.fullwidth && result.note.fullwidth.value !== 'false', tags: result.note.tags, content: result.content }) } else { res.sendStatus(400) } })()
It does quite a lot to the content to make it suitable for the site.
I also have a third endpoint, image. It just returns an image when requested. It's so that images I upload are available on the site.
Image
const { req, res } = api ;(async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) const note = await api.getNote(target) if (!note) return res.sendStatus(404) if (!['image', 'file', 'code'].includes(note.type)) return res.sendStatus(400) const public_fileserve = await note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) const mime = await note.getAttribute('label', 'serve_mime') let mimeV = note.mime if (mime && mime.value) mimeV = mime.value res.header('Content-Type', mimeV) res.header('Access-Control-Allow-Origin', '*') res.header('Cross-Origin-Resource-Policy', 'cross-origin') res.header( 'Content-Security-Policy', 'frame-ancestors https://wingysam.xyz;' ) res.header('X-Frame-Options', '') const content = await note.getContent() res.header('Content-Length', content.length) res.send(content) } else { res.sendStatus(400) } })()
Additionally, I have an endpoint for accessing code notes directly.
Code
const { req, res } = api ;async () => { if (req.method == 'GET') { const target = req.query.id if (!target) return res.sendStatus(404) const note = await api.getNote(target) if (!note) return res.sendStatus(404) if (note.type !== 'code') return res.sendStatus(400) const public_fileserve = await note.getAttribute( 'label', 'public_fileserve' ) if (!public_fileserve) return res.sendStatus(403) if (public_fileserve.value !== 'true') return res.sendStatus(403) res.header('Content-Type', note.mime) res.header('Access-Control-Allow-Origin', '*') res.header( 'Content-Security-Policy', 'frame-ancestors https://wingysam.xyz;' ) res.header('X-Frame-Options', '') const content = await note.getContent() res.header('Content-Length', content.length) res.send(content) } else { res.sendStatus(400) } }
In the wingysam.xyz note, I have an inherited attribute that allows files or notes to be served by the API.
You may have also noticed that I use getContent
and it's not defined anywhere. I have it defined in a code note child in the endpoints:
getContent
const cheerio = require('cheerio') const firstBy = require('thenby') const CLASSMAP = { 'language-application-javascript-env-backend': 'language-js', 'language-application-javascript-env-frontend': 'language-js', 'language-text-x-rustsrc': 'language-rust' } const MIMEMAP = { 'application/javascript;env=backend': 'language-js', 'application/javascript;env=frontend': 'language-js' } async function getContent(id, recursionDepth) { // Options Sanity Check if (!id) throw new Error('No ID provided') if (!recursionDepth) recursionDepth = 0 // Setup const note = await api.getNote(id) const cNote = note const content = await note.getContent() // --- Files Special Case--- if (note.type === 'file') { let resultingContent = `This is a ${note.mime} file. There is currently not support for displaying ${note.mine} files.` switch (note.mime) { case 'audio/mp4': resultingContent = `<audio controls autoplay src="https://samwing.dev/posts/image/${id}">` break case 'application/pdf': resultingContent = `<a href="https://samwing.dev/posts/image/${id}">View PDF</a><embed src="https://samwing.dev/posts/image/${id}" style='width: 100%; height: 20em; margin-top: 1em;'/>` break } return { note, content: resultingContent } } // --- End Files --- let $ = cheerio.load(content, { xml: { decodeEntities: false } }) // Transforms if (note.type === 'text') { // Convert images to use the public file API instead of trying to use the internal trilium link // The links look like: // api/attachments/<id>/image/image.png // or: // api/images/<id>/image.png $('[src]').map((i, el) => { const e = $(el) try { e.attr( 'src', 'https://samwing.dev/posts/image/' + e.attr('src').split('attachments/')[1].split('/')[0] ) } catch { e.attr( 'src', 'https://samwing.dev/posts/image/' + e.attr('src').split('images/')[1].split('/')[0] ) } e.css('width', '100%') e.css('height', 'auto') }) // Mostly for converting code classes to prism.js ones for (const classMapEntry in CLASSMAP) { replaceClass(classMapEntry, CLASSMAP[classMapEntry]) } // Convert local links to post links $('a').each((i, el) => { let href = $(el).attr('href') if (href.startsWith('#root/')) href = '/posts/' + href.split('/').reverse()[0] $(el).attr('href', href) }) // Put card data on tables $('figure.table > table').each((_0, table) => { let items = [] $(table) .find('thead > tr > th') .each((i, td) => { items[i] = $(td).text() }) $(table) .find('tbody > tr') .each((_1, tr) => { $(tr) .find('td') .each((i, td) => { $(td).attr('data-label', items[i]) // Have you ever seen hackier code? // Puts td content inside a div so that it doesn't break in display: flex $(td).html(`<div>${$(td).html()}</div>`) }) }) }) // Remove inline CSS from tables $('figure.table').each((_, table) => { $(table).attr('style', '') }) await Promise.all( $('code.language-text-plain') .map(async (_, codeEl) => { codeEl = $(codeEl) let text = require('he').decode(codeEl.html()) if (!text.startsWith('{{') || !text.endsWith('}}')) return text = text.substr(1, text.length - 2) const el = codeEl.parent().wrap('<div>').parent() const data = JSON.parse(text) switch (data.template) { case 'subnotes': el.html('<ul></ul>') const children = await note.getChildNotes() if (data.sort === true) children.sort(firstBy('title', -1)) for (const child of children) { if (child.isDeleted) continue if (data.startsWith && !child.title.startsWith(data.startsWith)) continue el.find('ul').append( `<li class="subnotes-${child.noteId}"><a/></li>` ) const a = el.find(`li.subnotes-${child.noteId} > a`) a.attr('href', '/posts/' + child.noteId) a.text(child.title) } break case 'rawhtml': el.replaceWith(data.html) break default: el.html('<p>Unknown Template</p>') break } }) .get() ) } else if (note.type === 'code') { // bit hacky, seems to work ok though $ = cheerio.load('<pre><code></code></pre>', { xml: true }) const mimeClass = MIMEMAP[note.mime] if (mimeClass) $('code').addClass(mimeClass) $('code').text(content) } // Recursion if (recursionDepth < 3) await Promise.all( $('.include-note[data-note-id]') .map(async (i, el) => { try { const { note, content } = await getContent( $(el).attr('data-note-id'), recursionDepth + 1 ) const $2 = cheerio.load(content) const $all = cheerio.load('<div></div>') const all = $all('div') const section = await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_section` ) const quote = (await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_quote` )) || { value: section ? 'false' : 'true' } const head = (await cNote.getAttribute( 'label', `includeNoteLink_${note.noteId}_head` )) || { value: section ? 'false' : 'true' } if (section) { const sectionName = section.value const header = $2('h1, h2, h3, h4, h5, h6').filter( (_, h) => $2(h).text() === sectionName ) all.append(header.nextUntil(header.prop('tagName'))) $(el).html(all.html()) } else $(el).html(content) if (!head || head.value !== 'false') $(el).html(`<h2>${note.title}</h2>${$(el).html()}`) if (!quote || quote.value !== 'false') $(el).html(`<blockquote>${$(el).html()}</blockquote>`) } catch {} }) .get() ) // Return return { note, content: $.html() } // Utilities function replaceClass(oldClass, newClass) { $(`.${oldClass}`).each((i, el) => { $(el).removeClass(oldClass) $(el).addClass(newClass) }) } } module.exports = getContent
Update: I've added a feed endpoint. It's available at https://trilium.home.wingysam.xyz/custom/wingysam.xyz-feed.
Update 2024-09-22: In an effort to harden my security posture, Trilium is no longer accessible except through a VPN. samwing.dev now proxies it at https://samwing.dev/posts/feed.
Feed
// This script is the API for getting an RSS feed // express.js API const { req, res } = api // Modules // thenby: my favorite micro-library, helps sorting things const firstBy = require('thenby') // feed: generates an RSS/Atom feed. const { Feed } = require('feed') ;(async () => { if (req.method == 'GET') { // X0jZjTNA58Yy is the folder with my posts in it const targetParentNoteId = 'X0jZjTNA58Yy' const parent = await api.getNote(targetParentNoteId) const children = await parent.getChildNotes() // I don't want drafts in the post list. Should probably extract some things to (... months later, no idea what i was trying to write there) const filtered = ( await Promise.all( children.map(async (note) => { note.con = (await getContent(note.noteId)).content console.log('AB', note.con) const draft = await note.getAttribute('label', 'draft') if (draft && draft.value === 'true') return null note.date = await note.getAttribute('label', 'date') note.date = (note.date && note.date.value) || note.utcDateCreated note.tags = await note.getAttributes('label', 'tag') if (!note.tags) note.tags = [] note.tags = note.tags.map((tag) => tag.value) note.description = await note.getAttribute('label', 'description') if (!note.description) note.description = { value: 'No description provided' } note.description = note.description.value const article = await note.getAttribute('label', 'article') if (!article) return note if (article.value === 'false') return null return note }) ) ) .filter((noteOrNull) => noteOrNull) .sort(firstBy('date', -1)) res.header('Access-Control-Allow-Origin', '*') const author = { name: 'Wingy', email: 'feed@wingysam.xyz', link: 'https://wingysam.xyz' } const feed = new Feed({ title: 'Wingy', description: 'Student developer who creates things', id: 'https://wingysam.xyz', link: 'https://wingysam.xyz/posts', language: 'en', image: 'https://wingysam.xyz/wingy.svg', favicon: 'https://wingysam.xyz/favicon.ico', copyright: 'All rights reseved 2020, Wingy', feedLinks: {}, author }) filtered.forEach((note) => { feed.addItem({ title: note.title, id: note.noteId, link: `https://wingysam.xyz/posts/${note.noteId}`, description: note.description, author: [author], date: new Date(note.date), content: note.con || 'none' }) }) res.header('Content-Type', 'application/xml') res.send(feed.rss2()) } else { res.sendStatus(400) } })()