#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ============================================================================= # Nazev: evernote_to_joplin_mirror_v1.0.py # Verze: 1.0 # Datum: 2026-06-11 # Autor: Claude (pro Vladimira Buzalku) # Popis: Jednosmerne zrcadleni Evernote -> self-hosted Joplin Server. # Cte poznamky primo z lokalni databaze nastroje evernote-backup # (en_backup.db, pres knihovnu evernote_backup) a zapisuje je do # Joplin Serveru pres jeho sync API (PUT /api/items, X-API-AUTH). # # Evernote notebook -> Joplin folder (stack -> nadrazeny folder) # Evernote note -> Joplin note (telo = ENML prevedeny na HTML) # Evernote resource -> Joplin resource (priloha + binarni blob) # # ID v Joplinu jsou odvozena deterministicky z Evernote GUID # (md5), takze opakovane behy poznamky AKTUALIZUJI, neduplikuji. # # POZOR (v1.0): upsert only. Poznamky/notebooky smazane v Evernote tento # skript zatim v Joplinu NEMAZE (planovano do v1.1). Mirror je # jednosmerny - do Joplinu rucne nepiste, prepise se. # # Pouziti: # # pilotni beh na jednom notebooku: # python evernote_to_joplin_mirror_v1.0.py --notebook "CL2-78989-011" # # vic notebooku + limit poznamek (test): # python evernote_to_joplin_mirror_v1.0.py --notebook "Recepty" --limit 5 # # plne zrcadleni vseho: # python evernote_to_joplin_mirror_v1.0.py --all # # jen vypsat co by se delalo, nic nezapisovat: # python evernote_to_joplin_mirror_v1.0.py --all --dry-run # ============================================================================= import argparse import hashlib import json import re import sys import urllib.request import urllib.error from datetime import datetime, timezone from pathlib import Path # --- konfigurace ------------------------------------------------------------ DB_PATH = Path(__file__).with_name("en_backup.db") JOPLIN_BASE = "https://joplin.buzalka.cz" JOPLIN_EMAIL = "vladimir.buzalka@buzalka.cz" JOPLIN_PASSWORD = "Vlado7309208104++" # heslo = Vlado + RC + ++ (shodne s Postgres) # Joplin item typy T_NOTE = 1 T_FOLDER = 2 T_RESOURCE = 4 IMG_MIMES = ("image/",) # --- pomocne ----------------------------------------------------------------- def jid(prefix: str, *parts: str) -> str: """Deterministicke 32-hex Joplin ID odvozene z Evernote identifikatoru.""" h = hashlib.md5((prefix + ":" + ":".join(parts)).encode("utf-8")) return h.hexdigest() def iso(ms) -> str: """ms timestamp (int) -> Joplin ISO 8601 'YYYY-MM-DDTHH:MM:SS.000Z'.""" if not ms: ms = 0 dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") def now_iso() -> str: return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") # --- prevod ENML -> Joplin HTML telo ---------------------------------------- EN_NOTE_RE = re.compile(r"]*>(.*)", re.DOTALL) EN_MEDIA_RE = re.compile(r"]*?/?>", re.DOTALL) EN_TODO_RE = re.compile(r"]*?)/?>", re.DOTALL) HASH_ATTR_RE = re.compile(r'hash="([0-9a-fA-F]+)"') def enml_to_html(content: str, hash_to_res: dict) -> str: """Vytahne vnitrek a prevede en-media/en-todo na Joplin HTML.""" m = EN_NOTE_RE.search(content or "") body = m.group(1) if m else (content or "") def repl_media(mt): tag = mt.group(0) hm = HASH_ATTR_RE.search(tag) if not hm: return "" res = hash_to_res.get(hm.group(1).lower()) if not res: return "" rid, mime, fname = res if mime.startswith(IMG_MIMES): return f'' label = fname or "priloha" return f'{label}' body = EN_MEDIA_RE.sub(repl_media, body) def repl_todo(tt): checked = "checked" in tt.group(1).lower() and "true" in tt.group(1).lower() return "☑ " if checked else "☐ " body = EN_TODO_RE.sub(repl_todo, body) return body.strip() # --- Joplin sync API klient ------------------------------------------------- class Joplin: def __init__(self, base, email, password, dry_run=False): self.base = base.rstrip("/") self.dry_run = dry_run self.token = None if not dry_run: self._login(email, password) def _login(self, email, password): req = urllib.request.Request( f"{self.base}/api/sessions", data=json.dumps({"email": email, "password": password}).encode(), headers={"Content-Type": "application/json"}, ) r = urllib.request.urlopen(req, timeout=30) self.token = json.loads(r.read().decode())["id"] def put_item(self, path: str, body: bytes): """PUT na /api/items/root:/:/content.""" if self.dry_run: return None url = f"{self.base}/api/items/root:/{path}:/content" req = urllib.request.Request( url, data=body, method="PUT", headers={"X-API-AUTH": self.token, "Content-Type": "application/octet-stream"}, ) r = urllib.request.urlopen(req, timeout=120) return json.loads(r.read().decode()) # --- skladani Joplin sync polozek ------------------------------------------- def folder_item(fid, title, parent_id=""): t = now_iso() return ( f"{title}\n\n" f"id: {fid}\n" f"created_time: {t}\n" f"updated_time: {t}\n" f"user_created_time: {t}\n" f"user_updated_time: {t}\n" f"encryption_cipher_text: \n" f"encryption_applied: 0\n" f"parent_id: {parent_id}\n" f"is_shared: 0\n" f"share_id: \n" f"master_key_id: \n" f"icon: \n" f"user_data: \n" f"deleted_time: 0\n" f"type_: {T_FOLDER}" ).encode("utf-8") def note_item(nid, parent_id, title, body_html, created, updated, author): ct, ut = iso(created), iso(updated) safe_title = (title or "(bez nazvu)").replace("\n", " ") return ( f"{safe_title}\n\n" f"{body_html}\n\n" f"id: {nid}\n" f"parent_id: {parent_id}\n" f"created_time: {ct}\n" f"updated_time: {ut}\n" f"user_created_time: {ct}\n" f"user_updated_time: {ut}\n" f"is_conflict: 0\n" f"latitude: 0.00000000\n" f"longitude: 0.00000000\n" f"altitude: 0.0000\n" f"author: {author or ''}\n" f"source_url: \n" f"is_todo: 0\n" f"todo_due: 0\n" f"todo_completed: 0\n" f"source: evernote-mirror\n" f"source_application: evernote_to_joplin_mirror\n" f"application_data: \n" f"order: 0\n" f"encryption_cipher_text: \n" f"encryption_applied: 0\n" f"markup_language: 2\n" # 2 = HTML f"is_shared: 0\n" f"share_id: \n" f"conflict_original_id: \n" f"master_key_id: \n" f"user_data: \n" f"deleted_time: 0\n" f"type_: {T_NOTE}" ).encode("utf-8") def resource_item(rid, title, mime, filename, size, created, updated): ct, ut = iso(created), iso(updated) ext = "" if filename and "." in filename: ext = filename.rsplit(".", 1)[1].lower() elif "/" in mime: ext = mime.split("/", 1)[1] return ( f"{title}\n\n" f"id: {rid}\n" f"mime: {mime}\n" f"filename: {filename or ''}\n" f"created_time: {ct}\n" f"updated_time: {ut}\n" f"user_created_time: {ct}\n" f"user_updated_time: {ut}\n" f"file_extension: {ext}\n" f"encryption_cipher_text: \n" f"encryption_applied: 0\n" f"encryption_blob_encrypted: 0\n" f"size: {size}\n" f"is_shared: 0\n" f"share_id: \n" f"master_key_id: \n" f"user_data: \n" f"blob_updated_time: {ut}\n" f"ocr_text: \n" f"ocr_details: \n" f"ocr_status: 0\n" f"ocr_error: \n" f"type_: {T_RESOURCE}" ).encode("utf-8") # --- hlavni logika ---------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description="Evernote -> Joplin mirror v1.0") ap.add_argument("--notebook", action="append", default=[], help="nazev notebooku k zrcadleni (lze opakovat)") ap.add_argument("--all", action="store_true", help="zrcadlit vsechny notebooky") ap.add_argument("--limit", type=int, default=0, help="max poznamek na notebook (0 = bez limitu, pro test)") ap.add_argument("--dry-run", action="store_true", help="nic nezapisovat, jen vypsat") ap.add_argument("--db", default=str(DB_PATH), help="cesta k en_backup.db") args = ap.parse_args() if not args.all and not args.notebook: ap.error("zadej --notebook NAZEV nebo --all") from evernote_backup.note_storage import SqliteStorage storage = SqliteStorage(Path(args.db)) jop = Joplin(JOPLIN_BASE, JOPLIN_EMAIL, JOPLIN_PASSWORD, dry_run=args.dry_run) mode = "DRY-RUN" if args.dry_run else "ZAPIS" print(f"[{mode}] Joplin {JOPLIN_BASE} | db {args.db}") notebooks = list(storage.notebooks.iter_notebooks()) if not args.all: wanted = set(args.notebook) notebooks = [nb for nb in notebooks if nb.name in wanted] if not notebooks: print("Zadny odpovidajici notebook nenalezen.", file=sys.stderr) sys.exit(1) # stack -> folder id (vytvarime nadrazene foldery podle stacku) stack_ids = {} n_nb = n_notes = n_res = n_err = 0 for nb in notebooks: parent_id = "" if nb.stack: sid = stack_ids.get(nb.stack) if sid is None: sid = jid("evernote-stack", nb.stack) stack_ids[nb.stack] = sid try: jop.put_item(f"{sid}.md", folder_item(sid, nb.stack)) except Exception as e: print(f" ! stack '{nb.stack}': {e}", file=sys.stderr) parent_id = sid fid = jid("evernote-notebook", nb.guid) try: jop.put_item(f"{fid}.md", folder_item(fid, nb.name, parent_id)) n_nb += 1 except Exception as e: print(f" ! notebook '{nb.name}': {e}", file=sys.stderr) continue print(f"[notebook] {nb.name}") count = 0 for note in storage.notes.iter_notes(nb.guid): if args.limit and count >= args.limit: break count += 1 # priprav resources + mapu hash->resid hash_to_res = {} for res in (note.resources or []): if not (res.data and res.data.body): continue bh = res.data.bodyHash hexhash = bh.hex() if isinstance(bh, (bytes, bytearray)) else str(bh) fname = res.attributes.fileName if res.attributes else None rid = jid("evernote-resource", note.guid, hexhash) hash_to_res[hexhash.lower()] = (rid, res.mime or "application/octet-stream", fname) try: jop.put_item(f"{rid}.md", resource_item( rid, fname or "priloha", res.mime or "application/octet-stream", fname, len(res.data.body), note.created, note.updated)) jop.put_item(f".resource/{rid}", res.data.body) n_res += 1 except Exception as e: n_err += 1 print(f" ! resource {fname}: {e}", file=sys.stderr) body = enml_to_html(note.content, hash_to_res) nid = jid("evernote-note", note.guid) author = note.attributes.author if note.attributes else None try: jop.put_item(f"{nid}.md", note_item( nid, fid, note.title, body, note.created, note.updated, author)) n_notes += 1 except Exception as e: n_err += 1 print(f" ! note '{note.title}': {e}", file=sys.stderr) print(f" ({count} poznamek)") print(f"\nHOTOVO: notebooku={n_nb} poznamek={n_notes} priloh={n_res} chyb={n_err}") if not args.dry_run: print("V Joplin klientovi spust synchronizaci, aby se polozky stahly.") if __name__ == "__main__": main()