Editing notes

How to change note content from the Jotura CLI: the read-then-write hash loop, every edit operation, multi-op applies, batches, and conflict recovery.

A Jotura vault is an ordinary folder of Markdown files, so any text editor can change a note. The CLI exists because it adds four things a plain file write does not have:

  • A compare-and-swap precondition that refuses to overwrite a change the desktop app or sync just made.
  • Strict matching rules that refuse to guess which occurrence of a string you meant.
  • Frontmatter that survives every body edit untouched.
  • Machine-readable errors with stable exit codes.

This page covers the three commands that change note content. Use edit for structured changes to a single note, write for replacing content wholesale, and batch for many files in one call. Creating, renaming, moving, and deleting notes are covered in Creating, moving, and deleting. Frontmatter keys and tags have their own commands, described in Frontmatter and tags.

The safe-edit loop

Every write from the CLI should start with a read. The read --json output carries a hash field. Pass that hash back as --if-hash and the write becomes a compare-and-swap, so a file that changed since your read is left alone instead of clobbered.

HASH=$(jotura read notes/today.md --json | jq -r .hash)
jotura edit notes/today.md --replace "old" --with "new" --if-hash "$HASH"

jq is a separate third-party install and is not bundled with Jotura. An agent that parses the JSON itself does not need it, and the loop is the same either way.

Exit code 2 means the hash no longer matches, so read again, re-derive your change, and retry. Exit code 14 means the file was deleted or moved since your read, and Jotura deliberately does not recreate it. Both cases are described under Recovering from a hash conflict below.

One warning before you script that retry. The argument parser also exits 2 for its own usage errors, such as an unknown flag, and prints plain text rather than JSON. Check whether stderr parses as JSON before you treat exit 2 as a hash conflict, or a typo will loop forever.

edit performs an implicit compare-and-swap even when you omit --if-hash, because it reads the note and then writes with the hash it just read. Passing --if-hash widens that guard to cover the time you spent deciding what to change, which is where an agent that reads a note, reasons about it, and writes later needs the protection most.

write is different and has no implicit guard. Without --if-hash it overwrites the target unconditionally, with no hash check at all, which is why the flag matters more on write than on edit.

Hash-checked writes are atomic against every other Jotura writer. The CLI and the desktop app take a per-vault advisory lock at <vault>/.jotura/write.lock and hold it across the hash compare and the write. Two writers validating the same hash can never both succeed, and the loser exits 2.

edit: one structured change

edit applies a structured change to a note’s body. The YAML frontmatter block is split off before the change and prepended again verbatim afterward, so no edit operation can ever alter frontmatter. Use frontmatter set, frontmatter delete, tag add, and tag remove for that.

The full synopsis names the operation selectors, exactly as jotura edit --help prints it:

jotura edit [OPTIONS] <--replace <OLD>|--replace-line <N>|--replace-lines <N:M>|--insert-before <N>|--insert-after <N>|--delete-lines <N:M>|--append|--prepend|--apply <FILE>> <PATH>

The note must already exist, because edit reads before it writes. A bare <PATH> with no slash and no extension resolves like a wikilink to the unique note with that filename, exactly as it does for read, and several matches exit 4 with candidates. Exactly one operation selector is required. Supplying none or more than one is a usage error from the argument parser rather than a Jotura error, so you get human-readable text on stderr and exit code 2 rather than the JSON envelope.

Flag Type Default What it does
<PATH> string required The vault-relative note to edit.
--replace <OLD> string none Operation selector. Replace a literal substring. Requires --with.
--with <NEW> string none The replacement text. Required by --replace, --replace-line, and --replace-lines, and used by no other operation.
--all bool false Replace every occurrence. Only valid with --replace.
--nth <NTH> integer none Replace the Nth occurrence, counting from 1. Only valid with --replace.
--replace-line <N> integer none Operation selector. Replace line N, counting from 1. Requires --with.
--replace-lines <N:M> range none Operation selector. Replace the inclusive line range N to M. Requires --with.
--insert-before <N> integer none Operation selector. Insert content before line N.
--insert-after <N> integer none Operation selector. Insert content after line N.
--delete-lines <N:M> range none Operation selector. Delete the inclusive line range N to M.
--append bool false Operation selector. Append content to the end of the body.
--prepend bool false Operation selector. Prepend content to the start of the body.
--apply <FILE> path or - none Operation selector. Apply a JSON array of operations in sequence. - reads stdin.
--content <CONTENT> string stdin Content for the insert, append, and prepend operations. Omit it to read stdin to end of file. Rejected by every other operation.
--if-hash <IF_HASH> string none Refuse the write unless the note’s current hash matches.
--dry-run bool false Compute the change and print it without writing.
--diff bool false Include a unified diff in the output. Works with and without --dry-run.

