Skip to main content

Overview

Sync Folds is built as a lightweight Obsidian plugin that intercepts localStorage operations to capture fold state changes and synchronizes them through Obsidian’s plugin data storage system. This architecture enables cross-device synchronization while maintaining compatibility with Obsidian’s existing fold management.

Core Components

SyncFolds Plugin Class

The main plugin class (main.ts:6-269) extends Obsidian’s Plugin base class and manages the entire fold synchronization lifecycle:
export default class SyncFolds extends Plugin {
    settings: SyncFoldSettings
    private debounceTimer: number | null = null
    private originalSetItem: typeof Storage.prototype.setItem
    private originalRemoveItem: typeof Storage.prototype.removeItem
    private cachedFolds: FoldStateData = {}

    async onload() { /* ... */ }
    onunload() { /* ... */ }
}
Key Responsibilities:
  • Initialize localStorage interception
  • Manage bidirectional sync between localStorage and plugin settings
  • Handle external settings changes from file sync
  • Provide manual export/import commands
  • Maintain a cached copy of fold states for change detection

localStorage Interception Mechanism

The plugin uses a monkey-patching approach to intercept all localStorage operations related to fold states (main.ts:130-159):
interceptLocalStorage() {
    const app = this.app
    const appId = app.appId
    const foldPrefix = `${appId}-note-fold-`

    this.originalSetItem = localStorage.setItem.bind(localStorage)
    this.originalRemoveItem = localStorage.removeItem.bind(localStorage)

    localStorage.setItem = (key: string, value: string) => {
        this.originalSetItem(key, value)
        if (key.startsWith(foldPrefix)) {
            const filePath = key.replace(foldPrefix, '')
            this.debouncedSyncFile(filePath, value)
        }
    }

    localStorage.removeItem = (key: string) => {
        this.originalRemoveItem(key)
        if (key.startsWith(foldPrefix)) {
            const filePath = key.replace(foldPrefix, '')
            this.debouncedSyncFile(filePath, null)
        }
    }
}
Why This Approach:
  • Obsidian stores fold states in localStorage using the pattern {appId}-note-fold-{filePath}
  • By intercepting setItem and removeItem, the plugin captures all fold changes without modifying Obsidian’s core behavior
  • Original methods are preserved and restored on unload to prevent conflicts

Debouncing System

To prevent excessive disk writes, the plugin debounces fold state changes with a 150ms delay (main.ts:170-182):
debouncedSyncFile(filePath: string, value: string | null) {
    if (this.debounceTimer !== null) {
        window.clearTimeout(this.debounceTimer)
    }

    this.debounceTimer = window.setTimeout(async () => {
        await this.upsertFoldStateForFile(filePath, value)
        this.debounceTimer = null
    }, 150)
}
Performance Benefits:
  • Rapid fold/unfold operations (e.g., keyboard shortcuts) trigger only one write
  • Reduces I/O operations and prevents settings file thrashing
  • Each new change resets the timer, ensuring only the final state is saved

Settings Management

Settings are defined in settings.ts:1-9 with a minimal interface:
export interface SyncFoldSettings {
    enableSync: boolean
    folds: string  // JSON-serialized FoldStateData
}

export const DEFAULT_SETTINGS: SyncFoldSettings = {
    enableSync: true,
    folds: '{}'
}
The plugin provides helper methods to work with the serialized folds data:
// Parse the JSON string into a FoldStateData object
private getFoldsObject(): FoldStateData {
    try {
        return JSON.parse(this.settings.folds)
    } catch (e) {
        console.error('Failed to parse folds string:', e)
        return {}
    }
}

// Serialize FoldStateData object to JSON string
private setFoldsObject(folds: FoldStateData): void {
    this.settings.folds = JSON.stringify(folds)
}

External Change Detection

