Jotura

Docs / The CLI / Documents from the CLI

Documents from the CLI

Import PDFs and Office files into a Jotura vault from the command line, then list, search, and regenerate the markdown mirrors the CLI builds.

A Jotura vault holds more than markdown notes. PDFs, Word files, spreadsheets, and slide decks can live in the vault as ordinary files on disk. The CLI imports them and generates a searchable markdown mirror next to each one, so the text inside a PDF becomes findable the same way note text is.

This page covers the four document commands: import, regenerate-md-store, search-documents, and ls-documents. For the desktop side of the same feature, see Documents. For general note search, see Reading and searching.

How the mirror works

When you import an eligible document, Jotura writes a second file beside it. The mirror path is the document’s full filename with .md appended, so attachments/report.pdf gets a mirror at attachments/report.pdf.md.

Appending to the full filename rather than replacing the extension is deliberate. It means report.pdf and report.docx in the same folder produce report.pdf.md and report.docx.md and never collide.

A mirror is an ordinary note. It appears in jotura ls, and it is indexed by jotura search. It is not queued for sync by the CLI: import enqueues a sync operation for the document itself but not for the generated mirror, so a mirror written on a CLI-only machine waits for the desktop app’s file watcher to notice it. regenerate-md-store enqueues nothing at all. Sync itself is a paid option, priced on the pricing page.

The command name regenerate-md-store and the JSON field mdStorePath are both named after a .md_store/ folder that older vaults used. That folder is legacy. Current builds write siblings, and the .md_store/ paths you may still see referenced exist only so old vaults can be migrated.

Which files get a mirror

Only file types the bundled converter can pull real text out of are eligible. The allowlist is fixed and case-insensitive.

CategoryExtensions
Portable documentspdf, epub, rtf
Word processingdoc, docx, odt
Spreadsheetsxls, xlsx, ods
Presentationsppt, pptx, odp
Emailmsg, with the caveat below

.msg is a special case. jotura import mail.msg does write mail.msg.md, because the converter allowlist accepts msg. But msg is not in Jotura’s list of synced file extensions, and every other command on this page walks the synced-file set. So a .msg file never appears in ls-documents, is never swept by regenerate-md-store --all or --stale-only, is never indexed by search-documents, and is not counted by jotura doctor. The file itself is also outside the set of files sync uploads. Treat .msg as import-only.

Anything outside the allowlist is stored in the vault as-is with no mirror. Images, audio, video, and archives are excluded on purpose, because converting them would only scrape metadata such as image EXIF tags into a note that reads as noise.

Four text formats are also excluded on purpose: html, htm, csv, and tsv. They are first-class text files that Jotura indexes directly, so a generated mirror would duplicate content that is already searchable. Mirrors left over from an older build that did convert them are deleted by the next regenerate-md-store --stale-only run, which is described below.

Two further exclusions apply. Files under the vault data directory .jotura/ are never eligible, and neither are Office lock files, which are the transient ~$name.docx files Word and Excel write next to a document you have open.

Importing a document

jotura import [OPTIONS] <FILE>
Argument or flagTypeDefaultWhat it does
<FILE>path or -requiredSource file on your disk, or - to read raw bytes from standard input.
--folder <FOLDER>stringattachmentsDestination folder inside the vault. Ignored when --as is set.
--as <AS_PATH>stringnoneExact vault path for the imported file, overriding folder and filename.
--no-convertboolfalseStore the document only. No mirror is generated.
--forceboolfalseWith --as, replace an existing file instead of failing.

The simplest form imports into the default folder:

jotura import ~/Downloads/quarterly-report.pdf

import always emits JSON, whether or not you pass --json:

{
  "contentHash": "3ae7d805f6789a6402acb70ad4096a85",
  "converted": false,
  "mdStorePath": "attachments/quarterly-report.pdf.md",
  "note": "markdown conversion deferred (converter unavailable); a stub entry was written and will self-heal",
  "path": "attachments/quarterly-report.pdf",
  "size": 481022
}

size is the byte length of the imported file. mdStorePath appears only when conversion was attempted, so it is absent for --no-convert and for ineligible file types. note appears only when conversion was attempted and did not produce real text.

Choosing where the file lands

--folder places the file under a folder of your choice and suffixes the name on collision, so importing the same file twice gives you report.pdf, then report-1.pdf, then report-2.pdf. Nothing is ever overwritten on this path.

