Updating a Note in Obsidian from Drafts

I bet this is way more complicated than it needs to be, but I wanted to use Drafts as an editor for in-progress notes in Obsidian. I troubleshot while Claude coded and eventually put together a Drafts action that uses a UUID in the YAML frontmatter to maintain the link between a draft in Drafts and a note in Obsidian.

It functions as a single action that handles both creation of the note in Obsidian from Drafts and note updates:

  1. New Notes: Generates a UUID, creates the YAML, and saves to an iCloud folder (I use Hazel to move these into the vault).

  2. Existing Notes: Detects the UUID, preserves specific Obsidian metadata (like Status/Area/Type) so you don’t lose changes made in Obsidian, and overwrites the Obsidian note content with the new content from Drafts via the Advanced URI plugin.

Requirements:

Here is the script:

/**
 * Smart Sync - Obsidian Project Manager
 * Version: 10.5
 * Created by: Mike Burke (https://www.themikeburke.com) and Claude (Anthropic)
 * Last Updated: 2025-11-20
 * 
 * License: MIT
 * Copyright (c) 2025 Mike Burke
 * Permission is hereby granted to use, copy, modify, and share this script freely,
 * provided the above copyright and attribution notices are preserved.
 * Full license text: https://opensource.org/licenses/MIT
 * 
 * Description:
 * A Drafts action that creates and synchronizes notes between Drafts and Obsidian.
 * Maintains bidirectional workflow where Drafts owns content/tags and Obsidian owns 
 * metadata (status, area, type). Uses UUID-based detection anchored to YAML region
 * to reliably distinguish new notes from existing ones. Robust YAML parsing handles
 * edge cases including indented delimiters, case-insensitive UUIDs, and Unicode
 * filename normalization for cross-platform stability.
 * 
 * Functionality:
 * 1. Detects whether draft is new or existing by searching for UUID pattern
 *    a. UUID format: YYYYMMDD-XXXXXX (case-insensitive detection, normalized to uppercase)
 *    b. Searches within complete YAML block using explicit regex capture
 *    c. Handles both standard and indented YAML closing delimiters
 *    d. More reliable than checking for "---" which could be horizontal rules
 *    e. Only notes created by this script contain the UUID pattern
 * 2. For NEW notes (Init Mode - no UUID found):
 *    a. Generates unique UUID identifier in YYYYMMDD-XXXXXX format
 *    b. Extracts title from first line and sanitizes for filename
 *    c. Unicode normalizes filenames to NFC for macOS/iCloud stability
 *    d. Creates YAML frontmatter with default metadata (type: note, status: inbox)
 *    e. Field order: created, modified, type, area, tags, status, uuid
 *    f. Tags formatted as alphabetized YAML list with two-space indentation
 *    g. Tags with special characters properly escaped for YAML safety
 *    h. Saves file to iCloud folder for Hazel processing
 *    i. Updates draft with YAML header and adds workspace tag
 * 3. For EXISTING notes (Update Mode - UUID found):
 *    a. Validates title line exists and YAML structure is intact
 *    b. Uses explicit regex capture for robust YAML extraction
 *    c. Extracts and preserves Obsidian-managed metadata (status, area, type, created date)
 *    d. Reconstructs YAML with updated tags and modified date from Drafts
 *    e. Maintains consistent field order and tag formatting across all syncs
 *    f. Syncs content to Obsidian via Advanced URI using UUID lookup
 *    g. Updates local draft with reconstructed YAML
 * 4. Data ownership model:
 *    - Drafts manages: Content body, title (filename), tags, modified timestamp
 *    - Obsidian manages: Status, area, type, created date, UUID
 *    - Sync preserves all fields from both systems
 * 5. Error handling:
 *    - Validates non-empty drafts before processing
 *    - Checks for broken YAML structure in update mode
 *    - Verifies title line exists before syncing
 *    - Provides specific error messages for troubleshooting
 * 
 * Usage:
 * 1. NEW NOTE: Write title on line 1, optional body below, run action
 * 2. EXISTING NOTE: Edit content/tags in Drafts, run action to sync changes
 * 3. Files appear in Obsidian via Hazel (init) or Advanced URI (update)
 * 4. Edit metadata (status/area/type) in Obsidian; changes preserved on next sync
 * 5. Can safely use "---" horizontal rules in body text without triggering false positives
 * 6. Can use tags with spaces or special characters - they'll be properly escaped
 * 7. UUID detection is case-insensitive for manual edit resilience
 * 
 * Note: Requires Obsidian Advanced URI plugin and iCloud Drive access for Hazel 
 * integration. Configure vault name and folder paths in Configuration section below.
 */

