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.
- backlog_digest/__init__.py +4 -0
- backlog_digest/__main__.py +3 -0
- backlog_digest/autostart.py +201 -0
- backlog_digest/cli.py +256 -0
- backlog_digest/config.py +179 -0
- backlog_digest/db.py +532 -0
- backlog_digest/doctor.py +210 -0
- backlog_digest/export.py +205 -0
- backlog_digest/folders.py +47 -0
- backlog_digest/i18n.py +160 -0
- backlog_digest/keys.py +82 -0
- backlog_digest/library.py +362 -0
- backlog_digest/llm.py +297 -0
- backlog_digest/locales/en.toml +532 -0
- backlog_digest/locales/es.toml +532 -0
- backlog_digest/locales/fr.toml +532 -0
- backlog_digest/locales/uk.toml +535 -0
- backlog_digest/menu.py +797 -0
- backlog_digest/notes.py +210 -0
- backlog_digest/paths.py +62 -0
- backlog_digest/pipeline.py +585 -0
- backlog_digest/raindrop.py +147 -0
- backlog_digest/readers.py +314 -0
- backlog_digest/runstate.py +84 -0
- backlog_digest/schedule.py +280 -0
- backlog_digest/shortcut.py +97 -0
- backlog_digest/sorting_menu.py +165 -0
- backlog_digest/summarize.py +250 -0
- backlog_digest/tools.py +88 -0
- backlog_digest/web.py +280 -0
- backlog_digest/wizard.py +113 -0
- backlog_digest/youtube.py +210 -0
- backlog_digest-1.0.0.dist-info/METADATA +200 -0
- backlog_digest-1.0.0.dist-info/RECORD +37 -0
- backlog_digest-1.0.0.dist-info/WHEEL +4 -0
- backlog_digest-1.0.0.dist-info/entry_points.txt +3 -0
- backlog_digest-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
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")
|
backlog_digest/config.py
ADDED
|
@@ -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__}")
|