jotura import ~/Downloads/contract.docx --folder legal/contracts

The default folder is fixed at attachments in the CLI. It does not follow the desktop app’s “Default document folder” setting, so pass --folder if your vault uses a different convention. That desktop setting is described in the settings reference.

--as takes an exact vault-relative path and does no suffixing at all. If the target already exists, the command fails with exit code 9 rather than writing anywhere else.

jotura import ~/Downloads/contract.docx --as legal/2026-msa.docx

Add --force to replace the existing file deliberately. --force has no effect without --as, because the folder path already avoids collisions by suffixing.

An --as value goes through the same path rules as every other CLI path argument. An empty path, any . or .. component, and any target inside .jotura/ all exit 9. A path that resolves outside the vault, including through a symlink, exits 8. Backslashes are normalized to forward slashes, so Windows-style paths are accepted.

Importing from standard input

Passing - as the file reads raw bytes from standard input. There is no filename to derive in that case, so --as is required and omitting it exits 9 with stdin import requires --as <vault-path>.

curl -sL https://example.com/spec.pdf | jotura import - --as refs/spec.pdf

Skipping conversion

--no-convert stores the document without generating a mirror. Use it when you want the file in the vault as an attachment and its text is not worth indexing.

jotura import ~/Downloads/scan.pdf --folder archive --no-convert

The choice is not sticky, and this catches people out. A --no-convert import leaves no mirror on disk, which is exactly the condition the currency sweep calls Missing. The next regenerate-md-store --stale-only or regenerate-md-store --all therefore converts the document anyway. If you want a document to stay unconverted, either keep it out of those sweeps or accept that a sweep will generate the mirror.

To convert a deferred document on purpose, name its path in regenerate-md-store.

Import errors

A source file that does not exist on your disk exits 1, not 3, with a message of the form read <path>: The system cannot find the file specified. Importing into a share you only have viewer access to exits 15. Share behavior is covered in Sync and shares.

What a mirror file contains

Every generated mirror carries a YAML frontmatter block recording where it came from, followed by the converted text.

jotura read attachments/quarterly-report.pdf.md --with-frontmatter
---
jotura_converted_doc: true
source_path: "attachments/quarterly-report.pdf"
source_content_hash: "3ae7d805f6789a6402acb70ad4096a85"
converted_body_hash: "4683c597a17702441e0f0837a727003e"
markitdown_version: null
converted_at: "2026-08-30T08:17:02Z"
conversion_ok: false
---
> The markdown converter is not available yet, so this document could not be indexed.
>
> `attachments/quarterly-report.pdf` will be converted automatically once the converter is installed.
Frontmatter keyMeaning
jotura_converted_docMarks the note as machine-generated. Mirrors written by older builds carry jotura_md_store instead, and both markers are accepted on read.
source_pathVault-relative path of the document this mirror was built from.
source_content_hashHash of the document’s bytes at conversion time. A change here means the document was replaced.
converted_body_hashHash of the generated body at write time. A body that no longer matches means a person edited the mirror. Legacy mirrors have no recorded hash, and a missing hash counts as unedited.
markitdown_versionThe converter version recorded at conversion time. It is null when the converter binary was not found, and also when its version probe could not run, which is what happens when no sandbox is usable. A conversion that ran and failed records the installed version.
converted_atISO 8601 timestamp of the conversion attempt.
conversion_oktrue only when real text was extracted. false marks a placeholder.

When conversion cannot run, Jotura still writes the mirror with conversion_ok: false and a short blockquote body explaining why. The wording depends on the cause.

CausePlaceholder body
Converter binary not foundSays the converter is not available yet, and that the document will be converted automatically once the converter is installed.
No usable sandbox and no consent givenNames the missing sandbox as the reason and quotes the underlying detail.
Any other conversion failureSays the conversion failed and will be retried automatically, and quotes the reason.

Read the retry promise in that third row with care. Whether a retry actually happens is decided by the version gate described under Keeping mirrors current. For a failure recorded under a working converter, that gate stays shut until the converter is upgraded.

Mirrors Jotura refuses to overwrite

A mirror becomes protected the moment its body stops matching converted_body_hash. That happens when you edit it yourself, and also when a plain note you wrote happens to occupy the sibling path. A file carrying the marker but no body hash at all is a legacy mirror and counts as unedited, so it is not protected.

