Skip to content

State Model and Synchronization Rules

For every note: B = base snapshot (last verified sync), L = current local file, R = current Joplin note. Comparison uses canonical SHA-256 hashes of four components — title, body, sorted tag set, parent notebook — plus a combined hash.

Canonicalization

  • line endings → \n; nothing else about the body changes (no trailing- whitespace stripping, no Markdown reformatting, no Unicode normalization);
  • tags: lowercased, trimmed, deduplicated, sorted (matches Joplin's own tag-title normalization);
  • hashing and file emission share the exact same canonicalization;
  • component fields are length-prefixed before combined hashing, so field boundaries are unambiguous.

Remote change detection: if R.updated_time == B.updated_time the note's title/body/parent are unchanged (Joplin bumps it on every note write) and the body fetch is skipped. Tag changes do not bump updated_time and are detected via the tag map. File mtimes are never used for correctness.

Note state matrix

B L R Condition Status Planned action
L=B, R=B UNCHANGED none (path normalization at most)
L≠B, R=B, body changed LOCAL_MODIFIED push update
L≠B, R=B, title/tags only METADATA_MODIFIED push update
L≠B, R=B, parent only MOVED_LOCAL push update
L=B, R≠B REMOTE_MODIFIED / MOVED_REMOTE pull update
L≠B, R≠B, L=R BOTH_IDENTICAL rebase (update B only)
L≠B, R≠B, L≠R CONFLICT create bundle; touch nothing
L=B REMOTE_DELETED report; quarantine only with flag
L≠B DELETE_CONFLICT create bundle
R=B LOCAL_DELETED report; trash only with flag
R≠B DELETE_CONFLICT create bundle
BOTH_DELETED drop base
L=R BOTH_IDENTICAL adopt base (reconstruction)
L≠R CONFLICT (no base) create bundle
id present INVALID_LOCAL_FILE blocked; remove id to recreate
no id LOCAL_NEW push create (id written back atomically)
REMOTE_NEW pull create

Joplin's own conflict notes are reported as JOPLIN_CONFLICT_NOTE and never synchronized. Folder statuses (FOLDER_*) mirror the same idea; folder deletions and folder conflicts are report-only in v1.

Race protection (per operation)

Remote update: read note + tags → recompute canonical state → must equal the planned remote state (else abort op, exit 5) → PUT only the intended fields → reconcile tags separately → read again → must equal the intended result → only then commit the base snapshot.

Local update: re-read and re-hash the file → must equal the planned local state → write a temp file in the same directory → flush/fsync → os.replace() → verify the resulting content → commit base.

One exclusive workspace lock (fcntl/msvcrt) guards every command; a second process fails immediately with WORKSPACE_LOCKED (exit 5).

Journal and recovery

Every mutating run persists its full plan before applying anything and rewrites the journal atomically after each op. Ops are marked applied only after the base commit; therefore an interrupted run is settled by recover from current state alone: post-state verifiably present → applied; otherwise → skipped, rerun. Incomplete journals block all mutating commands (exit 6) until recovered.

Deletion policy

Never propagated by default; always reported. With --propagate-deletes: remote deletions quarantine the local file under .joplin-sync/quarantine/<run-id>/; local deletions move the Joplin note to the normal trash (never permanent=1). Delete-vs-edit is always a conflict.