155 lines
7.4 KiB
Python
Executable File
155 lines
7.4 KiB
Python
Executable File
#!/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()
|