Protected mirrors are never regenerated. If you name one explicitly in regenerate-md-store, the entry comes back with "converted": false and "skipped": true. Notes you add to a mirror are kept, and in exchange that mirror stops tracking the document. To get a fresh conversion, move your notes elsewhere and delete the mirror.

Reading mirror content

A mirror is a normal note, so every read command works on it. read returns the converted body and splits the provenance block into its own field:

jotura read attachments/quarterly-report.pdf.md --json

Add --with-frontmatter to get the whole file as text, or --numbered to prefix each line with its number. Both flags are ignored in --json mode, where frontmatter is always a separate field. Full details are in Reading and searching.

You can also read a mirror’s provenance fields one at a time:

jotura frontmatter get attachments/quarterly-report.pdf.md conversion_ok --json

Do not edit a mirror unless you intend to freeze it. See the protected-mirror section above. Editing commands are covered in Editing notes.

Searching documents

jotura search-documents [OPTIONS] <QUERY>
Argument or flagTypeDefaultWhat it does
<QUERY>stringrequiredQuery text.
--limit <LIMIT>u3220Maximum number of results.
jotura search-documents "termination clause" --limit 5 --json

The command respects --json. In JSON mode each hit carries five fields:

[
  {
    "documentPath": "refs/spec.pdf",
    "mdStorePath": "refs/spec.pdf.md",
    "score": 0.184,
    "snippet": "…isolate the <mark>converter</mark>, and converting untrusted documents…",
    "title": "spec.pdf"
  }
]

Text mode prints documentPath, then a tab, then the score, then a tab, then the snippet.

How matching works

Both search and search-documents run the same two-pass engine. A fuzzy filename pass runs first, and a full-text body pass fills whatever result slots are left.

PassWhat it matchesSnippetScore
FilenameA fuzzy match against the filename with the trailing .md removed, so the mirror refs/spec.pdf.md is matched as spec.pdf.Always empty.A fuzzy-match score, a whole number that is not comparable to a body score.
BodyA whole-word, case-insensitive full-text match. Several terms are combined with AND.The matching text, with <mark> around each matched term and an ellipsis character where text was trimmed.A relevance float, as in the sample above.

Three consequences follow, and all three surprise people.

A placeholder mirror with no extracted text still matches when the query looks like the document’s filename, because the filename pass never touches the body.

Some hits come back with an empty snippet and no <mark> anywhere, because they came from the filename pass.

Whole-word matching in the body pass is strict, so a search for convert does not match converter.

Filename hits come first and are capped at 20. A query wrapped in double quotes is treated as a phrase and skips the filename pass entirely. An empty or whitespace-only query returns an empty result set rather than an error.

Both commands run the engine above, but they feed it different files and return different paths.

jotura searchjotura search-documents
Filename pass coversEvery synced file, so a binary document matches on its own nameThe mirrors of eligible documents only
Body pass coversEvery synced file readable as text, including mirrorsThe mirrors of eligible documents only
Path returnedThe matching file’s own path, so a mirror hit returns refs/spec.pdf.mdThe original document path, refs/spec.pdf
Modeskeyword, semantic, smart via --modeKeyword only
Scope filter--in <SCOPE> restricts results to a path prefix, and also pulls legacy .md_store/ mirrors into the candidate setNone. Narrow with ls-documents --folder instead
JSON fieldspath, title, snippet, scoredocumentPath, mdStorePath, title, snippet, score
Result limit--limit, default 20--limit, default 20

Use search-documents when you want document hits and want the document path back so you can open the original. Use search when you want one ranked list across notes and documents together, and do not mind mirror paths in the results.

Both commands build a throwaway in-memory index on every invocation. That is fast enough for interactive use on vaults of several thousand files, but do not put either in a tight loop.

If a mirror’s path cannot be mapped back to a document path, search-documents drops the hit silently. In practice that affects leftover mirrors whose document has been deleted. The drop happens after --limit has already been applied inside the search, so --limit 5 can return fewer than five results while further matches exist.

Listing documents

jotura ls-documents [OPTIONS]
FlagTypeDefaultWhat it does
--folder <FOLDER>stringnoneRestrict the listing to documents under this folder.

The listing covers eligible documents only, not every non-markdown file in the vault, and not .msg files. Results are sorted by path.

