From 162b66d0caa609728501dbdda9315f65b4f0f6dc Mon Sep 17 00:00:00 2001 From: VasilevNStas Date: Thu, 18 Jun 2026 22:40:51 +0300 Subject: [PATCH] release: v0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete: массовое удаление (--older-than, --before, --after, --keep-last, --project, --interactive) - prune: alias для delete - README обновлён с новыми флагами - .opencode/_roadmap.md обновлён --- README.md | 47 +++---- README.ru.md | 41 +++--- cmd_delete.py | 297 +++++++++++++++++++++++++++++++++++++++-- cmd_prune.py | 116 +--------------- i18n.py | 52 +++++++- pyproject.toml | 2 +- tests/test_commands.py | 2 +- utils.py | 13 ++ 8 files changed, 402 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 6fc9b19..6302592 100644 --- a/README.md +++ b/README.md @@ -210,20 +210,35 @@ If the project directory contains `.obsidian`, a `log.md` file is automatically --- -### `delete` — delete a session +### `delete` — delete sessions -Removes a session and all related data (messages, parts, todos) thanks to CASCADE constraints in the database schema. +Removes sessions and all related data (messages, parts, todos). Supports single deletion by ID or bulk deletion with filters. ```bash -opencode-db delete ses_1c9d --dry-run # show what would be deleted -opencode-db delete ses_1c9d # delete with confirmation -opencode-db delete ses_1c9d --force # delete without confirmation +opencode-db delete ses_1c9d # single session with confirmation +opencode-db delete ses_1c9d --force # single session, no confirmation +opencode-db delete ses_1c9d --dry-run # preview what would be deleted +opencode-db delete --older-than 90d # sessions older than 90 days +opencode-db delete --older-than 6m --dry-run # preview sessions older than 6 months +opencode-db delete --keep-last 30 # keep 30 most recent, delete the rest +opencode-db delete --before 2026-05-20 # sessions before a specific date +opencode-db delete --after 2026-01-01 # sessions after a specific date +opencode-db delete --project proj_xxx # sessions in a specific project +opencode-db delete --older-than 90d --keep-last 20 # combined filters +opencode-db delete --interactive # choose from a list interactively ``` | Flag | Description | |---|---| +| `session_id` | Session ID (optional with `--interactive`) | +| `--older-than` | Delete sessions older than (e.g. `30d`, `6m`, `1y`) | +| `--before` | Delete sessions before date (`YYYY-MM-DD`) | +| `--after` | Delete sessions after date (`YYYY-MM-DD`) | +| `--keep-last` | Keep N most recent, delete the rest | +| `--project` | Limit to a specific project | | `--dry-run` | Only show what would be deleted | | `--force`, `-f` | Skip confirmation prompt | +| `--interactive` | Pick sessions from a list | --- @@ -252,31 +267,17 @@ Costs are taken directly from the database (`session.cost`), which stores the ac --- -### `prune` — bulk cleanup of old sessions +### `prune` — alias for `delete` (bulk cleanup) -Deletes sessions by age, project, or keeps a specified number of the most recent ones. +Alias for `delete` with the same flags: `--older-than`, `--keep-last`, `--project`, `--dry-run`, `--force`. ```bash -opencode-db prune --older-than 30d --dry-run # preview deletions -opencode-db prune --older-than 90d # delete sessions older than 90 days -opencode-db prune --older-than 6m # older than 6 months -opencode-db prune --older-than 1y # older than 1 year -opencode-db prune --keep-last 20 # keep the 20 most recent -opencode-db prune --older-than 30d --keep-last 10 # combined filters -opencode-db prune --project proj_xxx # per project -opencode-db prune --older-than 30d --force # skip confirmation +opencode-db prune --older-than 90d # same as opencode-db delete --older-than 90d +opencode-db prune --keep-last 20 # same as opencode-db delete --keep-last 20 ``` Time spec formats: `30d` (days), `6m` (months), `1y` (years). -| Flag | Description | -|---|---| -| `--older-than SPEC` | Delete sessions older than the given duration | -| `--keep-last N` | Keep N most recent sessions | -| `--project ID` | Limit to a specific project | -| `--dry-run` | Only show what would be deleted | -| `--force`, `-f` | Skip confirmation prompt | - --- ### `search` — search across messages diff --git a/README.ru.md b/README.ru.md index 19080ad..f20b599 100644 --- a/README.ru.md +++ b/README.ru.md @@ -198,22 +198,35 @@ opencode-db export -o ~/backup # сохранить в друг --- -### `delete` — удаление сессии +### `delete` — удаление сессий -Удаляет сессию и все связанные данные (сообщения, части, todo) благодаря CASCADE в схеме БД. +Удаляет сессии и все связанные данные (сообщения, части, todo). Поддерживает удаление по ID или массовое с фильтрами. ```bash -opencode-db delete ses_1c9d --dry-run # показать что будет удалено -opencode-db delete ses_1c9d # с подтверждением -opencode-db delete ses_1c9d --force # без подтверждения +opencode-db delete ses_1c9d # одна сессия с подтверждением +opencode-db delete ses_1c9d --force # одна сессия без подтверждения +opencode-db delete ses_1c9d --dry-run # показать что будет удалено +opencode-db delete --older-than 90d # сессии старше 90 дней +opencode-db delete --older-than 6m --dry-run # предпросмотр старше 6 месяцев +opencode-db delete --keep-last 30 # оставить 30 последних +opencode-db delete --before 2026-05-20 # сессии до даты +opencode-db delete --after 2026-01-01 # сессии после даты +opencode-db delete --project proj_xxx # по проекту +opencode-db delete --older-than 90d --keep-last 20 # комбинация фильтров +opencode-db delete --interactive # выбор из списка ``` -Всегда показывает информацию о сессии перед удалением. - | Флаг | Описание | |---|---| +| `session_id` | ID сессии (опционально с `--interactive`) | +| `--older-than` | Удалить старше (например `30d`, `6m`, `1y`) | +| `--before` | Удалить до даты (`ГГГГ-ММ-ДД`) | +| `--after` | Удалить после даты (`ГГГГ-ММ-ДД`) | +| `--keep-last` | Оставить N последних | +| `--project` | Фильтр по проекту | | `--dry-run` | Только показать что будет удалено | | `--force`, `-f` | Без подтверждения | +| `--interactive` | Выбор из списка | --- @@ -242,19 +255,13 @@ opencode-db costs ses_1c9d --json # в JSON --- -### `prune` — массовая очистка старых сессий +### `prune` — alias для `delete` -Удаляет сессии по возрасту, проекту или с ограничением на количество сохраняемых. +Псевдоним команды `delete` с теми же флагами: `--older-than`, `--keep-last`, `--project`, `--dry-run`, `--force`. ```bash -opencode-db prune --older-than 30d --dry-run # что можно удалить -opencode-db prune --older-than 90d # удалить сессии старше 90 дней -opencode-db prune --older-than 6m # старше 6 месяцев -opencode-db prune --older-than 1y # старше года -opencode-db prune --keep-last 20 # оставить 20 последних -opencode-db prune --older-than 30d --keep-last 10 # комбинация -opencode-db prune --project proj_xxx # по проекту -opencode-db prune --older-than 30d --force # без подтверждения +opencode-db prune --older-than 90d # то же что opencode-db delete --older-than 90d +opencode-db prune --keep-last 20 # то же что opencode-db delete --keep-last 20 ``` Форматы `--older-than`: `30d` (дни), `6m` (месяцы), `1y` (года). diff --git a/cmd_delete.py b/cmd_delete.py index 58da6fd..aa69680 100644 --- a/cmd_delete.py +++ b/cmd_delete.py @@ -1,4 +1,4 @@ -"""Команда delete: удаление сессии.""" +"""Команда delete: удаление сессии или массовая очистка.""" import argparse @@ -6,17 +6,22 @@ SessionError, get_message_count, get_part_count, + get_recent_sessions, get_session_info, get_session_title, parse_model, ) from i18n import _ -from utils import build_help_epilog, confirm, format_cost, format_ts +from utils import build_help_epilog, confirm, format_cost, format_ts, parse_date, parse_time_spec _DELETE_EXAMPLES = [ ("", "help.delete.e0"), (" --dry-run", "help.delete.e1"), (" --force", "help.delete.e2"), + ("--older-than 90d", "help.delete.e3"), + ("--keep-last 30", "help.delete.e4"), + ("--before 2026-05-20", "help.delete.e5"), + ("--interactive", "help.delete.e6"), ] @@ -27,29 +32,35 @@ def register(subparsers) -> None: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=build_help_epilog("delete", _DELETE_EXAMPLES), ) - p.add_argument("session_id", help="Session ID to delete") + p.add_argument("session_id", nargs="?", help="Session ID (optional with --interactive)") + p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)") + p.add_argument("--before", type=str, help="Delete sessions before date (YYYY-MM-DD)") + p.add_argument("--after", type=str, help="Delete sessions after date (YYYY-MM-DD)") + p.add_argument("--keep-last", type=int, help="Keep N most recent sessions") + p.add_argument("--project", type=str, help="Limit to project") p.add_argument("--dry-run", action="store_true", help="Preview without deleting") p.add_argument("--force", "-f", action="store_true", help="Skip confirmation") + p.add_argument("--interactive", action="store_true", help="Choose sessions from a list") -def run(args, db) -> int: +def _delete_single(db, session_id, dry_run, force) -> int: + """Удаление одной сессии по ID.""" try: - info = get_session_info(db, args.session_id) + info = get_session_info(db, session_id) except SessionError as e: print(e.message) return 1 - session_id = info["id"] + sid = info["id"] title = get_session_title(info) model = parse_model(info) - - msg_count = get_message_count(db, session_id) - part_count = get_part_count(db, session_id) + msg_count = get_message_count(db, sid) + part_count = get_part_count(db, sid) created = format_ts(info["time_created"]) print(f"\n{'=' * 50}") print(f" {_('delete.header')}") print(f"{'=' * 50}") - print(f" {_('delete.id')}: {session_id}") + print(f" {_('delete.id')}: {sid}") print(f" {_('delete.title')}: {title}") print(f" {_('delete.model')}: {model}") print(f" {_('delete.created')}: {created}") @@ -57,18 +68,276 @@ def run(args, db) -> int: print(f" {_('delete.parts')}: {part_count}") print(f" {_('delete.cost')}: {format_cost(info['cost'])}") - if args.dry_run: + if dry_run: print(f"\n{_('delete.dry_run')}") print(f" {_('delete.dry_run_hint')}") return 0 - if not args.force and not confirm(_("delete.confirm", id=session_id[:24])): + if not force and not confirm(_("delete.confirm", id=sid[:24])): print(_("canceled")) return 0 - db.execute("DELETE FROM session WHERE id = ?", (session_id,)) + db.execute("DELETE FROM session WHERE id = ?", (sid,)) db.commit() - print(f"\n{_('delete.done', id=session_id[:24])}") + print(f"\n{_('delete.done', id=sid[:24])}") print(_("delete.done_detail", msg=msg_count, parts=part_count)) return 0 + + +def _build_mass_conditions(args): + """Строит условия WHERE для массового удаления.""" + conditions = [] + params = [] + project = _a(args, "project") + older_than = _a(args, "older_than") + before = _a(args, "before") + after = _a(args, "after") + + if project: + conditions.append("s.project_id = ?") + params.append(project) + + if older_than: + cutoff = parse_time_spec(older_than) + if cutoff is None: + return None, None, _("delete.bad_spec", spec=older_than) + conditions.append("s.time_created < ?") + params.append(int(cutoff.timestamp() * 1000)) + + if before: + dt = parse_date(before) + if dt is None: + return None, None, _("delete.bad_date", date=before) + conditions.append("s.time_created < ?") + params.append(int(dt.timestamp() * 1000)) + + if after: + dt = parse_date(after) + if dt is None: + return None, None, _("delete.bad_date", date=after) + conditions.append("s.time_created > ?") + params.append(int(dt.timestamp() * 1000)) + + return conditions, params, None + + +def _collect_mass_targets(db, conditions, params, keep_last): + """Собирает список сессий для массового удаления.""" + exclude_ids = set() + if keep_last: + inner_where = " AND ".join(conditions) if conditions else "1=1" + keep_rows = db.execute( + f""" + SELECT s.id FROM session s + WHERE {inner_where} + ORDER BY s.time_created DESC + LIMIT ? + """, + (*params, keep_last), + ).fetchall() + exclude_ids = {r["id"] for r in keep_rows} + + where = " AND ".join(conditions) if conditions else "1=1" + + if exclude_ids: + placeholders = ",".join("?" for _ in exclude_ids) + rows = db.execute( + f""" + SELECT s.id, s.title, s.model, s.time_created, s.cost + FROM session s + WHERE {where} + AND s.id NOT IN ({placeholders}) + ORDER BY s.time_created DESC + """, + (*params, *exclude_ids), + ).fetchall() + else: + rows = db.execute( + f""" + SELECT s.id, s.title, s.model, s.time_created, s.cost + FROM session s + WHERE {where} + ORDER BY s.time_created DESC + """, + params, + ).fetchall() + + return rows + + +def _delete_mass(db, args) -> int: + """Массовое удаление сессий.""" + keep_last = _a(args, "keep_last") + dry_run = _a(args, "dry_run", False) + force = _a(args, "force", False) + + conditions, params, err = _build_mass_conditions(args) + if err: + print(err) + return 1 + + if not conditions and not keep_last: + print(_("delete.filter_required")) + return 1 + + target_rows = _collect_mass_targets(db, conditions, params, keep_last) + + if not target_rows: + print(_("delete.none")) + return 0 + + total_cost = sum(r["cost"] or 0 for r in target_rows) + total_tokens = sum( + db.execute( + "SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?", + (r["id"],), + ).fetchone()[0] + for r in target_rows + ) + + print(f"\n {'=' * 55}") + print(f" {_('delete.mass_header', n=len(target_rows))}") + print(f" {'=' * 55}") + + for r in target_rows[:20]: + created = format_ts(r["time_created"]) + print(f" • {r['id'][:24]} {created} {r['title'] or '—'}") + + if len(target_rows) > 20: + print(f" … и ещё {len(target_rows) - 20}") + + print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}") + print(f" {_('delete.tokens_total', n=str(total_tokens))}") + + if dry_run: + print(f"\n{_('delete.dry_run')}") + return 0 + + if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))): + print(_("canceled")) + return 0 + + ids = [r["id"] for r in target_rows] + placeholders = ",".join("?" for _ in ids) + + db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids) + db.commit() + + print(f"\n{_('delete.done_mass', n=len(ids))}") + return 0 + + +def _delete_interactive(db, args) -> int: + """Интерактивное удаление — выбор из списка последних сессий.""" + sessions = get_recent_sessions(db, limit=20) + + if not sessions: + print(_("delete.no_sessions")) + return 1 + + print(f"\n {_('delete.interactive_header')}") + print(f" {'─' * 55}") + for i, s in enumerate(sessions, 1): + title = get_session_title(s) + project = s.get("project_id", "") or "" + print(f" {i:2d}) {s['id'][:24]} {title[:40]:40s} {project[:20]}") + + print() + choices = input(_("delete.interactive_prompt")).strip() + + if not choices: + print(_("canceled")) + return 0 + + selected = _parse_choices(choices, len(sessions)) + if not selected: + return 1 + + target_rows = [sessions[i - 1] for i in selected] + total_cost = sum(r["cost"] or 0 for r in target_rows) + + print(f"\n {'=' * 55}") + print(f" {_('delete.mass_header', n=len(target_rows))}") + print(f" {'=' * 55}") + for r in target_rows: + created = format_ts(r["time_created"]) + print(f" • {r['id'][:24]} {created} {r['title'] or '—'}") + + dry_run = _a(args, "dry_run", False) + force = _a(args, "force", False) + print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}") + + if dry_run: + print(f"\n{_('delete.dry_run')}") + return 0 + + if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))): + print(_("canceled")) + return 0 + + ids = [r["id"] for r in target_rows] + placeholders = ",".join("?" for _ in ids) + + db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids) + db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids) + db.commit() + + print(f"\n{_('delete.done_mass', n=len(ids))}") + return 0 + + +def _parse_choices(text: str, max_n: int) -> list[int]: + """Парсит ввод пользователя вида '1,3-5,7' в список индексов.""" + result = set() + for part in text.replace(" ", "").split(","): + if not part: + continue + if "-" in part: + try: + a, b = part.split("-", 1) + for i in range(int(a), int(b) + 1): + if 1 <= i <= max_n: + result.add(i) + except ValueError: + continue + else: + try: + i = int(part) + if 1 <= i <= max_n: + result.add(i) + except ValueError: + continue + return sorted(result) + + +def _a(args, name: str, default=None): + """getattr с дефолтом — для совместимости Namespace из разных парсеров.""" + return getattr(args, name, default) + + +def run(args, db) -> int: + if _a(args, "interactive", False): + return _delete_interactive(db, args) + + if _a(args, "session_id"): + session_id = _a(args, "session_id") + dry_run = _a(args, "dry_run", False) + force = _a(args, "force", False) + return _delete_single(db, session_id, dry_run, force) + + older_than = _a(args, "older_than") + before = _a(args, "before") + after = _a(args, "after") + keep_last = _a(args, "keep_last") + project = _a(args, "project") + if older_than or before or after or keep_last or project: + return _delete_mass(db, args) + + print(_("delete.usage")) + return 1 diff --git a/cmd_prune.py b/cmd_prune.py index ca0a57d..2f5a09d 100644 --- a/cmd_prune.py +++ b/cmd_prune.py @@ -1,10 +1,10 @@ -"""Команда prune: массовая очистка старых сессий.""" +"""Команда prune: массовая очистка (alias для delete).""" import argparse -from typing import Literal +from cmd_delete import run as delete_run from i18n import _ -from utils import build_help_epilog, confirm, format_ts, parse_time_spec +from utils import build_help_epilog _PRUNE_EXAMPLES = [ ("--older-than 90d", "help.prune.e0"), @@ -28,111 +28,5 @@ def register(subparsers) -> None: p.add_argument("--force", "-f", action="store_true", help="Skip confirmation") -def run(args, db) -> Literal[1] | Literal[0]: - if not args.older_than and not args.keep_last and not args.project: - print(_("prune.filter_required")) - print(_("prune.filter_example")) - return 1 - - conditions = [] - params = [] - - if args.project: - conditions.append("s.project_id = ?") - params.append(args.project) - - if args.older_than: - cutoff = parse_time_spec(args.older_than) - if cutoff is None: - print(_("prune.bad_spec", spec=args.older_than)) - print(_("prune.bad_spec_hint")) - return 1 - cutoff_ms = int(cutoff.timestamp() * 1000) - conditions.append("s.time_created < ?") - params.append(cutoff_ms) - - exclude_ids = set() - if args.keep_last: - inner_where = " AND ".join(conditions) if conditions else "1=1" - keep_rows = db.execute( - f""" - SELECT s.id FROM session s - WHERE {inner_where} - ORDER BY s.time_created DESC - LIMIT ? - """, - (*params, args.keep_last), - ).fetchall() - exclude_ids = {r["id"] for r in keep_rows} - - where = " AND ".join(conditions) if conditions else "1=1" - - if exclude_ids: - placeholders = ",".join("?" for _ in exclude_ids) - target_rows = db.execute( - f""" - SELECT s.id, s.title, s.model, s.time_created, s.cost - FROM session s - WHERE {where} - AND s.id NOT IN ({placeholders}) - ORDER BY s.time_created DESC - """, - (*params, *exclude_ids), - ).fetchall() - else: - target_rows = db.execute( - f""" - SELECT s.id, s.title, s.model, s.time_created, s.cost - FROM session s - WHERE {where} - ORDER BY s.time_created DESC - """, - params, - ).fetchall() - - if not target_rows: - print(_("prune.none")) - return 0 - - total_cost = sum(r["cost"] or 0 for r in target_rows) - total_tokens = sum( - db.execute( - "SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?", - (r["id"],), - ).fetchone()[0] - for r in target_rows - ) - - print(f"\n {'=' * 55}") - print(f" {_('prune.header', n=len(target_rows))}") - print(f" {'=' * 55}") - - for r in target_rows[:20]: - created = format_ts(r["time_created"]) - print(f" • {r['id'][:24]} {created} {r['title'] or '—'}") - - if len(target_rows) > 20: - print(f" … и ещё {len(target_rows) - 20}") - - print(f"\n {_('prune.cost_total', cost=f'${total_cost:.4f}')}") - print(f" {_('prune.tokens_total', n=str(total_tokens))}") - - if args.dry_run: - print(f"\n{_('prune.dry_run')}") - return 0 - - if not args.force and not confirm(_("prune.confirm", n=len(target_rows))): - print(_("canceled")) - return 0 - - ids_to_delete = [r["id"] for r in target_rows] - placeholders = ",".join("?" for _ in ids_to_delete) - - db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids_to_delete) - db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids_to_delete) - db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids_to_delete) - db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids_to_delete) - db.commit() - - print(f"\n{_('prune.done', n=len(ids_to_delete))}") - return 0 +def run(args, db) -> int: + return delete_run(args, db) diff --git a/i18n.py b/i18n.py index 069a7f9..16046b3 100644 --- a/i18n.py +++ b/i18n.py @@ -185,6 +185,40 @@ "ru": " Сообщений: {msg}, частей: {parts}", "en": " Messages: {msg}, parts: {parts}", }, + "delete.none": {"ru": " ✅ Нет сессий для удаления.", "en": " ✅ No sessions to delete."}, + "delete.filter_required": { + "ru": "❌ Укажи ID сессии, фильтр, или --interactive", + "en": "❌ Specify session ID, a filter, or --interactive", + }, + "delete.usage": { + "ru": " Использование: opencode-db delete ", + "en": " Usage: opencode-db delete ", + }, + "delete.bad_spec": { + "ru": "❌ Не могу разобрать: --older-than {spec}", + "en": "❌ Cannot parse: --older-than {spec}", + }, + "delete.bad_date": { + "ru": "❌ Неверный формат даты: {date}. Используй ГГГГ-ММ-ДД", + "en": "❌ Invalid date format: {date}. Use YYYY-MM-DD", + }, + "delete.mass_header": { + "ru": "Будет удалено сессий: {n}", + "en": "Sessions to delete: {n}", + }, + "delete.cost_total": { + "ru": "Стоимость удаляемых: {cost}", + "en": "Cost of deleted sessions: {cost}", + }, + "delete.tokens_total": {"ru": "Освободится токенов: ~{n}", "en": "Tokens freed: ~{n}"}, + "delete.mass_confirm": {"ru": "Удалить {n} сессий?", "en": "Delete {n} sessions?"}, + "delete.done_mass": {"ru": " ✅ Удалено {n} сессий.", "en": " ✅ Deleted {n} sessions."}, + "delete.interactive_header": {"ru": "Последние сессии:", "en": "Recent sessions:"}, + "delete.interactive_prompt": { + "ru": " Введи номера (1,3-5) или Enter для отмены: ", + "en": " Enter numbers (1,3-5) or Enter to cancel: ", + }, + "delete.no_sessions": {"ru": "❌ Нет сессий для удаления.", "en": "❌ No sessions to delete."}, # ================================================================== # COSTS # ================================================================== @@ -369,7 +403,7 @@ "help.cmd.list": {"ru": "Список сессий", "en": "List sessions"}, "help.cmd.info": {"ru": "Детальная информация о сессии", "en": "Session details"}, "help.cmd.export": {"ru": "Экспорт диалога в Markdown", "en": "Export dialog to Markdown"}, - "help.cmd.delete": {"ru": "Удаление сессии", "en": "Delete a session"}, + "help.cmd.delete": {"ru": "Удаление сессий", "en": "Delete sessions"}, "help.cmd.costs": {"ru": "Анализ расходов на токены", "en": "Token cost analysis"}, "help.cmd.prune": {"ru": "Массовая очистка сессий", "en": "Bulk session cleanup"}, "help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"}, @@ -408,6 +442,22 @@ "help.delete.e0": {"ru": "Удалить с подтверждением", "en": "Delete with confirmation"}, "help.delete.e1": {"ru": "Показать что будет удалено", "en": "Preview what will be deleted"}, "help.delete.e2": {"ru": "Без подтверждения", "en": "Skip confirmation"}, + "help.delete.e3": { + "ru": "Удалить сессии старше 90 дней", + "en": "Delete sessions older than 90 days", + }, + "help.delete.e4": { + "ru": "Оставить 30 последних, остальное удалить", + "en": "Keep 30 most recent, delete the rest", + }, + "help.delete.e5": { + "ru": "Удалить сессии до указанной даты", + "en": "Delete sessions before a date", + }, + "help.delete.e6": { + "ru": "Интерактивный выбор из списка", + "en": "Choose sessions from a list", + }, "help.costs.e0": {"ru": "Топ сессий по расходам", "en": "Top sessions by cost"}, "help.costs.e1": {"ru": "Детально по сессии", "en": "Single session breakdown"}, "help.costs.e2": {"ru": "Сумма по всей БД", "en": "Total across all sessions"}, diff --git a/pyproject.toml b/pyproject.toml index 375423a..839befa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "opencode-db" -version = "0.5.1" +version = "0.6.0" description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions" readme = "README_PYPI.md" requires-python = ">=3.12" diff --git a/tests/test_commands.py b/tests/test_commands.py index 95996ef..8f85a78 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -477,7 +477,7 @@ def test_prune_canceled(self, db) -> None: from cmd_prune import run args = _ns(older_than="30d", keep_last=None, project=None, dry_run=False, force=False) - with patch("cmd_prune.confirm", return_value=False): + with patch("cmd_delete.confirm", return_value=False): assert run(args, db) == 0 assert _count_sessions(db) == 4 diff --git a/utils.py b/utils.py index 220aace..43e8b96 100644 --- a/utils.py +++ b/utils.py @@ -37,6 +37,19 @@ def summarize_val(v, max_len=120) -> str: return s +def parse_date(date_str: str) -> datetime | None: + """Парсит дату в формате ГГГГ-ММ-ДД.""" + if not date_str or not isinstance(date_str, str): + return None + try: + parts = date_str.strip().split("-") + if len(parts) != 3: + return None + return datetime(int(parts[0]), int(parts[1]), int(parts[2]), tzinfo=UTC) + except (ValueError, IndexError): + return None + + def parse_time_spec(spec) -> None | datetime: """Парсит спецификацию времени ('30d', '6m', '1y') в timedelta.""" if not spec or not isinstance(spec, str):