// =============================================================================
// CONFIGURATION
// =============================================================================
// Modify these constants to match your setup

const vaultName = "TMB";                    // Your Obsidian vault name
const hazelFolder = "Send to Obsidian/";    // iCloud folder monitored by Hazel
const draftMarkerTag = "active";            // Drafts workspace tag (not synced to Obsidian)
const yamlSpecialTag = "edit-in-drafts";    // Always added to Obsidian YAML tags

// =============================================================================
// HELPER FUNCTIONS
// =============================================================================

/**
 * Get today's date in YYYY-MM-DD format
 * Used for created/modified timestamps in YAML
 */
const now = new Date();
const today = now.getFullYear() + "-" + 
              String(now.getMonth() + 1).padStart(2, '0') + "-" + 
              String(now.getDate()).padStart(2, '0');

/**
 * Sanitize a string for use as a filename
 * Removes/replaces problematic characters and limits length
 * Normalizes Unicode to NFC for cross-platform stability (macOS/iCloud)
 * @param {string} name - Raw filename input (typically from title line)
 * @returns {string} - Cleaned filename safe for filesystem
 */
function sanitizeFilename(name) {
    if (!name || name.trim().length === 0) return "Untitled Note";
    
    // Normalize to NFC (Canonical Composition) for cross-system stability
    // macOS stores as NFD, iCloud can switch to NFC - this prevents sync issues
    let cleaned = name.normalize("NFC");
    
    cleaned = cleaned
        .replace(/^#+\s*/, '')               // Remove markdown headers
        .replace(/[\/\\:*?"<>|]/g, '-')      // Replace illegal chars
        .replace(/^\.+|\.+$/g, '')           // Remove leading/trailing dots
        .replace(/\s+/g, ' ')                // Collapse multiple spaces
        .trim()
        .substring(0, 100);                  // Limit length
    
    return cleaned || "Untitled Note";
}

/**
 * Escape tag for YAML safety
 * Tags with special characters (spaces, colons, etc.) need to be quoted
 * @param {string} tag - Raw tag string
 * @returns {string} - Escaped tag safe for YAML
 */
function yamlEscapeTag(tag) {
    // Check if tag contains characters that require quoting in YAML
    if (/[^A-Za-z0-9_-]/.test(tag)) {
        return `"${tag}"`;
    }
    return tag;
}

/**
 * Extract a specific field value from YAML text block
 * Uses regex to find "key: value" pattern at start of line
 * @param {string} text - YAML content to search
 * @param {string} key - Field name to extract (without colon)
 * @param {string} defaultValue - Fallback if field not found
 * @returns {string} - Extracted value or default
 */
function getField(text, key, defaultValue) {
    const regex = new RegExp(`^${key}:\\s*(.*)$`, "m");
    const match = text.match(regex);
    return match ? match[1].trim() : defaultValue;
}

/**
 * Build complete YAML frontmatter block with consistent formatting
 * Preserves Obsidian-managed fields, updates Drafts-managed fields
 * Tags formatted as alphabetized YAML list with two-space indentation and proper escaping
 * Field order: created, modified, type, area, tags, status, uuid
 * @param {string} uuid - Unique identifier for file tracking
 * @param {string} created - ISO date when note was created
 * @param {string} oldStatus - Current status from Obsidian (inbox, active, etc.)
 * @param {string} oldArea - Current area from Obsidian (work, home, etc.)
 * @param {string} oldType - Current type from Obsidian (note, project, etc.)
 * @param {array} currentTags - Current tags from Drafts workspace
 * @returns {string} - Complete YAML block with opening/closing delimiters
 */
function buildYaml(uuid, created, oldStatus, oldArea, oldType, currentTags) {
    // Filter out workspace-only tag, keep all others
    let tagsToWrite = currentTags.filter(t => t !== draftMarkerTag);
    
    // Combine special tag with user tags and alphabetize all together
    const allTags = [yamlSpecialTag, ...tagsToWrite].sort();
    
    // Build tag block using map/join to preserve indentation on all lines
    // Previous approach with string concatenation + trim() would strip leading spaces from first tag
    const tagLines = allTags.map(tag => `  - ${yamlEscapeTag(tag)}`);
    const tagBlock = tagLines.join("\n");
    
    // Use preserved values or sensible defaults
    const statusVal = oldStatus || "inbox";
    const areaVal = oldArea || "";
    const typeVal = oldType || "note";  // Default type is 'note'
    
    // Return formatted YAML with consistent field order matching vault structure
    return `---
created: ${created}
modified: ${today}
type: ${typeVal}
area: ${areaVal}
tags:
${tagBlock}
status: ${statusVal}
uuid: ${uuid}
---`;
}

// =============================================================================
// MAIN EXECUTION LOGIC
// =============================================================================

// SAFETY CHECK: Prevent running on empty drafts
if (draft.content.trim().length === 0) {
    alert("Draft is empty.");
    context.cancel();
}

// CRITICAL: Detect mode based on UUID presence, anchored to YAML region
// Improved regex handles both standard and indented closing delimiters
// Removed ^ anchor from closing --- to handle plugin-indented YAML blocks
const yamlBlockRegex = /^---\s*([\s\S]*?)\s*---/m;
const yamlBlockMatch = draft.content.match(yamlBlockRegex);

let hasUUID = null;

if (yamlBlockMatch) {
    // YAML exists - extract the content between delimiters
    const yamlContent = yamlBlockMatch[1];
    
    // Search for UUID - now case-insensitive for resilience to manual edits
    // Pattern allows both uppercase and lowercase, normalized to uppercase later
    const uuidRegex = /^uuid:\s*([0-9]{8}-[A-Za-z0-9]{6})/m;
    hasUUID = yamlContent.match(uuidRegex);
}

if (hasUUID) {
    // =========================================================================
    // UPDATE MODE: Sync existing note to Obsidian (UUID detected in YAML)
    // =========================================================================
    
    // 1. Find the title by locating where YAML starts
    const yamlStartRegex = /^---/m;
    const yamlStartMatch = draft.content.match(yamlStartRegex);
    const yamlIndex = yamlStartMatch.index;
    
    // Split draft into title section (before YAML) and remaining content
    const titlePart = draft.content.substring(0, yamlIndex);
    
    // SAFETY: Ensure title line exists and isn't blank
    if (titlePart.trim().length === 0) {
        alert("Error: Title line is missing/empty.\nPlease ensure the first line is your Title.");
        context.cancel();
    }

    // 2. Extract body content (everything after the complete YAML block)
    // Using the full match ensures we skip past all YAML delimiters and whitespace
    const fullMatchString = yamlBlockMatch[0];  // The entire ---...--- block
    const yamlEndIndex = yamlBlockMatch.index + fullMatchString.length;
    const bodyContent = draft.content.substring(yamlEndIndex).trim();
    
    // 3. Extract YAML content and metadata to preserve
    const oldYamlContent = yamlBlockMatch[1];
    
    // Normalize UUID to uppercase for consistency (handles manual edits in lowercase)
    const uuid = hasUUID[1].trim().toUpperCase();
    
    // Extract Obsidian-managed metadata to preserve during sync
    const created = getField(oldYamlContent, "created", today);
    const existingStatus = getField(oldYamlContent, "status", "inbox");
    const existingArea = getField(oldYamlContent, "area", "");
    const existingType = getField(oldYamlContent, "type", "note");

    // Log what we're preserving for debugging
    console.log(`Syncing UUID: ${uuid} | Status: ${existingStatus} | Area: ${existingArea || "(none)"}`);

    // 4. Rebuild YAML with preserved Obsidian data + updated Drafts data
    const newYaml = buildYaml(uuid, created, existingStatus, existingArea, existingType, draft.tags);

    // Update local draft with reconstructed content
    draft.content = titlePart + newYaml + "\n\n" + bodyContent;
    draft.update();

    // Prepare payload for Obsidian (YAML + body, no title)
    const obsidianPayload = newYaml + "\n\n" + bodyContent;

    // Construct Advanced URI callback to sync via UUID lookup
    const baseURL = "obsidian://advanced-uri";
    var cb = CallbackURL.create();
    cb.baseURL = baseURL;
    cb.addParameter("vault", vaultName);
    cb.addParameter("uid", uuid);               // UUID-based file lookup
    cb.addParameter("data", obsidianPayload);   // Content to write
    cb.addParameter("mode", "overwrite");       // Replace existing content
    
    // Execute sync and report result
    if (cb.open()) {
        app.displaySuccessMessage("🔄 Synced");
    } else {
        app.displayErrorMessage("❌ Sync Failed");
    }

} else {
    // =========================================================================
    // INITIALIZE MODE: Create new note (no UUID detected)
    // =========================================================================
    
    console.log("No UUID detected - initializing new note");

    // Generate unique identifier: YYYYMMDD-XXXXXX format (always uppercase)
    const dateString = now.getFullYear() + 
                      String(now.getMonth() + 1).padStart(2, '0') + 
                      String(now.getDate()).padStart(2, '0');
    const randomString = Math.random().toString(36).substring(2, 8).toUpperCase();
    const uuid = dateString + "-" + randomString;

    // Parse draft content into title and body sections
    const lines = draft.content.split('\n');
    
    // SAFETY: Ensure title line exists
    if (lines.length < 1 || lines[0].trim() === "") {
        alert("Please type a Title on the first line.");
        context.cancel();
    }
    
    const rawTitle = lines[0]; 
    const bodyRest = lines.length > 1 ? lines.slice(1).join('\n').trim() : "";
    
    // Create filesystem-safe filename from title (with Unicode normalization)
    const fileName = sanitizeFilename(rawTitle);
    
    // Build initial YAML with default values for new note (type: note)
    const frontmatter = buildYaml(uuid, today, "inbox", "", "note", draft.tags);

    // Log what we're creating
    console.log(`Initializing new note: ${fileName} (${uuid})`);

    // Construct two versions of content:
    // 1. Obsidian version (YAML + body, no title) - for file creation
    // 2. Drafts version (title + YAML + body) - for local draft
    const obsidianContent = frontmatter + "\n\n" + bodyRest;
    const draftsContent = rawTitle + "\n" + frontmatter + "\n\n" + bodyRest;

    // Save to iCloud folder for Hazel processing
    const f = FileManager.createCloud();
    const relativePath = hazelFolder + fileName + ".md";
    
    if (f.writeString(relativePath, obsidianContent)) {
        // File created successfully - update draft and add workspace tag
        draft.content = draftsContent;
        draft.addTag(draftMarkerTag); 
        draft.update();
        app.displaySuccessMessage("✅ Initialized");
    } else {
        // File write failed - abort with error
        app.displayErrorMessage("❌ Save Failed");
        context.fail();
    }
}

Just a general question- is there any reason why you’re using hazel / the advanced url scheme to change those notes instead of directly accessing the files in the obsidian folder?

You can access files outside the Drafts sandbox by using the Bookmark class:

If you have to stick to the approach that Claude coded in your script could you explain a bit more about the setup of folders (hazel rule?) and so on, plus what is currently working or not.

The honest answer to your question is I tried using the bookmark feature, but didn’t get it to work immediately, so I demonstrated the resilience of a wet paper bag and immediately switched to using Hazel because I’m familiar with it and was already using it for some Obsidian-related stuff anyway, lol.

Regarding the Hazel setup, the action saves new notes to a folder in my Drafts iCloud directory, then Hazel moves them into my Obsidian inbox. From there, I have rules that route notes based on their status field, “active” notes go to my Codex folder, “archived” notes get moved to an archive folder with “ARCHIVED” prepended to the filename. Some notes get moved manually into project-specific folders depending on what I’m working on.

The reason this workflow matters to me is that Obsidian is clearly the right tool for long-term storage and organization, but I vastly prefer working in Drafts. For documents I’ll be editing over weeks or months, I want Obsidian’s organizational power combined with Drafts’ editing experience. The UUID-based approach lets me rename files or move them between folders in Obsidian and the sync still works because it’s tracking via UUID rather than filename or path. This gives me flexibility to organize without breaking the connection to Drafts.

I’m more than happy to learn about a more native or streamlined way that I can get Drafts to work with my workflow, but separate from me not being able to get the bookmarked feature to work, I’m under the impression that I’d only be able to get this “syncing” feature working via an action like this.

Thanks, and how do you select files that you want to open in Drafts? Is this with an action or what’s your workflow to get things from obsidian back to drafts again?

So the content never leaves Drafts; a copy gets added to Obsidian, but the original content is still in Drafts, and I can keep working on it. Then I can push the changes to Obsidian and keep working in Drafts, pushing updates whenever I get to a stopping point.

I don’t use GitHub, but it’s almost my understanding of how it works; I’m using Obsidian as GitHub and Drafts as my editor.

Gotcha. And with the uuid you’re replacing the file that you pushed first?