jotura ls-documents --folder legal --json
[
  {
    "path": "legal/2026-msa.docx",
    "size": 41233,
    "hasMdStore": true,
    "conversionOk": false
  }
]
JSON fieldMeaning
pathVault-relative path of the document.
sizeSize of the document in bytes.
hasMdStoreWhether a mirror file exists on disk beside it.
conversionOkWhether that mirror holds real extracted text rather than a placeholder.

Text mode prints the path, a tab, and conversionOk only. That makes a document you imported with --no-convert look identical to one whose conversion failed, since both print false. Pass --json when you need to tell them apart, because only the failed one has hasMdStore: true.

Keeping mirrors current

jotura regenerate-md-store [OPTIONS] [PATHS]...
Argument or flagTypeDefaultWhat it does
[PATHS]...list of stringsemptyOne or more document paths to regenerate.
--allboolfalseRegenerate the mirror for every eligible document in the vault.
--stale-onlyboolfalseRegenerate only the mirrors that are out of date.

The command always emits JSON, an array with one entry per document processed, whether or not you pass --json. Passing none of the three selectors exits 9 with specify one or more paths, or --all, or --stale-only.

Selection precedence is --stale-only first, then --all, then explicit paths. The precedence is silent: if you pass paths alongside either flag, the paths are ignored with no warning. Pass one selector at a time to avoid the surprise.

The everyday command retries whatever is out of date:

jotura regenerate-md-store --stale-only

A full rebuild reconverts every eligible document, which can take a long time on a large vault:

jotura regenerate-md-store --all

Naming paths converts exactly those documents, including ones you imported with --no-convert:

jotura regenerate-md-store archive/scan.pdf archive/scan-2.pdf

Explicit paths bypass the eligibility filter that --all and --stale-only apply, so a mirror is written for whatever you name, useful or not. Naming a file type outside the allowlist hands it to the converter anyway, which may succeed and record conversion_ok: true on scraped metadata. Naming a path that does not exist in the vault is an error and exits 3. Sticking to paths that ls-documents reports avoids both outcomes.

Unlike import, regenerate-md-store applies no share guard, so it never exits 15.

A successful run looks like this:

[
  {
    "path": "refs/spec.pdf",
    "mdStorePath": "refs/spec.pdf.md",
    "converted": true,
    "reason": "SourceChanged"
  }
]

skipped is present only when it is true, which means the mirror was protected and left alone. reason is present only under --stale-only.

What --stale-only deletes

--stale-only is not purely additive. Before it looks for stale mirrors, it sweeps the vault for machine-generated mirrors of the four retired text formats, html, htm, csv, and tsv, and deletes them. A mirror is deleted only when its source file still exists and the mirror still carries valid conversion provenance, so a mirror you have edited is left alone. Leftovers under the legacy .md_store/ folder are swept the same way.

The CLI does not report what it removed. If you want to know before running the sweep, list the candidates yourself first:

jotura ls --json | jq -r '.[].path | select(test("\\.(html|htm|csv|tsv)\\.md$"))'

Why a mirror counts as stale

reasonWhy the mirror is stale
MissingNo mirror exists yet for this document.
SourceChangedThe document’s bytes no longer match source_content_hash.
ConverterUpgradedThe installed converter version is strictly newer than the version recorded in the mirror.
PreviousFailureThe mirror is a placeholder, the converter is installed, conversion is currently permitted, and either no converter version was recorded or the installed converter is strictly newer than the recorded one.

That PreviousFailure predicate is the important one, because it decides how much self-healing you actually get.

A missing converter and a missing sandbox both leave markitdown_version null, so those placeholders are picked up as soon as the converter is installed or a sandbox becomes usable. This is the case the placeholder text and the import output are describing when they say the entry will self-heal.

A conversion that ran and failed, such as a timeout or a corrupt file, records the installed converter version instead. Equal versions do not count as newer, so that placeholder is not retried until you upgrade the converter, no matter how many times you run --stale-only. jotura doctor still points you at regenerate-md-store --stale-only for failed conversions, and in that state the command will do nothing for them.

Protected mirrors never appear in a --stale-only run either. They are tracked separately in the core, but the CLI does not print that list.

When conversion does not happen

Conversion runs in a bundled helper program called the markitdown sidecar, inside an operating-system sandbox. If either piece is unavailable, the import still succeeds and the mirror is still written, but with a placeholder body.

