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.
| Category | Extensions |
|---|---|
| Portable documents | pdf, epub, rtf |
| Word processing | doc, docx, odt |
| Spreadsheets | xls, xlsx, ods |
| Presentations | ppt, pptx, odp |
msg, 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 flag | Type | Default | What it does |
|---|---|---|---|
<FILE> | path or - | required | Source file on your disk, or - to read raw bytes from standard input. |
--folder <FOLDER> | string | attachments | Destination folder inside the vault. Ignored when --as is set. |
--as <AS_PATH> | string | none | Exact vault path for the imported file, overriding folder and filename. |
--no-convert | bool | false | Store the document only. No mirror is generated. |
--force | bool | false | With --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 key | Meaning |
|---|---|
jotura_converted_doc | Marks the note as machine-generated. Mirrors written by older builds carry jotura_md_store instead, and both markers are accepted on read. |
source_path | Vault-relative path of the document this mirror was built from. |
source_content_hash | Hash of the document’s bytes at conversion time. A change here means the document was replaced. |
converted_body_hash | Hash 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_version | The 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_at | ISO 8601 timestamp of the conversion attempt. |
conversion_ok | true 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.
| Cause | Placeholder body |
|---|---|
| Converter binary not found | Says 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 given | Names the missing sandbox as the reason and quotes the underlying detail. |
| Any other conversion failure | Says 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 flag | Type | Default | What it does |
|---|---|---|---|
<QUERY> | string | required | Query text. |
--limit <LIMIT> | u32 | 20 | Maximum 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.
| Pass | What it matches | Snippet | Score |
|---|---|---|---|
| Filename | A 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. |
| Body | A 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.
How it differs from search
Both commands run the engine above, but they feed it different files and return different paths.
jotura search | jotura search-documents | |
|---|---|---|
| Filename pass covers | Every synced file, so a binary document matches on its own name | The mirrors of eligible documents only |
| Body pass covers | Every synced file readable as text, including mirrors | The mirrors of eligible documents only |
| Path returned | The matching file’s own path, so a mirror hit returns refs/spec.pdf.md | The original document path, refs/spec.pdf |
| Modes | keyword, semantic, smart via --mode | Keyword only |
| Scope filter | --in <SCOPE> restricts results to a path prefix, and also pulls legacy .md_store/ mirrors into the candidate set | None. Narrow with ls-documents --folder instead |
| JSON fields | path, title, snippet, score | documentPath, 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]
| Flag | Type | Default | What it does |
|---|---|---|---|
--folder <FOLDER> | string | none | Restrict 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 field | Meaning |
|---|---|
path | Vault-relative path of the document. |
size | Size of the document in bytes. |
hasMdStore | Whether a mirror file exists on disk beside it. |
conversionOk | Whether 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 flag | Type | Default | What it does |
|---|---|---|---|
[PATHS]... | list of strings | empty | One or more document paths to regenerate. |
--all | bool | false | Regenerate the mirror for every eligible document in the vault. |
--stale-only | bool | false | Regenerate 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
reason | Why the mirror is stale |
|---|---|
Missing | No mirror exists yet for this document. |
SourceChanged | The document’s bytes no longer match source_content_hash. |
ConverterUpgraded | The installed converter version is strictly newer than the version recorded in the mirror. |
PreviousFailure | The 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.
| Situation | What happens |
|---|---|
| Sidecar not installed next to the CLI | A placeholder says the document will be converted automatically once the converter is installed. |
| No usable sandbox | A placeholder names the missing sandbox. Retries are suppressed until a sandbox is usable or you opt in. |
| Conversion takes longer than 60 seconds | The 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 error | The whole process tree is terminated and a failure placeholder names the limit that was exceeded. |
| The converter exceeds its CPU time or file size limits | The whole process tree is terminated and a failure placeholder records a resource-limit failure. |
| Converter crashes or the file is corrupt | A 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.
| Variable | Effect |
|---|---|
JOTURA_ALLOW_UNSANDBOXED_CONVERSION | Counts as consent and lets conversion run unsandboxed. doctor reports it as a warning. |
JOTURA_DISABLE_SANDBOX | Same effect through a different opt-in. doctor reports it as a warning. |
JOTURA_MARKITDOWN_SIDECAR | Path 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 name | What it tells you |
|---|---|
documents | Counts in the form N converted / N failed / N pending / N total. |
documents-failed | Up to ten paths of documents whose conversion did not succeed. |
documents-failed-note | A pointer back to jotura regenerate-md-store --stale-only. |
markitdown-sidecar | The sidecar version, or a warning that it was not found next to the CLI. |
markitdown-sandbox | Which 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 code | Error code field | When you see it here |
|---|---|---|
| 0 | none | Success, including imports whose conversion produced only a placeholder. |
| 1 | Error | The source file passed to import could not be read from your disk. |
| 2 | none | A 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. |
| 3 | NotFound | A path passed to regenerate-md-store does not exist in the vault. |
| 6 | NoVault | No vault resolved. Pass --vault or set JOTURA_VAULT. |
| 8 | PermissionDenied | A filesystem permission error, or a path that resolves outside the vault. |
| 9 | InvalidArgs | import - 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. |
| 15 | ReadOnlyShare | The 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.