← Back to Plan

Evernote Import

Evernote (ENEX) → Steel Notes Import

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.


1. What the archive actually contains

Full element inventory across all 106 files (occurrence counts, not notes):

ENEX / ENML elementCountNotes
note3,6631,416 of them live in Trash.enex
resource5,9885.76 GB decoded
en-media6,950 refs / 5,658 unique hashes29 refs resolve to nothing
div79,083Evernote's line container
span35,425almost all styling noise
li / ul / ol25,904 / 6,748 / 461nesting is <ul> sibling-of-<li>
br15,633
a13,776155 are evernote:/// internal links
td / tr / table / th / thead8,743 / 4,190 / 1,758 / 329 / 42mostly email layout tables
b / strong / i / em5,225 / 1,731 / 2,216 / 1,098
img2,020**not** resources — see §5.3
h1 / h2 / h3880 / 1,170 / 1,251
h4 / h5 / h6289 / 70 / 9368 headings past what Steel Notes supports
hr1,006
blockquote627
u / s / strike / ins598 / 24 / 2 / 26no markdown equivalent
code / pre / tt536 / 84 / 28
dl / dt / dd28 / 549 / 75
en-todo16160 checked, 101 unchecked (legacy form)
sup / sub106 / 106
en-crypt6encrypted, undecryptable without passphrase
font / small / big / center / cite / abbr3,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 propertyCountMeaning
--en-naturalWidth / Height2,169 eachintrinsic image size
--en-nodeId898internal editor ids, pure noise
--en-checked540**modern checkbox state**
--en-highlight387text highlight
--en-viewAs340attachment display mode
--en-todo146modern checkbox container
--en-codeblock38fenced code block
--en-richlink / --en-href31 / 31rich link cards
--en-contactBlock*72business-card blocks
--en-tableofcontents11generated TOC
--en-calendarBlock / Event7 / 7calendar embeds
--en-task4task blocks

Note-level and resource-level attributes:

AttributeCountLevel
file-name5,714resource
author2,477note
resource2,008note
source-url1,940 / 350resource / note
latitude/longitude829 eachnote (94 on resource)
altitude786note
source-application637note
attachment (bool)345resource — 30 true, 315 false
subject-date273note
timestamp214resource
content-class74note
camera-make / camera-model53 / 28resource
reminder-order / reminder-done-time2 / 1note

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 schemeCountimg src schemeCount
https / http11,879 / 1,021data:1,656
mailto427https / http236 / 1
**evernote****155**en-cache:17
tel38blob4
message / x-msg / file / blob / vscode-file16

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).


2. The target contract

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).


3. Note-level field mapping

3.0 Type selection: note vs resource, and vault folder placement

A 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:

