How every kind of value an .enex export can carry maps into a Steel Notes
vault, and the design of a reproducible importer that takes a pile of ENEX
files and produces notes/.md + attachments/ that the app can open
without a migration step.
Measured against the archive at
~/Documents/Evernote-Archive-2026-07-14/enex/ on 2026-07-27:
106 ENEX files, 3,663 notes, 5,988 resources, 5.76 GB of attachment bytes.
Target format verified against NoteSerializer, NoteFactory,
FrontmatterParser, MarkdownLineClassifier, InlineTokenizer and
AttachmentNaming at the same commit.
A test fixture covering every situation below — one note each, 42 in all —
lives at tools/enex-import/fixture.enex
(see its README). It is generated, so the
en-media MD5 hashes are real and the orphan is orphaned on purpose.
Every open decision below was resolved by the vault owner on 2026-07-28.
§9 is the settled-decisions summary; the sections in between explain each
choice and its consequences in place, so this stays a design record rather
than a bare settings dump.
Full element inventory across all 106 files (occurrence counts, not notes):
| ENEX / ENML element | Count | Notes |
|---|---|---|
note | 3,663 | 1,416 of them live in Trash.enex |
resource | 5,988 | 5.76 GB decoded |
en-media | 6,950 refs / 5,658 unique hashes | 29 refs resolve to nothing |
div | 79,083 | Evernote's line container |
span | 35,425 | almost all styling noise |
li / ul / ol | 25,904 / 6,748 / 461 | nesting is <ul> sibling-of-<li> |
br | 15,633 | |
a | 13,776 | 155 are evernote:/// internal links |
td / tr / table / th / thead | 8,743 / 4,190 / 1,758 / 329 / 42 | mostly email layout tables |
b / strong / i / em | 5,225 / 1,731 / 2,216 / 1,098 | |
img | 2,020 | **not** resources — see §5.3 |
h1 / h2 / h3 | 880 / 1,170 / 1,251 | |
h4 / h5 / h6 | 289 / 70 / 9 | 368 headings past what Steel Notes supports |
hr | 1,006 | |
blockquote | 627 | |
u / s / strike / ins | 598 / 24 / 2 / 26 | no markdown equivalent |
code / pre / tt | 536 / 84 / 28 | |
dl / dt / dd | 28 / 549 / 75 | |
en-todo | 161 | 60 checked, 101 unchecked (legacy form) |
sup / sub | 106 / 106 | |
en-crypt | 6 | encrypted, undecryptable without passphrase |
font / small / big / center / cite / abbr | 3,697 / 70 / 6 / 11 / 34 / 8 |
<note> has exactly seven possible children in this export — title,
created, updated, tag, note-attributes, content, resource.
There is no <guid> element, which is the single most consequential fact
in this document (§6.3).
Evernote's newer block features ride as CSS custom properties rather than
elements:
| Custom property | Count | Meaning |
|---|---|---|
--en-naturalWidth / Height | 2,169 each | intrinsic image size |
--en-nodeId | 898 | internal editor ids, pure noise |
--en-checked | 540 | **modern checkbox state** |
--en-highlight | 387 | text highlight |
--en-viewAs | 340 | attachment display mode |
--en-todo | 146 | modern checkbox container |
--en-codeblock | 38 | fenced code block |
--en-richlink / --en-href | 31 / 31 | rich link cards |
--en-contactBlock* | 72 | business-card blocks |
--en-tableofcontents | 11 | generated TOC |
--en-calendarBlock / Event | 7 / 7 | calendar embeds |
--en-task | 4 | task blocks |
Note-level and resource-level attributes:
| Attribute | Count | Level |
|---|---|---|
file-name | 5,714 | resource |
author | 2,477 | note |
resource | 2,008 | note |
source-url | 1,940 / 350 | resource / note |
latitude/longitude | 829 each | note (94 on resource) |
altitude | 786 | note |
source-application | 637 | note |
attachment (bool) | 345 | resource — 30 true, 315 false |
subject-date | 273 | note |
timestamp | 214 | resource |
content-class | 74 | note |
camera-make / camera-model | 53 / 28 | resource |
reminder-order / reminder-done-time | 2 / 1 | note |
Every <data> element is encoding="base64". There are no <recognition>
or <alternate-data> blocks in this export, so no OCR text to salvage.
Link and image URL schemes:
href scheme | Count | img src scheme | Count | |
|---|---|---|---|---|
| https / http | 11,879 / 1,021 | data: | 1,656 | |
| mailto | 427 | https / http | 236 / 1 | |
| **evernote** | **155** | en-cache: | 17 | |
| tel | 38 | blob | 4 | |
| message / x-msg / file / blob / vscode-file | 16 |
Provenance (<resource>): mobile.iphone 845, mail.smtp 500, web.clip7
335, mobile.ios 238, desktop.mac 38, plus a long tail.
content-class: evernote.skitch 37, evernote.contact.1 33,
evernote.penultimate.notebook 4.
347 unique tags. 2,926 unique titles across 3,663 notes — the collisions are
dominated by "Untitled Note" (197) and "Untitled" (175).
These are hard constraints read out of the code, not preferences. An importer
that violates them produces files the app silently mangles.
Vault layout (VaultManager.kt:33-38): notes/, resources/,
quotes/, attachments/, .steel/. attachments/ is flat — a
migration actively flattens subdirectories (:227-247), so never write
attachments/2024/foo.jpg.
**notes/ supports user folders — corrected from an earlier draft of this
document.** folder is a plain TEXT column on the notes table
(SteelNotes.sq:15), moveNote/createFolder/listNotesByFolder
(VaultManager.kt:878-899) accept arbitrary relative paths, and the full
vault scan indexes via listFilesRecursively (VaultIndexer.kt:35) — so a
note at notes/2. Areas/A. Youth Group/note-….md stores and indexes exactly
like one at notes/note-….md. Note ids are unique regardless of folder, and
steel://id links resolve by id alone, so nesting never affects linking.
An earlier draft of this section warned that the iOS sidebar drew only one
flat level under notes/. That has since been fixed as part of this
work: listFolders() walks the tree and materializes intermediate levels,
listNotesByFolderTree returns a folder's descendants so selecting a stack
isn't empty, and SidebarView renders nested DisclosureGroup rows with
rollup counts. resources/ still has no folder API (no
listResourcesByFolder, no sidebar affordance) and is kept flat by this
importer; see §3.0.
Note id == filename stem. notes/[<folder>/]<id>.md. IdGenerator
shape: note-YYYY-MM-DD-NNN (and src-YYYY-MM-DD-NNN for Resources —
independent counters, keyed by prefix, so a Resource and its companion Note
minted the same day never collide).
Frontmatter is a hand-rolled YAML subset (FrontmatterParser):
flat key-values, inline [a, b] lists, and lists of maps. No nested objects,
no multi-line strings, no anchors. parseInlineList splits on , before
unquoting, so a list value may never contain a comma — quoting does not
save it.
Unknown frontmatter keys are destroyed on first edit. NoteFactory
reads a fixed key set; NoteSerializer.buildMetadata rebuilds frontmatter
from the model alone. Any evernote_guid: or notebook: key you invent
survives until the user types one character into that note, then vanishes.
Therefore the importer must not invent frontmatter keys. Provenance goes in
the body (§4.3).
The only keys a type: note file may carry: type, id, title,
resources, companion, resource_url, status, tags, created, updated.
companion and resource_url are real fields (Note.companionOf /
Note.resourceUrl, see COMPANION_NOTES.md) — the
importer may and should write them on the companions it creates, precisely
because they are known keys that survive an edit. A type: resource file may
additionally carry author, url, kind, site_name, cover_image,
pages, thumbnail, thumbnail_source, isbn, table_of_contents,
date_added (NoteSerializer.kt) — real fields, not invented ones, so
source-url and kind have a safe home there (§3.0). author is not one of
them by the owner's choice, not by model limitation.
Every Resource needs a companion Note. Per docs/NOTE_ARCHITECTURE.md:
"Every Resource automatically gets a companion Note created alongside it. The
user never interacts with a Resource alone." VaultManager.createResourceWithNote
enforces this in the live app — it always writes both files in one call, the
Note holding resources: [<src-id>], companion: <src-id>, the Resource's
resource_url, and an empty body. An importer
that writes a bare resources/<id>.md with no companion note produces a resource
the app's own creation path can never produce and its navigation assumes
never exists. §3.0 mirrors the pairing exactly.
The body markdown grammar (MarkdownLineClassifier) supports exactly:
headings #–### (h1–h3 only, MAX_HEADING_LEVEL = 3), - / *
bullets, 1. ordered, - [ ] / - [x] checkboxes, > blockquote,
``` ` `` fences, --- / *** / ___ rules, !alt` images,
!Title transclusions.
Not supported: tables — there is no table kind, so a GFM table renders as
a run of plain paragraph lines.
Inline (InlineTokenizer): bold, italic, ==highlight==,
` code , text, Title`. **No backslash escaping
exists** — \- renders as a literal backslash-hyphen, so escaping is not an
available tool (§7.1).
Links vs. transclusions. A transclusion embeds a note:
!Title where the id is a note id. MarkdownLineClassifier
routes !… to TRANSCLUSION only when the path carries the steel://
scheme, and to IMAGE otherwise. So attachments are never transclusions —
the user's phrasing notwithstanding, there is no mechanism to transclude a
file. Images become !alt; every other file type becomes
an ordinary markdown link (§5.2).
note vs resource, and vault folder placementA note imports as type: resource (plus a companion type: note, §2) when
note-attributes/resource starts with web.clip or
note-attributes/source-application equals webclipper.evernote. ~344
notes match by the union of those two signals (335 via resource: web.clip7
alone, 344 via source-application alone — the sets overlap but not
completely). Every other note — phone scans, emailed notes, everything with
no clip signal — imports as type: note, unaffected by this section.
For each match the importer writes two files, mirroring
createResourceWithNote (§2) exactly:
| File | Fields |
|---|---|
resources/<src-id>.md | type: resource, id, title (the ENEX title), kind: article, url: ← source-url, site_name: derived from the source-url host when the export doesn't supply one (fs.blog from https://fs.blog/brain-food), tags: ← <tag>, status: unread, created/updated. Body: the ENML-converted markdown (§4) — the clipped content lives here. |
notes/[<folder>/]<note-id>.md | type: note, id, title = Notes — <resource title>, resources: [<src-id>], companion: <src-id>, resource_url: ← the same source-url as the Resource, tags: ← the same <tag> list, status: active, created/updated. Body: empty, matching what the app produces when a Resource is created through its normal flow. |
companion is what makes the pairing a fact rather than something later
re-derived by an oldest-wins scan over resources, and resource_url is what
lets the note read as notes on a named article outside the app. Both are
required on imported companions — see COMPANION_NOTES.md.
author is intentionally omitted from resources/<id>.md even though
Resource.author is a real field (§9) — the owner's call, not a model
limitation; the field stays available if that changes later.
Folder placement. The companion Note (and every plain type: note file)
lands at notes/<stack-folder>/<notebook-folder>/<id>.md, reproducing the
archive's PARA layout as real directories (§2). The Resource itself stays flat
at resources/<id>.md — no folder API exists for resources, and the user never
browses to one directly.
An imported companion therefore arrives already filed, which is what the
archive says about it, and stays visible in its folder. Only companions the
app itself creates — with no folder of their own — are browsed solely through
their Resource (COMPANION_NOTES.md §C2).
Folder names are derived, not copied verbatim, because Evernote's own
export already lossily encodes : as _ in filenames (A_ Youth Group.enex
for notebook "A: Youth Group") and colons are unsafe in filenames on some
filesystems anyway:
--guid-db:** use notebooks.name / notebooks.stack directly(the true names, not the export's mangled filenames) and replace : with
. — "A: Youth Group" → A. Youth Group.
and parent directory — A_ Youth Group.enex → A. Youth Group.
inserted — 2 Areas → 2. Areas — and a leading . (.Bible, .Other)
is stripped so the folder isn't hidden on disk — .Bible → Bible.
| ENEX | → Steel Notes | Decision |
|---|---|---|
<title> | title: | Verbatim, single-line, unchanged from the resource — including the 372 notes titled "Untitled Note" / "Untitled" (owner's choice, §9; ids stay unique regardless since the filename is the id, not the title). Collapse internal newlines to spaces; quoteIfNeeded handles quoting. |
<created> | created: | 20250302T132747Z → 2025-03-02T13:27:47Z. Also drives the note id and attachment filenames. |
<updated> | updated: | Same conversion. Absent → equals created. |
<tag> (2,192) | tags: | Verbatim. This archive's tags are ASCII with no commas/spaces/quotes, so they round-trip cleanly. **General case:** a tag containing a comma is unrepresentable — replace , with - and log it. |
| notebook name (filename/db) | folder | notes/<stack>/<notebook>/, per 3.0 above — not a tag. |
| stack name (dir/db) | folder | notes/<stack>/, per 3.0 above — not a tag. |
| — | type: | resource for web clips (+ companion note), note for everything else. §3.0. |
| — | id: | note-<created-date>-NNN / src-<created-date>-NNN, sequence per date and per prefix across the whole import run (§8.2). |
| — | status: | active (Note) / unread (Resource). |
<content> | body | §4. |
<resource> | attachments/ + body refs | §5. |
note-attributesNone of these have a home in the Note model, and inventing frontmatter
keys is unsafe (§2). By the owner's decision (§9), almost all of them are
now dropped outright rather than footnoted:
| Attribute | Decision |
|---|---|
source-url (350) | → url: frontmatter when the note became a Resource (§3.0). Otherwise (the rare non-clip note that still carries a source-url) → a footer link (§4.3) — Note has no url field. |
author (2,477) | **Dropped.** Not footnoted, not written to Resource.author even when the note became a Resource. |
resource (2,008), source-application (637), content-class (74) | **Dropped.** No evernote/webclip-style tags; only used to decide type: resource in §3.0, never carried into the output. |
latitude/longitude/altitude (829) | **Dropped.** |
subject-date (273) | **Dropped.** |
reminder-order / reminder-time / reminder-done-time (3 total) | **Dropped.** |
application-data, creator-id, last-editor-id | Absent from this export; drop if present. |
<content> is a CDATA block holding an XHTML document rooted at <en-note>.
Parse it as XML (it is well-formed by spec), walk the tree, and emit block
lines. Never regex the HTML.
| ENML | Markdown | Notes | |
|---|---|---|---|
<div> | one line | Evernote's line container. <div><br/></div> → blank line. Nested divs do not nest output — flatten. | |
<p> | one line + blank line | ||
<br/> | line break | Two consecutive <br/> → blank line. | |
<h1>–<h3> | # –### | ||
<h4>–<h6> (368) | ####–######, unclamped | **Owner's choice (§9):** left as literal text rather than clamped to h3. MarkdownLineClassifier.headingPrefixLength only recognizes 1–3 hashes, so these 368 lines render as an ordinary paragraph that happens to start with hash characters — no heading styling, no hierarchy, just visible #### in the text. Accepted as a real, known-and-logged cosmetic cost (§8.4) in exchange for never rewriting the source text. | |
<ul>/<ol>/<li> | - / 1. | Evernote nests as a <ul> *sibling* of <li>, not inside it — handle both shapes. Indent 2 spaces per level. | |
| `<en-todo checked="true\ | false"/>` (161) | - [x] / - [ ] | Legacy form: a void element at the start of a div. The div becomes the item text. |
div[style*="--en-todo"] + --en-checked:true (540) | - [x] / - [ ] | **Modern form.** An importer that only handles <en-todo> loses 77% of this archive's checkboxes. | |
<blockquote> (627) | > | Nested → > > . | |
<hr/> (1,006) | --- | ||
<pre> (84), div[style*="--en-codeblock"] (38) | ``` ` ``` fence | Use --en-syntaxLanguage as the info string when present. | |
<table> (1,758) | §4.2 | ||
<dl>/<dt>/<dd> | - **term** / 2-space-indented line | 549 dt vs 75 dd — mostly contact blocks, not real definition lists. | |
<center>, <font>, <span>, <small>, <big> | unwrapped | Container only; keep children, drop the element. | |
<en-crypt> (6) | > 🔒 [Encrypted Evernote block — not importable] | Undecryptable without the passphrase. Emit a visible marker and list the 6 notes in the report rather than silently dropping content. | |
--en-contactBlock, --en-calendarBlock, --en-richlink, --en-tableofcontents | unwrapped to their text/links | Structured widgets with no Steel Notes equivalent; their inner text and hrefs are the salvageable part. |
1,758 tables but only 42 <thead> and 329 <th> — and the sampled ones are
deeply nested Outlook/newsletter layout scaffolding, not data. Steel Notes has
no table support at all, so a faithful GFM table renders as a wall of
pipe-prefixed paragraph lines.
Classify, then act:
<table>, or any cell longer than ~200 chars → unwrap. Emit each cell's
content as ordinary blocks, in document order. This is what makes forwarded
newsletters readable.
degrades to plain lines in the editor but stays aligned and legible, and
survives if table rendering is added later.
Shape alone decides that, not <th>: Evernote's editor has no "make this
a header row" affordance, so a real header arrives as <td>s the author
coloured in or bolded, and requiring <th> unwrapped genuine two-column data
tables into loose paragraphs.
The header row is then inferred separately. A first row that is real
<th>/<thead>, or whose every cell carries a background colour, a bold
font-weight or fully-bold text, becomes the markdown header. Otherwise the
table is emitted with a blank header row and every resource row in the body:
GFM has no headerless table, and promoting real data into the header would
misstate what it is.
Emit colspan/rowspan merges as repeated cell values; log tables where they
appear.
With author, geo, subject-date, resource, source-application and
content-class all dropped (§3.1), and source-url moved to Resource.url for
every web clip (§3.0), this footer now applies to a narrow case: a plain
type: note that still carries a source-url note-attribute — a non-clip
note (no web.clip* / webclipper.evernote signal) that nonetheless has a
resource link, since Note has no url field to hold it. Uses supported
grammar only:
```markdown
Resource: https://fs.blog/brain-food
```
--- is a HORIZONTAL_RULE; the italic line is an ordinary paragraph. No
invented frontmatter, so nothing is lost when the note is next edited.
en-media to resource<en-media hash="…"> references a resource by the **MD5 of its decoded
bytes**. This export has no <recognition> block and resources carry no
explicit hash element, so the importer must decode every resource and compute
its MD5 to build the lookup. Verified: doing this resolves 5,629 of the 5,658
distinct hashes referenced across the archive, covering all but 29 of 6,950
references.
| Situation | Count | Decision |
|---|---|---|
| Reference resolves | 5,629 hashes | Emit the reference (§5.2). |
**Orphan** en-media — hash matches no resource | 29 | Emit *[Missing attachment — <type>]* and list in the report. Evernote itself would show a broken block here. |
**Unreferenced** resource — never named by en-media | 59 | These are true "attached files" rather than inline media. Append them to the end of the note body under an ## Attachments heading. Dropping them would lose real files. |
| Duplicate content within a file | 300 | Deduplicated automatically by content-addressed naming (§5.2). |
Filename shape must match AttachmentNaming.generate — YYMMDD-{8hex}.ext —
because the flattening migration and the attachment GC both key off
attachments/<flat name>. The generator uses Random for the hex; the
importer instead uses the first 8 hex chars of the resource MD5:
```
attachments/250302-dabefbc7.png
```
Same shape, but content-addressed, which buys three things for free:
identical bytes collapse to one file (the 300 duplicates), re-running the
importer is idempotent, and the filename is traceable back to the en-media
hash. Date component comes from resource timestamp if present, else the
note's created.
Dedup keys on the MD5, not on the finished filename. Those are not the
same thing, and assuming they were is how the first implementation produced
26 files where 22 were expected: the date component varies when one copy of a
payload carries its own <timestamp> and another doesn't, and the extension
varies when the same application/octet-stream bytes arrive under file-names
with different suffixes. Either difference gives one payload two names. So
the first appearance of a hash names the file and every later appearance
reuses that name, whatever date or extension it would have computed. Import
order is deterministic (§8.2), so which appearance wins is deterministic too.
Extension is derived from <mime>, not from file-name — the archive
contains extensions like .pd f, . pdf, .p df, .file, .downloadstatement
and a 66-character hash-with-timestamp. Fall back to file-name's extension
only when the mime is application/octet-stream (19 resources).
Reference form depends on whether the app can render it inline:
| Kind | Mime examples | Count | Emitted markdown |
|---|---|---|---|
| **Inline image** | image/png, image/jpeg, image/gif, image/heic, image/tiff | ~4,400 | !<file-name or alt> on its own line — required, MarkdownLineClassifier only matches whole-line images. |
| **Image the renderer can't load** | image/svg+xml (48), image/webp (165), image/avif (124), image/x-icon | ~340 | Same image syntax, but flagged in the report — ImageAttachmentProvider loads via UIKit and these may render as a placeholder tile. Bytes are preserved either way. |
| **Everything else** | application/pdf (1,032), Office docs (96), audio/video (55), message/rfc822 (10), zip, csv | ~1,200 | 📎 <file-name> — an ordinary markdown link. **Not** ![…], which would classify as IMAGE and produce a broken image tile. |
PDFs are the notable loss: 1,032 of them become links the editor renders
blue-and-underlined but, per LINKING_ARCHITECTURE.md, does not make tappable
(there is no textView(_:shouldInteractWith:) in the app). The bytes are in
the vault and the path is correct; opening them needs an app-side change, not
an importer change.
alt text: prefer the en-media alt attribute, then file-name, then
empty. Strip [, ], (, ) and newlines — same hazards
SteelUri.sanitizeTitle guards against.
Sizing (--en-naturalWidth/Height, width/height attributes) is dropped;
Steel Notes sizes image tiles from the line fragment.
<img> tags — the easy thing to miss2,020 <img> elements are not ENEX resources; they live inside the HTML
of clipped and emailed notes. An importer that only walks <resource>
silently drops all of them.
src scheme | Count | Decision |
|---|---|---|
data: | 1,656 | **Decode, hash, and write to attachments/ exactly like a resource.** Same content-addressed naming, so an image that appears both ways lands on one file. |
https:/http: | 237 | **Downloaded into attachments/ by default (§9)** — same content-addressed naming as any other resource (§5.2), then referenced as !alt, a real inline image. On a failed fetch (dead URL, network error, non-2xx), fall back to 🖼 <alt or url> — a **link, not an image**:  would classify as IMAGE, and ImageAttachmentProvider resolves vault-relative paths only, so a broken remote URL as an image line renders as a permanently broken tile. Every fetch, success or fallback, is logged (§8.4). |
en-cache: | 17 | Dead references to Evernote's local cache. Emit *[Missing image]* and report. |
blob: | 4 | Same — dead. |
| Resource | Decision |
|---|---|
https/http (12,900) | text. If the anchor text is empty or equals the URL, use the URL as the text. Strip []() from the text. |
mailto (427), tel (38) | text — preserved verbatim; harmless as an inline link token. |
message:, x-msg:, file:, vscode-file:, blob: (16) | Preserve as-is. They are dead outside the origin machine, but rewriting them would lose information. |
evernote:/// (155) | §6.3. |
Internal links look like:
```
evernote:///view/37398643/s278/0e0da5cf-…-1e5302fb5e2/03daf0ce-…-f1e5302fb5e2/
```
The fourth path segment is the note GUID. ENEX does not export
<guid> — confirmed above, <note> has only seven children — so **an
ENEX-only importer cannot resolve these links**. This is a property of the
format, not of the implementation.
Three tiers, tried in order for every link, on by default (§9):
1. GUID resource (this archive has one: en_backup.db, a SQLite file with
notes(guid, title, notebook_guid, is_active) and
notebooks(guid, name, stack), 3,663 rows matching the ENEX note count
exactly). Build guid → title → imported note id and rewrite to
Title — a real, resolvable Steel Notes
link. Used whenever --guid-db is supplied.
2. Title matching. Evernote renders a note link with the target's title as
the anchor text. When the anchor text uniquely matches one imported note
title, rewrite to steel://<id>. Given 2,926 unique titles among 3,663
notes this resolves most cases; ambiguous matches (e.g. two notes titled
"Untitled Note") fall to tier 3. On by default; note that titles are kept
verbatim per §3.0, including the 372 duplicated placeholder titles, which
is exactly the case this tier declines to guess on.
3. Fallback. Emit text unchanged and list every one in
the report. The link is dead but visible and hand-fixable.
Run this as a second pass, after all notes have ids — a link's target is
frequently in a different ENEX file.
Resolution spans imports, not just batches. Evernote exports one notebook
per file and users import them one at a time, so the pass also reads the notes
already in the vault: a new import can link to an old note, and a link an
earlier import left dead is repaired (counted as retroResolvedLinks) once
its target arrives. A repair rewrites the body through updateNote and
deliberately leaves updated alone — it is a fixup, not an edit.
Identity for tiers 1 and 2 is title + created, compared at whole seconds
(created round-trips exactly through frontmatter). Candidates sharing an
instant are one note exported twice and resolve to the earliest-imported copy,
so a re-export or an overlapping notebook no longer poisons a title; different
instants under one title are different notes and the link stays dead. A link
carries no date of its own, so a unique-but-wrong title still matches — and a
link with no anchor text and no --guid-db names nothing, permanently.
InlineTokenizer has no backslash-escape handling. \* renders as a literal
backslash followed by an asterisk. So the usual defence does not exist, and
the importer must not emit escapes.
Consequences to accept, ranked by real risk:
- , * , 1. , # , > or --- will beclassified as that block kind. Mostly benign — a line that reads like a
bullet was almost certainly a bullet.
*, == and ` `` only become markers when a matching closerappears later in the same line, so accidental styling is rare.
text inside prose becomes a link token. Rare; accept.The one active mitigation: when a text run contains ]( — which would forge a
link — insert a zero-width-space-free break by leaving the run as-is and
logging it. Do not mutate the user's text to defend the parser.
Evernote 10.x emits only the five predefined XML entities (&,
<, >, ", ') and writes everything else as literal
UTF-8 — verified against the archive, which contains zero and zero
numeric entities. Non-breaking spaces (U+00A0), zero-width spaces (U+200B),
em dashes and curly quotes all arrive as characters.
That matters because ENML's DOCTYPE (enml2.dtd) declares the full HTML
entity set, so is legal in ENML even though this exporter never
produces it. Legacy Evernote versions and third-party tools do. A stock XML
parser rejects them as undefined entities, which would fail the whole note.
So: parse ENML with the HTML entity set pre-registered, or substitute known
entities before parsing — and either way, never let one unknown entity abort
a note. Fall back to salvaging the note as text.
Then normalize \r\n → \n, collapse runs of 3+ blank lines to one, trim
trailing whitespace per line, and strip U+200B. Preserve the rest of the
Unicode as-is — the body is not frontmatter, so nothing needs quoting.
quoteIfNeeded handles :, #, quotes and leading/trailing spaces. The
importer's own obligations: strip newlines from titles, and reject commas in
tags (§2).
```bash
enex-import --in ./enex --vault ~/SteelNotes [options]
```
| Option | Default | Purpose |
|---|---|---|
--in <path> | — | ENEX file, or directory scanned recursively. |
--vault <path> | — | Target vault. Created if absent, with the five standard dirs. |
--include-trash | off | Trash.enex is 1,416 notes (39%) and 1.6 GB. Off by default; the filename and is_active=0 both identify it. |
--webclips-as-notes | off | Opt out of §3.0's default — import web clips as plain type: note instead of resource + companion note. |
--guid-db <path> | — | SQLite GUID resource for internal-link resolution tier 1 (§6.3). |
--no-resolve-links-by-title | off | Disable tier 2 (title-match) link resolution; on by default. |
--no-fetch-remote-images | off | Disable downloading https: <img> resources into attachments/; on by default (§5.3). Leaves them as external links instead. |
--dry-run | off | Full parse and report, no writes. |
--report <path> | import-report.md | Per-note ledger of every lossy decision. |
Notebook and stack names, and the folder path they produce, are covered in
§3.0 — not repeated here since it's the same derivation whether or not
--guid-db is supplied.
1. Scan the target vault for existing note/resource ids across every
folder (listFilesRecursively, §2) and attachments/*, so an import into
a non-empty vault never collides.
2. Pass 1 — parse. Stream each ENEX with a pull parser (iterparse and
el.clear(); the largest file is 1.6 GB and must not be loaded whole).
For each note: classify note/resource (§3.0), derive its folder path and
mkdir -p it, assign id(s), decode resources, compute MD5s, write
attachment bytes, fetch and write any remote <img> bytes (§5.3), and
build the body — leaving evernote:/// links as placeholders. Record
guid-less identity as (file, title, created).
3. Pass 2 — resolve links. With the full title/GUID → id map built,
rewrite internal links, then write the .md files — the Resource body and
its bodyless companion Note as one atomic pair for web clips (§3.0).
4. Pass 3 — verify. Re-read every written file through the same rules
NoteFactory uses: frontmatter parses, type/id/title present, id
matches the filename stem, every Resource has exactly one companion Note
referencing it, every attachments/… path in the body exists on disk, no
line exceeds the classifier's expectations for its kind.
5. Report. Write the ledger.
Each input file is read inside its own boundary: an exception reading one is
recorded against that file and the run carries on with the next, keeping the
notes already converted from it. A folder of notebooks is several imports, not
one indivisible batch — one truncated or oversized export must not cost the
other twenty-nine. On iOS this is load-bearing rather than a nicety, since an
exception crossing back into Swift takes the app down with it. Progress is
reported per file (ImportPhase.FILE_START / CONVERT / FILE_DONE, each
carrying that file's own note count) so a caller can list every input and show
each one's state; pass 2's WRITE ticks belong to the batch instead, and carry
the run's total.
Sequence numbers in note-YYYY-MM-DD-NNN are assigned per created-date across
the whole run, in a deterministic order (sorted by file path, then document
order), so two runs over the same input produce identical ids. Combined
with content-addressed attachment names, the importer is idempotent — a
re-run overwrites with identical bytes.
5.76 GB of attachments, plus ~1,656 embedded data-URI images. Excluding trash
cuts this substantially. The importer should refuse to start if free space is
under 1.5× the archive size, and stream each resource's bytes straight to disk
rather than accumulating.
Half of that holds today. A <data> payload is decoded as its fragments
arrive, so the base64 never exists as a string — the largest single resource in
the real archive is 164 MB of base64, and staging it as text cost about eight
bytes of peak memory per byte of attachment. But the decoded bytes are still
materialised whole on RawResource, so the high-water mark is roughly twice
the largest single resource. Removing that ceiling means hashing and writing
the payload as it decodes and having RawResource carry a path instead of
bytes; until then, a very large export is safer through the CLI than on
device.
Remote-image downloading (§5.3) being on by default means the importer now
also depends on network access and remote-server latency for ~237 fetches.
Each is independent, so one dead or slow host degrades to a link (§5.3)
rather than stalling the run — no fetch should block the pipeline
indefinitely; apply a short per-request timeout.
Per note: id, title, resource ENEX, and every decision that lost or
transformed information — unclamped h4–h6 (now literal text, §4.1),
unwrapped layout tables, dropped <u>/<s> formatting, unresolved
evernote:/// links, orphan en-media, en-crypt blocks, remote images
that failed to fetch and fell back to a link, non-image attachments emitted
as links, web clips converted to resource + companion note. This is what
makes the import auditable rather than a leap of faith.
Per file: notes read, files written, and whether it succeeded or the reason it
stopped — the same per-input accounting the pipeline above keeps, so a failed
notebook is visible in the ledger next to the ones that landed rather than
inferred from a shortfall in the totals.
Also tallied — informational, not per-note warnings, since these are
intentional policy drops (§3.1, §9) rather than fallback behavior: total
author, resource/source-application/content-class, geo, subject-date,
and reminder attributes discarded. Seeing "2,477 authors dropped" in the
summary is the point — it's what makes the policy auditable too, not just
the edge cases.
Expected totals for this archive (trash excluded): ~2,247 notes, ~4,289
resources, ~344 notes converted to resource + companion note, 139 internal
links, 5 encrypted blocks, 15 orphan media references, and 266 notes with
literal un-clamped ####+ headings.
Every open question in this document, and where it's implemented:
| # | Decision | Chosen | Where |
|---|---|---|---|
| 1 | Trash notes (1,416, 39% of the archive) | Excluded by default, --include-trash to opt in | §8.1 |
| 2 | Web clips (~344 notes) | type: resource + companion type: note, not plain note | §3.0 |
| 3 | Notebooks and stacks | Real nested folders (notes/2. Areas/A. Youth Group/), not tags — corrects an earlier draft's "no folders" claim | §2, §3.0 |
| 4 | evernote:/// internal links (155) | GUID db (tier 1) + title matching (tier 2), both on by default | §6.3, §8.1 |
| 5 | Tables (1,758) | Auto-classify: unwrap layout scaffolding, GFM for real data tables | §4.2 |
| 6 | Headings h4–h6 (368) | Left as literal text, not clamped to h3 | §4.1 |
| 7 | author (2,477) | Dropped everywhere, including Resource.author | §3.0, §3.1 |
| 8 | source-url (350) | url: frontmatter for Resources; footer link for the rare Note that has one | §3.0, §4.3 |
| 9 | en-crypt blocks (6) | Visible inline marker + reported, rest of note imports normally | §4.1 |
| 10 | Geolocation (829) | Dropped entirely | §3.1 |
| 11 | subject-date (273) | Dropped entirely | §3.1 |
| 12 | resource/source-application/content-class → tags | Dropped entirely — no evernote/webclip-style tags | §3.1 |
| 13 | Reminder fields (3) | Dropped entirely | §3.1 |
| 14 | Remote <img src="https:"> (237) | Downloaded into attachments/ by default; falls back to a link only if the fetch fails | §5.3, §8.3 |
| 15 | Placeholder titles (372, "Untitled Note"/"Untitled") | Kept verbatim, not derived from body content | §3.0, §6.3 |
Two things worth re-checking before a real run, not because the choice was
wrong but because they weren't asked directly:
footer clutter; Resource.author is a real, free field with no clutter cost.
Currently omitted there too, per the letter of the decision — flag if that
reads as over-applying it.
the nested folder structure, but the Resource itself stays flat in
resources/ (§3.0) since there's no folder API for it — so a web clip and a
phone-scanned note from the same notebook end up organized differently
under the hood, invisibly, since the user only ever navigates via the Note.