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.