When the plugin settings file (data.json) changes externally (e.g., via Obsidian Sync, iCloud, or Git), the onExternalSettingsChange() method (main.ts:73-107) intelligently merges changes:
public async onExternalSettingsChange(): Promise<void> {
    const previousFolds = { ...this.cachedFolds }
    await this.loadSettings()
    const currentFolds = this.getFoldsObject()

    const app = this.app 
    const appId = app.appId

    // Remove folds that were deleted
    for (const filePath of Object.keys(previousFolds)) {
        if (!currentFolds[filePath]) {
            const key = `${appId}-note-fold-${filePath}`
            this.originalRemoveItem.call(localStorage, key)
        }
    }

    // Upsert folds that changed
    for (const [filePath, foldData] of Object.entries(currentFolds)) {
        if (JSON.stringify(previousFolds[filePath]) !== JSON.stringify(foldData)) {
            const key = `${appId}-note-fold-${filePath}`
            const value = JSON.stringify(foldData)
            this.originalSetItem.call(localStorage, key, value)
        }
    }

    this.cachedFolds = currentFolds
}
Change Detection Strategy:
  1. Cache the previous fold states before reloading
  2. Compare previous and current states by serializing to JSON
  3. Remove localStorage entries for deleted folds
  4. Update localStorage entries for modified folds
  5. Update the cache to reflect the new state
This method bypasses the intercepted setItem/removeItem by calling originalSetItem.call() and originalRemoveItem.call() to avoid triggering the debounced sync logic.

Data Structures

The plugin uses three TypeScript interfaces defined in types.ts:1-13:

Fold

export interface Fold {
    from: number  // Starting line number
    to: number    // Ending line number
}
Represents a single folded region in a document.

FoldedProperties

export interface FoldedProperties {
    folds: Fold[]  // Array of all folded regions in the file
    lines: number  // Total number of lines in the document
}
Stores all fold information for a single file, including the document length for validation.

FoldStateData

export interface FoldStateData {
    [filePath: string]: FoldedProperties
}
A dictionary mapping file paths to their fold states. This is the top-level data structure stored in settings.folds.

appId-Based Key System

Obsidian uses a unique appId for each vault instance. The plugin constructs localStorage keys using this pattern (main.ts:132-133):
const app = this.app
const appId = app.appId
const foldPrefix = `${appId}-note-fold-`
Example key: abc123-note-fold-Daily Notes/2024-03-15.md Why This Matters:
  • Multiple Obsidian vaults on the same device use different appId values
  • This prevents fold state collisions between vaults sharing the same localStorage
  • The plugin must use the same appId when reading/writing to match Obsidian’s keys

Sync Flow Diagrams

Local Change → Storage

External Change → Local

Performance Considerations

Caching Strategy

The plugin maintains a cachedFolds object (main.ts:11) that stores the last known state:
private cachedFolds: FoldStateData = {}
Benefits:
  • Enables efficient change detection without re-parsing JSON repeatedly
  • Reduces unnecessary localStorage operations when external changes arrive
  • Provides a rollback point if settings loading fails

Debouncing Parameters