FileFields
resources/<src-id>.mdtype: 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>.mdtype: 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:

  • **With --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.

  • **Without it:** undo the export's substitution from the ENEX filename stem
  • and parent directory — A_ Youth Group.enexA. Youth Group.

  • Either way, a stack name that's a leading number + space gets a period
  • inserted — 2 Areas2. Areas — and a leading . (.Bible, .Other)

    is stripped so the folder isn't hidden on disk — .BibleBible.

    ENEX→ Steel NotesDecision
    <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:20250302T132747Z2025-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)foldernotes/<stack>/<notebook>/, per 3.0 above — not a tag.
    stack name (dir/db)foldernotes/<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.

    3.1 note-attributes

    None 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:

    AttributeDecision
    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-idAbsent from this export; drop if present.

    4. ENML → markdown conversion

    <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.

    4.1 Block elements

    ENMLMarkdownNotes
    <div>one lineEvernote's line container. <div><br/></div> → blank line. Nested divs do not nest output — flatten.
    <p>one line + blank line
    <br/>line breakTwo 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)``` ` ``` fenceUse --en-syntaxLanguage as the info string when present.
    <table> (1,758)§4.2
    <dl>/<dt>/<dd>- **term** / 2-space-indented line549 dt vs 75 dd — mostly contact blocks, not real definition lists.
    <center>, <font>, <span>, <small>, <big>unwrappedContainer 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-tableofcontentsunwrapped to their text/linksStructured widgets with no Steel Notes equivalent; their inner text and hrefs are the salvageable part.

    4.2 Tables

    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:

  • **Layout table** — 1×1, fewer than 2 rows or 2 columns, or contains a nested
  • <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.

  • **Data table** — ≥2 columns, ≥2 rows, all cells short → emit a GFM table. It
  • 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.

    4.3 Provenance footer

    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.


    5. Attachments

    5.1 Matching 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.

    SituationCountDecision
    Reference resolves5,629 hashesEmit the reference (§5.2).
    **Orphan** en-media — hash matches no resource29Emit *[Missing attachment — <type>]* and list in the report. Evernote itself would show a broken block here.
    **Unreferenced** resource — never named by en-media59These 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 file300Deduplicated automatically by content-addressed naming (§5.2).

    5.2 Writing files and referencing them

    Filename shape must match AttachmentNaming.generateYYMMDD-{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:

    KindMime examplesCountEmitted 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~340Same 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.

    5.3 <img> tags — the easy thing to miss

    2,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 schemeCountDecision
    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**: ![](https://…) 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:17Dead references to Evernote's local cache. Emit *[Missing image]* and report.
    blob:4Same — dead.

    6. Links

    ResourceDecision
    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.

    6.3 Internal note links — the one unavoidable ENEX loss

    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.


    7. Text-level hazards

    7.1 No escaping is available

    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:

  • A text line beginning with - , * , 1. , # , > or --- will be
  • classified as that block kind. Mostly benign — a line that reads like a

    bullet was almost certainly a bullet.

  • Inline *, == and ` `` only become markers when a matching closer
  • appears later in the same line, so accidental styling is rare.

  • Literal 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.

    7.2 Entities and whitespace

    Evernote 10.x emits only the five predefined XML entities (&amp;,

    &lt;, &gt;, &quot;, &apos;) and writes everything else as literal

    UTF-8 — verified against the archive, which contains zero &nbsp; 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 &nbsp; 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.

    7.3 Frontmatter safety

    quoteIfNeeded handles :, #, quotes and leading/trailing spaces. The

    importer's own obligations: strip newlines from titles, and reject commas in

    tags (§2).


    8. Importer design

    8.1 Interface

    ```bash

    enex-import --in ./enex --vault ~/SteelNotes [options]

    ```

    OptionDefaultPurpose
    --in <path>ENEX file, or directory scanned recursively.
    --vault <path>Target vault. Created if absent, with the five standard dirs.
    --include-trashoffTrash.enex is 1,416 notes (39%) and 1.6 GB. Off by default; the filename and is_active=0 both identify it.
    --webclips-as-notesoffOpt 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-titleoffDisable tier 2 (title-match) link resolution; on by default.
    --no-fetch-remote-imagesoffDisable downloading https: <img> resources into attachments/; on by default (§5.3). Leaves them as external links instead.
    --dry-runoffFull parse and report, no writes.
    --report <path>import-report.mdPer-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.

    8.2 Pipeline

    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.

    8.3 Disk and network

    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.

    8.4 The report

    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.


    9. Decisions (finalized by the vault owner, 2026-07-28)

    Every open question in this document, and where it's implemented:

    #DecisionChosenWhere
    1Trash notes (1,416, 39% of the archive)Excluded by default, --include-trash to opt in§8.1
    2Web clips (~344 notes)type: resource + companion type: note, not plain note§3.0
    3Notebooks and stacksReal nested folders (notes/2. Areas/A. Youth Group/), not tags — corrects an earlier draft's "no folders" claim§2, §3.0
    4evernote:/// internal links (155)GUID db (tier 1) + title matching (tier 2), both on by default§6.3, §8.1
    5Tables (1,758)Auto-classify: unwrap layout scaffolding, GFM for real data tables§4.2
    6Headings h4–h6 (368)Left as literal text, not clamped to h3§4.1
    7author (2,477)Dropped everywhere, including Resource.author§3.0, §3.1
    8source-url (350)url: frontmatter for Resources; footer link for the rare Note that has one§3.0, §4.3
    9en-crypt blocks (6)Visible inline marker + reported, rest of note imports normally§4.1
    10Geolocation (829)Dropped entirely§3.1
    11subject-date (273)Dropped entirely§3.1
    12resource/source-application/content-class → tagsDropped entirely — no evernote/webclip-style tags§3.1
    13Reminder fields (3)Dropped entirely§3.1
    14Remote <img src="https:"> (237)Downloaded into attachments/ by default; falls back to a link only if the fetch fails§5.3, §8.3
    15Placeholder 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:

  • **#7's edge case.** "Don't need author" was decided in the context of
  • 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.

  • **#2's folder consequence for Resources.** Web clips get a companion Note in
  • 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.