Dynalist —> Logseq migration tools

https://drive.proton.me/urls/A9BMM8C8AW#UcyzySYXHe6u

I made this versatile python Dynalist → Logseq migration kit for myself. I thought I’d share it here for anyone that wants it. Due to forum limitations, it is 3 posts for the python file plus 1 post for the guide.

#!/usr/bin/env python3
"""
dynalist_migrate.py

High-fidelity Dynalist -> Logseq migration tool.

Stages:
  export  Dynalist API -> local lossless JSON archive
  build   API archive (+ optional OPML cross-check) -> Logseq file graph

Python 3.9+ standard library only; suitable for Ubuntu/WSL.
"""
from __future__ import annotations

import argparse
import csv
import getpass
import hashlib
import html
import json
import os
import re
import shutil
import tempfile
import unicodedata
import time
import urllib.error
import urllib.request
import uuid
import zipfile
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
import xml.etree.ElementTree as ET

API_BASE = "https://dynalist.io/api/v1"
SCRIPT_VERSION = "4.0.0"
UUID_NAMESPACE = uuid.UUID("7c29b4c4-19d8-5e62-9af8-1ea3abf82023")
COLOR_MAP = {0:"none",1:"red",2:"orange",3:"yellow",4:"green",5:"blue",6:"purple"}
TRUE_VALUES = {"1","true","yes","checked","done"}
FALSE_VALUES = {"0","false","no","unchecked"}
DYN_URL_RE = re.compile(r"https?://(?:www\.)?dynalist\.io/d/(?P<doc>[A-Za-z0-9_-]+)(?:#z=(?P<node>[A-Za-z0-9_-]+))?", re.I)
DYN_MD_LINK_RE = re.compile(r"\[(?P<label>[^\]]*)\]\((?P<url>https?://(?:www\.)?dynalist\.io/d/(?P<doc>[A-Za-z0-9_-]+)(?:#z=(?P<node>[A-Za-z0-9_-]+))?)\)", re.I)
ITALIC_RE = re.compile(r"(?<!_)__(.+?)__(?!_)", re.S)
HIGHLIGHT_RE = re.compile(r"(?<![=])==(.+?)==(?![=])", re.S)
WINDOWS_BAD = {"\\":"%5C",":":"%3A","*":"%2A","?":"%3F",'"':"%22","<":"%3C",">":"%3E","|":"%7C","#":"%23"}


def eprint(*args, **kwargs):
    print(*args, file=os.sys.stderr, **kwargs)


def json_dump(path: Path, data) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    tmp.replace(path)


def read_json(path: Path):
    return json.loads(path.read_text(encoding="utf-8"))


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024*1024), b""):
            h.update(chunk)
    return h.hexdigest()


def sha256_obj(obj) -> str:
    raw = json.dumps(obj, ensure_ascii=False, separators=(",",":"), sort_keys=False).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def utc_iso(ms):
    if not isinstance(ms, (int,float)):
        return None
    return datetime.fromtimestamp(ms/1000, tz=timezone.utc).isoformat()


def boolish(v):
    if v is None or isinstance(v, bool): return v
    s = str(v).strip().lower()
    if s in TRUE_VALUES: return True
    if s in FALSE_VALUES: return False
    return None

# ---------------- API export ----------------

