Action
Send to Rune & Quill
Posted by Christopher Lawley,
Last update
2 months ago
- Unlisted
Create a searchable PDF in Rune & Quill from the active draft. Uses a private local .runequill handoff, preserves supported Drafts wiki links, and optionally targets a case-insensitive nested Rune & Quill folder. Rune & Quill is required and sold separately.
Steps
-
configurationKey
name Rune & Quill Folder
key runeQuillFolder
-
script
/* * Send to Rune & Quill for Drafts * Copyright (c) 2026 Christopher Lawley * Licensed under the MIT License. See LICENSE. */ (function installRuneQuillDraftsAction(root) { "use strict"; const MAX_ENVELOPE_BYTES = 2 * 1024 * 1024; const MAX_TITLE_BYTES = 512; const MAX_FILENAME_BYTES = 240; const FILE_EXTENSION = ".runequill"; class RuneQuillActionError extends Error {} function utf8ByteLength(value) { let count = 0; for (let index = 0; index < value.length; index += 1) { const first = value.charCodeAt(index); let codePoint = first; if (first >= 0xD800 && first <= 0xDBFF && index + 1 < value.length) { const second = value.charCodeAt(index + 1); if (second >= 0xDC00 && second <= 0xDFFF) { codePoint = 0x10000 + ((first - 0xD800) * 0x400) + (second - 0xDC00); index += 1; } } if (codePoint <= 0x7F) count += 1; else if (codePoint <= 0x7FF) count += 2; else if (codePoint <= 0xFFFF) count += 3; else count += 4; } return count; } function truncateUTF8(value, maximumBytes) { let output = ""; let bytes = 0; for (const character of Array.from(value)) { const cost = utf8ByteLength(character); if (bytes + cost > maximumBytes) break; output += character; bytes += cost; } return output; } function normalizedTitle(value) { const collapsed = String(value || "") .replace(/[\u0000-\u001F\u007F]/gu, " ") .replace(/\s+/gu, " ") .trim(); return truncateUTF8(collapsed || "Untitled", MAX_TITLE_BYTES) || "Untitled"; } function safeFilename(title) { const cleaned = Array.from(title, (character) => { const code = character.charCodeAt(0); return code < 32 || code === 127 || /[\/\\:?*<>|#]/u.test(character) ? "-" : character; }) .join("") .replace(/\s+/gu, " ") .trim(); const stem = truncateUTF8( cleaned || "Untitled", MAX_FILENAME_BYTES - utf8ByteLength(FILE_EXTENSION) ).trim(); return `${stem || "Untitled"}${FILE_EXTENSION}`; } function fencedCodeRanges(markdown) { const ranges = []; let open = null; let offset = 0; const lines = markdown.match(/.*(?:\r\n|\n|\r|$)/gu) || []; for (const line of lines) { if (line.length === 0) continue; const content = line.replace(/(?:\r\n|\n|\r)$/u, ""); const match = content.match(/^\s*(`{3,}|~{3,})/u); if (match) { const marker = match[1]; if (!open) { open = { character: marker[0], length: marker.length, start: offset }; } else if (marker[0] === open.character && marker.length >= open.length) { ranges.push({ start: open.start, end: offset + line.length }); open = null; } } offset += line.length; } if (open) ranges.push({ start: open.start, end: markdown.length }); return ranges; } function inlineCodeRanges(markdown, excludedRanges) { const ranges = []; let index = 0; while (index < markdown.length) { const excluded = excludedRanges.find((range) => index >= range.start && index < range.end); if (excluded) { index = excluded.end; continue; } if (markdown[index] !== "`") { index += 1; continue; } const start = index; while (index < markdown.length && markdown[index] === "`") index += 1; const marker = markdown.slice(start, index); const end = markdown.indexOf(marker, index); const lineEnd = markdown.indexOf("\n", index); if (end >= 0 && (lineEnd < 0 || end < lineEnd)) { ranges.push({ start, end: end + marker.length }); index = end + marker.length; } else { ranges.push({ start, end: lineEnd < 0 ? markdown.length : lineEnd }); index = lineEnd < 0 ? markdown.length : lineEnd; } } return ranges; } function intersects(range, protectedRanges) { return protectedRanges.some((protectedRange) => ( range.start < protectedRange.end && range.end > protectedRange.start )); } function escapeMarkdownLabel(value) { return String(value).replace(/\\/gu, "\\\\").replace(/\[/gu, "\\[").replace(/\]/gu, "\\]"); } function draftsURL(item) { const key = String(item.key || ""); const value = String(item.value || "").trim(); if (!value) return null; const encoded = encodeURIComponent(value).replace(/[!'()*]/gu, (character) => ( `%${character.charCodeAt(0).toString(16).toUpperCase()}` )); if (key === "" || key === "d") return `drafts://open?title=${encoded}&allowCreate=true`; if (key === "u") return `drafts://open?uuid=${encoded}`; if (key === "w") return `drafts://workspace?name=${encoded}`; if (key === "s") return `drafts://quickSearch?query=${encoded}`; return null; } function prepareMarkdown(markdown, linkedItems) { const source = String(markdown || ""); const fenced = fencedCodeRanges(source); const protectedRanges = fenced.concat(inlineCodeRanges(source, fenced)); const candidates = Array.isArray(linkedItems) ? linkedItems.slice() : []; const replacements = []; for (const item of candidates) { const start = Number(item.location); const length = Number(item.length); if (!Number.isInteger(start) || !Number.isInteger(length) || start < 0 || length <= 0) continue; const range = { start, end: start + length }; if (range.end > source.length || intersects(range, protectedRanges)) continue; const expected = `${item.prefix || ""}${item.link || ""}${item.suffix || ""}`; if (expected && source.slice(range.start, range.end) !== expected) continue; const url = draftsURL(item); if (!url) continue; replacements.push({ start: range.start, end: range.end, value: `[${escapeMarkdownLabel(item.value)}](${url})` }); } replacements.sort((left, right) => right.start - left.start || right.end - left.end); let output = source; let nextStart = source.length; for (const replacement of replacements) { if (replacement.end > nextStart) continue; output = output.slice(0, replacement.start) + replacement.value + output.slice(replacement.end); nextStart = replacement.start; } return output; } function makeEnvelope(activeDraft, configuredValues) { if (!activeDraft) throw new RuneQuillActionError("No active draft is available."); const title = normalizedTitle(activeDraft.displayTitle); const folderPath = String((configuredValues || {}).runeQuillFolder || "").trim(); const sourcePath = String( activeDraft.permalink || (activeDraft.uuid ? `drafts://open?uuid=${encodeURIComponent(activeDraft.uuid)}` : "") ); const envelope = { version: 1, title, vault: "Drafts", sourcePath, folderPath, markdown: prepareMarkdown(activeDraft.content, activeDraft.linkedItems) }; const payload = JSON.stringify(envelope); if (utf8ByteLength(payload) > MAX_ENVELOPE_BYTES) { throw new RuneQuillActionError("This draft is larger than Rune & Quill's 2 MiB import limit."); } return { envelope, payload, filename: safeFilename(title) }; } function run(environment) { const prepared = makeEnvelope(environment.draft, environment.context.configuredValues); const shared = environment.Share.shareAsFile(prepared.filename, prepared.payload); if (!shared) environment.context.cancel("The Rune & Quill share was cancelled."); return { shared, prepared }; } const api = { FILE_EXTENSION, MAX_ENVELOPE_BYTES, RuneQuillActionError, draftsURL, makeEnvelope, normalizedTitle, prepareMarkdown, run, safeFilename, utf8ByteLength }; if (typeof module !== "undefined" && module.exports) module.exports = api; root.RuneQuillDraftsAction = api; if (typeof draft !== "undefined" && typeof Share !== "undefined" && typeof context !== "undefined") { try { run({ draft, Share, context }); } catch (error) { context.fail(error instanceof Error ? error.message : "Drafts could not prepare this note for Rune & Quill."); } } }(this));
Options
-
After Success Nothing Notification Error Log Level Error
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.