d2e8a70bfe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
348 lines
12 KiB
Python
348 lines
12 KiB
Python
#!/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"<en-note[^>]*>(.*)</en-note>", re.DOTALL)
|
|
EN_MEDIA_RE = re.compile(r"<en-media\b[^>]*?/?>", re.DOTALL)
|
|
EN_TODO_RE = re.compile(r"<en-todo\b([^>]*?)/?>", 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 <en-note> 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'<img src=":/{rid}"/>'
|
|
label = fname or "priloha"
|
|
return f'<a href=":/{rid}">{label}</a>'
|
|
|
|
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:/<path>:/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()
|