The two text flags are not interchangeable, and each operation accepts exactly one of them. --with carries the new text for --replace, --replace-line, and --replace-lines. --content carries it for --insert-before, --insert-after, --append, and --prepend. --delete-lines and --apply take neither.

Neither requirement is enforced by the argument parser. Both are checked when the command is dispatched, before the note is read, so every violation exits 9 (InvalidArgs) with the normal JSON error envelope and leaves the file untouched:

Command Message
jotura edit x.md --replace "a" --replace requires --with
jotura edit x.md --append --with "text" --append takes its text from --content, not --with; --with is only for --replace, --replace-line and --replace-lines
jotura edit x.md --replace "a" --with "b" --content "c" --replace takes its text from --with, not --content
jotura edit x.md --delete-lines 1:2 --with "x" --delete-lines does not take --with

In CLI 0.8.1 and earlier the mismatched flag was ignored rather than rejected, which made --append --with "text" resolve its content to the empty string and report changes: 1 on a write that added nothing.

--content, --replace, and --with all accept values that begin with a hyphen, so you do not need a -- separator to pass text like --with "-1".

Every operation reports a changes count in its output. The counts mean different things per operation:

Operation Reported changes
--replace with a single match 1
--replace --nth N 1
--replace --all The number of occurrences replaced.
--replace-line N 1
--replace-lines N:M The number of lines replaced.
--insert-before N, --insert-after N 1
--delete-lines N:M The number of lines removed.
--append, --prepend 1
--apply The sum of the per-operation counts.

Replacing a substring

--replace matches a literal byte sequence, not a regular expression, and it is strict on purpose. Zero matches exits 3 (NotFound). More than one match exits 4 (Ambiguous) rather than silently picking one. An empty OLD string exits 9.

Both error envelopes carry needle, the string you searched for. The Ambiguous envelope adds occurrences and lineHits, the 1-indexed line number of every hit.

jotura edit notes/plan.md --replace "status: draft" --with "status: final"

You resolve ambiguity in one of three ways: lengthen the needle until it is unique, pass --all to replace every occurrence, or pass --nth N to pick one.

jotura edit notes/plan.md --replace "TODO" --with "DONE" --all
jotura edit notes/plan.md --replace "TODO" --with "DONE" --nth 2

--nth counts from 1. Passing 0, or a number larger than the occurrence count, exits 5 (OutOfRange). Read that error carefully, because the envelope reuses the line fields for a different concept: line holds the N you passed and lineCount holds the occurrence count, not a line count.

--nth also suppresses the ambiguity check, since you have already said which match you want. If you pass both --all and --nth, --all wins.

Replacing lines

--replace-line N replaces a single line, counting from 1, and --replace-lines N:M replaces an inclusive range. Both require --with.

jotura edit notes/plan.md --replace-line 3 --with "## Revised heading"
jotura edit notes/plan.md --replace-lines 10:14 --with "One line replaces five."

Line operations preserve the original line ending. If the line ended with a carriage return and newline, the replacement keeps that pairing. If the target was the final line and had no line ending at all, none is added. For a range, the line ending of line M is the one preserved, and the whole range collapses into a single replacement block.

A malformed range exits 9, because N:M needs the colon and needs numbers on both sides. A range where M is less than N, where N is 0, or where either end is past the end of the file, exits 5.

Use the line operations when the text you are changing is not textually unique. Pair them with jotura read <path> --numbered so the numbers you pass match what you just looked at.

jotura read notes/plan.md --numbered