SituationWhat happens
Sidecar not installed next to the CLIA placeholder says the document will be converted automatically once the converter is installed.
No usable sandboxA placeholder names the missing sandbox. Retries are suppressed until a sandbox is usable or you opt in.
Conversion takes longer than 60 secondsThe converter and every process it started are terminated, and a failure placeholder is written.
The converter prints more than 32 MiB to standard output, or more than 1 MiB to standard errorThe whole process tree is terminated and a failure placeholder names the limit that was exceeded.
The converter exceeds its CPU time or file size limitsThe whole process tree is terminated and a failure placeholder records a resource-limit failure.
Converter crashes or the file is corruptA failure placeholder records the reason, subject to the retry gate described above.

Conversion is sandboxed unless you opt out. Converting a document means parsing an untrusted file, so Jotura skips conversion rather than running the parser with your full account privileges. The desktop app has an “Allow unsandboxed conversion” setting for the cases where you trust every document you import, described in the settings reference. That setting is process-global to the desktop app and does not reach the CLI.

The CLI reads three environment variables of its own.

VariableEffect
JOTURA_ALLOW_UNSANDBOXED_CONVERSIONCounts as consent and lets conversion run unsandboxed. doctor reports it as a warning.
JOTURA_DISABLE_SANDBOXSame effect through a different opt-in. doctor reports it as a warning.
JOTURA_MARKITDOWN_SIDECARPath to a converter binary to use instead of the one beside the CLI. An empty value forces the no-converter path.

Without that override, the sidecar is resolved only from the directory holding the running executable.

jotura doctor reports the state of all of this without changing anything:

jotura doctor --json
Check nameWhat it tells you
documentsCounts in the form N converted / N failed / N pending / N total.
documents-failedUp to ten paths of documents whose conversion did not succeed.
documents-failed-noteA pointer back to jotura regenerate-md-store --stale-only.
markitdown-sidecarThe sidecar version, or a warning that it was not found next to the CLI.
markitdown-sandboxWhich sandbox is in use, or that none is usable. On Windows this check notes that the restricted token does not deny network access.

The documents counts do not always add up, and that is expected. Protected mirrors are counted in a fifth bucket that doctor does not print, while total still includes them. On a vault with any protected mirror, the first three numbers therefore sum to less than the fourth.

doctor always exits 0, even when a check reports fail. Scripts must read the status field from --json output rather than testing the exit code. Diagnostics are covered in Watching and diagnostics.

Housekeeping caveats

Deleting, trashing, or renaming a document with the CLI does not move or remove its mirror. jotura delete attachments/report.pdf leaves attachments/report.pdf.md behind as an orphan note, jotura trash attachments/report.pdf does the same, and jotura rename leaves the mirror at the old path. Only the legacy .md_store/ copy, if a migrated vault still has one, is cleaned up automatically.

Handle the mirror yourself when you move a document:

jotura rename attachments/report.pdf legal/report.pdf
jotura rename attachments/report.pdf.md legal/report.pdf.md

Or delete both when you remove one:

jotura delete attachments/report.pdf
jotura delete attachments/report.pdf.md

An orphaned mirror still matches in jotura search, though search-documents drops it because its document path no longer resolves. Note lifecycle commands are covered in Creating, moving, and deleting.

Exit codes

These are the codes the document commands produce. The full list is in Exit codes and JSON output.

Exit codeError code fieldWhen you see it here
0noneSuccess, including imports whose conversion produced only a placeholder.
1ErrorThe source file passed to import could not be read from your disk.
2noneA missing required argument or an unknown flag. This is clap’s own usage error, printed as plain text on standard error, not the JSON envelope. It collides with HashConflict, which uses the same code, so check whether standard error parses as JSON before concluding anything.
3NotFoundA path passed to regenerate-md-store does not exist in the vault.
6NoVaultNo vault resolved. Pass --vault or set JOTURA_VAULT.
8PermissionDeniedA filesystem permission error, or a path that resolves outside the vault.
9InvalidArgsimport - without --as, an --as target that exists without --force, an --as path that is empty or contains . or .. or targets .jotura/, or regenerate-md-store with no selector.
15ReadOnlyShareThe import target is inside a share where your role is viewer.

Errors are always a single-line JSON object on standard error, whether or not you passed --json.