task-recorder-cui 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,3 @@
1
+ """task-recorder-cui: 時間記録のためのCUIツール。"""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """`python -m task_recorder_cui` 用エントリ。"""
2
+
3
+ from task_recorder_cui.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ """argparse ベースの CLI エントリポイント。
2
+
3
+ 本モジュールはサブコマンドの受け口 (parser) と main ディスパッチャを持つ。
4
+ 記録系 (start/stop/now/add)、参照系 (today/week/month/range/all)、カテゴリ管理
5
+ (cat ...) は commands/ 配下の実装に dispatch する。インタラクティブメニューは
6
+ `menu.run()` に dispatch する。
7
+ """
8
+
9
+ import argparse
10
+
11
+ from task_recorder_cui import __version__
12
+ from task_recorder_cui.commands import add as add_cmd
13
+ from task_recorder_cui.commands import all as all_cmd
14
+ from task_recorder_cui.commands import cat as cat_cmd
15
+ from task_recorder_cui.commands import month as month_cmd
16
+ from task_recorder_cui.commands import now as now_cmd
17
+ from task_recorder_cui.commands import range as range_cmd
18
+ from task_recorder_cui.commands import start as start_cmd
19
+ from task_recorder_cui.commands import stop as stop_cmd
20
+ from task_recorder_cui.commands import today as today_cmd
21
+ from task_recorder_cui.commands import week as week_cmd
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ """tsk の引数パーサを構築する。
26
+
27
+ Returns:
28
+ サブコマンドを含む設定済み ArgumentParser。
29
+
30
+ """
31
+ parser = argparse.ArgumentParser(
32
+ prog="tsk",
33
+ description="日々の時間の使い方を記録して、週平均・月平均で可視化するCUIツール",
34
+ )
35
+ parser.add_argument(
36
+ "--version",
37
+ action="version",
38
+ version=f"tsk {__version__}",
39
+ )
40
+
41
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
42
+
43
+ # --- 記録系 ---
44
+ p_start = sub.add_parser("start", help="新しいセッションを開始")
45
+ p_start.add_argument("category_key", help="カテゴリキー (例: game, study, dev)")
46
+ p_start.add_argument("description", nargs="?", default=None, help="活動内容 (任意)")
47
+
48
+ sub.add_parser("stop", help="記録中のセッションを終了")
49
+
50
+ p_add = sub.add_parser("add", help="事後に手動で追加")
51
+ p_add.add_argument("category_key", help="カテゴリキー")
52
+ p_add.add_argument("minutes", type=int, help="記録する分数")
53
+ p_add.add_argument("description", nargs="?", default=None, help="活動内容 (任意)")
54
+
55
+ sub.add_parser("now", help="記録中のセッションを表示")
56
+
57
+ # --- 参照系 ---
58
+ sub.add_parser("today", help="今日の記録一覧")
59
+ p_week = sub.add_parser("week", help="直近7日 (default) または今週 (--calendar)")
60
+ p_week.add_argument(
61
+ "--calendar",
62
+ action="store_true",
63
+ help="月曜〜今日の今週集計にする",
64
+ )
65
+ p_month = sub.add_parser("month", help="直近30日 (default) または今月 (--calendar)")
66
+ p_month.add_argument(
67
+ "--calendar",
68
+ action="store_true",
69
+ help="今月1日〜今日の集計にする",
70
+ )
71
+ p_range = sub.add_parser("range", help="任意期間の集計")
72
+ p_range.add_argument("--from", dest="from_date", required=True, help="開始日 YYYY-MM-DD")
73
+ p_range.add_argument(
74
+ "--to", dest="to_date", required=True, help="終了日 YYYY-MM-DD (inclusive)"
75
+ )
76
+ sub.add_parser("all", help="全累計")
77
+
78
+ # --- カテゴリ管理 ---
79
+ p_cat = sub.add_parser("cat", help="カテゴリ管理")
80
+ cat_sub = p_cat.add_subparsers(dest="cat_action", metavar="<action>", required=True)
81
+
82
+ p_cat_list = cat_sub.add_parser("list", help="カテゴリ一覧")
83
+ list_filter = p_cat_list.add_mutually_exclusive_group()
84
+ list_filter.add_argument(
85
+ "--active", action="store_true", help="archived でないカテゴリだけ表示"
86
+ )
87
+ list_filter.add_argument("--archived", action="store_true", help="archived のカテゴリだけ表示")
88
+
89
+ p_cat_add = cat_sub.add_parser(
90
+ "add", help="カテゴリを追加 (archived同名があれば復帰+表示名上書き)"
91
+ )
92
+ p_cat_add.add_argument("key", help="新しいカテゴリキー")
93
+ p_cat_add.add_argument("display_name", help="表示名")
94
+ p_cat_remove = cat_sub.add_parser("remove", help="カテゴリをアーカイブ")
95
+ p_cat_remove.add_argument("key", help="アーカイブするカテゴリキー")
96
+ p_cat_restore = cat_sub.add_parser("restore", help="アーカイブから復帰")
97
+ p_cat_restore.add_argument("key", help="復帰させるカテゴリキー")
98
+ p_cat_rename = cat_sub.add_parser("rename", help="表示名を変更")
99
+ p_cat_rename.add_argument("key", help="変更するカテゴリキー")
100
+ p_cat_rename.add_argument("new_display_name", help="新しい表示名")
101
+
102
+ return parser
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ """CLI エントリポイント。
107
+
108
+ Args:
109
+ argv: 引数リスト。Noneの場合は sys.argv[1:] が使われる。
110
+
111
+ Returns:
112
+ 終了コード (0: 成功)。
113
+
114
+ Raises:
115
+ SystemExit: --help / --version 表示時、または usage error 時に
116
+ argparse が送出する。正常終了は code=0、usage error は code=2。
117
+
118
+ """
119
+ parser = build_parser()
120
+ args = parser.parse_args(argv)
121
+
122
+ if args.command is None:
123
+ from task_recorder_cui.menu import run as menu_run
124
+
125
+ return menu_run()
126
+
127
+ # --- 記録系 (Phase 3) ---
128
+ if args.command == "start":
129
+ return start_cmd.run(args.category_key, args.description)
130
+ if args.command == "stop":
131
+ return stop_cmd.run()
132
+ if args.command == "now":
133
+ return now_cmd.run()
134
+ if args.command == "add":
135
+ return add_cmd.run(args.category_key, args.minutes, args.description)
136
+
137
+ # --- 参照系 (Phase 4) ---
138
+ if args.command == "today":
139
+ return today_cmd.run()
140
+ if args.command == "week":
141
+ return week_cmd.run(calendar=args.calendar)
142
+ if args.command == "month":
143
+ return month_cmd.run(calendar=args.calendar)
144
+ if args.command == "range":
145
+ return range_cmd.run(args.from_date, args.to_date)
146
+ if args.command == "all":
147
+ return all_cmd.run()
148
+
149
+ # --- カテゴリ管理 (Phase 5) ---
150
+ if args.command == "cat":
151
+ if args.cat_action == "list":
152
+ return cat_cmd.list_categories(active_only=args.active, archived_only=args.archived)
153
+ if args.cat_action == "add":
154
+ return cat_cmd.add_category(args.key, args.display_name)
155
+ if args.cat_action == "remove":
156
+ return cat_cmd.remove_category(args.key)
157
+ if args.cat_action == "restore":
158
+ return cat_cmd.restore_category(args.key)
159
+ if args.cat_action == "rename":
160
+ return cat_cmd.rename_category(args.key, args.new_display_name)
161
+
162
+ return 0
File without changes
@@ -0,0 +1,246 @@
1
+ """集計系コマンド (today/week/month/range/all) の共通ロジック。
2
+
3
+ SQLite からレコードを取得し、ローカル日付でグルーピングして日別×カテゴリの
4
+ 集計を行う。表示は rich.Table を使って CJK 幅のズレを吸収する。
5
+
6
+ 仕様:
7
+ - 日付境界はシステムのローカルタイムゾーンにおける 00:00〜翌00:00
8
+ - 日またぎレコードは started_at の日に計上 (分割しない)
9
+ - 記録中セッションは started_at が期間内のとき、現在時刻までの経過分を計上
10
+ - display_name は常に現在の categories テーブルから引く (rename 即時反映)
11
+ """
12
+
13
+ import sqlite3
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime, time, timedelta
17
+
18
+ from rich.box import SIMPLE
19
+ from rich.markup import escape
20
+ from rich.table import Table
21
+
22
+ from task_recorder_cui.io import print_line, print_table
23
+ from task_recorder_cui.repo import find_active_record, row_to_record
24
+ from task_recorder_cui.utils.time import format_duration, now_utc, to_iso
25
+
26
+ WEEKDAY_EN: list[str] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DayTotal:
31
+ """1日分のカテゴリ別集計。
32
+
33
+ Attributes:
34
+ local_date: 対象のローカル日付。
35
+ per_category_minutes: カテゴリkey → 分数。
36
+ total_minutes: 全カテゴリ合計分。
37
+
38
+ """
39
+
40
+ local_date: date
41
+ per_category_minutes: dict[str, int]
42
+ total_minutes: int
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PeriodSummary:
47
+ """期間全体の集計結果。
48
+
49
+ Attributes:
50
+ start_local: 集計期間の開始日 (inclusive)。
51
+ end_local: 集計期間の終了日 (inclusive)。
52
+ days: レコードがあった日の DayTotal リスト (昇順)。
53
+ per_category_minutes: カテゴリkey → 期間内分数合計。
54
+ total_minutes: 全カテゴリの期間内合計分。
55
+ display_names: カテゴリkey → display_name (現在の値)。
56
+ active_partial_minutes: 記録中セッションから計上された分数 (0 なら含まない)。
57
+
58
+ """
59
+
60
+ start_local: date
61
+ end_local: date
62
+ days: list[DayTotal]
63
+ per_category_minutes: dict[str, int]
64
+ total_minutes: int
65
+ display_names: dict[str, str]
66
+ active_partial_minutes: int
67
+
68
+
69
+ def today_local() -> date:
70
+ """システムのローカルタイムゾーンにおける今日の日付を返す。"""
71
+ return datetime.now().astimezone().date()
72
+
73
+
74
+ def _to_local_date(dt: datetime) -> date:
75
+ """tz付き datetime をローカル日付に変換する。"""
76
+ return dt.astimezone().date()
77
+
78
+
79
+ def period_bounds_utc(start_local_date: date, end_local_date: date) -> tuple[datetime, datetime]:
80
+ """ローカル日付範囲を UTC の半開区間 [start_utc, end_next_utc) に変換。"""
81
+ start_utc = datetime.combine(start_local_date, time.min).astimezone().astimezone(UTC)
82
+ end_next_utc = (
83
+ datetime.combine(end_local_date + timedelta(days=1), time.min).astimezone().astimezone(UTC)
84
+ )
85
+ return start_utc, end_next_utc
86
+
87
+
88
+ def aggregate_period(
89
+ conn: sqlite3.Connection,
90
+ start_local_date: date,
91
+ end_local_date: date,
92
+ *,
93
+ include_active: bool = True,
94
+ ) -> PeriodSummary:
95
+ """指定ローカル日付区間 [start, end] の集計を返す (両端inclusive)。
96
+
97
+ Args:
98
+ conn: DB接続。
99
+ start_local_date: 集計開始日 (local)。
100
+ end_local_date: 集計終了日 (local, inclusive)。
101
+ include_active: 記録中セッションを期間に含めるか。
102
+
103
+ Returns:
104
+ PeriodSummary。レコードが1件もなければ days=[]。
105
+
106
+ """
107
+ start_utc, end_next_utc = period_bounds_utc(start_local_date, end_local_date)
108
+
109
+ rows = conn.execute(
110
+ "SELECT * FROM records WHERE started_at >= ? AND started_at < ? ORDER BY started_at",
111
+ (to_iso(start_utc), to_iso(end_next_utc)),
112
+ ).fetchall()
113
+ records = [row_to_record(r) for r in rows]
114
+
115
+ if include_active:
116
+ active = find_active_record(conn)
117
+ if (
118
+ active is not None
119
+ and start_utc <= active.started_at < end_next_utc
120
+ and all(r.id != active.id for r in records)
121
+ ):
122
+ records.append(active)
123
+
124
+ now = now_utc()
125
+ day_buckets: dict[date, dict[str, int]] = defaultdict(lambda: defaultdict(int))
126
+ per_category: dict[str, int] = defaultdict(int)
127
+ active_partial_minutes = 0
128
+
129
+ for rec in records:
130
+ local_d = _to_local_date(rec.started_at)
131
+ if rec.duration_minutes is not None:
132
+ minutes = rec.duration_minutes
133
+ else:
134
+ minutes = max(0, int((now - rec.started_at).total_seconds() / 60))
135
+ active_partial_minutes += minutes
136
+ day_buckets[local_d][rec.category_key] += minutes
137
+ per_category[rec.category_key] += minutes
138
+
139
+ days = [
140
+ DayTotal(
141
+ local_date=d,
142
+ per_category_minutes=dict(buckets),
143
+ total_minutes=sum(buckets.values()),
144
+ )
145
+ for d, buckets in sorted(day_buckets.items())
146
+ ]
147
+
148
+ category_keys = list(per_category.keys())
149
+ display_names: dict[str, str] = {}
150
+ if category_keys:
151
+ placeholders = ",".join("?" for _ in category_keys)
152
+ category_rows = conn.execute(
153
+ f"SELECT key, display_name FROM categories WHERE key IN ({placeholders})",
154
+ category_keys,
155
+ ).fetchall()
156
+ display_names = {row["key"]: row["display_name"] for row in category_rows}
157
+ for key in category_keys:
158
+ display_names.setdefault(key, key)
159
+
160
+ return PeriodSummary(
161
+ start_local=start_local_date,
162
+ end_local=end_local_date,
163
+ days=days,
164
+ per_category_minutes=dict(per_category),
165
+ total_minutes=sum(per_category.values()),
166
+ display_names=display_names,
167
+ active_partial_minutes=active_partial_minutes,
168
+ )
169
+
170
+
171
+ def _sorted_category_keys(summary: PeriodSummary) -> list[str]:
172
+ """合計分数の降順でカテゴリキーを並べる (同点は key 昇順)。"""
173
+ return sorted(
174
+ summary.per_category_minutes.keys(),
175
+ key=lambda k: (-summary.per_category_minutes[k], k),
176
+ )
177
+
178
+
179
+ def render_breakdown_table(summary: PeriodSummary, title: str) -> Table:
180
+ """日別×カテゴリの内訳テーブルを組み立てる。
181
+
182
+ Args:
183
+ summary: 集計結果。
184
+ title: テーブルタイトル。
185
+
186
+ Returns:
187
+ そのまま print_table() に渡せる Table。
188
+
189
+ """
190
+ keys = _sorted_category_keys(summary)
191
+ table = Table(title=title, box=SIMPLE, show_edge=True, pad_edge=False)
192
+ table.add_column("日付", justify="left", no_wrap=True)
193
+ for key in keys:
194
+ table.add_column(escape(summary.display_names.get(key, key)), justify="right")
195
+ table.add_column("合計", justify="right", style="bold")
196
+
197
+ for day in summary.days:
198
+ weekday = WEEKDAY_EN[day.local_date.weekday()]
199
+ date_cell = f"{day.local_date.strftime('%m-%d')} {weekday}"
200
+ row = [date_cell]
201
+ for key in keys:
202
+ minutes = day.per_category_minutes.get(key, 0)
203
+ row.append(format_duration(minutes) if minutes > 0 else "")
204
+ row.append(format_duration(day.total_minutes))
205
+ table.add_row(*row)
206
+
207
+ return table
208
+
209
+
210
+ def render_category_totals(summary: PeriodSummary, *, with_daily_avg: bool = False) -> None:
211
+ """カテゴリ別合計 (降順) を表示する。
212
+
213
+ Args:
214
+ summary: 集計結果。
215
+ with_daily_avg: True のとき "/ 日平均 1h30m" を付記する (week/month/range用)。
216
+
217
+ """
218
+ total = summary.total_minutes
219
+ if not summary.per_category_minutes:
220
+ print_line("記録なし")
221
+ return
222
+
223
+ day_count = (summary.end_local - summary.start_local).days + 1
224
+ keys = _sorted_category_keys(summary)
225
+
226
+ grid = Table.grid(padding=(0, 1))
227
+ grid.add_column() # label:
228
+ grid.add_column(justify="right") # duration
229
+ grid.add_column() # percentage
230
+ if with_daily_avg:
231
+ grid.add_column() # daily avg
232
+
233
+ for key in keys:
234
+ minutes = summary.per_category_minutes[key]
235
+ name = summary.display_names.get(key, key)
236
+ pct = round(minutes * 100 / total) if total > 0 else 0
237
+ label = f"{escape(name)}:"
238
+ duration = format_duration(minutes)
239
+ percent = f"({pct}%)"
240
+ if with_daily_avg:
241
+ avg = minutes // day_count if day_count > 0 else 0
242
+ grid.add_row(label, duration, percent, f"/ 日平均 {format_duration(avg)}")
243
+ else:
244
+ grid.add_row(label, duration, percent)
245
+
246
+ print_table(grid)
@@ -0,0 +1,63 @@
1
+ """tsk add: 事後に手動で時間記録を追加する。"""
2
+
3
+ from datetime import timedelta
4
+
5
+ from rich.markup import escape
6
+
7
+ from task_recorder_cui.db import open_db
8
+ from task_recorder_cui.io import print_error, print_line
9
+ from task_recorder_cui.repo import find_category, insert_record
10
+ from task_recorder_cui.utils.time import format_duration, now_utc
11
+
12
+
13
+ def run(category_key: str, minutes: int, description: str | None) -> int:
14
+ """started_at = 現在 - minutes, ended_at = 現在 として1レコードを挿入する。
15
+
16
+ Args:
17
+ category_key: 対象カテゴリのkey。
18
+ minutes: 記録する分数 (1以上の整数)。
19
+ description: 活動内容 (任意)。
20
+
21
+ Returns:
22
+ 0: 追加成功 / 1: 未登録カテゴリ、または不正な minutes。
23
+
24
+ """
25
+ if minutes <= 0:
26
+ print_error(f"minutes は1以上である必要があります: {minutes}")
27
+ return 1
28
+
29
+ with open_db() as conn:
30
+ category = find_category(conn, category_key)
31
+ if category is None:
32
+ print_error(
33
+ f"カテゴリ '{category_key}' が存在しません。`tsk cat list` で一覧を確認してください"
34
+ )
35
+ return 1
36
+ if category.archived:
37
+ print_error(
38
+ f"カテゴリ '{category_key}' はアーカイブ済みです。 "
39
+ f"`tsk cat restore {category_key}` または "
40
+ f"`tsk cat add {category_key} <display_name>` で復帰させてください"
41
+ )
42
+ return 1
43
+ ended_at = now_utc()
44
+ started_at = ended_at - timedelta(minutes=minutes)
45
+ with conn:
46
+ insert_record(
47
+ conn,
48
+ category_key=category_key,
49
+ description=description,
50
+ started_at=started_at,
51
+ ended_at=ended_at,
52
+ duration_minutes=minutes,
53
+ )
54
+ display_name = category.display_name
55
+
56
+ started_hm = started_at.astimezone().strftime("%H:%M")
57
+ ended_hm = ended_at.astimezone().strftime("%H:%M")
58
+ detail = f" {escape(description)}" if description else ""
59
+ print_line(
60
+ f"追加: [{escape(display_name)}]{detail}"
61
+ f" ({started_hm}-{ended_hm}, {format_duration(minutes)})"
62
+ )
63
+ return 0
@@ -0,0 +1,37 @@
1
+ """tsk all: 全累計。"""
2
+
3
+ from datetime import date
4
+
5
+ from task_recorder_cui.commands._summary import (
6
+ aggregate_period,
7
+ render_category_totals,
8
+ today_local,
9
+ )
10
+ from task_recorder_cui.db import open_db
11
+ from task_recorder_cui.io import print_line
12
+ from task_recorder_cui.utils.time import format_duration, from_iso
13
+
14
+
15
+ def run() -> int:
16
+ """最古の記録日から今日までの全累計を表示する (日別内訳は出さない)。
17
+
18
+ Returns:
19
+ 常に 0。
20
+
21
+ """
22
+ today = today_local()
23
+
24
+ with open_db() as conn:
25
+ row = conn.execute("SELECT MIN(started_at) AS earliest FROM records").fetchone()
26
+ if row is None or row["earliest"] is None:
27
+ print_line("全累計")
28
+ print_line("記録なし")
29
+ return 0
30
+ start: date = from_iso(row["earliest"]).astimezone().date()
31
+ summary = aggregate_period(conn, start, today)
32
+
33
+ title = f"全累計 ({start.isoformat()} 以降)"
34
+ print_line(title)
35
+ print_line(f"合計: {format_duration(summary.total_minutes)}")
36
+ render_category_totals(summary, with_daily_avg=True)
37
+ return 0
@@ -0,0 +1,172 @@
1
+ """tsk cat サブコマンド群 (list/add/remove/restore/rename)。
2
+
3
+ 各関数は cli.py から `args.cat_action` に応じて呼び出される。
4
+ """
5
+
6
+ from rich.box import SIMPLE
7
+ from rich.markup import escape
8
+ from rich.table import Table
9
+
10
+ from task_recorder_cui.db import open_db
11
+ from task_recorder_cui.io import print_error, print_line, print_table
12
+ from task_recorder_cui.repo import (
13
+ find_category,
14
+ insert_category,
15
+ list_all_categories,
16
+ update_category_archived,
17
+ update_category_display_name,
18
+ )
19
+ from task_recorder_cui.utils.validate import validate_category_key
20
+
21
+
22
+ def list_categories(*, active_only: bool = False, archived_only: bool = False) -> int:
23
+ """カテゴリ一覧を表示する。
24
+
25
+ Args:
26
+ active_only: True なら archived=0 のみ表示。
27
+ archived_only: True なら archived=1 のみ表示。
28
+
29
+ Returns:
30
+ 常に 0。
31
+
32
+ """
33
+ with open_db() as conn:
34
+ categories = list_all_categories(conn, active_only=active_only, archived_only=archived_only)
35
+
36
+ if not categories:
37
+ if archived_only:
38
+ print_line("archived カテゴリはありません")
39
+ elif active_only:
40
+ print_line("active カテゴリはありません")
41
+ else:
42
+ print_line("カテゴリがありません")
43
+ return 0
44
+
45
+ table = Table(title="カテゴリ", box=SIMPLE, show_edge=True, pad_edge=False)
46
+ table.add_column("key", style="bold")
47
+ table.add_column("表示名")
48
+ table.add_column("archived", justify="center")
49
+ for cat in categories:
50
+ table.add_row(escape(cat.key), escape(cat.display_name), "✓" if cat.archived else "")
51
+ print_table(table)
52
+ return 0
53
+
54
+
55
+ def add_category(key: str, display_name: str) -> int:
56
+ """カテゴリを新規追加する。
57
+
58
+ 同じ key が archived 状態で既に存在する場合は、archived=0 に戻し、
59
+ display_name を今回指定された値に更新する (実質 restore + rename)。
60
+ active 状態で存在する場合は重複エラー。
61
+
62
+ Args:
63
+ key: 新しいカテゴリキー (ASCII 英小文字+数字+_)。
64
+ display_name: 表示名。
65
+
66
+ Returns:
67
+ 0: 追加または再有効化成功 / 1: 不正key、空 display_name、重複。
68
+
69
+ """
70
+ try:
71
+ validate_category_key(key)
72
+ except ValueError as exc:
73
+ print_error(str(exc))
74
+ return 1
75
+ if not display_name:
76
+ print_error("display_name は空にできません")
77
+ return 1
78
+
79
+ with open_db() as conn:
80
+ existing = find_category(conn, key)
81
+ if existing is not None:
82
+ if not existing.archived:
83
+ print_error(
84
+ f"カテゴリ '{key}' は既に存在します (display_name='{existing.display_name}')"
85
+ )
86
+ return 1
87
+ with conn:
88
+ update_category_archived(conn, key, archived=False)
89
+ update_category_display_name(conn, key, display_name)
90
+ print_line(
91
+ f"再有効化: {key} → '{escape(display_name)}' "
92
+ "(以前 archived だったカテゴリを復帰、display_name を上書き)"
93
+ )
94
+ return 0
95
+
96
+ with conn:
97
+ insert_category(conn, key, display_name)
98
+ print_line(f"追加: {key} → '{escape(display_name)}'")
99
+ return 0
100
+
101
+
102
+ def remove_category(key: str) -> int:
103
+ """カテゴリを archived にする (物理削除はしない)。
104
+
105
+ Args:
106
+ key: 対象カテゴリキー。
107
+
108
+ Returns:
109
+ 0: 成功、または既に archived (no-op) / 1: 未存在。
110
+
111
+ """
112
+ with open_db() as conn:
113
+ existing = find_category(conn, key)
114
+ if existing is None:
115
+ print_error(f"カテゴリ '{key}' が存在しません")
116
+ return 1
117
+ if existing.archived:
118
+ print_line(f"'{key}' は既にアーカイブ済みです")
119
+ return 0
120
+ with conn:
121
+ update_category_archived(conn, key, archived=True)
122
+ print_line(f"アーカイブ: {key} ('{escape(existing.display_name)}')")
123
+ return 0
124
+
125
+
126
+ def restore_category(key: str) -> int:
127
+ """archived カテゴリを active に戻す。display_name は維持する。
128
+
129
+ Args:
130
+ key: 対象カテゴリキー。
131
+
132
+ Returns:
133
+ 0: 成功、または既に active (no-op) / 1: 未存在。
134
+
135
+ """
136
+ with open_db() as conn:
137
+ existing = find_category(conn, key)
138
+ if existing is None:
139
+ print_error(f"カテゴリ '{key}' が存在しません")
140
+ return 1
141
+ if not existing.archived:
142
+ print_line(f"'{key}' は既に有効です")
143
+ return 0
144
+ with conn:
145
+ update_category_archived(conn, key, archived=False)
146
+ print_line(f"復帰: {key} ('{escape(existing.display_name)}')")
147
+ return 0
148
+
149
+
150
+ def rename_category(key: str, new_display_name: str) -> int:
151
+ """display_name のみを変更する。key は不変 (履歴保持のため)。
152
+
153
+ Args:
154
+ key: 対象カテゴリキー。
155
+ new_display_name: 新しい表示名。
156
+
157
+ Returns:
158
+ 0: 成功 / 1: 未存在、空 display_name。
159
+
160
+ """
161
+ if not new_display_name:
162
+ print_error("新しい display_name は空にできません")
163
+ return 1
164
+ with open_db() as conn:
165
+ existing = find_category(conn, key)
166
+ if existing is None:
167
+ print_error(f"カテゴリ '{key}' が存在しません")
168
+ return 1
169
+ with conn:
170
+ update_category_display_name(conn, key, new_display_name)
171
+ print_line(f"変更: {key} '{escape(existing.display_name)}' → '{escape(new_display_name)}'")
172
+ return 0