Plain --numbered counts the body only, which is exactly the numbering the edit operations use. Adding --with-frontmatter changes that: the YAML block is then numbered too, and every body line is offset by the length of the block. Never derive edit line numbers from a --numbered --with-frontmatter read.

Inserting content

--insert-before N and --insert-after N place a block of content relative to line N, counting from 1. The content comes from --content or, if that is omitted, from stdin read to end of file.

jotura edit notes/plan.md --insert-after 1 --content "A new second line."
cat snippet.md | jotura edit notes/plan.md --insert-before 5

The block gets a trailing newline added if it lacks one, so inserted content never runs into the following line. --insert-after also forces a newline onto the end of line N if that line did not have one. Content that resolves to the empty string, whether from --content "" or from empty stdin, exits 9 rather than writing an unchanged body; to insert a blank line, pass a newline as the content.

The bounds rules differ slightly between the two. --insert-before 0 exits 5, and an N past the end of the file exits 5 as well, except when the body is empty: inserting into an empty note works at any N. --insert-after exits 5 when the body is empty, when N is 0, or when N is past the end of the file.

Deleting lines

--delete-lines N:M removes an inclusive range and takes neither --with nor --content. It uses the same range parsing and bounds rules as --replace-lines.

jotura edit notes/plan.md --delete-lines 12:18

Appending and prepending

--append adds a block to the end of the body and --prepend adds one to the start. Both read content from --content or stdin, and neither accepts --with.

jotura edit journal/2026-08-30.md --append --content "- Shipped the CLI docs."

--append guarantees a newline between the existing body and the new block, and a trailing newline after it. --prepend always terminates the block with a newline. As with the insert operations, content that resolves to the empty string exits 9 instead of writing.

For appending to today’s daily note specifically, jotura today --append is the shorter route. See Daily notes, tasks, and templates for how the daily folder is resolved.

Editing a file that is not Markdown

A vault can hold text files that are not notes, such as .txt or .json. For any path whose name does not end in .md or .markdown, there is no frontmatter split at all and the body is the entire file. Line numbers, --replace matches, appends, and prepends therefore all operate over the whole file, including anything that looks like a YAML block at the top.

What a successful edit prints

edit always emits JSON on success, whether or not you pass --json. The shape depends on which mode you used, and a client parsing the output has to branch on it.

Mode Output
Single operation { "path", "hash", "changes", "resolvedFrom"?, "diff"? }
--apply { "path", "currentHash", "newHash", "changes", "opsApplied", "resolvedFrom"?, "diff"? }

Watch the key names, since they differ between the two modes: a single operation returns hash, while --apply returns currentHash plus newHash. The diff field appears only when you pass --diff, and resolvedFrom only when a bare name was resolved to a path, holding the name you passed.

Multiple operations in one write: edit --apply

--apply takes a JSON array of operations, applies them in sequence to one note, then performs a single atomic write at the end. Pass a file path, or - to read the array from stdin.

jotura edit notes/plan.md --apply ops.json --if-hash "$HASH"

Operations are applied to the evolving content, so operation N sees the result of operation N-1. Line numbers shift accordingly, which is the single most common source of surprise. If you delete lines 5 to 8 and then want to change what used to be line 12, refer to line 8.

Each operation is a JSON object with a kind field. There are eight kinds.

kind Fields Notes
replace old (string), new (string), all (bool, default false), nth (integer or null, default null) Same strictness as the flag form: zero matches is NotFound, more than one without all or nth is Ambiguous.
replace-line line (integer), content (string) Line numbers count from 1.
replace-lines start (integer), end (integer), content (string) Inclusive range.
insert-before line (integer), content (string)
insert-after line (integer), content (string)
delete-lines start (integer), end (integer) Inclusive range.
append content (string)
prepend content (string)
[ { "kind": "replace", "old": "TODO", "new": "DONE" },
  { "kind": "delete-lines", "start": 5, "end": 8 },
  { "kind": "append", "content": "trailing\n" } ]

Three names describe the same concept across this surface, and there is no way around learning all three. On the command line the replacement text is --with. In a replace operation it is new. In a replace-line or replace-lines operation it is content.