The 150ms debounce delay (main.ts:181) was chosen to:
  • Be imperceptible to users (< 200ms is considered instant)
  • Catch rapid sequences of folds/unfolds (e.g., Ctrl+Shift+[ repeatedly)
  • Balance between responsiveness and write reduction

Lazy Initialization

The plugin only initializes if enableSync is true (main.ts:17-20):
if (!this.settings.enableSync) {
    log('Sync disabled, skipping initialization')
    return
}
This prevents unnecessary localStorage interception and event handling when sync is disabled.

Logging System

The plugin uses a conditional logging system defined in log.ts:1-7:
declare const DEBUG: boolean

export const log = (...args: unknown[]) => {
    if (DEBUG) {
        console.debug('[SyncFolds]', ...args)
    }
}
Usage Pattern:
  • All internal operations are logged via log() calls throughout main.ts
  • Logs are only output when DEBUG is true (controlled by build configuration)
  • Prefixed with [SyncFolds] for easy filtering in the console
  • Uses console.debug() rather than console.log() for semantic clarity

Design Decisions

Why localStorage Interception?

Alternatives Considered:
  • File system watching: Would miss in-memory fold changes until file save
  • DOM mutation observers: Too low-level, tightly coupled to Obsidian’s rendering
  • Polling localStorage: Inefficient, high CPU usage, delayed detection
Chosen Approach Benefits:
  • Captures changes immediately when Obsidian updates folds
  • No dependency on Obsidian’s internal APIs (more stable across updates)
  • Clean separation: Obsidian manages folds, plugin syncs them

Why Store as JSON String?

The folds setting is stored as a JSON string rather than an object:
folds: string  // JSON-serialized FoldStateData
Reasoning:
  • Obsidian’s saveData() API requires serializable values
  • Storing pre-serialized JSON gives the plugin control over formatting
  • Easier to inspect/edit the data.json file manually if needed
  • Reduces risk of Obsidian’s serialization introducing inconsistencies

Why Manual Commands?

The plugin provides explicit export/import commands (main.ts:38-54) even though automatic sync is active:
this.addCommand({
    id: 'export-fold-states',
    name: 'Export folds from local storage',
    callback: async () => {
        await this.exportFoldsToFile()
        new Notice('Fold states saved to settings')
    }
})
Use Cases:
  • Force a sync before switching devices
  • Recover from a corrupted cache
  • Debugging: verify what’s currently stored
  • Manual control for users who prefer explicit actions

Thread Safety and Edge Cases

Concurrent Changes

The plugin handles several edge cases:
  1. Rapid local changes: Debouncing ensures only the final state is saved
  2. External changes during debounce: The cache comparison in onExternalSettingsChange() detects conflicts
  3. Plugin reload: Fresh load from data.json during onload() ensures consistency

Cleanup on Unload

The onunload() method (main.ts:109-116) ensures clean shutdown:
onunload() {
    log('Plugin unloading')
    this.restoreLocalStorage()

    if (this.debounceTimer !== null) {
        window.clearTimeout(this.debounceTimer)
    }
}
  • Restores original localStorage methods to prevent memory leaks
  • Cancels any pending debounced writes to avoid orphaned timers
  • Prevents the plugin from interfering with localStorage after unload

Mobile Compatibility

The plugin is marked as isDesktopOnly: false in manifest.json:12 because:
  • localStorage API is available on mobile WebView environments
  • No Node.js or Electron APIs are used
  • File I/O is handled through Obsidian’s Plugin base class APIs
  • Debouncing uses window.setTimeout, available in all browsers
Tested On:
  • Desktop: Windows, macOS, Linux
  • Mobile: iOS (via Obsidian mobile app), Android

Security and Privacy

The plugin follows Obsidian’s security best practices:
  • No network requests: All data stays local to the device
  • No telemetry: The log() function outputs to the local console only
  • Vault-only access: Only reads/writes plugin settings through Obsidian’s API
  • No external dependencies: Pure TypeScript, no third-party runtime libraries
  • Open source: Code is auditable at the source repository

Future Considerations

Scalability

The current architecture stores all fold states in a single JSON object. For vaults with thousands of files:
  • Current approach: Manageable up to ~10,000 files (< 1MB of fold data)
  • Potential optimization: Shard fold states across multiple setting keys if needed
  • Alternative: Implement a custom file-based storage system

Conflict Resolution

The plugin uses a “last write wins” strategy. Future enhancements could include:
  • Timestamp-based merge conflict detection
  • Per-file merge rather than whole-settings replacement
  • User-configurable conflict resolution strategies

Selective Sync

Currently, all folds are synced. Potential features:
  • Exclude patterns (e.g., don’t sync folds in .trash/ folder)
  • Per-folder sync toggles
  • Sync only specific file types

Build docs developers (and LLMs) love