def api_post(endpoint: str, payload: dict, retries: int = 8) -> dict:
    url = f"{API_BASE}/{endpoint}"
    body = json.dumps(payload).encode("utf-8")
    for attempt in range(retries):
        req = urllib.request.Request(url, data=body, headers={"Content-Type":"application/json","User-Agent":f"dynalist-migrate/{SCRIPT_VERSION}"}, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                data = json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8","replace")
            if exc.code == 429 and attempt + 1 < retries:
                time.sleep(min(60, 2**attempt)); continue
            raise RuntimeError(f"HTTP {exc.code} from {url}: {detail}") from exc
        except (urllib.error.URLError, TimeoutError) as exc:
            if attempt + 1 < retries:
                time.sleep(min(60, 2**attempt)); continue
            raise RuntimeError(f"Network error calling {url}: {exc}") from exc
        code_norm = str(data.get("_code", "")).strip().casefold()
        if code_norm in {"ok", "success"}:
            return data
        if code_norm in {"toomanyrequests", "lockfail"} and attempt + 1 < retries:
            time.sleep(min(60, max(2,2**attempt))); continue
        raise RuntimeError(f"Dynalist API {endpoint}: {data.get('_code')}: {data.get('_msg','')}")
    raise RuntimeError(f"Dynalist API {endpoint} failed after retries")


def get_token(args):
    if args.token_file:
        t = Path(args.token_file).expanduser().read_text(encoding="utf-8").strip()
        if not t: raise ValueError("Token file is empty")
        return t
    if args.token_env and os.environ.get(args.token_env):
        return os.environ[args.token_env].strip()
    return getpass.getpass("Dynalist API secret (hidden; not stored): ").strip()


def derive_paths(file_list):
    files = {f["id"]:f for f in file_list.get("files",[]) if f.get("id")}
    root_id = file_list.get("root_file_id")
    paths, anomalies = {}, []
    parents = {}
    for f in files.values():
        if f.get("type") == "folder":
            for c in f.get("children",[]) or []:
                if c in parents and parents[c] != f["id"]:
                    anomalies.append(f"multiple parents for {c}: {parents[c]}, {f['id']}")
                parents[c] = f["id"]
    def walk(fid, prefix, visiting):
        if fid in visiting:
            anomalies.append(f"folder cycle at {fid}"); return
        item = files.get(fid)
        if not item:
            anomalies.append(f"missing tree item {fid}"); return
        visiting = set(visiting); visiting.add(fid)
        here = prefix if fid == root_id else prefix + [item.get("title") or "Untitled"]
        paths[fid] = here
        if item.get("type") == "folder":
            for c in item.get("children",[]) or []:
                walk(c, here, visiting)
    if root_id in files: walk(root_id, [], set())
    else: anomalies.append("root_file_id missing from files")
    for fid,item in files.items():
        if fid not in paths:
            paths[fid] = ["_Unfiled", item.get("title") or "Untitled"]
            anomalies.append(f"not reachable from root: {fid}")
    return files, paths, anomalies


def command_export(args):
    out = Path(args.output).expanduser().resolve()
    docs_dir = out/"raw"/"documents"; docs_dir.mkdir(parents=True, exist_ok=True)
    token = get_token(args)
    if not token: raise ValueError("Empty token")
    print("Fetching Dynalist folder/document tree...")
    fl = api_post("file/list", {"token":token})
    json_dump(out/"raw"/"file-list.json", fl)
    files, paths, anomalies = derive_paths(fl)
    docs = [f for f in files.values() if f.get("type") == "document"]
    docs.sort(key=lambda f: ([x.casefold() for x in paths.get(f["id"],[])], f.get("title","").casefold(), f["id"]))
    print(f"Documents discovered: {len(docs)}")
    failures=[]; fetched=skipped=0
    for i,item in enumerate(docs,1):
        fid=item["id"]; dest=docs_dir/f"{fid}.json"; display="/".join(paths.get(fid,[item.get("title",fid)]))
        if args.resume and dest.exists():
            try:
                old=read_json(dest)
                if str(old.get("_code", "")).strip().casefold() in {"ok", "success"} and old.get("file_id") == fid:
                    skipped += 1; print(f"[{i}/{len(docs)}] resume  {display}"); continue
            except Exception: pass
        print(f"[{i}/{len(docs)}] fetch   {display}")
        try:
            data=api_post("doc/read", {"token":token,"file_id":fid}); json_dump(dest,data); fetched += 1
        except Exception as exc:
            failures.append({"file_id":fid,"title":item.get("title"),"error":str(exc)}); eprint("  FAILED:", exc)
        if i < len(docs) and args.delay > 0: time.sleep(args.delay)
    canonical_docs=[]; totals=Counter()
    for item in docs:
        fid=item["id"]; p=docs_dir/f"{fid}.json"
        if not p.exists(): continue
        try: d=read_json(p)
        except Exception: continue
        if str(d.get("_code", "")).strip().casefold() not in {"ok", "success"}: continue
        ns=d.get("nodes",[]) or []; totals["nodes"] += len(ns)
        for n in ns:
            if n.get("note"): totals["notes"] += 1
            if n.get("checkbox"):
                totals["tasks"] += 1
                if n.get("checked"): totals["done"] += 1
            totals["internal_links"] += len(DYN_URL_RE.findall((n.get("content") or "") + "\n" + (n.get("note") or "")))
        full=paths.get(fid,[item.get("title") or d.get("title") or "Untitled"])
        canonical_docs.append({"file_id":fid,"title":d.get("title") or item.get("title") or "Untitled","permission":item.get("permission"),"folder_path":full[:-1],"full_path":full,"version":d.get("version"),"raw_file":f"raw/documents/{fid}.json","raw_sha256":sha256_file(p),"nodes":ns})
    canonical={"format":"dynalist-api-canonical-v1","created_utc":datetime.now(timezone.utc).isoformat(),"root_file_id":fl.get("root_file_id"),"tree_anomalies":anomalies,"documents":canonical_docs}
    json_dump(out/"canonical.json",canonical)
    summary={"format":"dynalist-api-export-summary-v1","created_utc":datetime.now(timezone.utc).isoformat(),"documents_listed":len(docs),"documents_archived":len(canonical_docs),"documents_fetched_this_run":fetched,"documents_resumed":skipped,**dict(totals),"tree_anomalies":anomalies,"failures":failures}
    json_dump(out/"export-summary.json", summary)
    report=["Dynalist API export","===================",f"Archive:          {out}",f"Documents listed: {len(docs)}",f"Documents saved:  {len(canonical_docs)}",f"Nodes:            {totals['nodes']}",f"Notes:            {totals['notes']}",f"Checkbox tasks:   {totals['tasks']}",f"Completed tasks:  {totals['done']}",f"Internal links:   {totals['internal_links']}",f"Tree anomalies:   {len(anomalies)}",f"Failures:         {len(failures)}","","API token was NOT stored."]
    (out/"export-report.txt").write_text("\n".join(report)+"\n",encoding="utf-8")
    print("\n"+"\n".join(report))
    return 1 if failures else 0

# ---------------- OPML compare ----------------

def safe_extract_zip(zp,dest):
    base=dest.resolve()
    with zipfile.ZipFile(zp) as zf:
        for info in zf.infolist():
            t=(dest/info.filename).resolve()
            if t != base and base not in t.parents: raise ValueError(f"Unsafe ZIP path: {info.filename}")
        zf.extractall(dest)


def collect_opml(path):
    path=Path(path).expanduser().resolve(); tmp=None
    if path.is_file() and path.suffix.lower()==".zip":
        tmp=tempfile.TemporaryDirectory(prefix="dynalist-opml-"); root=Path(tmp.name); safe_extract_zip(path,root); return root,sorted(root.rglob("*.opml")),tmp
    if path.is_file() and path.suffix.lower()==".opml": return path.parent,[path],tmp
    if path.is_dir(): return path,sorted(path.rglob("*.opml")),tmp
    raise FileNotFoundError(path)


def outline_children(e): return [x for x in list(e) if x.tag.split("}")[-1]=="outline"]


def parse_opml(path,rootdir):
    tree=ET.parse(path); root=tree.getroot(); head=next((x for x in root.iter() if x.tag.split("}")[-1]=="head"),None); title=""
    if head is not None:
        for c in list(head):
            if c.tag.split("}")[-1]=="title": title=(c.text or "").strip(); break
    body=next((x for x in root.iter() if x.tag.split("}")[-1]=="body"),None); roots=outline_children(body) if body is not None else []
    nodes=[]
    def visit(e,pos):
        a=dict(e.attrib); nodes.append({"pos":list(pos),"text":a.get("text",""),"note":a.get("_note",""),"checkbox":boolish(a.get("checkbox")),"complete":boolish(a.get("complete")),"heading":a.get("heading"),"color":a.get("colorLabel"),"collapsed":boolish(a.get("collapsed")),"attrs":a})
        for i,c in enumerate(outline_children(e)): visit(c,pos+(i,))
    for i,e in enumerate(roots): visit(e,(i,))
    sig=sha256_obj([[n["pos"],n["text"],n["note"]] for n in nodes])
    return {"path":path.relative_to(rootdir).as_posix(),"title":title or path.stem,"nodes":nodes,"signature":sig}


def api_tree_nodes(doc):
    """
    Return only real Dynalist outline nodes.

    Dynalist doc/read includes a synthetic node with id="root". Its content is
    the document title and its children are the actual top-level outline items.
    OPML omits this synthetic node, so it must not be compared as a real block.
    """
    ns=doc.get("nodes",[]) or []
    by={n.get("id"):n for n in ns if n.get("id")}
    root=by.get("root")
    out=[]; seen=set()

    def visit(n,pos):
        nid=n.get("id")
        if not nid or nid=="root" or nid in seen:return
        seen.add(nid)
        out.append({
            "pos":list(pos),"id":nid,
            "text":n.get("content","") or "",
            "note":n.get("note","") or "",
            "checkbox":bool(n.get("checkbox",False)),
            "complete":bool(n.get("checked",False)),
            "heading":n.get("heading",0) or 0,
            "color":n.get("color",0) or 0,
            "collapsed":bool(n.get("collapsed",False))
        })
        for i,cid in enumerate(n.get("children",[]) or []):
            if cid in by: visit(by[cid],pos+(i,))

    if root is not None:
        for i,cid in enumerate(root.get("children",[]) or []):
            if cid in by: visit(by[cid],(i,))
    else:
        child={c for n in ns for c in (n.get("children",[]) or [])}
        roots=[n for n in ns if n.get("id") not in child]
        for i,n in enumerate(roots): visit(n,(i,))

    # Preserve malformed/unreachable real nodes for comparison rather than dropping them.
    j=len(out)
    for n in ns:
        if n.get("id") not in {None,"root"} and n.get("id") not in seen:
            visit(n,(j,)); j+=1

    return out,sha256_obj([[n["pos"],n["text"],n["note"]] for n in out])


def _norm_title(s):
    """Normalization used only for API↔OPML matching, never for stored titles."""
    s=s or ""
    s=unicodedata.normalize("NFKC",s)
    s=s.replace("\xa0"," ")
    s=re.sub(r"[\u200b\u200c\u200d\ufeff]","",s)
    s=" ".join(s.split())
    return s.casefold()

def compare_api_opml(canonical,opml_path):
    root,files,tmp=collect_opml(opml_path)
    try:
        ods=[]; parse_fail=[]
        for p in files:
            try: ods.append(parse_opml(p,root))
            except Exception as exc: parse_fail.append({"path":str(p),"error":str(exc)})

        ads=[]
        for d in canonical.get("documents",[]):
            nodes,sig=api_tree_nodes(d)
            ads.append({
                "file_id":d["file_id"],"title":d.get("title",""),
                "full_path":d.get("full_path",[]),"nodes":nodes,"signature":sig
            })

        bytitle=defaultdict(list)
        bysig=defaultdict(list)
        for d in ods:
            bytitle[_norm_title(d["title"])].append(d)
            bysig[d["signature"]].append(d)

        used=set(); matches=[]; unmatched_api=[]
        for a in ads:
            title_candidates=[
                d for d in bytitle.get(_norm_title(a["title"]),[])
                if d["path"] not in used
            ]
            cand=[]; method=None

            # Best case: same normalized title and exact real-outline signature.
            exact_title=[
                d for d in title_candidates if d["signature"]==a["signature"]
            ]
            if exact_title:
                cand=exact_title
                method="title+exact-structure-content-note"
            elif title_candidates:
                cand=title_candidates
                method="normalized-title-best-effort"
            else:
                # Fallback only when the content signature identifies exactly one
                # unused OPML file. Never arbitrarily pair common empty documents.
                sig_candidates=[
                    d for d in bysig.get(a["signature"],[])
                    if d["path"] not in used
                ]
                if len(sig_candidates)==1:
                    cand=sig_candidates
                    method="unique-signature-fallback"

            if cand:
                at=Counter(n["text"] for n in a["nodes"])
                cand.sort(key=lambda d:(
                    0 if d["signature"]==a["signature"] else 1,
                    abs(len(a["nodes"])-len(d["nodes"])),
                    -sum((at & Counter(n["text"] for n in d["nodes"])).values()),
                    d["path"]
                ))

            if not cand:
                unmatched_api.append({
                    "file_id":a["file_id"],"title":a["title"],
                    "full_path":a["full_path"],"node_count":len(a["nodes"])
                })
                continue

            o=cand[0]; used.add(o["path"])
            diffs=[]
            ab={tuple(n["pos"]):n for n in a["nodes"]}
            ob={tuple(n["pos"]):n for n in o["nodes"]}

            for pos in sorted(set(ab)|set(ob)):
                x=ab.get(pos); y=ob.get(pos)
                if x is None or y is None:
                    diffs.append({"pos":list(pos),"kind":"node-missing","api":x,"opml":y})
                    continue

                fields=[
                    ("text",x["text"],y["text"]),
                    ("note",x["note"],y["note"]),
                    ("checkbox",x["checkbox"],bool(y["checkbox"] or False)),
                    ("complete",x["complete"],bool(y["complete"] or False))
                ]
                if y["heading"] is not None:
                    try: ov=int(y["heading"])
                    except: ov=y["heading"]
                    fields.append(("heading",x["heading"],ov))
                if y["color"] is not None:
                    try: ov=int(y["color"])
                    except: ov=y["color"]
                    fields.append(("color",x["color"],ov))
                if y["collapsed"] is not None:
                    fields.append(("collapsed",x["collapsed"],y["collapsed"]))

                for k,av,ov in fields:
                    if av != ov:
                        diffs.append({
                            "pos":list(pos),"kind":"field-difference",
                            "field":k,"api":av,"opml":ov
                        })

            matches.append({
                "file_id":a["file_id"],"api_title":a["title"],
                "api_full_path":a["full_path"],"opml_path":o["path"],
                "opml_title":o["title"],"match_method":method,
                "api_node_count":len(a["nodes"]),
                "opml_node_count":len(o["nodes"]),
                "differences":diffs
            })

        unmatched_opml=[
            {"path":d["path"],"title":d["title"],"node_count":len(d["nodes"])}
            for d in ods if d["path"] not in used
        ]
        return {
            "opml_files":len(files),"opml_parse_failures":parse_fail,
            "matches":matches,"unmatched_api":unmatched_api,
            "unmatched_opml":unmatched_opml
        }
    finally:
        if tmp: tmp.cleanup()

# ---------------- Logseq generation ----------------

def block_uuid(node_id): return str(uuid.uuid5(UUID_NAMESPACE,f"dynalist-node:{node_id}"))
def page_uuid(doc_id): return str(uuid.uuid5(UUID_NAMESPACE,f"dynalist-document:{doc_id}"))

def clean_component(s):
    s=(s or "Untitled").replace("\r"," ").replace("\n"," ").strip(); return s or "Untitled"

def logseq_display_title(source_title):
    """
    Return a single-page Logseq-safe display title.

    Logseq 0.10 treats ASCII '/' as a namespace separator and DB Logseq
    disallows both '/' and '#'. Replace only those two characters with close
    Unicode lookalikes so the full title remains visually recognizable while
    still being one page.

      /  U+002F -> ∕ U+2215 DIVISION SLASH
      #  U+0023 -> # U+FF03 FULLWIDTH NUMBER SIGN

    The exact source title is always retained in canonical/provenance JSON.
    """
    title=clean_component(source_title or "Untitled")
    return title.replace("/", "∕").replace("#", "#")

def make_page_titles(canonical):
    """
    One Dynalist document -> one Logseq page using the Dynalist document title,
    not its folder path.

    Genuine duplicate resulting page names must be disambiguated because Logseq
    page identities are unique. Exact source titles remain in provenance.
    """
    groups=defaultdict(list)
    substitutions=[]
    for d in canonical.get("documents",[]):
        original=clean_component(d.get("title") or "Untitled")
        title=logseq_display_title(original)
        if title != original:
            substitutions.append({
                "file_id":d["file_id"],
                "original_title":original,
                "page_title":title
            })
        groups[title.casefold()].append((d["file_id"],original,title))

    out={}; collisions=[]
    for items in groups.values():
        if len(items)==1:
            fid,original,title=items[0]
            out[fid]=title
        else:
            for fid,original,title in items:
                new_title=f"{title} [Dynalist {fid[:8]}]"
                out[fid]=new_title
                collisions.append({
                    "file_id":fid,
                    "original_title":original,
                    "page_title":new_title,
                    "reason":"Logseq page-name collision"
                })
    return out,collisions,substitutions

def _truncate_utf8(s,max_bytes):
    """Truncate without splitting a UTF-8 code point."""
    if len(s.encode("utf-8")) <= max_bytes:return s
    out=[]; used=0
    for ch in s:
        n=len(ch.encode("utf-8"))
        if used+n > max_bytes:break
        out.append(ch); used+=n
    return "".join(out)

def encoded_page_stem(title):
    s=title.replace("%","%25").replace("/","___")
    for ch,enc in WINDOWS_BAD.items(): s=s.replace(ch,enc)
    while s.endswith(" "): s=s[:-1]+"%20"
    while s.endswith("."): s=s[:-1]+"%2E"
    return s or "Untitled"

def md_filename(title):
    """
    Generate a cross-platform-safe legacy Logseq filename.

    Filesystems commonly cap one filename component at 255 bytes. Keep the
    physical filename conservative (<= ~195 bytes including .md), while the
    exact page title is preserved by title:: frontmatter and provenance.
    """
    s=encoded_page_stem(title)
    max_stem_bytes=180
    if len(s.encode("utf-8")) > max_stem_bytes:
        digest=hashlib.sha256(title.encode("utf-8")).hexdigest()[:12]
        suffix=f"__{digest}"
        keep=max_stem_bytes-len(suffix.encode("utf-8"))
        s=_truncate_utf8(s,keep).rstrip(" ._") + suffix
    return s+".md"

def prop(v):
    if isinstance(v,str): return json.dumps(v,ensure_ascii=False)
    if v is True:return "true"
    if v is False:return "false"
    if v is None:return "nil"
    return str(v)

def convert_fmt(s,literal):
    if literal:return s
    return HIGHLIGHT_RE.sub(r"^^\1^^", ITALIC_RE.sub(r"*\1*",s))

def build_indexes(canonical,titles):
    exact={}; byid=defaultdict(list); docs={}
    for d in canonical.get("documents",[]):
        fid=d["file_id"]; docs[fid]={"doc_id":fid,"page_title":titles[fid]}
        for n in d.get("nodes",[]) or []:
            nid=n.get("id")
            if not nid or nid=="root":continue
            r={"doc_id":fid,"node_id":nid,"uuid":block_uuid(nid),"page_title":titles[fid]}; exact[(fid,nid)]=r; byid[nid].append(r)
    return exact,byid,docs

def resolve_target(docid,nodeid,exact,byid,docs):
    if nodeid and nodeid.lower() != "root":
        if (docid,nodeid) in exact:return "node",exact[(docid,nodeid)],"exact"
        c=byid.get(nodeid,[])
        if len(c)==1:return "node",c[0],"node-id-fallback"
        return None,None,"unresolved"
    if docid in docs:return "page",docs[docid],"exact"
    return None,None,"unresolved"

def convert_links(text,exact,byid,docs,stats,prov,generated_refs=None):
    """
    Convert Dynalist internal links. When generated_refs is supplied, native
    Logseq references are temporarily represented by opaque tokens so source
    syntax neutralization cannot accidentally escape them.
    """
    if not text:return text
    if generated_refs is None:
        generated_refs={}
        restore_here=True
    else:
        restore_here=False

    placeholders={}

    def protect_generated(repl):
        token=f"\x00GENREF{len(generated_refs)}\x00"
        generated_refs[token]=repl
        return token

    def mdrep(m):
        kind,t,method=resolve_target(m.group("doc"),m.group("node"),exact,byid,docs)
        token=f"\x00DYN{len(placeholders)}\x00"

        if kind=="node":
            final=f"[{m.group('label')}]((({t['uuid']})))"
            repl=protect_generated(final)
            stats["internal_node_links_resolved"]+=1
            if method=="node-id-fallback":stats["stale_doc_links_recovered"]+=1
        elif kind=="page":
            final=f"[{m.group('label')}]([[{t['page_title']}]])"
            repl=protect_generated(final)
            stats["internal_document_links_resolved"]+=1
        else:
            final=m.group(0)
            repl=final
            stats["internal_links_unresolved"]+=1

        prov.append({"original":m.group("url"),"replacement":final,"method":method})
        placeholders[token]=repl
        return token

    text=DYN_MD_LINK_RE.sub(mdrep,text)

    def bare(m):
        kind,t,method=resolve_target(m.group("doc"),m.group("node"),exact,byid,docs)
        url=m.group(0)

        if kind=="node":
            final=f"(({t['uuid']}))"
            repl=protect_generated(final)
            stats["internal_node_links_resolved"]+=1
            if method=="node-id-fallback":stats["stale_doc_links_recovered"]+=1
        elif kind=="page":
            final=f"[[{t['page_title']}]]"
            repl=protect_generated(final)
            stats["internal_document_links_resolved"]+=1
        else:
            final=url
            repl=url
            stats["internal_links_unresolved"]+=1

        prov.append({"original":url,"replacement":final,"method":method})
        return repl

    text=DYN_URL_RE.sub(bare,text)
    for k,v in placeholders.items():
        text=text.replace(k,v)

    if restore_here:
        for k,v in generated_refs.items():
            text=text.replace(k,v)

    return text
def _protect_fragments(text, patterns):
    """Replace regex matches with opaque tokens and return text + token map."""
    saved={}
    for pattern in patterns:
        def repl(m):
            token=f"\x00SAFE{len(saved)}\x00"
            saved[token]=m.group(0)
            return token
        text=re.sub(pattern,repl,text,flags=re.S)
    return text,saved


def _escape_hashes(s):
    """Escape literal hashes without double-escaping an existing backslash escape."""
    return re.sub(r'(?<!\\)#', r'\\#', s)


def _escape_source_page_refs(s):
    """
    Literal [[...]] in Dynalist is source text, not Dynalist's native internal
    link representation. Escape it so File->DB import does not synthesize pages.
    """
    s=re.sub(r'(?<!\\)\[\[', r'\\[\\[', s)
    s=re.sub(r'(?<!\\)\]\]', r'\\]\\]', s)
    return s


def neutralize_logseq_source(text,stats,tag_mode="neutralize"):
    """
    Preserve source text visually while preventing the Logseq Markdown/DB
    importer from inventing semantics that were not present in Dynalist.

    tag_mode="neutralize" (recommended for archives with no intentional tags):
      #foo        -> \\#foo
      # heading   -> \\# heading
      [[literal]] -> \\[\\[literal\\]\\]

    tag_mode="preserve":
      source #tags are retained, but literal [[...]] references and hashes
      inside URLs/HTML numeric entities are still protected from accidental
      page/tag creation.

    Inline/fenced code is protected unchanged. Genuine Dynalist internal links
    have already been replaced by opaque placeholders and are restored later.
    """
    if not text:
        return text
    if tag_mode not in {"neutralize","preserve"}:
        raise ValueError(f"Unknown tag mode: {tag_mode}")

    # Code spans/fences: backslashes are literal inside code, so do not modify.
    text,saved=_protect_fragments(
        text,
        [
            r'```.*?```',
            r'~~~.*?~~~',
            r'``[^`\n]*``',
            r'`[^`\n]*`',
        ]
    )

    # Existing Markdown links/images. Protect # in destinations because Logseq's
    # importer has been observed to treat URL fragments as hashtags/pages.
    md_saved={}
    md_pat=re.compile(r'(!?\[[^\]\n]*\]\()([^\)\n]*)(\))')
    def md_link_repl(m):
        prefix,dest,suffix=m.groups()
        open_paren=prefix.rfind("](")
        label_part=prefix[:open_paren]
        tail=prefix[open_paren:]
        if tag_mode=="neutralize":
            label_part=_escape_hashes(label_part)
        label_part=_escape_source_page_refs(label_part)
        n_hash=len(re.findall(r'(?<!\\)#',dest))
        if n_hash:
            stats["markdown_link_destination_hashes_protected"]+=n_hash
            dest=_escape_hashes(dest)
        rebuilt=label_part+tail+dest+suffix
        token=f"\x00MDLINK{len(md_saved)}\x00"
        md_saved[token]=rebuilt
        return token
    text=md_pat.sub(md_link_repl,text)

    # Raw URLs containing # are made explicit Markdown links. This preserves the
    # visible URL and target while keeping the DB importer out of hashtag mode.
    url_saved={}
    raw_url_re=re.compile(r'https?://[^\s<>"\']+')
    def raw_url_repl(m):
        url=m.group(0)
        token=f"\x00URL{len(url_saved)}\x00"
        if "#" in url:
            n_hash=url.count("#")
            escaped=_escape_hashes(url)
            url_saved[token]=f"[{escaped}]({escaped})"
            stats["raw_urls_with_hash_protected"]+=1
            stats["raw_url_hash_characters_protected"]+=n_hash
        else:
            url_saved[token]=url
        return token
    text=raw_url_re.sub(raw_url_repl,text)

    # HTML/XML numeric character entities such as &#8217; are data, not tags.
    # This matters especially in imported RSS/feed/XML material.
    entity_count=0
    def entity_repl(m):
        nonlocal entity_count
        entity_count+=1
        return m.group(0).replace("#",r"\#",1)
    text=re.sub(r'&#(?:[0-9]+|[xX][0-9A-Fa-f]+);?',entity_repl,text)
    stats["html_numeric_entity_hashes_protected"]+=entity_count

    # Dynalist did not use [[...]] as its native page-link syntax. Preserve such
    # source literally so Logseq does not create pages from it.
    before_ref=len(re.findall(r'(?<!\\)\[\[',text))
    text=_escape_source_page_refs(text)
    stats["source_page_refs_neutralized"]+=before_ref

    if tag_mode=="neutralize":
        before_hash=len(re.findall(r'(?<!\\)#',text))
        text=_escape_hashes(text)
        stats["source_hashes_neutralized"]+=before_hash
    else:
        stats["source_hashes_preserved"]+=len(re.findall(r'(?<!\\)#',text))

    # Restore protected content.
    for mapping in (url_saved,md_saved,saved):
        for k,v in mapping.items():
            text=text.replace(k,v)

    return text


def prepare_source_text(raw,exact,byid,docs,stats,link_prov,tag_mode="neutralize"):
    """
    Convert genuine Dynalist internal links, neutralize only the remaining
    source syntax, then restore generated native Logseq references.
    """
    generated={}
    text=convert_links(raw,exact,byid,docs,stats,link_prov,generated)
    text=neutralize_logseq_source(text,stats,tag_mode=tag_mode)
    for k,v in generated.items():
        text=text.replace(k,v)
    return text

def quote_note(s,indent):
    s=s.replace("\r\n","\n").replace("\r","\n"); return [f"{indent}> {line}" if line else f"{indent}>" for line in s.split("\n")]

def escape_logseq_literal_heading(text):
    """
    Dynalist heading level is metadata, not inferred from a literal '# ' prefix.

    In Logseq Markdown, however, '# text', '## text', etc. at the beginning of a
    block are rendered as headings. Escape only hashes followed by whitespace,
    leaving Dynalist-style tags such as '#compress' completely unchanged.
    """
    return re.sub(r'^(#{1,6})(?=\s)', r'\\\1', text)

def render_doc(doc,title,exact,byid,docs,literal,provenance,stats,tag_mode="neutralize"):
    """
    Render a clean Logseq page.

    User-visible/source metadata is NOT emitted as dynalist-* properties.
    Full raw metadata remains in provenance.json and canonical.json.

    Built-in id:: is retained for stable block references; title:: is retained
    so long/encoded physical filenames can still display the intended page name.
    """
    ns=doc.get("nodes",[]) or []
    by={n.get("id"):n for n in ns if n.get("id")}
    root=by.get("root")
    seen=set()

    # Logseq built-ins only. 'title' is hidden in normal rendered view.
    lines=[
        f"id:: {page_uuid(doc['file_id'])}",
        f"title:: {title}",
        ""
    ]

    if root is not None:
        stats["synthetic_document_roots"]+=1
        provenance.setdefault("document_roots",{})[doc["file_id"]]=root

    def visit(n,depth,path):
        nid=n.get("id")
        if not nid:
            stats["nodes_missing_id"]+=1
            return
        if nid in seen:
            stats["node_cycle_or_duplicate_reference"]+=1
            return

        seen.add(nid)
        rawc=n.get("content","") or ""
        rawn=n.get("note","") or ""
        lprov=[]

        content=convert_fmt(
            prepare_source_text(rawc,exact,byid,docs,stats,lprov,tag_mode=tag_mode),literal
        ).replace("\r\n","\n").replace("\r","\n").replace("\n","<br>")
        content=escape_logseq_literal_heading(content)

        cb=bool(n.get("checkbox",False))
        checked=bool(n.get("checked",False))
        task=("DONE " if checked else "TODO ") if cb else ""

        try:
            h=max(0,min(3,int(n.get("heading",0) or 0)))
        except:
            h=0

        visible=task + (("#"*h+" ") if h else "") + content
        if not visible.strip():
            visible="\u200b"
            stats["empty_content_nodes"]+=1

        indent="  "*depth
        pi="  "*(depth+1)
        lines.append(f"{indent}- {visible}")

        # Required for native Logseq block references; built-in identity property.
        lines.append(f"{pi}id:: {block_uuid(nid)}")

        # Preserve Logseq semantics, but keep all Dynalist-specific metadata out
        # of the page body.
        if n.get("collapsed") is True:
            lines.append(f"{pi}collapsed:: true")
            stats["collapsed"]+=1

        if h:
            stats["headings"]+=1

        try:
            color=int(n.get("color",0) or 0)
        except:
            color=n.get("color",0)
        if color:
            stats["colored"]+=1

        if cb:
            stats["done_tasks" if checked else "todo_tasks"]+=1

        if rawn:
            readable=convert_fmt(
                prepare_source_text(rawn,exact,byid,docs,stats,lprov,tag_mode=tag_mode),literal
            )
            readable="\n".join(escape_logseq_literal_heading(line) for line in readable.splitlines())
            lines.extend(quote_note(readable,pi))
            stats["notes"]+=1

        # This is the lossless metadata layer.
        provenance["nodes"][block_uuid(nid)]={
            "document_id":doc["file_id"],
            "node_id":nid,
            "tree_path":list(path),
            "raw":n,
            "link_conversions":lprov
        }
        stats["nodes"]+=1

        for i,cid in enumerate(n.get("children",[]) or []):
            if cid in by:
                visit(by[cid],depth+1,path+(i,))
            else:
                stats["missing_child_ids"]+=1
                provenance["missing_children"].append({
                    "document_id":doc["file_id"],
                    "parent_node_id":nid,
                    "missing_child_id":cid
                })

    if root is not None:
        top_ids=root.get("children",[]) or []
        for i,cid in enumerate(top_ids):
            if cid in by:
                visit(by[cid],0,(i,))
            else:
                stats["missing_child_ids"]+=1
                provenance["missing_children"].append({
                    "document_id":doc["file_id"],
                    "parent_node_id":"root",
                    "missing_child_id":cid
                })
        j=len(top_ids)
    else:
        child={c for n in ns for c in (n.get("children",[]) or [])}
        roots=[n for n in ns if n.get("id") not in child]
        for i,n in enumerate(roots):
            visit(n,0,(i,))
        j=len(roots)

    for n in ns:
        nid=n.get("id")
        if nid not in {None,"root"} and nid not in seen:
            stats["unreachable_nodes_recovered"]+=1
            visit(n,0,(j,))
            j+=1

    if not any(n.get("id") not in {None,"root"} for n in ns):
        lines.append("- \u200b")
        stats["empty_documents"]+=1

    return "\n".join(lines).rstrip()+"\n"

def _dir_nonempty(path: Path) -> bool:
    return path.exists() and any(path.iterdir())


def _safe_replace_output(out: Path, api: Path) -> None:
    """Delete only an explicitly requested output graph after conservative guards."""
    out=out.resolve()
    protected={Path("/").resolve(), Path.home().resolve(), api.resolve(), api.parent.resolve()}
    try:
        protected.add(Path.cwd().resolve())
    except Exception:
        pass
    if out in protected or out.parent==out:
        raise ValueError(f"Refusing to replace unsafe output path: {out}")
    if len(out.parts) < 3:
        raise ValueError(f"Refusing to replace suspiciously short path: {out}")
    if out.exists():
        shutil.rmtree(out)


def opml_status(cmp):
    exact_no=sum(
        1 for m in cmp["matches"]
        if m["match_method"] in {"title+exact-structure-content-note","unique-signature-fallback"}
        and not m["differences"]
    )
    diffdocs=sum(1 for m in cmp["matches"] if m["differences"])
    clean=(
        diffdocs==0
        and not cmp["unmatched_api"]
        and not cmp["unmatched_opml"]
        and not cmp["opml_parse_failures"]
        and exact_no==len(cmp["matches"])
    )
    return {
        "exact_no_differences":exact_no,
        "matched_with_differences":diffdocs,
        "clean":clean,
    }


def verify_generated_graph(api: Path, out: Path, manifest: dict, tag_mode: str):
    """
    Structural verification of the generated legacy Logseq graph. This does not
    replace the independent API↔OPML comparison; it verifies the generated files
    and manifest are internally consistent.
    """
    issues=[]
    pages_dir=out/"pages"
    docs=manifest.get("documents",[])
    expected_files={d["markdown_file"] for d in docs}
    actual_files={
        f"pages/{p.name}" for p in pages_dir.glob("*.md") if p.is_file()
    }

    for missing in sorted(expected_files-actual_files):
        issues.append(f"missing generated page: {missing}")
    for extra in sorted(actual_files-expected_files):
        issues.append(f"unexpected generated page: {extra}")

    block_ids=0
    quoted_titles=0
    for d in docs:
        p=out/d["markdown_file"]
        if not p.exists():
            continue
        raw=p.read_text(encoding="utf-8",errors="replace")
        lines=raw.splitlines()
        expected_title=f"title:: {d['page_title']}"
        if len(lines)<2 or lines[1] != expected_title:
            issues.append(
                f"title property mismatch in {d['markdown_file']}: "
                f"expected {expected_title!r}, got {(lines[1] if len(lines)>1 else None)!r}"
            )
        if len(lines)>1 and re.match(r'^title::\s+".*"\s*$',lines[1]):
            quoted_titles+=1
        block_ids += sum(
            1 for line in lines[2:]
            if re.match(r'^\s+id::\s+[0-9a-fA-F-]{36}\s*$',line)
        )
        expected_sha=d.get("markdown_sha256")
        if expected_sha and sha256_file(p) != expected_sha:
            issues.append(f"checksum mismatch: {d['markdown_file']}")

    if quoted_titles:
        issues.append(f"{quoted_titles} page title properties are still JSON-quoted")

    expected_blocks=manifest.get("stats",{}).get("nodes")
    if expected_blocks is not None and block_ids != expected_blocks:
        issues.append(
            f"block id count mismatch: generated={block_ids}, expected={expected_blocks}"
        )

    return {
        "ok":not issues,
        "issues":issues,
        "page_files_expected":len(expected_files),
        "page_files_actual":len(actual_files),
        "block_ids":block_ids,
        "tag_mode":tag_mode,
    }


def _importer_guidance(tag_mode):
    if tag_mode=="neutralize":
        return [
            "Recommended Logseq File -> DB import options:",
            "  Extract inline code snippets as child blocks: OFF",
            "  Import all tags:                         OFF",
            "  Import specific tags:                   blank",
            "  Remove inline tags:                     OFF",
            "  Import additional tags from properties: blank",
            "  Import tag parents from properties:     blank",
        ]
    return [
        "Recommended Logseq File -> DB import options:",
        "  Extract inline code snippets as child blocks: OFF",
        "  Import all tags:                         ON",
        "  Remove inline tags:                     OFF (unless intentionally desired)",
        "  Other tag-property options:              leave blank unless intentionally used",
    ]
def command_build(args):
    api=Path(args.api_archive).expanduser().resolve()
    out=Path(args.output).expanduser().resolve()
    cf=api/"canonical.json"
    if not cf.exists():
        raise FileNotFoundError(f"Missing {cf}; run export first")

    if args.replace_output:
        _safe_replace_output(out,api)
    elif _dir_nonempty(out):
        raise FileExistsError(
            f"Output directory is not empty: {out}\n"
            "Use a new directory or --replace-output. Refusing to risk stale pages."
        )

    canonical=read_json(cf)
    titles,collisions,title_substitutions=make_page_titles(canonical)
    exact,byid,docs=build_indexes(canonical,titles)

    pages=out/"pages"
    logseq=out/"logseq"
    imp=out/"_dynalist_import"
    pages.mkdir(parents=True,exist_ok=True)
    logseq.mkdir(parents=True,exist_ok=True)
    imp.mkdir(parents=True,exist_ok=True)
    (logseq/"config.edn").write_text("{}\n",encoding="utf-8")

    stats=Counter()
    provenance={
        "format":"dynalist-logseq-provenance-v2",
        "created_utc":datetime.now(timezone.utc).isoformat(),
        "script_version":SCRIPT_VERSION,
        "api_archive":str(api),
        "tag_mode":args.tag_mode,
        "nodes":{},
        "document_roots":{},
        "missing_children":[],
        "page_titles":titles,
        "title_collisions":collisions,
        "title_substitutions":title_substitutions,
    }
    used={}
    manifest=[]

    for d in canonical.get("documents",[]):
        fid=d["file_id"]
        title=titles[fid]
        fn=md_filename(title)
        if fn != encoded_page_stem(title)+".md":
            stats["long_filenames_shortened"]+=1
        if fn.casefold() in used and used[fn.casefold()] != fid:
            base=fn[:-3]
            suffix=f"__{fid[:8]}"
            base=_truncate_utf8(
                base,180-len(suffix.encode("utf-8"))
            ).rstrip(" ._")
            fn=base+suffix+".md"
            stats["filename_collisions"]+=1
        used[fn.casefold()]=fid

        p=pages/fn
        rendered=render_doc(
            d,title,exact,byid,docs,args.literal_formatting,
            provenance,stats,tag_mode=args.tag_mode
        )
        p.write_text(rendered,encoding="utf-8")
        manifest.append({
            "file_id":fid,
            "source_title":d.get("title"),
            "source_path":d.get("full_path"),
            "page_title":title,
            "page_uuid":page_uuid(fid),
            "markdown_file":f"pages/{fn}",
            "markdown_sha256":sha256_file(p),
            "node_count":len(d.get("nodes",[]) or []),
        })
        stats["documents"]+=1

    cmp=None
    cmpstat=None
    if args.opml:
        print("Cross-checking API archive against OPML backup...")
        cmp=compare_api_opml(canonical,Path(args.opml).expanduser().resolve())
        cmpstat=opml_status(cmp)
        json_dump(imp/"opml-comparison.json",cmp)

    provenance["documents"]=manifest
    provenance["stats"]=dict(stats)
    json_dump(imp/"provenance.json",provenance)

    unresolved=[]
    for buid,e in provenance["nodes"].items():
        for link in e.get("link_conversions",[]):
            if link.get("method")=="unresolved":
                unresolved.append({
                    "block_uuid":buid,
                    "document_id":e["document_id"],
                    "node_id":e["node_id"],
                    **link
                })
    json_dump(imp/"unresolved-links.json",unresolved)

    manifest_obj={
        "format":"dynalist-logseq-build-v2",
        "created_utc":datetime.now(timezone.utc).isoformat(),
        "script_version":SCRIPT_VERSION,
        "api_archive":str(api),
        "output_graph":str(out),
        "options":{
            "literal_formatting":args.literal_formatting,
            "tag_mode":args.tag_mode,
            "opml_crosscheck":str(Path(args.opml).expanduser().resolve()) if args.opml else None,
        },
        "stats":dict(stats),
        "documents":manifest,
        "title_collisions":collisions,
        "title_substitutions":title_substitutions,
    }
    json_dump(imp/"manifest.json",manifest_obj)

    verification=verify_generated_graph(api,out,manifest_obj,args.tag_mode)
    json_dump(imp/"verification.json",verification)

    report=[
        "Dynalist API -> Logseq file graph",
        "=================================",
        f"Script version:            {SCRIPT_VERSION}",
        f"Graph:                     {out}",
        f"Tag mode:                  {args.tag_mode}",
        f"Documents:                 {stats['documents']}",
        f"API nodes:                 {sum(len(d.get('nodes',[]) or []) for d in canonical.get('documents',[]))}",
        f"Synthetic document roots:  {stats['synthetic_document_roots']}",
        f"Logseq outline blocks:      {stats['nodes']}",
        f"TODO tasks:                {stats['todo_tasks']}",
        f"DONE tasks:                {stats['done_tasks']}",
        f"Notes:                     {stats['notes']}",
        f"Headings:                  {stats['headings']}",
        f"Colours:                   {stats['colored']}",
        f"Collapsed blocks:          {stats['collapsed']}",
        f"Node links resolved:       {stats['internal_node_links_resolved']}",
        f"Document links resolved:   {stats['internal_document_links_resolved']}",
        f"Stale doc IDs recovered:   {stats['stale_doc_links_recovered']}",
        f"Internal links unresolved: {stats['internal_links_unresolved']}",
        f"Source # neutralized:      {stats['source_hashes_neutralized']}",
        f"Source # preserved:        {stats['source_hashes_preserved']}",
        f"Source [[ ]] neutralized:  {stats['source_page_refs_neutralized']}",
        f"HTML entity # protected:   {stats['html_numeric_entity_hashes_protected']}",
        f"Hash URLs protected:       {stats['raw_urls_with_hash_protected']}",
        f"Hash URL chars protected:  {stats['raw_url_hash_characters_protected']}",
        f"MD-link # protected:       {stats['markdown_link_destination_hashes_protected']}",
        f"Title collisions:          {len(collisions)}",
        f"Titles with / or # adjusted: {len(title_substitutions)}",
        f"Long filenames shortened: {stats['long_filenames_shortened']}",
        f"Filename collisions:       {stats['filename_collisions']}",
        f"Missing child IDs:         {stats['missing_child_ids']}",
        f"Recovered unreachable:     {stats['unreachable_nodes_recovered']}",
        "",
        "Generated-graph verification:",
        f"  Page files:               {verification['page_files_actual']}/{verification['page_files_expected']}",
        f"  Block IDs:                {verification['block_ids']}",
        f"  Status:                   {'PASS' if verification['ok'] else 'FAIL'}",
    ]
    if verification["issues"]:
        report.append("  Issues:")
        report.extend(f"    - {x}" for x in verification["issues"])

    if cmp is not None:
        report += [
            "",
            "OPML cross-check:",
            f"  OPML files:               {cmp['opml_files']}",
            f"  Matched documents:        {len(cmp['matches'])}",
            f"  Exact/no differences:     {cmpstat['exact_no_differences']}",
            f"  Matched with differences: {cmpstat['matched_with_differences']}",
            f"  Unmatched API documents:  {len(cmp['unmatched_api'])}",
            f"  Unmatched OPML files:     {len(cmp['unmatched_opml'])}",
            f"  OPML parse failures:      {len(cmp['opml_parse_failures'])}",
            f"  Status:                   {'PASS' if cmpstat['clean'] else 'FAIL'}",
        ]

    report += [""] + _importer_guidance(args.tag_mode)
    report += [
        "",
        "Details:",
        "  _dynalist_import/report.txt",
        "  _dynalist_import/manifest.json",
        "  _dynalist_import/provenance.json",
        "  _dynalist_import/verification.json",
        "  _dynalist_import/unresolved-links.json",
    ]
    (imp/"report.txt").write_text("\n".join(report)+"\n",encoding="utf-8")
    print("\n"+"\n".join(report))

    if not verification["ok"]:
        return 3
    if cmp is not None and not cmpstat["clean"] and not args.allow_opml_differences:
        eprint("\nOPML cross-check failed. Graph was written for inspection, but exit status is non-zero.")
        return 2
    return 0


def command_verify(args):
    api=Path(args.api_archive).expanduser().resolve()
    out=Path(args.graph).expanduser().resolve()
    mp=out/"_dynalist_import"/"manifest.json"
    if not mp.exists():
        raise FileNotFoundError(f"Missing {mp}")
    manifest=read_json(mp)

    # Verify canonical raw document checksums when available.
    archive_issues=[]
    cf=api/"canonical.json"
    if not cf.exists():
        archive_issues.append(f"missing canonical archive: {cf}")
    else:
        canonical=read_json(cf)
        for d in canonical.get("documents",[]):
            rf=d.get("raw_file")
            expected=d.get("raw_sha256")
            if rf and expected:
                p=api/rf
                if not p.exists():
                    archive_issues.append(f"missing raw API file: {rf}")
                elif sha256_file(p) != expected:
                    archive_issues.append(f"raw API checksum mismatch: {rf}")

    tag_mode=manifest.get("options",{}).get("tag_mode","unknown")
    graph=verify_generated_graph(api,out,manifest,tag_mode)

    cmp=None
    cmpstat=None
    if args.opml:
        if not cf.exists():
            archive_issues.append("cannot run OPML comparison without canonical.json")
        else:
            print("Cross-checking API archive against OPML backup...")
            cmp=compare_api_opml(
                read_json(cf),Path(args.opml).expanduser().resolve()
            )
            cmpstat=opml_status(cmp)

    print("Dynalist migration verification")
    print("==============================")
    print(f"Script:                    {SCRIPT_VERSION}")
    print(f"API archive issues:        {len(archive_issues)}")
    print(f"Generated graph status:    {'PASS' if graph['ok'] else 'FAIL'}")
    print(f"Page files:                {graph['page_files_actual']}/{graph['page_files_expected']}")
    print(f"Block IDs:                 {graph['block_ids']}")
    if cmpstat is not None:
        print(f"OPML status:               {'PASS' if cmpstat['clean'] else 'FAIL'}")
        print(f"OPML exact documents:      {cmpstat['exact_no_differences']}")
        print(f"OPML differences:          {cmpstat['matched_with_differences']}")
    for x in archive_issues+graph["issues"]:
        print(f"ISSUE: {x}")

    ok=(not archive_issues and graph["ok"] and (cmpstat is None or cmpstat["clean"]))
    return 0 if ok else 2


def _read_db_page_titles(edn_path: Path):
    text=edn_path.read_text(encoding="utf-8",errors="replace")
    raws=re.findall(r':block/title\s+("(?:\\.|[^"\\])*")',text,flags=re.S)
    titles=[]
    for raw in raws:
        try:
            titles.append(json.loads(raw))
        except Exception:
            pass
    return titles


def _direct_source_causes(extra_titles, pages_dir: Path):
    rows=[]
    extras=set(extra_titles)
    if not pages_dir.exists():
        return rows
    for md in sorted(pages_dir.glob("*.md")):
        for lineno,line in enumerate(
            md.read_text(encoding="utf-8",errors="replace").splitlines(),1
        ):
            if line.lstrip().startswith(("id::","title::")):
                continue
            for title in extras:
                token="#"+title
                start=0
                while token and True:
                    idx=line.find(token,start)
                    if idx<0:
                        break
                    if idx==0 or line[idx-1]!="\\":
                        rows.append({
                            "synthetic_page":title,
                            "cause":"hashtag",
                            "source_file":md.name,
                            "line":lineno,
                            "column":idx+1,
                            "source_text":line[:1000],
                        })
                    start=idx+max(1,len(token))
                ref="[["+title+"]]"
                start=0
                while ref and True:
                    idx=line.find(ref,start)
                    if idx<0:
                        break
                    if idx==0 or line[idx-1]!="\\":
                        rows.append({
                            "synthetic_page":title,
                            "cause":"page-ref",
                            "source_file":md.name,
                            "line":lineno,
                            "column":idx+1,
                            "source_text":line[:1000],
                        })
                    start=idx+len(ref)
    return rows


def command_audit_db(args):
    edn=Path(args.edn).expanduser().resolve()
    graph=Path(args.graph).expanduser().resolve()
    mp=graph/"_dynalist_import"/"manifest.json"
    if not mp.exists():
        raise FileNotFoundError(f"Missing {mp}")
    manifest=read_json(mp)
    expected=[d["page_title"] for d in manifest.get("documents",[])]
    expected_set=set(expected)
    db_titles=_read_db_page_titles(edn)
    db_set=set(db_titles)

    missing=sorted(expected_set-db_set,key=str.casefold)
    quoted_variants=sorted(
        t for t in db_set
        if len(t)>=2 and t.startswith('"') and t.endswith('"')
        and t[1:-1] in expected_set
    )
    extras=sorted(
        (db_set-expected_set)-set(quoted_variants),
        key=str.casefold
    )

    known_db_entities={
        "TODO":"Logseq task entity",
        "DONE":"Logseq task entity",
        "Quote":"Logseq built-in quote/tag entity",
        "Tags":"Logseq built-in tag entity",
    }

    rows=[]
    for t in expected:
        rows.append({
            "classification":"expected Dynalist page",
            "db_page_title":t,
            "note":"present" if t in db_set else "MISSING",
        })
    for t in quoted_variants:
        rows.append({
            "classification":"quoted-title import error",
            "db_page_title":t,
            "note":f"expected {t[1:-1]!r}",
        })
    for t in extras:
        rows.append({
            "classification":"DB extra page/entity",
            "db_page_title":t,
            "note":known_db_entities.get(t,""),
        })

    out=Path(args.output).expanduser().resolve() if args.output else Path.cwd()/"logseq-db-page-audit.tsv"
    with out.open("w",encoding="utf-8",newline="") as f:
        w=csv.DictWriter(f,fieldnames=["classification","db_page_title","note"],delimiter="\t")
        w.writeheader()
        w.writerows(rows)

    causes=_direct_source_causes(extras,graph/"pages")
    cause_out=out.with_name(out.stem+"-source-causes.tsv")
    with cause_out.open("w",encoding="utf-8",newline="") as f:
        fields=["synthetic_page","cause","source_file","line","column","source_text"]
        w=csv.DictWriter(f,fieldnames=fields,delimiter="\t")
        w.writeheader()
        w.writerows(causes)

    direct={r["synthetic_page"] for r in causes}
    print("Logseq DB page audit")
    print("====================")
    print(f"Expected Dynalist pages:    {len(expected_set)}")
    print(f"DB titles in EDN:           {len(db_titles)}")
    print(f"Expected pages present:     {len(expected_set & db_set)}")
    print(f"Missing expected pages:     {len(missing)}")
    print(f"Quoted-title errors:        {len(quoted_variants)}")
    print(f"Extra DB pages/entities:    {len(extras)}")
    print(f"Extras traced to source:    {len(direct)}")
    print(f"Audit:                      {out}")
    print(f"Source causes:              {cause_out}")
    if missing:
        print("\nMissing expected pages:")
        for t in missing[:50]:
            print("  "+repr(t))
    if quoted_variants:
        print("\nQuoted-title errors:")
        for t in quoted_variants[:50]:
            print("  "+repr(t))
    unexplained=[t for t in extras if t not in direct and t not in known_db_entities]
    if unexplained:
        print("\nUnexplained extra DB pages/entities:")
        for t in unexplained[:100]:
            print("  "+repr(t))

    # Extras can include legitimate built-in DB entities, so do not fail solely
    # because extras exist. Missing/quoted intended page names are failures.
    return 2 if missing or quoted_variants else 0


def parser():
    p=argparse.ArgumentParser(
        description="High-fidelity Dynalist API/OPML -> Logseq migration",
        epilog=(
            "Recommended workflow: export once; keep the API archive immutable; "
            "build with a fresh output directory and --opml; inspect/import the "
            "generated graph with Logseq File -> DB graph; optionally export the "
            "DB Pages view as EDN and run audit-db."
        )
    )
    s=p.add_subparsers(dest="command",required=True)

    e=s.add_parser("export",help="Export all Dynalist data via official API")
    e.add_argument("output",help="New/local API archive directory")
    e.add_argument("--token-file")
    e.add_argument("--token-env",default="DYNALIST_TOKEN")
    e.add_argument("--delay",type=float,default=2.05)
    e.add_argument("--resume",action="store_true")
    e.set_defaults(func=command_export)

    b=s.add_parser("build",help="Build a legacy Logseq file graph from the API archive")
    b.add_argument("api_archive")
    b.add_argument("output")
    b.add_argument("--opml",help="Dynalist OPML file/zip for independent cross-check")
    b.add_argument(
        "--allow-opml-differences",action="store_true",
        help="Return success even if an --opml cross-check has differences"
    )
    b.add_argument(
        "--replace-output",action="store_true",
        help="Safely remove and recreate the output graph directory"
    )
    b.add_argument("--literal-formatting",action="store_true")
    b.add_argument(
        "--tag-mode",choices=["neutralize","preserve"],default="neutralize",
        help=(
            "neutralize: display source #text literally without creating tags/pages "
            "(recommended when Dynalist had no intentional tags); preserve: retain "
            "source hashtags for intentional tag import"
        )
    )
    b.set_defaults(func=command_build)

    v=s.add_parser("verify",help="Verify API archive and generated graph integrity")
    v.add_argument("api_archive")
    v.add_argument("graph")
    v.add_argument("--opml",help="Optionally repeat the independent OPML cross-check")
    v.set_defaults(func=command_verify)

    a=s.add_parser(
        "audit-db",
        help="Compare an exported Logseq DB Pages EDN view with the migration manifest"
    )
    a.add_argument("edn",help="EDN copied/exported from the Logseq DB Pages view")
    a.add_argument("graph",help="Generated legacy file graph containing _dynalist_import")
    a.add_argument("--output",help="Output TSV path (default: ./logseq-db-page-audit.tsv)")
    a.set_defaults(func=command_audit_db)

    return p

def main():
    print(f"dynalist_migrate.py v{SCRIPT_VERSION}")
    a=parser().parse_args()
    try:return a.func(a)
    except KeyboardInterrupt: eprint("\nInterrupted. Re-run export with --resume."); return 130
    except Exception as exc: eprint("ERROR:",exc); return 1

if __name__ == "__main__": raise SystemExit(main())

Dynalist → Logseq DB Migration Guide

Companion script: dynalist_logseq_migrate.py
Script version: 4.0.0
Guide date: 2026-08-26
Migration goal: High-fidelity Dynalist → Logseq migration with an independently verifiable source archive, a clean legacy/file graph, and a final Logseq DB graph.

This guide documents the complete workflow that was tested and refined during a real migration of:

  • 533 Dynalist documents
  • 33,088 Dynalist API nodes
  • 533 synthetic document-root nodes
  • 32,555 real outline blocks
  • 10 notes
  • 13 checkbox tasks
  • 1 internal Dynalist node link
  • 96 coloured nodes
  • 5 collapsed nodes

The final API ↔ OPML cross-check for that migration was 533/533 documents exact.


1. What this migration does

The recommended route is:

Dynalist
   │
   ├── official Dynalist API export  ← canonical/high-fidelity archive
   │
   └── fresh Dynalist OPML backup    ← independent cross-check
   │
   ▼
dynalist_logseq_migrate.py
   │
   ▼
legacy Logseq Markdown/file graph
   │
   ├── optional visual inspection in Logseq 0.10.15
   │
   └── Logseq File → DB graph importer
   │
   ▼
current Logseq DB graph
   │
   └── optional Pages EDN export → audit-db

The API archive is the canonical migration source. The OPML backup is an independent validation source.

Do not destroy or overwrite the original API archive or OPML backup after a successful export.


2. Supported tag modes

Yes: the script supports users who intentionally used tags in Dynalist.

A. No intentional Dynalist tag system

Use:

--tag-mode neutralize

This is the safest mode for technical archives or archives where #text was ordinary content rather than deliberate classification.

Examples:

#Bitcoin
# compress
&#8217;
https://example.com/page/#respond

are protected so Logseq’s File → DB importer does not manufacture unwanted pages/tags from them.

The visible text is preserved as closely as Markdown allows.

Recommended Logseq DB import options:

Extract inline code snippets as child blocks: OFF
Import all tags:                         OFF
Import specific tags:                   blank
Remove inline tags:                     OFF
Import additional tags from properties: blank
Import tag parents from properties:     blank

B. Intentional Dynalist hashtags/tags

Use:

--tag-mode preserve

This preserves source hashtags so Logseq can import them as DB tags.

Recommended Logseq DB import options:

Extract inline code snippets as child blocks: OFF
Import all tags:                         ON
Remove inline tags:                     OFF
Other tag/property import fields:        blank unless deliberately required

Remove inline tags is a presentation choice:

  • OFF: maximum source-text fidelity; the hashtag remains visible inline.
  • ON: Logseq removes converted inline hashtags from the text and shows DB tags using its DB-tag UI.

Critical Logseq importer behaviour

If hashtags are present but Import all tags is OFF, any hashtags not explicitly selected for tag import are converted into ordinary pages/page references.

That can create hundreds of apparently random pages.

Therefore:

  • archive has real tags → normally use --tag-mode preserve + Import all tags ON
  • archive has no real tags → use --tag-mode neutralize + Import all tags OFF

For a mixed technical archive containing both meaningful tags and many literal # constructs, test carefully. A conservative option is neutralize, then rebuild a cleaner tag taxonomy inside Logseq DB later.


3. Logseq versions used in this workflow

Legacy/file-graph validator

Tested with:

Logseq 0.10.15

This version was used only to visually inspect the generated Markdown graph before conversion to a DB graph.

It is not required if you are confident in the script’s verification and want to go directly to current Logseq’s File → DB importer.

Important Windows 0.10.15 executable-path issue

Do not run the extracted Logseq 0.10.15 application from a directory containing #.

A path such as:

C:\Users\<USER>\Downloads\# installs\Logseq-win-x64-0.10.15

caused Electron to interpret the # as a file-URL fragment and fail with an error similar to:

ERR_FILE_NOT_FOUND
file:///C:/Users/.../#%20installs/.../electron.html

Use a simple path instead, for example:

C:\Apps\Logseq-win-x64-0.10.15

--disable-gpu does not fix this path problem.

Current DB Logseq

The final target is the current DB-centric Logseq 2.x generation.

As of this guide, Logseq’s DB importer includes:

Import
  SQLite
  SQLite + assets (.zip)
  File to DB graph
  Debug Transit
  EDN to DB graph

For this migration select:

File to DB graph

Do not select pages/; select the root of the generated file graph.


4. File-graph storage vs DB-graph storage

These are fundamentally different.

Legacy/file graph

A file graph is an ordinary directory containing files such as:

dynalist-logseq-final/
├── pages/
├── journals/
├── logseq/
└── _dynalist_import/

The graph is the root directory above pages/.

It can live anywhere convenient.

Example Windows location:

C:\Users\<USER>\Documents\dynalist-logseq-final

Example WSL/Linux location:

~/dynalist-logseq-final

An older Logseq installation may also have an existing graph at a location such as:

C:\Users\<USER>\Documents\Logseq

Do not copy the 533 generated Markdown files into an unrelated existing graph unless you explicitly want to merge the graphs.

For migration, keep the generated graph standalone.

Current DB graph

DB graphs are stored differently.

Typical home-directory layout:

~/logseq/graphs/<GRAPH-NAME>/db.sqlite

On Windows this corresponds to approximately:

C:\Users\<USER>\logseq\graphs\<GRAPH-NAME>\db.sqlite

Do not manually place the generated Markdown graph inside this DB storage directory.

Instead:

  1. keep the generated file graph standalone;
  2. open current Logseq;
  3. choose Import → File to DB graph;
  4. select the generated file-graph root;
  5. let Logseq create its own DB graph.

5. WSL + Windows directory examples

A convenient WSL workflow is:

Canonical API archive:
  /home/<USER>/dynalist-api
  or ~/dynalist-api

Generated file graph:
  /home/<USER>/dynalist-logseq-final
  or ~/dynalist-logseq-final

OPML backup:
  /home/<USER>/dynalist-backup-YYYY-MM-DD.zip

To make the generated graph directly available to Windows:

cp -a ~/dynalist-logseq-final /mnt/c/Users/<USER>/Documents/

Result:

C:\Users\<USER>\Documents\dynalist-logseq-final

If replacing an older Windows copy:

rm -rf /mnt/c/Users/<USER>/Documents/dynalist-logseq-final
cp -a ~/dynalist-logseq-final /mnt/c/Users/<USER>/Documents/

Be certain the path is correct before using rm -rf.

The v4 script’s --replace-output option safely handles replacement of the Linux/WSL generated output graph, but it does not automatically replace an arbitrary Windows copy.


6. Windows Zone.Identifier files in WSL

A downloaded file may appear in WSL with a companion name such as:

dynalist_logseq_migrate.py:Zone.Identifier

This is Windows Mark-of-the-Web metadata exposed as an ordinary filename.

It is not Python code and is not part of the migration.

Remove one:

rm -- 'dynalist_logseq_migrate.py:Zone.Identifier'

Remove all such files below the current directory:

find . -type f -name '*:Zone.Identifier' -delete

7. Requirements

The script is deliberately standard-library-only.

You need:

Python 3
Internet access for the initial Dynalist API export
A Dynalist API token/secret
A fresh Dynalist OPML backup for independent verification (strongly recommended)
Current Logseq for the final File → DB conversion

No pip install is required.

Check Python:

python3 --version

Make the script executable:

chmod +x dynalist_logseq_migrate.py

Show built-in help:

./dynalist_logseq_migrate.py --help

8. Obtain two independent Dynalist sources

A. Official API export

Use the official Dynalist API credentials/token.

The safest mode is to let the script prompt interactively so the token is not placed in shell history.

The script also supports:

--token-file
--token-env

if required.

Do not paste private API tokens into chats, notes, migration reports, or source-control repositories.

B. Fresh OPML backup

Download a fresh Dynalist backup/export containing OPML.

Keep the ZIP unchanged.

Example:

dynalist-backup-2026-08-25.zip

The API and OPML sources are deliberately compared independently. This caught several real migration bugs during development.


9. Step 1 — export the Dynalist API archive

Run once:

./dynalist_logseq_migrate.py export ~/dynalist-api

The script prompts for the API token unless another token source is specified.

For a partially completed export:

./dynalist_logseq_migrate.py export ~/dynalist-api --resume

After a successful export, treat:

~/dynalist-api

as immutable source data.

Do not repeatedly re-export simply because you are changing the conversion logic. Rebuild the generated Logseq graph from the same API archive.


10. Step 2 — build the legacy/file graph

Archive with no intentional tags

./dynalist_logseq_migrate.py build \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-2026-08-25.zip \
  --replace-output \
  --tag-mode neutralize

Archive with intentional Dynalist hashtags

./dynalist_logseq_migrate.py build \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-2026-08-25.zip \
  --replace-output \
  --tag-mode preserve

--replace-output removes and recreates the generated output graph. It does not alter the API archive.

Strict OPML validation

When --opml is supplied, OPML differences cause a non-zero exit status by default.

Only use:

--allow-opml-differences

when you deliberately want to inspect/output a graph despite a mismatch.

Do not use that option merely to make an unexplained validation failure disappear.


11. Expected graph output

The generated root contains the legacy Logseq graph plus migration metadata:

dynalist-logseq-final/
├── pages/
├── journals/
├── logseq/
└── _dynalist_import/
    ├── report.txt
    ├── manifest.json
    ├── provenance.json
    ├── verification.json
    └── unresolved-links.json

The visible Logseq pages are intentionally kept clean.

Detailed Dynalist source metadata stays in _dynalist_import/ and the canonical API archive rather than being displayed at the top of every note.


12. What is preserved

The API archive preserves substantially more source information than plain OPML.

The migration accounts for:

  • document IDs
  • node IDs
  • block hierarchy/order
  • document titles
  • node content
  • notes
  • task checkbox state
  • creation/modification metadata available from the API
  • colours
  • collapse state
  • internal Dynalist links
  • folder/document hierarchy in provenance
  • source metadata for audit/recovery

13. Dynalist synthetic root nodes

Dynalist’s doc/read response includes a synthetic node with:

id = "root"

for each document.

This node:

  • contains/represents the document title;
  • is not a genuine user outline block;
  • has the real top-level block IDs as its children.

The converter therefore maps:

533 API documents
533 synthetic roots

to:

533 Logseq pages

without generating an extra root bullet on every page.

This prevents:

  • duplicate document-title blocks;
  • one extra nesting level;
  • collisions caused by every document containing an ID literally named root.

14. Page-title handling

The intended model is:

one Dynalist document = one Logseq page

The Dynalist folder hierarchy is not converted into Logseq namespace pages.

The exact original folder/document path is retained in provenance.

/ and # in titles

Logseq page naming has special/restricted treatment for these characters.

For the displayed Logseq page title the converter substitutes:

/  →  ∕    U+2215 DIVISION SLASH
#  →  #    U+FF03 FULLWIDTH NUMBER SIGN

The original exact title remains available in provenance/API data.

Duplicate document titles

Separate Dynalist documents can have identical titles, while Logseq page identity requires uniqueness.

The converter does not silently merge them.

Duplicates are disambiguated with a deterministic Dynalist-ID suffix such as:

Example title [Dynalist abc12345]

The exact original title remains in provenance.

Long filesystem names

Linux/Windows filesystems have per-component filename limits.

A very long document title previously caused:

OSError: [Errno 36] File name too long

The converter now shortens only the physical Markdown filename using a deterministic hash.

The visible/full page title is retained separately.


15. The page-title quote bug that was fixed

An earlier converter emitted page properties such as:

title:: "Actual Dynalist title"

Legacy Logseq displayed these acceptably, but the DB File → DB importer retained the quote marks literally in page identities.

This produced 533 DB page names like:

"Actual Dynalist title"

The final script no longer JSON-quotes the title property:

title:: Actual Dynalist title

The post-DB audit-db command explicitly checks for recurrence of this error.


16. Markdown # parsing problem that was fixed

Dynalist ordinary text can contain:

# compress/attribs

In Markdown:

# text

means an H1 heading.

An earlier conversion therefore made some ordinary Dynalist lines display as very large headings.

The converter now neutralizes source hashes in neutralize mode.

It also accounts for source fragments that previously generated unexpected DB pages, including examples such as:

#Bitcoin
#include
#8217;t
#38;d=identicon&
#.posts
#[expression]

Some of these were real textual hashtags; others came from:

  • HTML numeric entities such as &#8217;
  • URLs containing #fragment
  • technical/CSS/shell text
  • copied web-feed/XML material
  • source-code-like content

17. Literal [[...]] parsing problem

Logseq interprets:

[[Something]]

as a page reference.

Some Dynalist notes contained literal double-bracket text that was not intended to create Logseq pages.

In neutralize mode these source constructs are escaped so they display literally without manufacturing a page.

Native Logseq references intentionally generated from genuine Dynalist internal links are protected and remain native references.


18. URL hash handling

Blindly escaping every # would damage URLs and technical text.

The final converter treats URLs separately.

Examples:

https://example.com/#section
https://blog.example/post/#respond

remain usable/displayable while the hash does not become an accidental Logseq tag/page.

HTML numeric entities such as:

&#8217;
&#8220;

are also protected from accidental hashtag interpretation.


19. Internal Dynalist links

The API supplies stable node IDs that OPML alone does not reliably preserve.

The converter can map a Dynalist internal node link to a native Logseq block reference using deterministic UUIDs.

It also keeps unresolved-link reporting in:

_dynalist_import/unresolved-links.json

A clean migration should ideally report:

Internal links unresolved: 0

20. Tasks and notes

Dynalist checkboxes map to Logseq task syntax:

unchecked → TODO
checked   → DONE

Dynalist notes are rendered as attached quote/continuation content while the raw note remains in provenance.

Current DB Logseq may create built-in DB entities for concepts such as tasks, quote blocks and tags. Therefore a DB graph can legitimately contain more DB “pages/entities” than the number of source Dynalist documents.

The audit should focus first on:

Expected Dynalist pages present
Missing expected pages
Quoted-title errors

rather than assuming the DB’s total Page count must equal the number of Dynalist documents exactly.


21. Formatting conversion

The migration includes the mappings developed for the archive, including:

Dynalist __italic__     → Markdown *italic*
Dynalist ==highlight==  → Logseq ^^highlight^^

Use:

--literal-formatting

only when you deliberately want to disable/avoid such formatting conversion.


22. OPML comparison bug that was fixed

Python’s XML parser already decodes XML entities in attributes.

An earlier validator then applied html.unescape() a second time.

This created false API ↔ OPML mismatches, including:

literal &nbsp;   incorrectly becoming NBSP
literal &mdash;  incorrectly becoming —
URL &ampshare=   incorrectly becoming &share=

The final comparison does not double-unescape parsed OPML attributes.


23. Dynalist API success-code bug that was fixed

Dynalist API responses use a success marker such as:

_code: "Ok"

An early exporter treated this as a failure because it expected a different success representation.

The final exporter correctly normalizes Dynalist’s API status.


24. Run the independent verifier

After building:

./dynalist_logseq_migrate.py verify \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-2026-08-25.zip

This checks the generated graph against its manifest/API archive and optionally repeats the API ↔ OPML comparison.

Do this before importing to DB if the migration is important.


25. Example known-good validation numbers

One tested archive produced:

Documents:                 533
API nodes:                 33088
Synthetic document roots:  533
Logseq outline blocks:      32555
TODO tasks:                12
DONE tasks:                1
Notes:                     10
Headings:                  0
Colours:                   96
Collapsed blocks:          5
Node links resolved:       1
Document links resolved:   0
Stale doc IDs recovered:   0
Internal links unresolved: 0
Title collisions:          6
Titles with / or # adjusted: 83
Long filenames shortened:  2
Missing child IDs:         0
Recovered unreachable:     0

Independent OPML comparison:

OPML files:               533
Matched documents:        533
Exact/no differences:     533
Matched with differences: 0
Unmatched API documents:  0
Unmatched OPML files:     0
OPML parse failures:      0

Do not expect your own counts to match these; expect your own API/OPML accounting to be internally consistent.


26. Optional visual check with Logseq 0.10.15

If desired, copy the generated graph to Windows:

cp -a ~/dynalist-logseq-final /mnt/c/Users/<USER>/Documents/

Then in Logseq 0.10.15 open:

C:\Users\<USER>\Documents\dynalist-logseq-final

Select the graph root, not:

...\dynalist-logseq-final\pages

A freshly opened standalone graph is indexed automatically.

When to reindex in legacy Logseq

Manual reindexing is generally only useful when:

  • files were changed/replaced underneath an already-open graph;
  • Logseq is showing stale/missing page information.

A brand-new generated graph opened for the first time should not require manual reindexing.

If replacing/deleting a graph that is already registered in Logseq 0.10.15, unlink/remove it from the app first if convenient. Unlinking the graph registration does not delete your canonical Dynalist API archive.


27. Import into current Logseq DB

In current Logseq:

⋯ menu
→ Import
→ File to DB graph

Select:

C:\Users\<USER>\Documents\dynalist-logseq-final

or whichever standalone file-graph root you generated.

Do not select:

...\pages

Do not select:

...\_dynalist_import

Choose a new DB graph name.

Then use the import settings matching the tag mode documented earlier.


28. Why turning all tag-import options OFF can be dangerous

This was a major discovery during testing.

If the source Markdown contains hashtags and Logseq’s:

Import all tags

is OFF, Logseq does not simply ignore those hashtags.

Unselected tags are converted to ordinary pages/page references.

In one test:

533 intended Dynalist pages
147 additional DB pages/entities traced largely to # syntax

Examples included:

Bitcoin
include
.posts
8217;t
38;d=identicon&
[expression]

That is why the converter has explicit neutralize and preserve modes.


29. Audit the final DB import

After importing into DB, open the Pages view.

Clear search/filters.

Export the Pages view as EDN using its view/header export action.

If the EDN is copied to the Windows clipboard, one way to save it is PowerShell:

Get-Clipboard | Set-Content -Encoding UTF8 "$env:USERPROFILE\Desktop\logseq-pages.edn"

Then run:

./dynalist_logseq_migrate.py audit-db \
  ./logseq-pages.edn \
  ~/dynalist-logseq-final

Optional explicit output file:

./dynalist_logseq_migrate.py audit-db \
  ./logseq-pages.edn \
  ~/dynalist-logseq-final \
  --output ./logseq-db-page-audit.tsv

This produces an audit TSV and a source-cause TSV for extra pages/entities.

The most important successful results are:

Expected pages present:  all expected pages
Missing expected pages:  0
Quoted-title errors:      0

Extra DB entities alone are not automatically considered a failure because DB Logseq creates native entities for some built-in concepts.


30. Common errors and their fixes

Symptom Cause Fix
Dynalist API export says _code: "Ok" is failure Early exporter misread Dynalist success value Use v4.0.0
Every page contains a duplicate title/root block Dynalist synthetic root was imported as a real node v4 excludes synthetic roots
Every outline has one extra nesting level Same synthetic-root problem v4 maps root children directly to page top level
OPML reports hundreds of differences involving &nbsp;, &mdash;, URL &amp... OPML XML attributes were double-unescaped v4 fixes OPML normalization
OSError: [Errno 36] File name too long Very long document title used directly as filename v4 shortens physical filename only
Text beginning # becomes a giant heading Markdown interprets it as H1 neutralize mode escapes source hashes
Many random pages such as 8217;t, include, .posts File → DB importer interpreted #... as tags; with tag import off, converted them to pages use neutralize, or preserve + Import all tags ON
Literal [[Source]] becomes a page Logseq page-reference syntax neutralize escapes source [[...]]
533 DB titles appear surrounded by quotes title:: was JSON-quoted fixed in v4
DB Pages count is greater than Dynalist document count Could be accidental tag/ref pages or legitimate DB-native entities run audit-db; do not judge from total count alone
Logseq 0.10.15 gives ERR_FILE_NOT_FOUND on Windows Executable path contains # move Logseq to e.g. C:\Apps\...
*:Zone.Identifier appears in WSL Windows Mark-of-the-Web metadata delete safely with find ... -name '*:Zone.Identifier' -delete
Fresh graph seems to need reindex Usually unnecessary first open indexes automatically; reindex only stale/replaced open graph
File → DB import doesn’t see intended graph structure pages/ selected instead of graph root select root containing pages/, logseq/, _dynalist_import/
Duplicate Dynalist document titles collide Logseq page identity must be unique v4 adds deterministic Dynalist-ID suffix
/ in document title creates namespaces / has special meaning in file-graph page names v4 displays and preserves original title in provenance
# in page title is invalid/special DB/file-graph syntax conflict v4 displays and preserves original title in provenance

31. What not to do

Do not:

  • re-export the API archive every time you alter conversion logic;
  • delete the API archive after importing to Logseq;
  • rely on OPML alone when the API archive is available;
  • merge the generated pages into an unrelated existing Logseq graph during testing;
  • select the generated pages/ directory rather than the graph root;
  • use --allow-opml-differences to silence unexplained mismatches;
  • assume DB Page count must equal Dynalist document count;
  • use --tag-mode preserve and then turn all tag import options off unless you understand that Logseq may convert those hashtags to ordinary pages;
  • manually delete hundreds of unexpected DB pages before identifying which source syntax created them.

32. Recommended end-to-end commands

No intentional tags

chmod +x ./dynalist_logseq_migrate.py

./dynalist_logseq_migrate.py export ~/dynalist-api

./dynalist_logseq_migrate.py build \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-YYYY-MM-DD.zip \
  --replace-output \
  --tag-mode neutralize

./dynalist_logseq_migrate.py verify \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-YYYY-MM-DD.zip

Then import the graph root with current Logseq:

Import → File to DB graph

with all tag fields OFF/blank.

After DB import:

./dynalist_logseq_migrate.py audit-db \
  ./logseq-pages.edn \
  ~/dynalist-logseq-final

Intentional Dynalist tags

chmod +x ./dynalist_logseq_migrate.py

./dynalist_logseq_migrate.py export ~/dynalist-api

./dynalist_logseq_migrate.py build \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-YYYY-MM-DD.zip \
  --replace-output \
  --tag-mode preserve

./dynalist_logseq_migrate.py verify \
  ~/dynalist-api \
  ~/dynalist-logseq-final \
  --opml dynalist-backup-YYYY-MM-DD.zip

Then File → DB import with:

Import all tags: ON

and choose whether Remove inline tags should be OFF or ON according to presentation preference.


33. Useful built-in command help

./dynalist_logseq_migrate.py --help
./dynalist_logseq_migrate.py export --help
./dynalist_logseq_migrate.py build --help
./dynalist_logseq_migrate.py verify --help
./dynalist_logseq_migrate.py audit-db --help

34. Backup recommendations after migration

Keep at least:

1. Original Dynalist OPML backup ZIP
2. Canonical ~/dynalist-api archive
3. Final generated legacy/file graph
4. Final Logseq DB graph backup/export

The API archive and OPML backup are especially valuable because they let you regenerate a future Logseq representation if Logseq’s data model or importer changes.

Do not treat a standard Markdown export from the final DB graph as the only long-term backup of all DB-specific metadata.


35. Reference documentation

Official Dynalist API documentation:

https://apidocs.dynalist.io/

Current Logseq DB-version documentation, including tags and File → DB importer behaviour:

https://github.com/logseq/docs/blob/master/db-version.md

Logseq releases:

https://github.com/logseq/logseq/releases

Because Logseq DB is actively evolving, re-check the DB importer documentation if using this guide substantially later than 2026.


36. Migration design principles

The final tool follows these principles:

  1. Preserve first; transform second.
  2. Keep an immutable canonical source archive.
  3. Use OPML as an independent cross-check, not as the sole source.
  4. Never silently merge duplicate documents.
  5. Keep migration metadata out of visible notes.
  6. Do not invent tags where none existed.
  7. Do preserve real tags when the user deliberately had them.
  8. Neutralize Logseq-specific syntax only when it was ordinary source text.
  9. Protect genuine generated Logseq references from neutralization.
  10. Audit the DB result rather than assuming the importer behaved as expected.
  11. Fail loudly on unexplained validation differences.
  12. Retain enough provenance to rebuild the migration later.
1 Like

I’m not sure I understand—you use the Dynalist API to pull extra detail and the script migrates everything according to the instructions by the “recommended route” in “1. What this migration does”.

Regular Dynalist export and OPML import does not work as there were too many issues.

This is simply the content of your zip, but broken in multiple posts, because the forum doesn’t support big posts.