Out in a file, back in through the front door

Hand a translator a spreadsheet. Move a section between installations. Seed staging from production. Each starts with "export a record", which sounds like one decision and is three: how far the export reaches, what a record means somewhere else, and whether an import may do anything its caller could not do by hand.

For whoever moves content between systems, and whoever has to trust what arrives

Exactly one hop

A record on its own is rarely useful — an article without its author, tags and hero image imports elsewhere as an article with empty fields. So an export travels. But content graphs are connected, and following relations recursively from one article reliably reaches most of the library. So it travels a fixed distance: the records you selected and their language siblings, then every record one relation away, in full. Records two hops out are not exported; references to them survive as references, carrying enough to be linked to whatever the other installation already has.

Four independent toggles decide what is actually reached for: related records, file bytes, the other languages of the selected records, and the other languages of the related ones. The last is off by default — it multiplies the payload by the locale count on top of the relation count.

The dialog shows the count each toggle produces before you pay for it, from the same graph walk the export runs — not an estimate. "Include related records" is the difference between forty records and four thousand, and the count is the only warning.

Four formats

Behind one port. A capability table the dialog and the server both read decides what each may promise.

Format What it carries
JSON One document: a manifest plus every record, any number of types. Lossless. No file bytes.
NDJSON One record per line, manifest first. Lossless, streams, any number of types. No file bytes.
ZIP A manifest, one record file per type, and the asset bytes. The only format that carries files, so the only one the dialog offers the files toggle for.
CSV A flat table per type. Lossy by construction — a cell cannot hold a rich-text document or an ordered link list — and it says so. Several types arrive as a ZIP of tables.

A CSV media cell is never turned back into an asset reference. A filename in a spreadsheet is not a file, and inventing one would relink records to whatever shares a name.

Every write goes through the same writer

An import does not touch a content table. Every record it creates or updates goes through the write service the editor and the delivery API use — where field validation, the workspace scope, relation-target checks, the same-language rule, the media-asset check, revisions and the outbox all live. An importer that reached for the table directly would be a supported way to write rows none of those rules ever saw.

That is also what bounds it. Import needs its own permission key, then re-checks the caller’s ordinary create and update keys on every record, so an import can never exceed what its caller could have done by hand. Nothing in the file names a workspace: the manifest records where it came from, the importer never reads that, and the request’s workspace is stamped onto every write.

Dry run and apply are one pipeline with a flag, so the verdicts in the dialog — create, update, link, skip — describe what the apply will do. A dry run writes no row and no byte; the apply runs in one transaction, values first and links second, so cycles resolve whatever order the file is in. And because webhooks, alarms and the activity log hang off the same writer, an import shows up everywhere an editor’s save would.

Two import files arriving at one check, the entry writer, which enforces validation, workspace scope, relation targets and the caller’s permissions before anything reaches the workspace.

One door. The delivery API, the assistant, the editor and an import all use it, so none of them can write a row the rules did not see.

Identity travels as a natural key

A row id is a fact about one database. Elsewhere it names nothing, so an import keyed on ids can only create — run a file twice and you have two of everything.

Every record carries a key
The values of the fields that identify a record of its type: named per type in configuration, or derived — a field named like an identifier, then the first required text field. The choice is written into the manifest, so an import matches on what the export keyed on, not on a schema that may have drifted since.
The language is part of it
A transfer record is one row, so the English and German versions of an article travel as two records with the locale folded into the key. That keeps en/hello and de/hello apart, and lets a fully translated type have a key at all.
Two policies, because depth asks two questions
"If a record is already here" governs what you selected; "records this file links to" governs what came along because something pointed at it. Duplicating an article is a reasonable request; duplicating its author because the article was duplicated is not. The relation choice defaults to link, which writes nothing to a related record that already exists.

Where it lives in the interface

No page of its own — actions on content you are already looking at.

  1. Export from the editor, or from a selection

    "Export…" in an entry’s menu, or "Export" in the library’s selection bar, opens the same dialog: choose a format, set the four toggles, watch the counts, download.

  2. Import from the collection’s menu

    Nothing is selected when you import — what arrives is whatever the file holds — so "Import…" sits on the collection, not the selection. The format is guessed from the name and decided by the bytes.

  3. Read the verdicts, then apply

    The dry run lists every record with what will happen to it. Answer the two policy questions; the table is retired the moment you change the file or either answer, so one file’s verdicts never sit above another file’s button.

Seeding staging on a Tuesday

You need the forty articles the campaign team is working on, with their authors and images, on staging. Select them, turn on related records and files, note the count reads 61 records and 18 assets rather than the whole library, and download a ZIP. On staging the dry run shows 40 creates, 21 links — the authors already exist under the same slugs — and 18 uploads. Apply. Every author is linked, not duplicated.

Questions before the first export

Can I export the whole library?

Not in one request, by design: an export runs under configurable ceilings — five thousand records by default — and a request past one is answered with a sentence naming the limit. For a backup use your database tooling; this is for moving content.

Can an untrusted file hurt the server?

It is treated as untrusted. The archive reader is hand-written and reads the central directory first, so entry count, sizes and compression ratio are refused before anything is inflated. Paths that climb out are rejected, not sanitised, and a document from a newer format version is refused rather than read as best it can.

Who may do it?

Export and import are their own permission keys, granted to contributors and administrators. A viewer can read every record page by page and still cannot take the library out in one file — bulk egress is a different act from access.

Every feature is included, free

The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.