backlog-digest 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """backlog-digest: розбір завалів збережених відео, статей, книг і постів."""
2
+
3
+ __version__ = "1.0.0"
4
+ APP_NAME = "backlog-digest"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,201 @@
1
+ """Автозапуск розкладу системним планувальником: щогодини й після входу в систему — `run-jobs`.
2
+
3
+ Windows — Планувальник завдань (без вікна: pythonw); macOS — launchd (LaunchAgent користувача);
4
+ Linux — таймер systemd користувача, а якщо systemd немає — cron. Права адміністратора не потрібні.
5
+ Що й коли робити, вирішує вже сама програма (schedule.py), тож системне завдання однакове для всіх.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from .paths import HOME_ENV
16
+
17
+ NAME = "backlog-digest"
18
+ LABEL = "io.github.backlog-digest"
19
+ CRON_MARK = "# backlog-digest run-jobs"
20
+ TIMEOUT = 60
21
+
22
+
23
+ class AutostartError(Exception):
24
+ pass
25
+
26
+
27
+ def command() -> list[str]:
28
+ """Чим запускати: той самий Python, що й зараз (venv чи uv tool), без вікна на Windows."""
29
+ exe = Path(sys.executable)
30
+ if sys.platform == "win32" and (exe.parent / "pythonw.exe").exists():
31
+ exe = exe.parent / "pythonw.exe"
32
+ cmd = [str(exe), "-m", "backlog_digest"]
33
+ home = os.environ.get(HOME_ENV)
34
+ if home:
35
+ cmd += ["--home", home]
36
+ return [*cmd, "run-jobs"]
37
+
38
+
39
+ def _run(args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess[str]:
40
+ try:
41
+ return subprocess.run(args, input=stdin, capture_output=True, text=True, encoding="utf-8", errors="replace",
42
+ timeout=TIMEOUT)
43
+ except (OSError, subprocess.SubprocessError) as e:
44
+ raise AutostartError(str(e)) from e
45
+
46
+
47
+ def _check(r: subprocess.CompletedProcess[str]) -> None:
48
+ if r.returncode != 0:
49
+ raise AutostartError((r.stderr or r.stdout).strip()[:500] or f"exit {r.returncode}")
50
+
51
+
52
+ # ---------------------------------------------------------------- Windows
53
+
54
+ def _ps(text: str) -> str:
55
+ return "'" + text.replace("'", "''") + "'"
56
+
57
+
58
+ def windows_script(cmd: list[str]) -> str:
59
+ args = subprocess.list2cmdline(cmd[1:])
60
+ return f"""
61
+ $action = New-ScheduledTaskAction -Execute {_ps(cmd[0])} -Argument {_ps(args)}
62
+ $hourly = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Hours 1)
63
+ $logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
64
+ $logon.Delay = 'PT5M'
65
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfNetworkAvailable `
66
+ -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew `
67
+ -ExecutionTimeLimit (New-TimeSpan -Hours 4)
68
+ Register-ScheduledTask -TaskName {_ps(NAME)} -Action $action -Trigger $hourly,$logon -Settings $settings `
69
+ -Description 'backlog-digest: run-jobs' -Force | Out-Null
70
+ """
71
+
72
+
73
+ # ---------------------------------------------------------------- macOS
74
+
75
+ def plist_path() -> Path:
76
+ return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
77
+
78
+
79
+ def plist(cmd: list[str], log: Path) -> str:
80
+ def esc(s: str) -> str:
81
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
82
+
83
+ args = "\n".join(f" <string>{esc(a)}</string>" for a in cmd)
84
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
85
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
86
+ <plist version="1.0">
87
+ <dict>
88
+ <key>Label</key>
89
+ <string>{LABEL}</string>
90
+ <key>ProgramArguments</key>
91
+ <array>
92
+ {args}
93
+ </array>
94
+ <key>StartInterval</key>
95
+ <integer>3600</integer>
96
+ <key>RunAtLoad</key>
97
+ <true/>
98
+ <key>StandardOutPath</key>
99
+ <string>{esc(str(log))}</string>
100
+ <key>StandardErrorPath</key>
101
+ <string>{esc(str(log))}</string>
102
+ </dict>
103
+ </plist>
104
+ """
105
+
106
+
107
+ # ---------------------------------------------------------------- Linux
108
+
109
+ def systemd_dir() -> Path:
110
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
111
+
112
+
113
+ def systemd_units(cmd: list[str]) -> dict[str, str]:
114
+ exec_line = " ".join(f'"{a}"' if " " in a else a for a in cmd)
115
+ return {
116
+ f"{NAME}.service": f"[Unit]\nDescription=backlog-digest: run-jobs\n\n[Service]\nType=oneshot\n"
117
+ f"ExecStart={exec_line}\n",
118
+ f"{NAME}.timer": "[Unit]\nDescription=backlog-digest hourly\n\n[Timer]\nOnBootSec=5min\n"
119
+ "OnUnitActiveSec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
120
+ }
121
+
122
+
123
+ def cron_line(cmd: list[str]) -> str:
124
+ return "0 * * * * " + " ".join(f"'{a}'" if " " in a else a for a in cmd) + f" >/dev/null 2>&1 {CRON_MARK}"
125
+
126
+
127
+ def _crontab() -> str:
128
+ r = _run(["crontab", "-l"])
129
+ return r.stdout if r.returncode == 0 else "" # «no crontab for user» — порожній
130
+
131
+
132
+ def _has_systemd() -> bool:
133
+ return bool(shutil.which("systemctl")) and _run(["systemctl", "--user", "show-environment"]).returncode == 0
134
+
135
+
136
+ # ---------------------------------------------------------------- спільне
137
+
138
+ def installed() -> bool:
139
+ try:
140
+ if sys.platform == "win32":
141
+ return _run(["schtasks", "/Query", "/TN", NAME]).returncode == 0
142
+ if sys.platform == "darwin":
143
+ return plist_path().exists()
144
+ return (systemd_dir() / f"{NAME}.timer").exists() or (bool(shutil.which("crontab"))
145
+ and CRON_MARK in _crontab())
146
+ except AutostartError:
147
+ return False
148
+
149
+
150
+ def install(log: Path) -> str:
151
+ """Ставить (або оновлює) системне завдання. Повертає, куди саме: для повідомлення користувачу."""
152
+ cmd = command()
153
+ if sys.platform == "win32":
154
+ _check(_run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windows_script(cmd)]))
155
+ return f"Task Scheduler: {NAME}"
156
+ if sys.platform == "darwin":
157
+ path = plist_path()
158
+ path.parent.mkdir(parents=True, exist_ok=True)
159
+ log.parent.mkdir(parents=True, exist_ok=True)
160
+ _run(["launchctl", "unload", str(path)]) # старе — прибрати; помилку «не було» ігноруємо
161
+ path.write_text(plist(cmd, log), encoding="utf-8")
162
+ _check(_run(["launchctl", "load", "-w", str(path)]))
163
+ return str(path)
164
+ if _has_systemd():
165
+ folder = systemd_dir()
166
+ folder.mkdir(parents=True, exist_ok=True)
167
+ for name, text in systemd_units(cmd).items():
168
+ (folder / name).write_text(text, encoding="utf-8")
169
+ _check(_run(["systemctl", "--user", "daemon-reload"]))
170
+ _check(_run(["systemctl", "--user", "enable", "--now", f"{NAME}.timer"]))
171
+ return str(folder / f"{NAME}.timer")
172
+ if not shutil.which("crontab"):
173
+ raise AutostartError("systemd / cron")
174
+ lines = [ln for ln in _crontab().splitlines() if CRON_MARK not in ln]
175
+ _check(_run(["crontab", "-"], "\n".join([*lines, cron_line(cmd)]) + "\n"))
176
+ return "crontab"
177
+
178
+
179
+ def remove() -> None:
180
+ if sys.platform == "win32":
181
+ r = _run(["schtasks", "/Delete", "/TN", NAME, "/F"])
182
+ if r.returncode != 0 and installed():
183
+ _check(r)
184
+ return
185
+ if sys.platform == "darwin":
186
+ path = plist_path()
187
+ if path.exists():
188
+ _run(["launchctl", "unload", "-w", str(path)])
189
+ path.unlink()
190
+ return
191
+ folder = systemd_dir()
192
+ if (folder / f"{NAME}.timer").exists():
193
+ _run(["systemctl", "--user", "disable", "--now", f"{NAME}.timer"])
194
+ for name in systemd_units([]):
195
+ (folder / name).unlink(missing_ok=True)
196
+ _run(["systemctl", "--user", "daemon-reload"])
197
+ if shutil.which("crontab"):
198
+ text = _crontab()
199
+ if CRON_MARK in text:
200
+ lines = [ln for ln in text.splitlines() if CRON_MARK not in ln]
201
+ _check(_run(["crontab", "-"], "\n".join(lines) + "\n" if lines else ""))
backlog_digest/cli.py ADDED
@@ -0,0 +1,256 @@
1
+ """Точка входу: backlog-digest / bdig [--lang uk|en|fr|es] [--home ТЕКА]
2
+ [menu | setup | doctor | add | sync | process | fetch | summarize | export | shortcut | run-jobs | autostart]."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import __version__, config, db, doctor, i18n
12
+ from .i18n import t
13
+ from .paths import HOME_ENV, get_paths
14
+
15
+ # команда → крок обробки: process — обидва кроки, fetch — лише тексти, summarize — лише конспекти
16
+ STAGES = {"process": db.ALL, "fetch": db.FETCH, "summarize": db.SUMMARIZE}
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ _setup_console()
21
+ argv = sys.argv[1:] if argv is None else argv
22
+ home = _option_from_argv(argv, "--home")
23
+ if home: # так розклад передає теку програми: у системних завданнях змінні середовища не задати
24
+ os.environ[HOME_ENV] = home
25
+ paths = get_paths()
26
+
27
+ # Мову треба знати ще до розбору аргументів: від неї залежать тексти довідки.
28
+ cfg: dict[str, Any] | None
29
+ try:
30
+ cfg = config.load(paths)
31
+ cfg_error = None
32
+ except config.ConfigError as e:
33
+ cfg, cfg_error = None, e
34
+ i18n.set_language(_lang_from_argv(argv) or (cfg["general"]["ui_language"] if cfg else i18n.detect_language()))
35
+
36
+ args = build_parser().parse_args(argv)
37
+ if args.lang and cfg:
38
+ cfg["general"]["ui_language"] = args.lang # лише на цей запуск, у файл не записуємо
39
+
40
+ if args.command == "doctor":
41
+ return doctor.run(paths, network=not args.offline)
42
+ if args.command == "shortcut":
43
+ from .menu import make_shortcut
44
+
45
+ return 0 if make_shortcut(paths) else 1
46
+
47
+ if cfg_error or cfg is None:
48
+ print(t("cli.config_error", error=cfg_error.message() if cfg_error else ""), file=sys.stderr)
49
+ print(t("cli.run_doctor"), file=sys.stderr)
50
+ return 2
51
+ if config.ensure(paths):
52
+ print(t("cli.config_created", path=str(paths.config_file)))
53
+ cfg = config.load(paths)
54
+ if args.lang:
55
+ cfg["general"]["ui_language"] = args.lang
56
+
57
+ try:
58
+ conn = db.connect(paths.db_file)
59
+ except db.DbError as e:
60
+ print(t("cli.db_error", error=str(e)), file=sys.stderr)
61
+ return 2
62
+ try:
63
+ return run_command(args, paths, cfg, conn)
64
+ except KeyboardInterrupt:
65
+ print(f"\n{t('menu.interrupted')}")
66
+ return 130
67
+ finally:
68
+ conn.close()
69
+
70
+
71
+ def run_command(args: argparse.Namespace, paths: Any, cfg: dict[str, Any], conn: Any) -> int:
72
+ from . import pipeline, web
73
+ from .menu import Menu
74
+
75
+ if args.command == "add" and args.urls[0].lower() == "raindrop":
76
+ return _add_raindrop(conn, paths, args)
77
+ if args.command == "add":
78
+ # тека на диску — джерело «папка»; посилання не на YouTube або файл закладок — «статті»;
79
+ # інакше — посилання YouTube
80
+ local = Path(args.urls[0]).expanduser()
81
+ is_folder = len(args.urls) == 1 and local.is_absolute() and local.is_dir()
82
+ is_web = not is_folder and any((web.is_url(a) and not web.is_youtube(a)) or Path(a).expanduser().is_file()
83
+ for a in args.urls)
84
+ name = args.name or (local.name if is_folder else t("sources.web_default_name") if is_web else "YouTube")
85
+ if any(s["name"] == name for s in db.list_sources(conn)):
86
+ print(t("sources.name_taken", name=name), file=sys.stderr)
87
+ return 2
88
+ if is_folder:
89
+ sid = pipeline.add_folder_source(conn, name, str(local), args.folder or name)
90
+ print(t("sources.added_folder", name=name))
91
+ elif is_web:
92
+ urls = [a for a in args.urls if web.is_url(a)]
93
+ files = [str(Path(a).expanduser().resolve()) for a in args.urls if not web.is_url(a)]
94
+ sid = pipeline.add_web_source(conn, name, urls, files, args.folder or name, args.only)
95
+ print(t("sources.added_web", name=name))
96
+ else:
97
+ sid = pipeline.add_youtube_source(conn, name, args.urls, args.folder or name)
98
+ print(t("sources.added", name=name))
99
+ source = db.get_source(conn, sid)
100
+ if source and not args.no_sync:
101
+ print(t("sources.synced", new=pipeline.sync_source(conn, source)[0]))
102
+ return 0
103
+ if args.command == "sync":
104
+ print(t("sources.synced", new=pipeline.sync_all(conn)))
105
+ return 0
106
+ if args.command == "export":
107
+ return _export(conn, paths, cfg, args)
108
+ if args.command == "run-jobs":
109
+ from . import schedule
110
+
111
+ schedule.run_due(conn, cfg, paths, force=args.force, only=args.job or None)
112
+ return 0
113
+ if args.command == "autostart":
114
+ return _autostart(paths, args.action)
115
+ if args.command == "setup":
116
+ from . import wizard
117
+
118
+ wizard.run(Menu(paths, cfg, conn))
119
+ return 0
120
+ if args.command in STAGES:
121
+ limit = args.limit or cfg["limits"]["per_run"]
122
+ stats = pipeline.Processor(conn, cfg, paths).run(limit, args.source, STAGES[args.command])
123
+ return 3 if stats.stopped else 0
124
+ Menu(paths, cfg, conn).run(offer_wizard=True)
125
+ return 0
126
+
127
+
128
+ def _add_raindrop(conn: Any, paths: Any, args: argparse.Namespace) -> int:
129
+ """bdig add raindrop [колекція ...] — усі закладки або лише названі колекції (з вкладеними)."""
130
+ from . import keys, pipeline, raindrop
131
+
132
+ token = keys.get_secret(raindrop.TOKEN, paths)
133
+ if not token:
134
+ print(t("raindrop.no_token"), file=sys.stderr)
135
+ return 2
136
+ wanted = args.urls[1:]
137
+ ids: list[int] = []
138
+ if wanted:
139
+ try:
140
+ cols = raindrop.collections(token)
141
+ except raindrop.RaindropError as e:
142
+ print(t("raindrop.failed", detail=str(e)), file=sys.stderr)
143
+ return 2
144
+ for title in wanted:
145
+ found = [c for c in cols if title.lower() in (c.title.lower(), c.title.rsplit("/", 1)[-1].lower())]
146
+ if not found:
147
+ print(t("raindrop.no_collection", name=title), file=sys.stderr)
148
+ return 2
149
+ ids += [c.id for c in found]
150
+ name = args.name or (wanted[0] if len(wanted) == 1 else "Raindrop")
151
+ if any(s["name"] == name for s in db.list_sources(conn)):
152
+ print(t("sources.name_taken", name=name), file=sys.stderr)
153
+ return 2
154
+ sid = pipeline.add_raindrop_source(conn, name, ids, args.folder or name)
155
+ print(t("sources.added_raindrop", name=name))
156
+ source = db.get_source(conn, sid)
157
+ if source and not args.no_sync:
158
+ print(t("sources.synced", new=pipeline.sync_source(conn, source)[0]))
159
+ return 0
160
+
161
+
162
+ def _export(conn: Any, paths: Any, cfg: dict[str, Any], args: argparse.Namespace) -> int:
163
+ """bdig export [--root НАЗВА] — усі списки з конспектами в Raindrop (лише нове й змінене)."""
164
+ from . import export, keys, notes, raindrop
165
+
166
+ token = keys.get_secret(raindrop.TOKEN, paths)
167
+ if not token:
168
+ print(t("raindrop.no_token"), file=sys.stderr)
169
+ return 2
170
+ language = cfg["general"]["summary_language"]
171
+ lists = [(int(r["source_id"]), str(r["list_name"])) for r in db.done_lists(conn, language)]
172
+ root = args.root or cfg["raindrop"]["export_root"] or export.DEFAULT_ROOT
173
+ try:
174
+ stats = export.export(conn, token, lists, root, language, notes.load_labels(paths, language))
175
+ except raindrop.RaindropError as e:
176
+ print(t("raindrop.failed", detail=str(e)), file=sys.stderr)
177
+ return 1
178
+ print(t("export.done", created=stats.created, updated=stats.updated, unchanged=stats.unchanged,
179
+ collections=stats.collections))
180
+ return 0
181
+
182
+
183
+ def _autostart(paths: Any, action: str) -> int:
184
+ from . import autostart
185
+
186
+ try:
187
+ if action == "on":
188
+ print(t("schedule.autostart_on", where=autostart.install(paths.data_dir / "logs" / "autostart.log")))
189
+ elif action == "off":
190
+ autostart.remove()
191
+ print(t("schedule.autostart_off"))
192
+ else:
193
+ print(t("schedule.autostart_yes") if autostart.installed() else t("schedule.autostart_no"))
194
+ except autostart.AutostartError as e:
195
+ print(t("schedule.autostart_failed", detail=str(e)), file=sys.stderr)
196
+ return 1
197
+ return 0
198
+
199
+
200
+ def build_parser() -> argparse.ArgumentParser:
201
+ parser = argparse.ArgumentParser(prog="backlog-digest", description=t("cli.description"))
202
+ parser.add_argument("--version", action="version", version=f"backlog-digest {__version__}")
203
+ parser.add_argument("--lang", choices=i18n.UI_LANGUAGES, help=t("cli.help_lang"))
204
+ parser.add_argument("--home", metavar="DIR", help=t("cli.help_home"))
205
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
206
+ sub.add_parser("menu", help=t("cli.help_menu"))
207
+ p = sub.add_parser("doctor", help=t("cli.help_doctor"))
208
+ p.add_argument("--offline", action="store_true", help=t("cli.help_offline"))
209
+ p = sub.add_parser("add", help=t("cli.help_add"))
210
+ p.add_argument("urls", nargs="+", metavar="URL", help=t("cli.help_urls"))
211
+ p.add_argument("--name", help=t("cli.help_name"))
212
+ p.add_argument("--folder", help=t("cli.help_folder"))
213
+ p.add_argument("--no-sync", action="store_true", help=t("cli.help_no_sync"))
214
+ p.add_argument("--only", action="append", default=[], metavar="FOLDER", help=t("cli.help_only"))
215
+ sub.add_parser("sync", help=t("cli.help_sync"))
216
+ p = sub.add_parser("export", help=t("cli.help_export"))
217
+ p.add_argument("--root", metavar="NAME", help=t("cli.help_root"))
218
+ sub.add_parser("shortcut", help=t("cli.help_shortcut"))
219
+ sub.add_parser("setup", help=t("cli.help_setup"))
220
+ p = sub.add_parser("run-jobs", help=t("cli.help_run_jobs"))
221
+ p.add_argument("--force", action="store_true", help=t("cli.help_force"))
222
+ p.add_argument("--job", type=int, action="append", default=[], metavar="ID", help=t("cli.help_job"))
223
+ p = sub.add_parser("autostart", help=t("cli.help_autostart"))
224
+ p.add_argument("action", choices=("on", "off", "status"))
225
+ for command in STAGES:
226
+ p = sub.add_parser(command, help=t(f"cli.help_{command}"))
227
+ p.add_argument("--limit", type=int, help=t("cli.help_limit"))
228
+ p.add_argument("--source", type=int, metavar="ID", help=t("cli.help_source"))
229
+ return parser
230
+
231
+
232
+ def _option_from_argv(argv: list[str], name: str) -> str | None:
233
+ for i, a in enumerate(argv):
234
+ if a.startswith(f"{name}="):
235
+ return a.split("=", 1)[1]
236
+ if a == name and i + 1 < len(argv):
237
+ return argv[i + 1]
238
+ return None
239
+
240
+
241
+ def _lang_from_argv(argv: list[str]) -> str | None:
242
+ for i, a in enumerate(argv):
243
+ value = a.split("=", 1)[1] if a.startswith("--lang=") else (
244
+ argv[i + 1] if a == "--lang" and i + 1 < len(argv) else None)
245
+ if value in i18n.UI_LANGUAGES:
246
+ return value
247
+ return None
248
+
249
+
250
+ def _setup_console() -> None:
251
+ # Коли вивід іде в файл чи конвеєр, Windows може взяти кодування без потрібних літер
252
+ # (напр. cp1251 без «é»). Краще знак питання замість літери, ніж падіння програми.
253
+ for stream in (sys.stdout, sys.stderr):
254
+ reconfigure = getattr(stream, "reconfigure", None)
255
+ if reconfigure:
256
+ reconfigure(errors="replace")
@@ -0,0 +1,179 @@
1
+ """Налаштування: config.toml у теці користувача (див. paths.py), поверх значень за замовчуванням.
2
+
3
+ Формат TOML, а не YAML (як у прототипі): менше пасток з лапками й двокрапками,
4
+ а читання вбудоване в Python (tomllib). Секрети тут не зберігаються — див. keys.py.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ import re
11
+ import tomllib
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import i18n
16
+ from .paths import Paths
17
+
18
+ OUTPUT_KINDS = ("obsidian", "markdown")
19
+
20
+ DEFAULTS: dict[str, Any] = {
21
+ "general": {
22
+ "ui_language": "", # мова інтерфейсу: uk / en / fr / es ("" — як у системі)
23
+ "summary_language": "", # мова конспектів, незалежно від мови джерела ("" — як інтерфейс)
24
+ },
25
+ "output": {
26
+ "kind": "obsidian", # obsidian / markdown
27
+ "path": "", # тека сховища Obsidian або просто тека для Markdown
28
+ },
29
+ "limits": {
30
+ "per_run": 30, # скільки елементів за один запуск
31
+ "per_day": 30, # скільки субтитрів збирати за добу: YouTube рахує й добову кількість запитів
32
+ },
33
+ "youtube": {
34
+ "delay_min": 60, # випадкова пауза між запитами субтитрів, с: YouTube обмежує IP за часті запити
35
+ "delay_max": 180,
36
+ "languages": [], # бажані мови субтитрів ([] — англійська, потім мова конспектів)
37
+ "newest_first": False, # обробляти спершу нові відео списку
38
+ },
39
+ "raindrop": {
40
+ "export_root": "Backlog digest", # коренева колекція для експорту конспектів
41
+ },
42
+ "models": {
43
+ "order": ["gemini", "openrouter", "deepseek"], # черга провайдерів: перший — основний
44
+ "thinking": "fast", # «роздуми» моделі: fast / deep / auto
45
+ "temperature": 0.3,
46
+ "max_chars": 200000, # довший текст обрізається
47
+ # Свій провайдер або свої моделі: [models.<назва>] base_url, key_env, models, json_mode
48
+ },
49
+ }
50
+
51
+ THINKING_MODES = ("fast", "deep", "auto")
52
+
53
+ _LANG_CODE = re.compile(r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$")
54
+
55
+
56
+ class ConfigError(Exception):
57
+ """Помилка в налаштуваннях. Текст — ключ перекладу з параметрами, перекладається під час показу."""
58
+
59
+ def __init__(self, key: str, **params: Any) -> None:
60
+ super().__init__(key)
61
+ self.key = key
62
+ self.params = params
63
+
64
+ def message(self) -> str:
65
+ return i18n.t(self.key, **self.params)
66
+
67
+
68
+ def defaults() -> dict[str, Any]:
69
+ cfg = copy.deepcopy(DEFAULTS)
70
+ lang = i18n.detect_language()
71
+ cfg["general"]["ui_language"] = lang
72
+ cfg["general"]["summary_language"] = lang
73
+ return cfg
74
+
75
+
76
+ def load(paths: Paths) -> dict[str, Any]:
77
+ """Читає config.toml і доповнює значеннями за замовчуванням. Файлу немає — лише значення за замовчуванням."""
78
+ cfg = defaults()
79
+ path = paths.config_file
80
+ if not path.exists():
81
+ return cfg
82
+ try:
83
+ user = tomllib.loads(path.read_text(encoding="utf-8-sig"))
84
+ except tomllib.TOMLDecodeError as e:
85
+ raise ConfigError("config.err_syntax", file=path.name, detail=str(e)) from e
86
+ except (OSError, UnicodeDecodeError) as e:
87
+ raise ConfigError("config.err_read", file=str(path), detail=str(e)) from e
88
+ _merge(cfg, user)
89
+ validate(cfg)
90
+ return cfg
91
+
92
+
93
+ def _merge(base: dict[str, Any], user: dict[str, Any]) -> None:
94
+ for k, v in user.items():
95
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
96
+ _merge(base[k], v)
97
+ else:
98
+ base[k] = v # невідомі ключі зберігаємо — їх могла записати новіша версія програми
99
+
100
+
101
+ def validate(cfg: dict[str, Any]) -> None:
102
+ g = cfg["general"]
103
+ if not g["ui_language"]:
104
+ g["ui_language"] = i18n.detect_language()
105
+ if g["ui_language"] not in i18n.UI_LANGUAGES:
106
+ raise ConfigError("config.err_ui_language", value=g["ui_language"],
107
+ allowed=", ".join(i18n.UI_LANGUAGES))
108
+ if not g["summary_language"]:
109
+ g["summary_language"] = g["ui_language"]
110
+ if not isinstance(g["summary_language"], str) or not _LANG_CODE.match(g["summary_language"]):
111
+ raise ConfigError("config.err_summary_language", value=g["summary_language"])
112
+ if cfg["output"]["kind"] not in OUTPUT_KINDS:
113
+ raise ConfigError("config.err_output_kind", value=cfg["output"]["kind"],
114
+ allowed=", ".join(OUTPUT_KINDS))
115
+ for section, name in (("limits", "per_run"), ("limits", "per_day"), ("models", "max_chars")):
116
+ v = cfg[section][name]
117
+ if not isinstance(v, int) or isinstance(v, bool) or v < 1:
118
+ raise ConfigError("config.err_positive_int", name=f"{section}.{name}", value=v)
119
+ yt = cfg["youtube"]
120
+ for name in ("delay_min", "delay_max"):
121
+ if not isinstance(yt[name], int | float) or isinstance(yt[name], bool) or yt[name] < 0:
122
+ raise ConfigError("config.err_positive_int", name=f"youtube.{name}", value=yt[name])
123
+ if yt["delay_max"] < yt["delay_min"]:
124
+ yt["delay_max"] = yt["delay_min"]
125
+ m = cfg["models"]
126
+ if m["thinking"] not in THINKING_MODES:
127
+ raise ConfigError("config.err_thinking", value=m["thinking"], allowed=", ".join(THINKING_MODES))
128
+ if not isinstance(m["order"], list) or not m["order"]:
129
+ raise ConfigError("config.err_models_order")
130
+
131
+
132
+ def save(paths: Paths, cfg: dict[str, Any]) -> Path:
133
+ path = paths.config_file
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ tmp = path.with_suffix(".tmp")
136
+ tmp.write_text(dumps(cfg, header=i18n.t("config.file_header")), encoding="utf-8")
137
+ tmp.replace(path) # атомарно: обірваний запис не зіпсує наявний файл
138
+ return path
139
+
140
+
141
+ def ensure(paths: Paths) -> bool:
142
+ """Створює config.toml зі значеннями за замовчуванням, якщо його немає. True — щойно створено."""
143
+ if paths.config_file.exists():
144
+ return False
145
+ save(paths, defaults())
146
+ return True
147
+
148
+
149
+ def dumps(cfg: dict[str, Any], header: str = "") -> str:
150
+ """Простий запис TOML: таблиці (й вкладені таблиці) зі скалярами та списками."""
151
+ lines = [f"# {line}" for line in header.splitlines()]
152
+ _dump_table(cfg, [], lines)
153
+ return "\n".join(lines) + "\n"
154
+
155
+
156
+ def _dump_table(table: dict[str, Any], path: list[str], lines: list[str]) -> None:
157
+ for k, v in table.items():
158
+ if not isinstance(v, dict):
159
+ lines.append(f"{_key(k)} = {_value(v)}")
160
+ for k, v in table.items():
161
+ if isinstance(v, dict):
162
+ lines += ["", f"[{'.'.join(_key(p) for p in [*path, k])}]"]
163
+ _dump_table(v, [*path, k], lines)
164
+
165
+
166
+ def _key(k: str) -> str:
167
+ return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else json.dumps(k, ensure_ascii=False)
168
+
169
+
170
+ def _value(v: Any) -> str:
171
+ if isinstance(v, bool):
172
+ return "true" if v else "false"
173
+ if isinstance(v, int | float):
174
+ return repr(v)
175
+ if isinstance(v, str):
176
+ return json.dumps(v, ensure_ascii=False) # екранування JSON сумісне з рядками TOML
177
+ if isinstance(v, list):
178
+ return "[" + ", ".join(_value(x) for x in v) + "]"
179
+ raise TypeError(f"unsupported value type: {type(v).__name__}")