The same split applies to hashes, which are --if-hash on the command line and ifHash inside batch JSON.

--apply is all-or-nothing. If any operation fails, the command exits 11 (MultiEditOpFailed) and nothing is written. The error JSON carries opIndex, the 0-based position of the failing operation, plus underlyingCode and a nested underlying object holding the original error’s code, message, and data. Invalid JSON in the operation list exits 9.

opsApplied in the output is the number of operations in the array, and changes is the sum of the per-operation change counts.

write: replacing content wholesale

write replaces a note’s content outright. Use it when you are generating the whole body rather than transforming part of it. The path is created if it does not exist.

jotura write [OPTIONS] <PATH>
Flag Type Default What it does
<PATH> string required The vault-relative destination. Created if it does not exist.
--full bool false Replace the entire file including frontmatter.
--content <CONTENT> string stdin The new content. Omit it to read stdin to end of file.
--if-hash <IF_HASH> string none Refuse the write unless the current hash matches.
--dry-run bool false Compute the change and print it without writing.
--diff bool false Include a unified diff in the output.

By default write replaces the body and preserves any existing frontmatter block verbatim. --full replaces everything, frontmatter included, which is what you want when the content you supply already contains its own YAML block.

jotura write notes/plan.md --content "# Plan

Rewritten from scratch."

pandoc -t markdown input.docx | jotura write imported/input.md --full

There is no bare positional form for content: it is --content or stdin, always. An empty stdin exits 9 (InvalidArgs) rather than clearing the note, since that is almost always a pipe that produced nothing; pass --content "" when you mean to empty a note deliberately.

For a path with no .md or .markdown extension there is no frontmatter to split off, so both forms behave identically. Binary files are refused with exit 9, so use jotura import instead, described in Documents from the CLI.

On success write always emits JSON, with or without --json: { "path", "hash", "diff"? }.

Without --if-hash, write performs no hash check and overwrites whatever is there. With --if-hash and an existing target, a mismatch exits 2 and the error carries a currentVsIntended diff. With --if-hash and a target that does not exist, the CLI-layer check is skipped and the core write path rejects the write, so you get exit 14 (TargetMissing) rather than exit 2.

Previewing with --dry-run and --diff

--dry-run computes exactly what would be written and prints it without touching the disk. --diff adds a unified diff, and works both with --dry-run and on a real write, where it shows you what you just changed.

jotura edit notes/plan.md --replace "draft" --with "final" --dry-run --diff

edit and write emit the content envelope, which carries both hashes so you can chain the preview into a real write:

{ "dryRun": true, "path": "notes/a.md",
  "currentHash": "1f3a...", "newHash": "9c02...", "changes": 1,
  "preview": { "kind": "replace", "before": "draft", "after": "final" },
  "diff": "--- a/notes/a.md\n+++ b/notes/a.md\n..." }

The preview object identifies the operation and varies by kind:

Command and mode preview.kind Other preview fields
edit --replace replace before (the needle), after (the replacement)
edit --replace-line replace-line before, after, lineRange
edit --replace-lines replace-lines before, after, lineRange
edit --insert-before insert-before after, lineRange
edit --insert-after insert-after after, lineRange
edit --delete-lines delete-lines before, lineRange
edit --append append after
edit --prepend prepend after
edit --apply multi-edit opsApplied
write write none
write --full write-full none

For replace the before and after values are the needle and the replacement text themselves, not the surrounding lines. For --apply, opsApplied appears twice: once inside preview and once at the top level of the envelope. For a write dry run, changes is always the literal 1 and is not a count of anything.

In text mode the same information prints as DRY RUN: <path> followed by indented current hash, new hash, and changes lines, then each preview key, then the diff.

A dry run skips some checks that a real run performs, so a clean preview does not guarantee a successful write. edit --dry-run, write --dry-run, and batch --dry-run all return before the share guard runs, and write --dry-run returns before the binary-file check.

So a dry run into a share mount you joined as a viewer previews cleanly, and the real run then exits 15. A write --dry-run at a binary path previews cleanly too, and the real run then exits 9.

