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