Andrey Epifancev bc69d5087a wiki: синхронизация — пачки страниц, копия, поиск (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:22:15 +04:00

390 lines
19 KiB
Python
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.

"""Синхронизация вики: сборка Markdown-копии из стадии, подмена каталога, строки wiki_page/вложений/FTS.
Порядок отчёта `done` (design 10): `prepare` вызывается до транзакции отчёта (CommandHooks.before_result) — готовит
каталог `wiki/.<метка>.new/` и выделяет id вложений; `commit` (в on_result) пишет строки и последним шагом подменяет
каталог. Перевод HTML → Markdown уже сделан при приёме пачек (stage.accept)."""
import json
import logging
import re
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from ...core import db
from ...core.paths import secure_dir
from . import convert, paths, search
log = logging.getLogger(__name__)
_BAD_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
NAME_CHARS = 80 # длина названия, символов (как safe_name в eva_wiki_export.py)
NAME_BYTES = 200 # и байт UTF-8: у файловой системы предел имени 255 байт, «NN_» и «_k» помещаются
class SyncFailed(Exception):
"""Синхронизация не удалась: текст идёт в error команды и sync_error проекта."""
@dataclass
class Prepared:
cmd_id: int
label: str
root: str
discarded: str | None = None
error: str | None = None
rows: list = field(default_factory=list)
attachments: list = field(default_factory=list) # (page_code, url, name)
kept_ids: set = field(default_factory=set)
new_ids: list = field(default_factory=list) # id вложений, добавленных подготовкой
new_dir: Path | None = None
root_title: str | None = None
_PREPARED: dict[int, Prepared] = {}
_lock = threading.Lock()
# --- раскладка копии ----------------------------------------------------------
def safe_name(title: str) -> str:
"""Название каталога: запрещённые символы → «_», до 80 символов и 200 байт, «.» и пробелы по краям убраны."""
name = _BAD_CHARS.sub("_", title or "")[:NAME_CHARS]
name = name.encode("utf-8")[:NAME_BYTES].decode("utf-8", "ignore").strip(" .")
return name or "untitled"
def _sorted(codes, by_code):
return sorted(codes, key=lambda c: (by_code[c]["orderno"] is None, by_code[c]["orderno"] or 0, c))
def _tree(order, by_code) -> dict[str, list[str]]:
"""Потомки по parent_code; страница без родителя в наборе (или в цикле) становится дочерней корню."""
root = order[0]
children = {c: [] for c in order}
parent = {}
for c in order[1:]:
p = by_code[c].get("parent_code")
parent[c] = p if p in children and p != c else root
children[parent[c]].append(c)
def reach():
seen, stack = set(), [root]
while stack:
c = stack.pop()
if c not in seen:
seen.add(c)
stack.extend(children[c])
return seen
seen = reach()
for c in order[1:]:
if c not in seen: # цикл родителей: оторванное звено цепляем к корню
children[parent[c]].remove(c)
parent[c] = root
children[root].append(c)
seen = reach()
return children
def _inside(target: Path, path: Path) -> None:
if not path.resolve().is_relative_to(target.resolve()):
raise ValueError(f"путь {path} вне каталога копии")
def build_copy(stage_pages: list[dict], codes: list[str], target: Path, ids: dict[tuple[str, str], int]) -> list[dict]:
"""Собрать копию дерева в `target`: index.md страниц и _tree.json. Корень — codes[0]. Возвращает строки wiki_page
(с полем markdown — текст с подставленными id вложений). ValueError — страница или путь негодны."""
by_code = {p["code"]: p for p in stage_pages}
order = list(dict.fromkeys(codes))
if not order:
raise ValueError("пустой список страниц")
for c in order:
if c not in by_code:
raise ValueError(f"нет страницы {c}")
children = _tree(order, by_code)
secure_dir(target)
synced_at = db.now()
rows, tree = [], []
stack = [(order[0], Path(), 0, 0)]
used: dict[str, set[str]] = {} # родитель → занятые имена (без регистра)
while stack:
code, parent_rel, depth, pos = stack.pop()
page = by_code[code]
taken = used.setdefault(str(parent_rel), set())
name, k = safe_name(page["title"]), 1
base = name
while name.lower() in taken:
k += 1
name = f"{base}_{k}"
taken.add(name.lower())
rel = parent_rel / (name if depth == 0 else f"{pos:02d}_{name}")
full = target / rel
_inside(target, full)
try:
text = convert.fill_ids(page["markdown"], [ids[(code, a["url"])] for a in page["attachments"]])
except (ValueError, KeyError) as e:
raise ValueError(f"{code}: {e}") from None
secure_dir(full)
title_line = " ".join(str(page["title"]).split())
head = ["---", f"code: {json.dumps(code, ensure_ascii=False)}", f"title: {json.dumps(page['title'], ensure_ascii=False)}",
f"url: {json.dumps(page.get('url'), ensure_ascii=False)}", f"synced_at: {json.dumps(synced_at)}", "---", "",
f"# {title_line}", ""]
(full / "index.md").write_text("\n".join(head) + "\n" + text, encoding="utf-8")
rows.append({"code": code, "eva_id": page.get("eva_id"), "title": page["title"], "parent_code": page.get("parent_code"),
"orderno": page.get("orderno"), "depth": depth, "path": rel.as_posix(), "url": page.get("url"),
"size": len(text), "synced_at": synced_at, "markdown": text})
tree.append({"code": code, "title": page["title"], "depth": depth, "path": rel.as_posix()})
kids = _sorted(children[code], by_code)
for i, kid in reversed(list(enumerate(kids, 1))): # стек: первый ребёнок — следующим
stack.append((kid, rel, depth + 1, i))
(target / "_tree.json").write_text(json.dumps(tree, ensure_ascii=False, indent=1), encoding="utf-8")
return rows
# --- подготовка (до транзакции отчёта) ---------------------------------------
def _siblings(conn, label: str) -> tuple[Path, Path, Path]:
cur = paths.project_dir(conn, label)
return cur, cur.with_name(f".{cur.name}.new"), cur.with_name(f".{cur.name}.old")
def _allocate_ids(conn, label, pages) -> tuple[dict, list, list]:
"""id вложений страниц: существующие по (страница, url) сохраняются, новые вставляются короткой транзакцией."""
ids, new_ids, attach = {}, [], []
existing = {(r["page_code"], r["url"]): r["id"] for r in conn.execute(
"SELECT id, page_code, url FROM wiki_attachment WHERE label = ?", (label,))}
with db.transaction(conn):
for page in pages:
for a in page["attachments"]:
key = (page["code"], a["url"])
if key not in existing:
cur = conn.execute("INSERT INTO wiki_attachment(label, page_code, url, name) VALUES (?, ?, ?, ?)",
(label, key[0], key[1], a["name"]))
existing[key] = cur.lastrowid
new_ids.append(cur.lastrowid)
ids[key] = existing[key]
attach.append((page["code"], a["url"], a["name"]))
return ids, new_ids, attach
def _abandon(conn, p: Prepared) -> None:
"""Подготовка не пригодилась: каталог .new и добавленные ею вложения убираются."""
if p.new_dir is not None:
shutil.rmtree(p.new_dir, ignore_errors=True)
if p.new_ids:
marks = ",".join("?" * len(p.new_ids))
conn.execute(f"DELETE FROM wiki_attachment WHERE id IN ({marks}) AND state = 'none'", p.new_ids)
p.new_ids = []
def prepare(conn, cmd: dict, result: dict | None) -> Prepared:
"""До транзакции: проверить корень и список кодов, выделить id вложений, собрать `.new`. Ошибка — в p.error."""
label, root = cmd["params"]["label"], cmd["params"]["root"]
p = Prepared(cmd["id"], label, root)
row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (label,)).fetchone()
if row is None or row["root_code"] != root:
p.discarded = "корень сменился"
return p
try:
codes = (result or {}).get("codes")
if not isinstance(codes, list) or not codes or not all(isinstance(c, str) for c in codes):
raise SyncFailed("в отчёте нет списка codes")
codes = list(dict.fromkeys(codes))
if codes[0] != root:
raise SyncFailed(f"первой в codes должна быть страница корня {root}")
stage = {r["code"]: json.loads(r["payload"]) for r in conn.execute(
"SELECT code, payload FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],))}
for c in codes:
if c not in stage:
raise SyncFailed(f"нет страницы {c}")
pages = [stage[c] for c in codes]
_, p.new_dir, _ = _siblings(conn, label)
secure_dir(p.new_dir.parent)
shutil.rmtree(p.new_dir, ignore_errors=True)
ids, p.new_ids, p.attachments = _allocate_ids(conn, label, pages)
p.kept_ids = set(ids.values())
p.rows = build_copy(pages, codes, p.new_dir, ids)
title = (result or {}).get("root_title")
p.root_title = title if isinstance(title, str) and title.strip() else p.rows[0]["title"]
except SyncFailed as e:
p.error = str(e)
except ValueError as e: # fill_ids, путь вне копии
p.error = str(e)
except OSError as e:
p.error = f"не удалось собрать копию: {e}"
except Exception as e: # noqa: BLE001 сбой не должен оставлять команду без отчёта
log.exception("wiki_sync %s: сборка копии", cmd["id"])
p.error = f"внутренняя ошибка сборки копии: {type(e).__name__}: {e}"
if p.error:
_abandon(conn, p)
return p
def before_result(conn, cmd, status, result, files_dir) -> None:
if cmd["type"] != "wiki_sync" or status != "done":
return
p = prepare(conn, cmd, result)
with _lock:
_PREPARED[cmd["id"]] = p
def _prepared(conn, cmd, result) -> Prepared:
with _lock:
p = _PREPARED.get(cmd["id"])
return p if p is not None else prepare(conn, cmd, result) # в обход before_result: в транзакции, но верно
def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]:
if cmd["type"] != "wiki_sync" or status != "done":
return status, None
p = _prepared(conn, cmd, result)
with _lock:
_PREPARED[cmd["id"]] = p
if p.discarded:
if isinstance(result, dict):
result["discarded"] = p.discarded
return "done", None
if p.error:
return "failed", p.error
return "done", None
# --- применение (в транзакции отчёта) ----------------------------------------
def _swap(cur: Path, new: Path, old: Path) -> None:
"""wiki/<метка> → .old, .new → <метка>, .old удаляется. При сбое прежний каталог возвращается на место."""
shutil.rmtree(old, ignore_errors=True)
had = cur.exists()
try:
if had:
cur.rename(old)
new.rename(cur)
except OSError:
if had and not cur.exists() and old.exists():
old.rename(cur)
raise
shutil.rmtree(old, ignore_errors=True)
def _fail(conn, cmd, error) -> None:
"""Команда уже записана как done, но копия не применилась: failed + sync_error."""
conn.execute("UPDATE command SET status = 'failed', error = ? WHERE id = ?", (error[:1500], cmd["id"]))
on_failed_or_reported_failed(conn, cmd, error)
def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) -> None:
if cmd["type"] != "wiki_sync":
return
with _lock:
p = _PREPARED.pop(cmd["id"], None)
if status != "done":
error = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone()["error"]
on_failed_or_reported_failed(conn, cmd, error or "синхронизация не удалась")
return
if p is None or p.error:
error = p.error if p else "копия не подготовлена"
on_failed_or_reported_failed(conn, cmd, error)
return
row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (p.label,)).fetchone()
if p.discarded or row is None or row["root_code"] != p.root: # корень сменился, пока шла подготовка
_abandon(conn, p)
if not p.discarded:
out = dict(result or {}, discarded="корень сменился")
conn.execute("UPDATE command SET result = ? WHERE id = ?", (json.dumps(out, ensure_ascii=False), cmd["id"]))
conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],))
conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (now, p.label))
return
try:
with db.transaction(conn):
_commit(conn, p, now)
except OSError as e:
_abandon(conn, p)
_fail(conn, cmd, f"не удалось подменить копию: {e}")
def _commit(conn, p: Prepared, now: str) -> None:
search.replace(conn, p.label, [(r["code"], r["title"], r["markdown"]) for r in p.rows])
conn.execute("DELETE FROM wiki_page WHERE label = ?", (p.label,))
conn.executemany(
"INSERT INTO wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[(p.label, r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"],
r["size"], r["synced_at"]) for r in p.rows])
conn.executemany("UPDATE wiki_attachment SET name = ? WHERE label = ? AND page_code = ? AND url = ?",
[(name, p.label, code, url) for code, url, name in p.attachments])
for r in conn.execute("SELECT id FROM wiki_attachment WHERE label = ?", (p.label,)).fetchall():
if r["id"] not in p.kept_ids:
conn.execute("DELETE FROM wiki_attachment WHERE id = ?", (r["id"],))
conn.execute("UPDATE wiki_project SET synced_at = ?, pages_count = ?, root_title = ?, sync_error = NULL, failed_at = NULL "
"WHERE label = ?", (now, len(p.rows), p.root_title, p.label))
conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (p.cmd_id,))
cur, new, old = _siblings(conn, p.label)
_swap(cur, new, old) # последним: сбой откатывает строки (savepoint)
def on_failed_or_reported_failed(conn, cmd: dict, error: str | None) -> None:
"""Отчёт failed или предел выдач: sync_error и failed_at проекта (если корень команды ещё текущий), стадия очищена."""
if cmd["type"] != "wiki_sync":
return
conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],))
conn.execute("UPDATE wiki_project SET sync_error = ?, failed_at = ? WHERE label = ? AND root_code = ?",
((error or "")[:1500], db.now(), cmd["params"]["label"], cmd["params"]["root"]))
def on_failed(conn, cmd, now) -> None:
"""Предел выдач: ошибка уже записана в команду."""
row = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone()
on_failed_or_reported_failed(conn, cmd, row["error"] if row else None)
def on_claim(conn, cmd, now, redelivery) -> None:
"""Повторная выдача: пачки прошлой выдачи не нужны, агент пришлёт заново."""
if cmd["type"] == "wiki_sync" and redelivery:
conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],))
def after_result(conn, cmd, status) -> None:
with _lock:
_PREPARED.pop(cmd["id"], None)
# --- уборка ----------------------------------------------------------------
def finish_pending_swaps(root: Path) -> None:
"""При старте: `.<имя>.new` без каталога `<имя>` — переименовать (подмена прервана); `.new` рядом с `<имя>` —
остаток неудачной сборки, удаляется; `.<имя>.old` — удаляется. Каталоги, которые собирает идущая подготовка, не трогаются."""
root = Path(root)
if not root.is_dir():
return
with _lock:
busy = {p.new_dir for p in _PREPARED.values() if p.new_dir}
for entry in sorted(root.iterdir()):
n = entry.name
if not n.startswith(".") or entry in busy:
continue
try:
if n.endswith(".new"):
target = root / n[1:-4]
if target.exists():
shutil.rmtree(entry, ignore_errors=True)
else:
entry.rename(target)
elif n.endswith(".old"):
shutil.rmtree(entry)
except OSError as e:
log.warning("вики: не удалось довести подмену %s: %s", entry, e)
def housekeeping(conn) -> None:
"""Стадия команд, которые уже не в работе (отмена, терминальный статус), очищается; отменённая синхронизация
сдвигает failed_at проекта, чтобы автосинхронизация не ставила пакеты каждый тик."""
conn.execute("DELETE FROM wiki_sync_stage WHERE command_id NOT IN "
"(SELECT id FROM command WHERE status IN ('queued', 'claimed'))")
for r in conn.execute("SELECT label, synced_at, failed_at FROM wiki_project").fetchall():
last = max((t for t in (r["synced_at"], r["failed_at"]) if t), default="")
c = conn.execute("SELECT MAX(finished_at) AS t FROM command WHERE type = 'wiki_sync' AND status = 'cancelled' "
"AND json_extract(params, '$.label') = ?", (r["label"],)).fetchone()["t"]
if c and c > last:
conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (c, r["label"]))