#!/usr/bin/env python3 """books-sync — заметки, рекапы и диалоги читалки books.ruzzy.dev → Obsidian-vault владельца (папка «Книги/»). Раз в 10 мин (books-sync.timer, юзер astra): GET /api/export с токеном «export:obsidian-rpi» (/etc/books-sync/token); если версия не изменилась — ничего не делаем. Файл на книгу: «Книги/<название>.md», главы — разделами (рекап, заметки, диалоги). Всё выше заголовка «## Мои мысли» перезаписывается из читалки, сам раздел и всё ниже — не трогаем. Синхронизация односторонняя: правки заметок — в читалке. Только stdlib. books-sync синхронизировать (таймер) books-sync --force переписать файлы, даже если версия та же """ import datetime as dt import json import os import re import sys import urllib.request from pathlib import Path from zoneinfo import ZoneInfo URL = os.environ.get("BOOKS_URL", "https://books.ruzzy.dev") TOKEN = Path(os.environ.get("BOOKS_SYNC_TOKEN", "/etc/books-sync/token")) STATE = Path(os.environ.get("BOOKS_SYNC_STATE", "/var/lib/books-sync/state.json")) VAULT = Path(os.environ.get("ASTRA_VAULT", "/home/astra/vault")) FOLDER = "Книги" MINE = "## Мои мысли" QUOTE_MAX = 1500 TZ = ZoneInfo("Asia/Nicosia") KIND = {"note": "📝 Заметка", "mine": "✎ Стикер", "later": "⏳ Разобрать позже"} def fetch(version): req = urllib.request.Request(f"{URL}/api/export?v={version}", headers={"Authorization": "Bearer " + TOKEN.read_text().strip()}) with urllib.request.urlopen(req, timeout=60) as r: return json.load(r) def safe(name, n=90): name = re.split(r"\s+--\s+", name or "")[0] # «Название -- Автор -- 2024 -- …» из имени файла name = re.sub(r'[\\/:*?"<>|#^\[\]\x00-\x1f]', " ", name).strip(" .") return re.sub(r"\s+", " ", name)[:n].rstrip(" .") or "Без названия" def quote(text): text = (text or "").strip() if len(text) > QUOTE_MAX: text = text[:QUOTE_MAX].rstrip() + " …" return "\n".join("> " + l if l.strip() else ">" for l in text.splitlines()) def demote(md): """Заголовки внутри рекапа/ответа ИИ — ниже уровня главы, чтобы не ломать структуру файла.""" return re.sub(r"(?m)^(#{1,4}) ", lambda m: "#" * min(6, len(m.group(1)) + 3) + " ", (md or "").strip()) def day(ts): try: return dt.datetime.fromisoformat(ts).strftime("%d.%m.%Y") except (TypeError, ValueError): return "" def render(site, b, chapters, notes, recaps, dialogs): url = f"{site}/book/{b['id']}" title = re.split(r"\s+--\s+", b["title"])[0] out = ["---", f"книга: {json.dumps(title, ensure_ascii=False)}"] if b.get("author"): out.append(f"автор: {json.dumps(b['author'], ensure_ascii=False)}") out += [f"читалка: {url}", "источник: books-sync — всё выше «Мои мысли» перезаписывается из читалки", "tags: [книга]", "---", "", f"# {title}", ""] out.append(" · ".join(x for x in (b.get("author") or "", f"[Открыть в читалке]({url})") if x)) idxs = sorted({x["chapter_idx"] for x in notes + recaps + dialogs}) for idx in idxs: ch = chapters.get(idx, {"title": f"Глава {idx}"}) out += ["", f"## {ch['title']}", f"[в читалке]({url}/ch/{idx})"] r = [x for x in recaps if x["chapter_idx"] == idx] if r: out += ["", "### Рекап", "", demote(r[0]["md"])] ns = [x for x in notes if x["chapter_idx"] == idx] if ns: out += ["", "### Заметки"] for n in ns: meta = " · ".join(x for x in (KIND.get(n["kind"], n["kind"]), f"с. {n['page']}" if n.get("page") else "", day(n.get("updated") or n.get("created")), "✓ разобрано" if n.get("status") == "done" else "") if x) out += ["", f"**{meta}**"] if n["kind"] != "note" and n.get("quote"): # у «заметки» цитата уже в тексте (как в читалке) out.append(quote(n["quote"])) if (n.get("body") or "").strip(): out += ["", n["body"].strip()] ds = [x for x in dialogs if x["chapter_idx"] == idx] if ds: out += ["", "### Диалоги с ИИ"] for d in ds: head = re.sub(r"\s+", " ", d.get("quote") or "").strip() head = head[:70].rstrip() + ("…" if len(head) > 70 else "") out += ["", f"#### «{head}» · {day(d.get('created'))}", quote(d.get("quote"))] for m in d["msgs"]: who = "**Я:**" if m["role"] == "user" else "**ИИ:**" out += ["", who, demote(m["text"])] return "\n".join(out).rstrip() + "\n" def write_book(path, generated): """Сгенерированная часть + раздел «Мои мысли» из прежнего файла (или пустой).""" mine = f"\n{MINE}\n\n" if path.exists(): old = path.read_text(encoding="utf-8") i = old.find("\n" + MINE) if i >= 0: mine = old[i:] text = generated + mine if path.exists() and path.read_text(encoding="utf-8") == text: return False path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name("." + path.name + ".tmp") tmp.write_text(text, encoding="utf-8") os.replace(tmp, path) return True def main(): force = "--force" in sys.argv state = json.loads(STATE.read_text()) if STATE.exists() else {} data = fetch("" if force else state.get("version", "")) if not data.get("changed"): return chapters = {} for ch in data["chapters"]: chapters.setdefault(ch["book_id"], {})[ch["idx"]] = ch files = state.get("files", {}) changed = [] for b in data["books"]: sel = lambda xs: [x for x in xs if x["book_id"] == b["id"]] notes, recaps, dialogs = sel(data["notes"]), sel(data["recaps"]), sel(data["dialogs"]) if not (notes or recaps or dialogs): continue rel = f"{FOLDER}/{safe(b['title'])}.md" old = files.get(str(b["id"])) if old and old != rel and (VAULT / old).exists() and not (VAULT / rel).exists(): os.replace(VAULT / old, VAULT / rel) # книгу переименовали — файл вместе с «Моими мыслями» if write_book(VAULT / rel, render(data["site"], b, chapters.get(b["id"], {}), notes, recaps, dialogs)): changed.append(rel) files[str(b["id"])] = rel STATE.parent.mkdir(parents=True, exist_ok=True) STATE.write_text(json.dumps({"version": data["version"], "files": files, "synced": dt.datetime.now(TZ).isoformat(timespec="seconds")}, ensure_ascii=False)) for rel in changed: print("обновлено:", rel) if __name__ == "__main__": main()