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:
-
New Notes: Generates a UUID, creates the YAML, and saves to an iCloud folder (I use Hazel to move these into the vault).
-
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:
- Obsidian Advanced URI plugin (UID Field set to
uuid).
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();
}
}