create, rename, and delete use a different, smaller envelope with fixed pseudo-diffs, because there is no old-versus-new body to compare. That envelope is documented in Creating, moving, and deleting.

batch: many files in one call

batch applies a JSON list of file-level operations across the vault.

jotura batch [OPTIONS] <FILE>
Flag Type Default What it does
<FILE> path or - required The JSON file containing the batch, or - to read it from stdin.
--dry-run bool false Plan and preview, writing nothing.
--diff bool false Include a per-entry diff in each result. Content operations get a real unified diff, while create, rename, and delete get the fixed manifest pseudo-diff instead.

The input is a JSON array of objects shaped { "path": "...", "op": { "kind": "...", ... } }. Invalid JSON exits 9, and a file that cannot be read exits 1.

jotura batch changes.json --dry-run --diff
cat changes.json | jotura batch -

There are nine operation kinds. Note the casing difference from the command line: inside batch JSON the precondition field is ifHash.

kind Fields Supports ifHash Notes
write content (string), full (bool, default false), ifHash (optional) Yes Same frontmatter-preserving behavior as jotura write.
edit apply (array of edit operations), ifHash (optional) Yes Uses the eight edit operation kinds above.
create parent (string), name (string) No Same .md extension rule and (2) collision suffixing as jotura create.
rename to (string) No The source is the entry’s path.
delete none No Permanent, not a move to trash.
frontmatter-set key (string), value (string), ifHash (optional) Yes The value is parsed as JSON, falling back to a string.
frontmatter-delete key (string), ifHash (optional) Yes
tag-add tag (string), ifHash (optional) Yes
tag-remove tag (string), ifHash (optional) Yes
[ { "path": "notes/a.md", "op": { "kind": "tag-add", "tag": "reviewed" } },
  { "path": "notes/b.md",
    "op": { "kind": "edit", "ifHash": "1f3a...",
            "apply": [ { "kind": "replace", "old": "v1", "new": "v2" } ] } },
  { "path": "notes/old.md", "op": { "kind": "rename", "to": "archive/old" } } ]

Every content operation carries an implicit compare-and-swap against the hash read during planning, whether or not you supplied ifHash. Adding ifHash asserts a hash you chose yourself and moves the check earlier, into the batch-wide verify pass.

batch always prints pretty JSON and ignores --json entirely:

{ "opsApplied": 3,
  "results": [ { "path": "notes/a.md", "kind": "tag-add",
                 "currentHash": "1f3a...", "newHash": "9c02...", "changes": 1 } ] }

Each result may carry path, kind, currentHash, newHash, changes, diff, and newPath. newPath appears for create and rename. The hash and changes fields are omitted for operations with no body content, and changes is always the literal 1 for the operations that do have one. A --dry-run prints the same shape from the planned results rather than from real writes.

What “atomic” means here

The command’s own summary calls the batch atomic, which holds for the preconditions but overstates the apply phase. The batch runs in four phases.

  1. Plan. Jotura reads the current content for every body-modifying operation and computes the intended new content. Operation-internal failures such as NotFound, Ambiguous, and OutOfRange surface here as exit 11 with opIndex, before anything is written.
  2. Verify. Every ifHash precondition is checked in a single pass against current state. Any mismatch aborts the whole batch with exit 2 and a conflicts array. No disk changes have happened.
  3. Guard. Every operation is routed through the share guards before any write. A write into a view-only share mount exits 15, and an operation that would break a mount’s structure exits 9. Either one aborts the batch as a whole.
  4. Apply. Each operation is an independent atomic file write.

The caveat lives in step 4. If an operation fails during the apply phase for an unexpected reason, the operations already committed in the same batch are not rolled back. Unexpected reasons include a file that vanished after the read-back and a rename whose destination already exists, which is not checked until the apply phase. The three preflight phases make this uncommon, though it can still happen.

Treat a failed batch as partially applied, and reconcile by reading the affected paths before you assume anything about the vault’s state.

Recovering from a hash conflict

Every CLI error is a single-line JSON object on stderr, with or without --json. A hash conflict on a single file looks like this:

{ "code": "HashConflict", "message": "hash conflict: expected abc, actual def",
  "expectedHash": "abc", "actualHash": "def",
  "expected": "abc", "actual": "def",
  "path": "notes/x.md",
  "currentVsIntended": "--- a/notes/x.md\n+++ b/notes/x.md\n..." }

currentVsIntended is a unified diff between what is on disk right now and what you were about to write. It is the fastest way to decide whether your change is still wanted. Often the other writer touched a different part of the note and you can replay your edit unchanged, and sometimes they already made your change.

The expected and actual fields duplicate expectedHash and actualHash and exist only for backwards compatibility, so read the Hash forms in new code.

The recovery loop is always the same. Read again to get the current hash, re-derive your change against the current content, and retry the write.

HASH=$(jotura read notes/x.md --json | jq -r .hash)
jotura edit notes/x.md --replace "old" --with "new" --if-hash "$HASH"

For batch, a precondition failure produces exit 2 with a conflicts array instead of a single set of fields. Each entry carries path, expectedHash, actualHash, and its own optional currentVsIntended diff, so one failed batch tells you every path that moved underneath you.

Exit 14 (TargetMissing) is a different situation and needs a different response. It means the file you read was deleted or moved before your write landed, and Jotura refuses to recreate it silently. The error carries path. Either read the vault to find out where the note went, or write without --if-hash if you do want the file recreated.

An edit --if-hash that fails on the early check reports the conflict without a currentVsIntended diff, because the intended content has not been computed yet. A conflict detected later, inside the write path, carries the diff.

Exit codes you will see while editing

The full list lives in Exit codes and JSON output. These are the ones the editing commands produce.

Code Symbol When it happens
0 Success.
1 Error Unclassified failure, such as an unreadable batch file. Read the message field.
2 HashConflict An --if-hash or ifHash precondition failed, or a compare-and-swap write lost the race.
3 NotFound The note does not exist, or a --replace needle matched zero times.
4 Ambiguous A --replace needle matched more than once and neither --all nor --nth was given.
5 OutOfRange A line number or range is out of bounds, or --nth N is 0 or beyond the occurrence count.
6 NoVault No vault resolved. Pass --vault, set JOTURA_VAULT, or open a vault in the desktop app.
8 PermissionDenied A filesystem permission error, or a path that resolves outside the vault.
9 InvalidArgs A missing --with, a malformed range, invalid JSON, an empty --replace needle, or a binary target passed to write.
11 MultiEditOpFailed One operation in an --apply sequence or a batch failed. Carries opIndex.
14 TargetMissing The target was deleted or moved between your read and your write.
15 ReadOnlyShare The target is inside a view-only share mount.

The exit-2 collision described in the safe-edit loop applies to all of these. The argument parser uses exit code 2 for its own usage errors, such as a missing required argument, an unknown flag, or supplying zero or two operation selectors to edit. Those print human-readable text on stderr rather than the JSON envelope, so check whether stderr parses as JSON before concluding you hit a hash conflict.

Limits worth knowing before you script this

Every path argument is normalized and confined to the vault. A path containing . or .. components exits 9, an empty path exits 9, and anything under .jotura/ exits 9 as well. The templates subtree at .jotura/templates/ is the documented exception.

Symlinks and Windows junctions that resolve inside the vault are followed, and anything resolving outside exits 8 with “path resolves outside the vault”. The desktop app enforces the same rule through the same shared code.

Editing a note inside a share mount you joined as a viewer exits 15 (ReadOnlyShare), and the error carries path, shareId, and mountRel. There is no override flag, deliberately: a forced viewer write would never sync and the next remote change would overwrite it. Ask the share owner for editor access instead. Sharing is part of Jotura’s paid Sync plan, described on the pricing page and in Sync and shares.

The edit surface cannot modify nested YAML in frontmatter. Complex YAML is preserved as opaque text, but the frontmatter commands can only set and delete scalar keys.

There is no commit or flush step. A CLI edit lands on disk atomically and the desktop app notices it. If the note is open in a tab, the app does not prompt: it runs a silent three-way merge between your CLI change and any unsaved editor content, then announces “Merged changes from disk”.

Deleting a note that is open is the one case that does prompt. See The editor for both behaviors.