Files
infra/stacks/rpi-books-sync/books-sync
T

155 lines
7.4 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()