Obsidian: import and export a vault
The add-on @skaile/forge-editor-obsidian moves notes between an Obsidian vault and the editor. The database stays canonical (ADR-0001): a vault is a file format for import and export, the editor does not watch a folder and does not keep a vault in sync.
The add-on has no editor feature and no UI. It is a set of pure functions, a small upload helper and a command. The host needs no wiring: install the package where the script runs.
Import
import { importVault } from '@skaile/forge-editor-obsidian'
const plan = importVault(files) // files: { path: 'Notes/Note B.md', text: '…' }[]
plan.documents // one PlannedDocument per note
plan.unresolved // links no note answers
plan.warnings // frontmatter or Templater code outside the supported subset, a missing heading or block id
plan.skipped // paths that are no notes (attachments, canvases, folders that start with a dot)
importVault writes nothing. Each PlannedDocument is { id, path, title, folder, template, properties, content }. content is the Document's TipTap JSON: one Typed block of the Block type document that holds the note's blocks, so [[Note]] has a block to point at. Its meta is the note's frontmatter, its tags are the note's tags, its title is the file name.
| In the note | In the Document |
|---|---|
Frontmatter keys (the subset parseFrontmatter reads: scalars, flow lists, block lists) | properties, the meta of the wrapping block. Nested maps are reported in warnings and skipped |
tags, tag in frontmatter | The tags of the wrapping block, in the editor's tag name form (Ideas/New → ideas/new) |
aliases in frontmatter | Kept as a property, and used to resolve links |
| Body | The library's Markdown parser (the same one the server uses for documentOps) |
text ^id at the end of a paragraph or heading | The block's id is that id; the marker leaves the text |
^id on a line of its own after a list, quote, table or code block | The id of the block before it |
[[Note]] | A reference chip to the note's wrapping block |
[[Note#Heading]], [[Note#^id]] | A chip to that heading or block; when the note has none, the chip points at the note and warnings says so |
[[Note|alias]] | The chip with the alias as its label |
[[#Heading]] | A chip to a heading of the same note |
![[Note]], ![[Note#Heading]] alone in a paragraph | A blockEmbed block (the whole note: its children; a heading or block: that block) |
[[Missing]] | Stays text, and is listed in unresolved |
<% tp.date.now() %> | {{date}}, see below |
A link names its note by file name, by path ([[folder/Note]]) or by an aliases entry, without regard to case. Where several notes match, the one in the same folder wins, then the one with the shortest path. Links are resolved in a second pass, after every note has been read, so a link may point at a note that comes later in the vault.
Block ids
The ids of blocks carry the links: a chip is { target: blockId, documentId }. importVault keeps every ^id as the block's id and gives all other blocks a new one. The first of a repeated id wins and the rest are reported in warnings. The ids are valid inside one Document only; a link [[Note#^id]] is resolved against the ids of that note.
Templater
Templater is code, and none of it runs. A small subset has a Dokumentvorlage placeholder (see Templates) and is translated:
| Templater | Placeholder |
|---|---|
<% tp.date.now() %> | {{date}} |
<% tp.date.now("YYYY-MM-DD") %> | {{date:iso}} |
<% tp.date.now("HH:mm") %> (a clock format) | {{time}} |
<% tp.date.now("DD.MM.YYYY") %>, "L" | {{date:short}} |
<% tp.date.now("LL") %>, "D MMM YYYY" | {{date:medium}} |
<% tp.date.now("LLL") %>, a format with MMMM | {{date:long}} |
<% tp.file.title %> | {{title}} |
<% tp.file.cursor() %> | {{cursor}} |
Every other <% … %> stays as written and is named in warnings. The notes of the vault's templates folder (templatesFolder, default Templates, null for none) are planned into the folder Dokumentvorlagen and have template: true. A Document made from one of them through documentCreate with templateId gets the placeholders expanded.
Options
| Option | Meaning |
|---|---|
markdown | The Markdown parser, createMarkdownBlocks({ blockTypes }) of the host, when notes hold ::: fences of the host's own Block types |
templatesFolder | The vault's templates folder, default Templates, null for none |
newId | Where Document ids and missing block ids come from, default crypto.randomUUID |
Create the Documents
Links between Documents name provisional ids. Create the Documents first, then swap the ids and store the content:
import { importVault, uploadVault } from '@skaile/forge-editor-obsidian'
const plan = importVault(files)
await uploadVault(plan.documents, { url: 'https://editor.example.com', token })
uploadVault calls documentCreate for every Document, then documentPut with the content after bindDocumentIds (API and routeUrl from @skaile/forge-editor/core name the routes). token goes out as Authorization: Bearer <token> and cookie as Cookie: <cookie>; what the host's authenticate makes of them is the host's choice. The Reference app reads its session cookie only, so a bearer token gets 401 there; use cookie. A Document goes into its folder when the server has it. The routes cannot create folders, so a note whose folder is missing lands in the root and warn says so. The routes do not know a note by its path: a second run creates the Documents again.
Export
import { exportVault } from '@skaile/forge-editor-obsidian'
const files = exportVault(documents) // documents: { id, title, folder?, content }[] → { path, text }[]
One .md per Document, at <folder>/<title>.md; two Documents with the same path get (2) added. The Markdown is the library's Markdown export (the same as the source mode), with these changes:
- A Document that is one Typed block of the Block type
document(whatimportVaultplans) is unwrapped: itsmetaand tags are the frontmatter, its body the text. Aderivationoredgesentry stays in the database. - A reference chip or embed to a Document in the export becomes
[[Note]],[[Note#Heading]],[[Note#^id]]or![[…]], with the chip's label as the alias. Where two Documents share a title, the link names the path. A chip to a Document that is not in the export stays[@…]. - A block that a link names gets its id as
^id: at the end of a paragraph or heading or list item, else on a line of its own after the block. The ids that came from a vault (intro,key-point) are written for every block that has one. A block the editor numbered (a UUID) that no link names gets none: the database keeps that id (ADR-0001). - A link that cannot name its block (the block sits inside a quote, or its id is not a legal Obsidian id) points at the note, and
warnsays so. - Typed blocks of other types are written as
::: kindfences. Obsidian shows them as text.
A vault that is exported and imported again keeps its notes' frontmatter, links and ^id lines.
Command
bunx @skaile/forge-editor-obsidian import ./MyVault --url https://editor.example.com --token $TOKEN
| Option | Meaning |
|---|---|
--url | The editor server, also EDITOR_URL |
--token | Sent as the bearer token (Authorization: Bearer), also EDITOR_TOKEN; for a host whose authenticate accepts a bearer token |
--cookie | Sent as the Cookie header, also EDITOR_COOKIE; for a host with a session cookie |
--templates | The vault's templates folder ("" for none) |
--dry-run | Plan and print the counts, call nothing; --url is then optional |
It prints the warnings and unresolved links, and one line per Document with the id the server gave.
Against the Reference app
The Reference app of this repository has no bearer tokens; it knows a caller by its session cookie. Sign in as one of its users with POST /api/auth/switch (anna, ben or bernd) and hand the set-cookie pair to --cookie:
COOKIE=$(curl -si -X POST http://localhost:3056/api/auth/switch -H 'content-type: application/json' -d '{"username":"anna"}' | grep -i '^set-cookie:' | sed 's/^[^:]*: *//; s/;.*//')
bunx @skaile/forge-editor-obsidian import ./MyVault --url http://localhost:3056 --cookie "$COOKIE"
$COOKIE is nuxt-session=Fe26.2**…. The Documents belong to that user.
Not included
- Attachments (images, PDFs) are skipped. Obsidian canvases, Dataview queries, callouts' foldable state and inline
#tagsoutside frontmatter stay as the Markdown parser reads them. - No two-way sync. The
.obsidianfolder and every folder that starts with a dot are never read.
PDF viewer: passages, anchors and quotes
The add-on @skaile/forge-editor-pdf-viewer: the source-anchor property, a pdfjs viewer with a text layer, scrolling to a passage on reveal, and „Zitat anlegen“, which creates a quote block derived from the PDF.
Typst: render a Document as a PDF with citations
The add-on @skaile/forge-editor-typst: the render target typst-pdf that turns a Document or a paper into Typst markup, writes the bibliography as BibTeX, and compiles a PDF in one of four citation styles.