Action

Bookmark to Karakeep

Posted by Jihad, Last update 3 days ago

Save the current draft to a self-hosted Karakeep instance.

The action detects the first URL in the draft and saves it as a Karakeep link bookmark. Any remaining text is saved as the bookmark note, and inline hashtags are attached as Karakeep tags. If no URL is found, the draft is saved as a text bookmark instead.

Optional settings let you include Drafts tags, remove the captured URL or hashtags from the saved note, archive the draft after saving, and add a Drafts permalink back to the original note.

Steps

  • configurationKey

    name
    Karakeep Base URL
    key
    karakeepBaseUrl
  • configurationKey

    name
    API Key
    key
    karakeepApiKey
  • configurationKey

    name
    Include Drafts Tags
    key
    includeDraftsTags
  • configurationKey

    name
    Remove Link from Note
    key
    removePrimaryUrlFromNote
  • configurationKey

    name
    Remove Hashtags from Note
    key
    removeHashtagsFromNote
  • configurationKey

    name
    Archive Draft After Saving
    key
    archiveDraftAfterSuccess
  • configurationKey

    name
    Add Drafts Link to Note
    key
    addDraftsPermalinkToNote
  • script

    /*
      Drafts action: Save current draft to Karakeep
    
      Recommended setup:
      1. Create these "Configured Value" steps before the Script step:
         - Key: karakeepBaseUrl, Type: String, Required: On
           Example: https://karakeep.example.com
         - Key: karakeepApiKey, Type: String, Required: On
           Value: API key from Karakeep Settings > API Keys
         - Key: includeDraftsTags, Type: Boolean, Default: true
         - Key: removePrimaryUrlFromNote, Type: Boolean, Default: true
         - Key: removeHashtagsFromNote, Type: Boolean, Default: true
         - Key: archiveDraftAfterSuccess, Type: Boolean, Default: false
         - Key: addDraftsPermalinkToNote, Type: Boolean, Default: false
      2. Add one "Script" step after those values.
      3. Paste this file into the script step.
    
      If karakeepBaseUrl or karakeepApiKey are not defined as Configured Values,
      the script falls back to Drafts Credentials. Credentials are a better fit for
      secrets, because Configured Values only store strings, numbers, and booleans.
    
      Behavior:
      - First http(s) URL in the draft becomes the Karakeep link bookmark.
      - Remaining draft text becomes the bookmark note.
      - If there is no URL, a Karakeep text bookmark is created instead.
      - Inline hashtags are attached as Karakeep tags.
    */
    
    const configuredValues = context.configuredValues || {};
    
    function hasConfiguredValue(key) {
      return Object.prototype.hasOwnProperty.call(configuredValues, key);
    }
    
    function configuredString(key, fallback) {
      if (hasConfiguredValue(key)) {
        return String(configuredValues[key] || "").trim();
      }
      return fallback;
    }
    
    function configuredBoolean(key, fallback) {
      if (!hasConfiguredValue(key)) {
        return fallback;
      }
    
      const value = configuredValues[key];
    
      if (typeof value === "boolean") {
        return value;
      }
    
      if (typeof value === "string") {
        return /^(true|yes|1)$/i.test(value.trim());
      }
    
      return Boolean(value);
    }
    
    const CONFIG = {
      includeDraftsTags: configuredBoolean("includeDraftsTags", true),
      removePrimaryUrlFromNote: configuredBoolean("removePrimaryUrlFromNote", true),
      removeHashtagsFromNote: configuredBoolean("removeHashtagsFromNote", true),
      archiveDraftAfterSuccess: configuredBoolean("archiveDraftAfterSuccess", false),
      addDraftsPermalinkToNote: configuredBoolean("addDraftsPermalinkToNote", false),
      source: configuredString("source", "api"),
      crawlPriority: configuredString("crawlPriority", "normal"),
    };
    
    function stop(message) {
      app.displayErrorMessage(message);
      context.fail(message);
      throw new Error(message);
    }
    
    function unique(values) {
      const seen = {};
      const out = [];
      for (const value of values) {
        const clean = String(value || "").trim();
        const key = clean.toLocaleLowerCase();
        if (clean && !seen[key]) {
          seen[key] = true;
          out.push(clean);
        }
      }
      return out;
    }
    
    function normalizeBaseUrl(value) {
      let baseUrl = String(value || "").trim();
      baseUrl = baseUrl.replace(/\/+$/, "");
      baseUrl = baseUrl.replace(/\/api\/v1$/i, "");
      if (!/^https?:\/\//i.test(baseUrl)) {
        stop("Karakeep URL must start with http:// or https://");
      }
      return baseUrl;
    }
    
    function apiPath(baseUrl, path) {
      return baseUrl + "/api/v1" + path;
    }
    
    function escapeRegex(value) {
      return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
    
    function extractHttpUrls(text) {
      const fromDrafts = draft.urls || [];
      const urls = fromDrafts.filter((url) => /^https?:\/\//i.test(url));
    
      if (urls.length > 0) {
        return unique(urls);
      }
    
      const matches = String(text).match(/https?:\/\/[^\s<>"')\]]+/gi) || [];
      return unique(matches.map((url) => url.replace(/[.,;:!?]+$/, "")));
    }
    
    function extractHashtags(text) {
      const withoutUrls = String(text).replace(/https?:\/\/[^\s<>"')\]]+/gi, " ");
      const tags = [];
      const tagRegex = /(^|[\s([{])#([^\s#.,;:!?()[\]{}<>]+)/g;
      let match;
    
      while ((match = tagRegex.exec(withoutUrls)) !== null) {
        const tag = match[2].replace(/^[#]+|[#.,;:!?()[\]{}<>]+$/g, "");
        if (tag && !/^\d+$/.test(tag)) {
          tags.push(tag);
        }
      }
    
      return unique(tags);
    }
    
    function removeHashtags(text) {
      return String(text).replace(/(^|[\s([{])#[^\s#.,;:!?()[\]{}<>]+/g, "$1");
    }
    
    function removePrimaryUrl(text, url) {
      if (!url) {
        return text;
      }
    
      const escapedUrl = escapeRegex(url);
      let cleaned = String(text);
    
      // Turn Markdown links that point to the primary URL into their label text.
      cleaned = cleaned.replace(new RegExp("\\[([^\\]]+)\\]\\(" + escapedUrl + "\\)", "g"), "$1");
      cleaned = cleaned.replace(new RegExp(escapedUrl, "g"), "");
    
      return cleaned;
    }
    
    function cleanNote(text, primaryUrl) {
      let note = String(text || "").replace(/\r\n/g, "\n");
    
      if (CONFIG.removePrimaryUrlFromNote) {
        note = removePrimaryUrl(note, primaryUrl);
      }
    
      if (CONFIG.removeHashtagsFromNote) {
        note = removeHashtags(note);
      }
    
      note = note
        .replace(/[ \t]+\n/g, "\n")
        .replace(/\n{3,}/g, "\n\n")
        .trim();
    
      if (CONFIG.addDraftsPermalinkToNote) {
        note = note ? note + "\n\nDrafts: " + draft.permalink : "Drafts: " + draft.permalink;
      }
    
      return note;
    }
    
    function firstLine(text) {
      return String(text || "")
        .split("\n")
        .map((line) => line.replace(/^#+\s*/, "").trim())
        .filter(Boolean)[0] || "";
    }
    
    const credential = Credential.create(
      "Karakeep",
      "Enter your self-hosted Karakeep URL and API key."
    );
    credential.addURLField("baseUrl", "Karakeep URL");
    credential.addPasswordField("apiKey", "API key");
    
    let rawBaseUrl = configuredString("karakeepBaseUrl", "");
    let apiKey = configuredString("karakeepApiKey", "");
    
    if (!rawBaseUrl || !apiKey) {
      if (!credential.authorize()) {
        stop("Karakeep credentials were not saved.");
      }
    
      rawBaseUrl = rawBaseUrl || credential.getValue("baseUrl");
      apiKey = apiKey || String(credential.getValue("apiKey") || "").trim();
    }
    
    const baseUrl = normalizeBaseUrl(rawBaseUrl);
    
    if (!apiKey) {
      stop("Karakeep API key is empty.");
    }
    
    const content = String(draft.content || "").trim();
    
    if (!content) {
      stop("Draft is empty.");
    }
    
    const urls = extractHttpUrls(content);
    const primaryUrl = urls[0] || "";
    const note = cleanNote(content, primaryUrl);
    let tags = extractHashtags(content);
    
    if (CONFIG.includeDraftsTags && draft.tags) {
      tags = unique(tags.concat(draft.tags));
    }
    
    const http = HTTP.create();
    
    function karakeepRequest(method, path, body) {
      const response = http.request({
        url: apiPath(baseUrl, path),
        method: method,
        encoding: "json",
        data: body,
        headers: {
          Authorization: "Bearer " + apiKey,
          Accept: "application/json",
          "Content-Type": "application/json",
        },
      });
    
      if (!response.success || response.statusCode < 200 || response.statusCode > 299) {
        const detail = response.responseText || response.error || "HTTP " + response.statusCode;
        stop("Karakeep request failed: " + detail);
      }
    
      if (response.responseData && typeof response.responseData === "object") {
        return response.responseData;
      }
    
      if (!response.responseText) {
        return {};
      }
    
      try {
        return JSON.parse(response.responseText);
      } catch (e) {
        return {};
      }
    }
    
    let payload;
    
    if (primaryUrl) {
      payload = {
        type: "link",
        url: primaryUrl,
        source: CONFIG.source,
        crawlPriority: CONFIG.crawlPriority,
      };
    
      if (note) {
        payload.note = note;
      }
    } else {
      payload = {
        type: "text",
        text: note || content,
        source: CONFIG.source,
      };
    
      const title = firstLine(note || content);
      if (title) {
        payload.title = title;
      }
    }
    
    const bookmark = karakeepRequest("POST", "/bookmarks", payload);
    
    if (!bookmark.id) {
      stop("Karakeep did not return a bookmark id.");
    }
    
    if (tags.length > 0) {
      karakeepRequest("POST", "/bookmarks/" + encodeURIComponent(bookmark.id) + "/tags", {
        tags: tags.map((tagName) => ({
          tagName: tagName,
          attachedBy: "human",
        })),
      });
    }
    
    if (CONFIG.archiveDraftAfterSuccess) {
      draft.isArchived = true;
      draft.update();
    }
    
    app.displaySuccessMessage(
      "Saved to Karakeep" + (tags.length ? " with " + tags.length + " tag(s)" : "")
    );
    

Options

  • After Success Archive , Tags: processed, bookmark
    Notification Info
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.