kaiten-cli 0.3.3__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.
Files changed (99) hide show
  1. kaiten_cli/__init__.py +5 -0
  2. kaiten_cli/__main__.py +9 -0
  3. kaiten_cli/agent_gateway.py +577 -0
  4. kaiten_cli/app.py +1636 -0
  5. kaiten_cli/completion.py +570 -0
  6. kaiten_cli/discovery.py +39 -0
  7. kaiten_cli/errors.py +79 -0
  8. kaiten_cli/i18n.py +92 -0
  9. kaiten_cli/locales/ru.json +2967 -0
  10. kaiten_cli/localized_click.py +160 -0
  11. kaiten_cli/models.py +202 -0
  12. kaiten_cli/profiles.py +316 -0
  13. kaiten_cli/registry/__init__.py +260 -0
  14. kaiten_cli/registry/addons.py +392 -0
  15. kaiten_cli/registry/audit_and_analytics.py +451 -0
  16. kaiten_cli/registry/automation_schema.py +44 -0
  17. kaiten_cli/registry/automation_schemas.json +7417 -0
  18. kaiten_cli/registry/automations.py +342 -0
  19. kaiten_cli/registry/base.py +62 -0
  20. kaiten_cli/registry/blockers.py +320 -0
  21. kaiten_cli/registry/boards.py +520 -0
  22. kaiten_cli/registry/card_relations.py +376 -0
  23. kaiten_cli/registry/card_types.py +363 -0
  24. kaiten_cli/registry/cards.py +1329 -0
  25. kaiten_cli/registry/charts.py +539 -0
  26. kaiten_cli/registry/checklists.py +600 -0
  27. kaiten_cli/registry/columns.py +558 -0
  28. kaiten_cli/registry/comments.py +224 -0
  29. kaiten_cli/registry/custom_directories.py +980 -0
  30. kaiten_cli/registry/custom_properties.py +1460 -0
  31. kaiten_cli/registry/dashboards.py +564 -0
  32. kaiten_cli/registry/documents.py +761 -0
  33. kaiten_cli/registry/external_links.py +128 -0
  34. kaiten_cli/registry/files.py +738 -0
  35. kaiten_cli/registry/github_addon.py +613 -0
  36. kaiten_cli/registry/iterations.py +397 -0
  37. kaiten_cli/registry/lanes.py +205 -0
  38. kaiten_cli/registry/live_contracts.py +361 -0
  39. kaiten_cli/registry/members.py +336 -0
  40. kaiten_cli/registry/module_docs.py +108 -0
  41. kaiten_cli/registry/projects.py +369 -0
  42. kaiten_cli/registry/query.py +129 -0
  43. kaiten_cli/registry/restrictions.py +249 -0
  44. kaiten_cli/registry/roles_and_groups.py +1102 -0
  45. kaiten_cli/registry/scim.py +263 -0
  46. kaiten_cli/registry/service_desk.py +1270 -0
  47. kaiten_cli/registry/snapshot.py +187 -0
  48. kaiten_cli/registry/spaces.py +272 -0
  49. kaiten_cli/registry/subscribers.py +168 -0
  50. kaiten_cli/registry/tags.py +193 -0
  51. kaiten_cli/registry/time_logs.py +353 -0
  52. kaiten_cli/registry/tree.py +352 -0
  53. kaiten_cli/registry/utilities.py +287 -0
  54. kaiten_cli/registry/webhooks.py +265 -0
  55. kaiten_cli/runtime/__init__.py +1 -0
  56. kaiten_cli/runtime/automation_validation.py +115 -0
  57. kaiten_cli/runtime/behaviors.py +787 -0
  58. kaiten_cli/runtime/cache.py +733 -0
  59. kaiten_cli/runtime/client.py +387 -0
  60. kaiten_cli/runtime/endpoints.py +59 -0
  61. kaiten_cli/runtime/executor.py +269 -0
  62. kaiten_cli/runtime/fs_security.py +168 -0
  63. kaiten_cli/runtime/input.py +310 -0
  64. kaiten_cli/runtime/output.py +59 -0
  65. kaiten_cli/runtime/public_validation.py +35 -0
  66. kaiten_cli/runtime/snapshot_build.py +527 -0
  67. kaiten_cli/runtime/snapshot_common.py +363 -0
  68. kaiten_cli/runtime/snapshot_query.py +306 -0
  69. kaiten_cli/runtime/snapshot_store.py +1192 -0
  70. kaiten_cli/runtime/snapshots.py +62 -0
  71. kaiten_cli/runtime/sqlite_errors.py +23 -0
  72. kaiten_cli/runtime/support/__init__.py +1 -0
  73. kaiten_cli/runtime/support/addons.py +953 -0
  74. kaiten_cli/runtime/support/audit.py +70 -0
  75. kaiten_cli/runtime/support/batch.py +234 -0
  76. kaiten_cli/runtime/support/card_move_url.py +213 -0
  77. kaiten_cli/runtime/support/cards.py +174 -0
  78. kaiten_cli/runtime/support/checklists.py +21 -0
  79. kaiten_cli/runtime/support/company_users.py +117 -0
  80. kaiten_cli/runtime/support/dashboards.py +35 -0
  81. kaiten_cli/runtime/support/documents.py +175 -0
  82. kaiten_cli/runtime/support/files.py +561 -0
  83. kaiten_cli/runtime/support/markdown_export.py +525 -0
  84. kaiten_cli/runtime/support/pagination.py +167 -0
  85. kaiten_cli/runtime/support/projects.py +66 -0
  86. kaiten_cli/runtime/support/relations.py +86 -0
  87. kaiten_cli/runtime/support/restrictions.py +62 -0
  88. kaiten_cli/runtime/support/spaces.py +20 -0
  89. kaiten_cli/runtime/support/time_logs.py +61 -0
  90. kaiten_cli/runtime/support/tree.py +140 -0
  91. kaiten_cli/runtime/support/tree_sharing.py +447 -0
  92. kaiten_cli/runtime/trace.py +500 -0
  93. kaiten_cli/runtime/transforms.py +128 -0
  94. kaiten_cli/schema_docs.py +22 -0
  95. kaiten_cli/update_check.py +519 -0
  96. kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
  97. kaiten_cli-0.3.3.dist-info/RECORD +99 -0
  98. kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
  99. kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,1192 @@
1
+ """SQLite schema and persistence for local Kaiten snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from platformdirs import user_data_path
15
+
16
+ from kaiten_cli.errors import ConfigError
17
+ from kaiten_cli.i18n import tr
18
+ from kaiten_cli.runtime.fs_security import (
19
+ ensure_private_file,
20
+ secure_directory,
21
+ secure_existing_file,
22
+ )
23
+ from kaiten_cli.runtime.snapshot_common import (
24
+ SNAPSHOT_DB_SCHEMA_VERSION,
25
+ SNAPSHOT_SCHEMA_VERSION,
26
+ _current_stage_entered_at,
27
+ _derive_done_at,
28
+ _duration_days,
29
+ _extract_id,
30
+ _extract_tag_ids,
31
+ _filter_time_logs_to_window,
32
+ _first_history_with_state,
33
+ _iso_timestamp,
34
+ _normalize_bool,
35
+ _normalize_int_list,
36
+ _now_iso,
37
+ _parse_timestamp,
38
+ _search_blob,
39
+ _time_log_minutes,
40
+ _time_log_timestamp,
41
+ )
42
+ from kaiten_cli.runtime.sqlite_errors import is_corrupt_database_error
43
+
44
+
45
+ def snapshot_db_path() -> Path:
46
+ return user_data_path("kaiten-cli") / "snapshots.sqlite3"
47
+
48
+
49
+ STORAGE_READ_ONLY_ENV = "KAITEN_CLI_STORAGE_READ_ONLY"
50
+ _TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
51
+
52
+
53
+ class SnapshotStore:
54
+ def __init__(
55
+ self,
56
+ path: Path | None = None,
57
+ reporter=None,
58
+ *,
59
+ storage_read_only: bool | None = None,
60
+ ):
61
+ self.path = path or snapshot_db_path()
62
+ self.reporter = reporter
63
+ environment_read_only = (
64
+ os.environ.get(STORAGE_READ_ONLY_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
65
+ )
66
+ self.storage_read_only = bool(storage_read_only) or environment_read_only
67
+
68
+ def ensure_writable(self) -> None:
69
+ if self.storage_read_only:
70
+ raise ConfigError(
71
+ tr("Local snapshot writes are disabled in this storage read-only environment.")
72
+ )
73
+
74
+ def _debug(self, message: str) -> None:
75
+ if self.reporter is not None:
76
+ self.reporter(message)
77
+
78
+ def _open_connection(self) -> sqlite3.Connection:
79
+ conn = sqlite3.connect(self.path)
80
+ try:
81
+ conn.row_factory = sqlite3.Row
82
+ conn.execute("PRAGMA foreign_keys = ON")
83
+ return conn
84
+ except BaseException:
85
+ self._close_quietly(conn)
86
+ raise
87
+
88
+ def _open_read_connection(self) -> sqlite3.Connection:
89
+ uri = f"{self.path.absolute().as_uri()}?mode=ro"
90
+ conn = sqlite3.connect(uri, uri=True)
91
+ try:
92
+ conn.row_factory = sqlite3.Row
93
+ conn.execute("PRAGMA foreign_keys = ON")
94
+ return conn
95
+ except BaseException:
96
+ self._close_quietly(conn)
97
+ raise
98
+
99
+ def _close_quietly(self, conn: sqlite3.Connection | None) -> None:
100
+ if conn is None:
101
+ return
102
+ try:
103
+ conn.close()
104
+ except sqlite3.Error:
105
+ return
106
+
107
+ def _reset_error(self, action: str) -> ConfigError:
108
+ return ConfigError(
109
+ tr(
110
+ "Unable to {value_0} local snapshot store at {value_1}. Remove the file and retry.",
111
+ value_0=tr(action),
112
+ value_1=self.path,
113
+ )
114
+ )
115
+
116
+ def _access_error(self, action: str, error: BaseException) -> ConfigError:
117
+ return ConfigError(
118
+ tr(
119
+ "Unable to {value_0} local snapshot store at {value_1}: {value_2}",
120
+ value_0=tr(action),
121
+ value_1=self.path,
122
+ value_2=error,
123
+ )
124
+ )
125
+
126
+ def _reset_store(self, reason: str) -> sqlite3.Connection:
127
+ self._debug(
128
+ tr("snapshot: local store dropped store=snapshots reason={value_0}", value_0=reason)
129
+ )
130
+ try:
131
+ self.path.unlink(missing_ok=True)
132
+ except OSError as exc:
133
+ raise self._reset_error("reset") from exc
134
+ conn: sqlite3.Connection | None = None
135
+ try:
136
+ ensure_private_file(self.path)
137
+ conn = self._open_connection()
138
+ secure_existing_file(self.path)
139
+ self._initialize_schema(conn)
140
+ self._debug(tr("snapshot: local store recreated store=snapshots"))
141
+ return conn
142
+ except (sqlite3.Error, OSError) as exc:
143
+ self._close_quietly(conn)
144
+ raise self._reset_error("recreate") from exc
145
+ except BaseException:
146
+ self._close_quietly(conn)
147
+ raise
148
+
149
+ def _connect(self) -> sqlite3.Connection:
150
+ conn: sqlite3.Connection | None = None
151
+ try:
152
+ secure_directory(self.path.parent)
153
+ existing = self.path.exists()
154
+ ensure_private_file(self.path)
155
+ conn = self._open_connection()
156
+ secure_existing_file(self.path)
157
+ version = int(conn.execute("PRAGMA user_version").fetchone()[0])
158
+ if existing and version != SNAPSHOT_DB_SCHEMA_VERSION:
159
+ self._close_quietly(conn)
160
+ return self._reset_store(f"incompatible-schema:{version}")
161
+ self._initialize_schema(conn)
162
+ return conn
163
+ except sqlite3.Error as exc:
164
+ self._close_quietly(conn)
165
+ if is_corrupt_database_error(exc):
166
+ return self._reset_store(type(exc).__name__)
167
+ raise self._access_error("open", exc) from exc
168
+ except OSError as exc:
169
+ self._close_quietly(conn)
170
+ raise self._access_error("open", exc) from exc
171
+ except BaseException:
172
+ self._close_quietly(conn)
173
+ raise
174
+
175
+ def _connect_read_only(self) -> sqlite3.Connection:
176
+ conn: sqlite3.Connection | None = None
177
+ try:
178
+ secure_existing_file(self.path)
179
+ conn = self._open_read_connection()
180
+ version = int(conn.execute("PRAGMA user_version").fetchone()[0])
181
+ if version != SNAPSHOT_DB_SCHEMA_VERSION:
182
+ raise ConfigError(
183
+ tr(
184
+ "Local snapshot store at {value_0} has incompatible schema {value_1}; refresh or rebuild it outside the storage read-only sandbox.",
185
+ value_0=self.path,
186
+ value_1=version,
187
+ )
188
+ )
189
+ return conn
190
+ except ConfigError:
191
+ self._close_quietly(conn)
192
+ raise
193
+ except (OSError, sqlite3.Error) as exc:
194
+ self._close_quietly(conn)
195
+ raise self._access_error("read", exc) from exc
196
+ except BaseException:
197
+ self._close_quietly(conn)
198
+ raise
199
+
200
+ @contextmanager
201
+ def _connection(self, *, read_only: bool = False) -> Iterator[sqlite3.Connection]:
202
+ """Provide transactional access and always release the SQLite handle."""
203
+
204
+ if self.storage_read_only and not read_only:
205
+ self.ensure_writable()
206
+ conn = self._connect_read_only() if self.storage_read_only else self._connect()
207
+ try:
208
+ if self.storage_read_only:
209
+ yield conn
210
+ else:
211
+ with conn:
212
+ yield conn
213
+ except sqlite3.Error as exc:
214
+ if self.storage_read_only:
215
+ raise self._access_error("read", exc) from exc
216
+ raise
217
+ finally:
218
+ self._close_quietly(conn)
219
+
220
+ def _initialize_schema(self, conn: sqlite3.Connection) -> None:
221
+ conn.execute(
222
+ """
223
+ CREATE TABLE IF NOT EXISTS snapshots (
224
+ name TEXT PRIMARY KEY,
225
+ profile_name TEXT,
226
+ domain TEXT,
227
+ space_id INTEGER NOT NULL,
228
+ board_ids_json TEXT NOT NULL,
229
+ preset TEXT NOT NULL,
230
+ window_start TEXT,
231
+ window_end TEXT,
232
+ schema_version INTEGER NOT NULL DEFAULT 2,
233
+ built_at TEXT NOT NULL,
234
+ spec_json TEXT NOT NULL,
235
+ dataset_counts_json TEXT NOT NULL,
236
+ build_trace_json TEXT NOT NULL
237
+ )
238
+ """
239
+ )
240
+ columns = {row["name"] for row in conn.execute("PRAGMA table_info(snapshots)").fetchall()}
241
+ if "schema_version" not in columns:
242
+ conn.execute(
243
+ "ALTER TABLE snapshots ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 2"
244
+ )
245
+ conn.execute(
246
+ """
247
+ CREATE TABLE IF NOT EXISTS snapshot_cards (
248
+ snapshot_name TEXT NOT NULL,
249
+ card_id INTEGER NOT NULL,
250
+ board_id INTEGER,
251
+ column_id INTEGER,
252
+ lane_id INTEGER,
253
+ type_id INTEGER,
254
+ owner_id INTEGER,
255
+ responsible_id INTEGER,
256
+ state INTEGER,
257
+ condition INTEGER,
258
+ created TEXT,
259
+ updated TEXT,
260
+ last_moved_to_done_at TEXT,
261
+ has_children INTEGER NOT NULL DEFAULT 0,
262
+ has_comments INTEGER NOT NULL DEFAULT 0,
263
+ children_count INTEGER NOT NULL DEFAULT 0,
264
+ comments_count INTEGER NOT NULL DEFAULT 0,
265
+ time_spent_total_minutes INTEGER NOT NULL DEFAULT 0,
266
+ last_time_log_at TEXT,
267
+ latest_column_id INTEGER,
268
+ latest_lane_id INTEGER,
269
+ current_stage_entered_at TEXT,
270
+ commitment_at TEXT,
271
+ work_started_at TEXT,
272
+ done_at TEXT,
273
+ age_days REAL,
274
+ lead_time_days REAL,
275
+ cycle_time_days REAL,
276
+ tag_ids_json TEXT NOT NULL,
277
+ search_text TEXT NOT NULL,
278
+ child_text TEXT NOT NULL,
279
+ comment_text TEXT NOT NULL,
280
+ card_json TEXT NOT NULL,
281
+ PRIMARY KEY (snapshot_name, card_id),
282
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
283
+ )
284
+ """
285
+ )
286
+ conn.execute(
287
+ """
288
+ CREATE TABLE IF NOT EXISTS snapshot_boards (
289
+ snapshot_name TEXT NOT NULL,
290
+ board_id INTEGER NOT NULL,
291
+ board_json TEXT NOT NULL,
292
+ PRIMARY KEY (snapshot_name, board_id),
293
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
294
+ )
295
+ """
296
+ )
297
+ conn.execute(
298
+ """
299
+ CREATE TABLE IF NOT EXISTS snapshot_columns (
300
+ snapshot_name TEXT NOT NULL,
301
+ board_id INTEGER NOT NULL,
302
+ column_id INTEGER NOT NULL,
303
+ title TEXT,
304
+ column_json TEXT NOT NULL,
305
+ PRIMARY KEY (snapshot_name, column_id),
306
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
307
+ )
308
+ """
309
+ )
310
+ conn.execute(
311
+ """
312
+ CREATE TABLE IF NOT EXISTS snapshot_lanes (
313
+ snapshot_name TEXT NOT NULL,
314
+ board_id INTEGER NOT NULL,
315
+ lane_id INTEGER NOT NULL,
316
+ title TEXT,
317
+ lane_json TEXT NOT NULL,
318
+ PRIMARY KEY (snapshot_name, lane_id),
319
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
320
+ )
321
+ """
322
+ )
323
+ conn.execute(
324
+ """
325
+ CREATE TABLE IF NOT EXISTS snapshot_activity (
326
+ snapshot_name TEXT NOT NULL,
327
+ row_index INTEGER NOT NULL,
328
+ created TEXT,
329
+ activity_json TEXT NOT NULL,
330
+ PRIMARY KEY (snapshot_name, row_index),
331
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
332
+ )
333
+ """
334
+ )
335
+ conn.execute(
336
+ """
337
+ CREATE TABLE IF NOT EXISTS snapshot_history (
338
+ snapshot_name TEXT NOT NULL,
339
+ card_id INTEGER NOT NULL,
340
+ row_index INTEGER NOT NULL,
341
+ changed TEXT,
342
+ column_id INTEGER,
343
+ lane_id INTEGER,
344
+ condition INTEGER,
345
+ state INTEGER,
346
+ history_json TEXT NOT NULL,
347
+ PRIMARY KEY (snapshot_name, card_id, row_index),
348
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
349
+ )
350
+ """
351
+ )
352
+ conn.execute(
353
+ """
354
+ CREATE TABLE IF NOT EXISTS snapshot_relations (
355
+ snapshot_name TEXT NOT NULL,
356
+ parent_card_id INTEGER NOT NULL,
357
+ child_card_id INTEGER NOT NULL,
358
+ relation_json TEXT NOT NULL,
359
+ PRIMARY KEY (snapshot_name, parent_card_id, child_card_id),
360
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
361
+ )
362
+ """
363
+ )
364
+ conn.execute(
365
+ """
366
+ CREATE TABLE IF NOT EXISTS snapshot_comments (
367
+ snapshot_name TEXT NOT NULL,
368
+ card_id INTEGER NOT NULL,
369
+ row_index INTEGER NOT NULL,
370
+ comment_id TEXT,
371
+ text TEXT,
372
+ comment_json TEXT NOT NULL,
373
+ PRIMARY KEY (snapshot_name, card_id, row_index),
374
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
375
+ )
376
+ """
377
+ )
378
+ conn.execute(
379
+ """
380
+ CREATE TABLE IF NOT EXISTS snapshot_time_logs (
381
+ snapshot_name TEXT NOT NULL,
382
+ card_id INTEGER NOT NULL,
383
+ row_index INTEGER NOT NULL,
384
+ time_log_id TEXT,
385
+ created TEXT,
386
+ for_date TEXT,
387
+ time_spent INTEGER,
388
+ comment TEXT,
389
+ time_log_json TEXT NOT NULL,
390
+ PRIMARY KEY (snapshot_name, card_id, row_index),
391
+ FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
392
+ )
393
+ """
394
+ )
395
+ card_columns = {
396
+ row["name"] for row in conn.execute("PRAGMA table_info(snapshot_cards)").fetchall()
397
+ }
398
+ card_alter_statements = {
399
+ "children_count": "ALTER TABLE snapshot_cards ADD COLUMN children_count INTEGER NOT NULL DEFAULT 0",
400
+ "comments_count": "ALTER TABLE snapshot_cards ADD COLUMN comments_count INTEGER NOT NULL DEFAULT 0",
401
+ "time_spent_total_minutes": "ALTER TABLE snapshot_cards ADD COLUMN time_spent_total_minutes INTEGER NOT NULL DEFAULT 0",
402
+ "last_time_log_at": "ALTER TABLE snapshot_cards ADD COLUMN last_time_log_at TEXT",
403
+ "age_days": "ALTER TABLE snapshot_cards ADD COLUMN age_days REAL",
404
+ "lead_time_days": "ALTER TABLE snapshot_cards ADD COLUMN lead_time_days REAL",
405
+ "cycle_time_days": "ALTER TABLE snapshot_cards ADD COLUMN cycle_time_days REAL",
406
+ }
407
+ for column_name, statement in card_alter_statements.items():
408
+ if column_name not in card_columns:
409
+ conn.execute(statement)
410
+ conn.execute(
411
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_board ON snapshot_cards(snapshot_name, board_id)"
412
+ )
413
+ conn.execute(
414
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_column ON snapshot_cards(snapshot_name, column_id)"
415
+ )
416
+ conn.execute(
417
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_lane ON snapshot_cards(snapshot_name, lane_id)"
418
+ )
419
+ conn.execute(
420
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_state ON snapshot_cards(snapshot_name, state)"
421
+ )
422
+ conn.execute(
423
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_condition ON snapshot_cards(snapshot_name, condition)"
424
+ )
425
+ conn.execute(
426
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_done ON snapshot_cards(snapshot_name, done_at)"
427
+ )
428
+ conn.execute(
429
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_cards_last_time_log ON snapshot_cards(snapshot_name, last_time_log_at)"
430
+ )
431
+ conn.execute(
432
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_history_changed ON snapshot_history(snapshot_name, changed)"
433
+ )
434
+ conn.execute(
435
+ "CREATE INDEX IF NOT EXISTS idx_snapshot_time_logs_created ON snapshot_time_logs(snapshot_name, created)"
436
+ )
437
+ try:
438
+ conn.execute(
439
+ """
440
+ CREATE VIRTUAL TABLE IF NOT EXISTS snapshot_card_search
441
+ USING fts5(
442
+ snapshot_name UNINDEXED,
443
+ card_id UNINDEXED,
444
+ search_text,
445
+ child_text,
446
+ comment_text
447
+ )
448
+ """
449
+ )
450
+ except sqlite3.OperationalError:
451
+ pass
452
+ conn.execute(f"PRAGMA user_version = {SNAPSHOT_DB_SCHEMA_VERSION}")
453
+ conn.commit()
454
+
455
+ def list_snapshots(self) -> list[dict[str, Any]]:
456
+ if self.storage_read_only and not self.path.exists() and not self.path.is_symlink():
457
+ return []
458
+ with self._connection(read_only=True) as conn:
459
+ rows = conn.execute(
460
+ """
461
+ SELECT name, profile_name, domain, space_id, preset, window_start, window_end, built_at, dataset_counts_json
462
+ , schema_version
463
+ FROM snapshots
464
+ ORDER BY name
465
+ """
466
+ ).fetchall()
467
+ return [
468
+ {
469
+ "name": row["name"],
470
+ "profile_name": row["profile_name"],
471
+ "domain": row["domain"],
472
+ "space_id": row["space_id"],
473
+ "preset": row["preset"],
474
+ "window": {"start": row["window_start"], "end": row["window_end"]},
475
+ "schema_version": row["schema_version"],
476
+ "built_at": row["built_at"],
477
+ "datasets": json.loads(row["dataset_counts_json"]),
478
+ }
479
+ for row in rows
480
+ ]
481
+
482
+ def get_snapshot(self, name: str) -> dict[str, Any]:
483
+ if self.storage_read_only and not self.path.exists() and not self.path.is_symlink():
484
+ raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
485
+ with self._connection(read_only=True) as conn:
486
+ row = conn.execute(
487
+ """
488
+ SELECT name, profile_name, domain, space_id, board_ids_json, preset, window_start, window_end,
489
+ schema_version,
490
+ built_at, spec_json, dataset_counts_json, build_trace_json
491
+ FROM snapshots
492
+ WHERE name = ?
493
+ """,
494
+ (name,),
495
+ ).fetchone()
496
+ if row is None:
497
+ raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
498
+ built_at = _parse_timestamp(row["built_at"])
499
+ now = datetime.now(timezone.utc)
500
+ staleness_seconds = None
501
+ if built_at is not None:
502
+ staleness_seconds = max(0, int((now - built_at).total_seconds()))
503
+ return {
504
+ "name": row["name"],
505
+ "profile_name": row["profile_name"],
506
+ "domain": row["domain"],
507
+ "space_id": row["space_id"],
508
+ "board_ids": json.loads(row["board_ids_json"]),
509
+ "preset": row["preset"],
510
+ "window": {"start": row["window_start"], "end": row["window_end"]},
511
+ "schema_version": row["schema_version"],
512
+ "built_at": row["built_at"],
513
+ "spec": json.loads(row["spec_json"]),
514
+ "datasets": json.loads(row["dataset_counts_json"]),
515
+ "last_build_trace": json.loads(row["build_trace_json"]),
516
+ "staleness_seconds": staleness_seconds,
517
+ }
518
+
519
+ def delete_snapshot(self, name: str) -> dict[str, Any]:
520
+ with self._connection() as conn:
521
+ deleted = conn.execute("DELETE FROM snapshots WHERE name = ?", (name,)).rowcount
522
+ try:
523
+ conn.execute("DELETE FROM snapshot_card_search WHERE snapshot_name = ?", (name,))
524
+ except sqlite3.OperationalError:
525
+ pass
526
+ conn.commit()
527
+ if not deleted:
528
+ raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
529
+ return {"name": name, "deleted": True}
530
+
531
+ def replace_snapshot(
532
+ self,
533
+ *,
534
+ name: str,
535
+ profile_name: str | None,
536
+ domain: str,
537
+ space_id: int,
538
+ board_ids: list[int],
539
+ preset: str,
540
+ window_start: str | None,
541
+ window_end: str | None,
542
+ spec: dict[str, Any],
543
+ dataset_counts: dict[str, int],
544
+ build_trace: dict[str, Any],
545
+ topology: dict[str, Any],
546
+ cards: list[dict[str, Any]],
547
+ history_map: dict[int, list[dict[str, Any]]],
548
+ children_map: dict[int, list[dict[str, Any]]],
549
+ comments_map: dict[int, list[dict[str, Any]]],
550
+ time_logs_map: dict[int, list[dict[str, Any]]],
551
+ activity_rows: list[dict[str, Any]],
552
+ ) -> None:
553
+ built_at = _now_iso()
554
+ built_at_dt = _parse_timestamp(built_at)
555
+ window_start_dt = _parse_timestamp(window_start)
556
+ window_end_dt = _parse_timestamp(window_end)
557
+ cutoff_dt = window_end_dt or built_at_dt or datetime.now(timezone.utc)
558
+ card_rows: list[tuple[Any, ...]] = []
559
+ relation_rows: list[tuple[Any, ...]] = []
560
+ comment_rows: list[tuple[Any, ...]] = []
561
+ history_rows: list[tuple[Any, ...]] = []
562
+ time_log_rows: list[tuple[Any, ...]] = []
563
+ search_rows: list[tuple[str, int, str, str, str]] = []
564
+
565
+ for card in cards:
566
+ if not isinstance(card, dict) or "id" not in card:
567
+ continue
568
+ card_id = int(card["id"])
569
+ history = history_map.get(card_id, [])
570
+ children = children_map.get(card_id, [])
571
+ comments = comments_map.get(card_id, [])
572
+ time_logs = _filter_time_logs_to_window(
573
+ time_logs_map.get(card_id, []),
574
+ window_start=window_start_dt,
575
+ window_end=window_end_dt,
576
+ )
577
+ type_id = card.get("type_id")
578
+ if type_id is None and isinstance(card.get("type"), dict):
579
+ type_id = card["type"].get("id")
580
+ owner_id = card.get("owner_id")
581
+ if owner_id is None and isinstance(card.get("owner"), dict):
582
+ owner_id = card["owner"].get("id")
583
+ responsible_id = card.get("responsible_id")
584
+ if responsible_id is None and isinstance(card.get("responsible"), dict):
585
+ responsible_id = card["responsible"].get("id")
586
+
587
+ child_titles = [
588
+ str(item.get("title", "")) for item in children if isinstance(item, dict)
589
+ ]
590
+ comment_texts = [
591
+ str(item.get("text", "")) for item in comments if isinstance(item, dict)
592
+ ]
593
+ search_text = _search_blob(
594
+ [str(card.get("title", "")), str(card.get("description", ""))]
595
+ )
596
+ child_text = _search_blob(child_titles)
597
+ comment_text = _search_blob(comment_texts)
598
+ done_at = _derive_done_at(card, history)
599
+ work_started_at = _first_history_with_state(history, 2)
600
+ current_stage_entered_at = _current_stage_entered_at(history)
601
+ latest = history[-1] if history else {}
602
+ latest_column_id = latest.get("column_id", card.get("column_id"))
603
+ latest_lane_id = latest.get("lane_id", card.get("lane_id"))
604
+ last_time_log_at = None
605
+ time_spent_total_minutes = 0
606
+ for item in time_logs:
607
+ if not isinstance(item, dict):
608
+ continue
609
+ time_spent_total_minutes += _time_log_minutes(item)
610
+ changed = _time_log_timestamp(item)
611
+ if changed is not None and (last_time_log_at is None or changed > last_time_log_at):
612
+ last_time_log_at = changed
613
+ age_anchor = (
614
+ current_stage_entered_at
615
+ or _parse_timestamp(card.get("updated"))
616
+ or _parse_timestamp(card.get("created"))
617
+ )
618
+ age_days = None
619
+ if card.get("condition") == 1 and card.get("state") != 3:
620
+ age_days = _duration_days(age_anchor, cutoff_dt)
621
+ lead_time_days = _duration_days(_parse_timestamp(card.get("created")), done_at)
622
+ cycle_time_days = _duration_days(work_started_at, done_at)
623
+
624
+ derived = {
625
+ "has_children": bool(children),
626
+ "has_comments": bool(comments),
627
+ "children_count": len(children),
628
+ "comments_count": len(comments),
629
+ "time_spent_total_minutes": time_spent_total_minutes,
630
+ "last_time_log_at": _iso_timestamp(last_time_log_at),
631
+ "latest_stage": {"column_id": latest_column_id, "lane_id": latest_lane_id},
632
+ "latest_column_id": latest_column_id,
633
+ "latest_lane_id": latest_lane_id,
634
+ "search_text": search_text,
635
+ "child_text": child_text,
636
+ "comment_text": comment_text,
637
+ "current_stage_entered_at": _iso_timestamp(current_stage_entered_at),
638
+ "commitment_at": None,
639
+ "work_started_at": _iso_timestamp(work_started_at),
640
+ "done_at": _iso_timestamp(done_at),
641
+ "age_days": age_days,
642
+ "lead_time_days": lead_time_days,
643
+ "cycle_time_days": cycle_time_days,
644
+ }
645
+ card_rows.append(
646
+ (
647
+ name,
648
+ card_id,
649
+ card.get("board_id"),
650
+ card.get("column_id"),
651
+ card.get("lane_id"),
652
+ type_id,
653
+ owner_id,
654
+ responsible_id,
655
+ card.get("state"),
656
+ card.get("condition"),
657
+ card.get("created"),
658
+ card.get("updated"),
659
+ card.get("last_moved_to_done_at"),
660
+ 1 if children else 0,
661
+ 1 if comments else 0,
662
+ len(children),
663
+ len(comments),
664
+ time_spent_total_minutes,
665
+ derived["last_time_log_at"],
666
+ latest_column_id,
667
+ latest_lane_id,
668
+ derived["current_stage_entered_at"],
669
+ derived["commitment_at"],
670
+ derived["work_started_at"],
671
+ derived["done_at"],
672
+ derived["age_days"],
673
+ derived["lead_time_days"],
674
+ derived["cycle_time_days"],
675
+ json.dumps(_extract_tag_ids(card), ensure_ascii=False, separators=(",", ":")),
676
+ search_text,
677
+ child_text,
678
+ comment_text,
679
+ json.dumps(card, ensure_ascii=False, separators=(",", ":")),
680
+ )
681
+ )
682
+ search_rows.append((name, card_id, search_text, child_text, comment_text))
683
+ for child in children:
684
+ if not isinstance(child, dict):
685
+ continue
686
+ child_id = _extract_id(child)
687
+ if child_id is None:
688
+ continue
689
+ relation_rows.append(
690
+ (
691
+ name,
692
+ card_id,
693
+ child_id,
694
+ json.dumps(child, ensure_ascii=False, separators=(",", ":")),
695
+ )
696
+ )
697
+ for index, comment in enumerate(comments):
698
+ if not isinstance(comment, dict):
699
+ continue
700
+ comment_rows.append(
701
+ (
702
+ name,
703
+ card_id,
704
+ index,
705
+ str(comment.get("id")) if comment.get("id") is not None else None,
706
+ str(comment.get("text", "")),
707
+ json.dumps(comment, ensure_ascii=False, separators=(",", ":")),
708
+ )
709
+ )
710
+ for index, row in enumerate(history):
711
+ history_rows.append(
712
+ (
713
+ name,
714
+ card_id,
715
+ index,
716
+ row.get("changed"),
717
+ row.get("column_id"),
718
+ row.get("lane_id"),
719
+ row.get("condition"),
720
+ row.get("state"),
721
+ json.dumps(row, ensure_ascii=False, separators=(",", ":")),
722
+ )
723
+ )
724
+ for index, time_log in enumerate(time_logs):
725
+ if not isinstance(time_log, dict):
726
+ continue
727
+ time_log_rows.append(
728
+ (
729
+ name,
730
+ card_id,
731
+ index,
732
+ str(time_log.get("id")) if time_log.get("id") is not None else None,
733
+ _iso_timestamp(_time_log_timestamp(time_log)),
734
+ str(time_log.get("for_date") or time_log.get("forDate") or "") or None,
735
+ _time_log_minutes(time_log),
736
+ str(time_log.get("comment", "")),
737
+ json.dumps(time_log, ensure_ascii=False, separators=(",", ":")),
738
+ )
739
+ )
740
+
741
+ board_rows: list[tuple[Any, ...]] = []
742
+ column_rows: list[tuple[Any, ...]] = []
743
+ lane_rows: list[tuple[Any, ...]] = []
744
+ for board in topology.get("boards", []):
745
+ if not isinstance(board, dict) or "id" not in board:
746
+ continue
747
+ board_rows.append(
748
+ (name, board["id"], json.dumps(board, ensure_ascii=False, separators=(",", ":")))
749
+ )
750
+ for column in board.get("columns", []):
751
+ if isinstance(column, dict) and "id" in column:
752
+ column_rows.append(
753
+ (
754
+ name,
755
+ board["id"],
756
+ column["id"],
757
+ column.get("title"),
758
+ json.dumps(column, ensure_ascii=False, separators=(",", ":")),
759
+ )
760
+ )
761
+ for lane in board.get("lanes", []):
762
+ if isinstance(lane, dict) and "id" in lane:
763
+ lane_rows.append(
764
+ (
765
+ name,
766
+ board["id"],
767
+ lane["id"],
768
+ lane.get("title"),
769
+ json.dumps(lane, ensure_ascii=False, separators=(",", ":")),
770
+ )
771
+ )
772
+
773
+ activity_insert_rows = [
774
+ (
775
+ name,
776
+ index,
777
+ row.get("created") if isinstance(row, dict) else None,
778
+ json.dumps(row, ensure_ascii=False, separators=(",", ":")),
779
+ )
780
+ for index, row in enumerate(activity_rows)
781
+ if isinstance(row, dict)
782
+ ]
783
+
784
+ with self._connection() as conn:
785
+ conn.execute("DELETE FROM snapshots WHERE name = ?", (name,))
786
+ try:
787
+ conn.execute("DELETE FROM snapshot_card_search WHERE snapshot_name = ?", (name,))
788
+ except sqlite3.OperationalError:
789
+ pass
790
+ conn.execute(
791
+ """
792
+ INSERT INTO snapshots (
793
+ name, profile_name, domain, space_id, board_ids_json, preset, window_start, window_end,
794
+ schema_version, built_at, spec_json, dataset_counts_json, build_trace_json
795
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
796
+ """,
797
+ (
798
+ name,
799
+ profile_name,
800
+ domain,
801
+ space_id,
802
+ json.dumps(board_ids, ensure_ascii=False, separators=(",", ":")),
803
+ preset,
804
+ window_start,
805
+ window_end,
806
+ SNAPSHOT_SCHEMA_VERSION,
807
+ built_at,
808
+ json.dumps(spec, ensure_ascii=False, separators=(",", ":")),
809
+ json.dumps(dataset_counts, ensure_ascii=False, separators=(",", ":")),
810
+ json.dumps(build_trace, ensure_ascii=False, separators=(",", ":")),
811
+ ),
812
+ )
813
+ if board_rows:
814
+ conn.executemany(
815
+ "INSERT INTO snapshot_boards (snapshot_name, board_id, board_json) VALUES (?, ?, ?)",
816
+ board_rows,
817
+ )
818
+ if column_rows:
819
+ conn.executemany(
820
+ """
821
+ INSERT INTO snapshot_columns (snapshot_name, board_id, column_id, title, column_json)
822
+ VALUES (?, ?, ?, ?, ?)
823
+ """,
824
+ column_rows,
825
+ )
826
+ if lane_rows:
827
+ conn.executemany(
828
+ """
829
+ INSERT INTO snapshot_lanes (snapshot_name, board_id, lane_id, title, lane_json)
830
+ VALUES (?, ?, ?, ?, ?)
831
+ """,
832
+ lane_rows,
833
+ )
834
+ if card_rows:
835
+ conn.executemany(
836
+ """
837
+ INSERT INTO snapshot_cards (
838
+ snapshot_name, card_id, board_id, column_id, lane_id, type_id, owner_id, responsible_id,
839
+ state, condition, created, updated, last_moved_to_done_at, has_children, has_comments,
840
+ children_count, comments_count, time_spent_total_minutes, last_time_log_at,
841
+ latest_column_id, latest_lane_id, current_stage_entered_at, commitment_at, work_started_at,
842
+ done_at, age_days, lead_time_days, cycle_time_days, tag_ids_json, search_text, child_text,
843
+ comment_text, card_json
844
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
845
+ """,
846
+ card_rows,
847
+ )
848
+ if history_rows:
849
+ conn.executemany(
850
+ """
851
+ INSERT INTO snapshot_history (
852
+ snapshot_name, card_id, row_index, changed, column_id, lane_id, condition, state, history_json
853
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
854
+ """,
855
+ history_rows,
856
+ )
857
+ if relation_rows:
858
+ conn.executemany(
859
+ """
860
+ INSERT INTO snapshot_relations (snapshot_name, parent_card_id, child_card_id, relation_json)
861
+ VALUES (?, ?, ?, ?)
862
+ """,
863
+ relation_rows,
864
+ )
865
+ if comment_rows:
866
+ conn.executemany(
867
+ """
868
+ INSERT INTO snapshot_comments (snapshot_name, card_id, row_index, comment_id, text, comment_json)
869
+ VALUES (?, ?, ?, ?, ?, ?)
870
+ """,
871
+ comment_rows,
872
+ )
873
+ if time_log_rows:
874
+ conn.executemany(
875
+ """
876
+ INSERT INTO snapshot_time_logs (
877
+ snapshot_name, card_id, row_index, time_log_id, created, for_date, time_spent, comment, time_log_json
878
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
879
+ """,
880
+ time_log_rows,
881
+ )
882
+ if activity_insert_rows:
883
+ conn.executemany(
884
+ """
885
+ INSERT INTO snapshot_activity (snapshot_name, row_index, created, activity_json)
886
+ VALUES (?, ?, ?, ?)
887
+ """,
888
+ activity_insert_rows,
889
+ )
890
+ if search_rows:
891
+ try:
892
+ conn.executemany(
893
+ """
894
+ INSERT INTO snapshot_card_search (snapshot_name, card_id, search_text, child_text, comment_text)
895
+ VALUES (?, ?, ?, ?, ?)
896
+ """,
897
+ search_rows,
898
+ )
899
+ except sqlite3.OperationalError:
900
+ pass
901
+ conn.commit()
902
+
903
+ def _row_to_card_record(self, row: sqlite3.Row) -> dict[str, Any]:
904
+ card = json.loads(row["card_json"])
905
+ derived = {
906
+ "has_children": bool(row["has_children"]),
907
+ "has_comments": bool(row["has_comments"]),
908
+ "children_count": row["children_count"],
909
+ "comments_count": row["comments_count"],
910
+ "time_spent_total_minutes": row["time_spent_total_minutes"],
911
+ "last_time_log_at": row["last_time_log_at"],
912
+ "latest_stage": {
913
+ "column_id": row["latest_column_id"],
914
+ "lane_id": row["latest_lane_id"],
915
+ },
916
+ "latest_column_id": row["latest_column_id"],
917
+ "latest_lane_id": row["latest_lane_id"],
918
+ "search_text": row["search_text"],
919
+ "child_text": row["child_text"],
920
+ "comment_text": row["comment_text"],
921
+ "current_stage_entered_at": row["current_stage_entered_at"],
922
+ "commitment_at": row["commitment_at"],
923
+ "work_started_at": row["work_started_at"],
924
+ "done_at": row["done_at"],
925
+ "age_days": row["age_days"],
926
+ "lead_time_days": row["lead_time_days"],
927
+ "cycle_time_days": row["cycle_time_days"],
928
+ }
929
+ return {
930
+ "card_id": row["card_id"],
931
+ "board_id": row["board_id"],
932
+ "column_id": row["column_id"],
933
+ "lane_id": row["lane_id"],
934
+ "type_id": row["type_id"],
935
+ "owner_id": row["owner_id"],
936
+ "responsible_id": row["responsible_id"],
937
+ "state": row["state"],
938
+ "condition": row["condition"],
939
+ "created": row["created"],
940
+ "updated": row["updated"],
941
+ "done_at": row["done_at"],
942
+ "work_started_at": row["work_started_at"],
943
+ "current_stage_entered_at": row["current_stage_entered_at"],
944
+ "has_children": bool(row["has_children"]),
945
+ "has_comments": bool(row["has_comments"]),
946
+ "children_count": row["children_count"],
947
+ "comments_count": row["comments_count"],
948
+ "time_spent_total_minutes": row["time_spent_total_minutes"],
949
+ "last_time_log_at": row["last_time_log_at"],
950
+ "age_days": row["age_days"],
951
+ "lead_time_days": row["lead_time_days"],
952
+ "cycle_time_days": row["cycle_time_days"],
953
+ "tag_ids": json.loads(row["tag_ids_json"]),
954
+ "search_text": row["search_text"],
955
+ "child_text": row["child_text"],
956
+ "comment_text": row["comment_text"],
957
+ "card": card,
958
+ "derived": derived,
959
+ }
960
+
961
+ def load_card_records(self, snapshot_name: str) -> list[dict[str, Any]]:
962
+ self.get_snapshot(snapshot_name)
963
+ with self._connection(read_only=True) as conn:
964
+ rows = conn.execute(
965
+ """
966
+ SELECT *
967
+ FROM snapshot_cards
968
+ WHERE snapshot_name = ?
969
+ ORDER BY card_id
970
+ """,
971
+ (snapshot_name,),
972
+ ).fetchall()
973
+ return [self._row_to_card_record(row) for row in rows]
974
+
975
+ def _build_card_query_parts(
976
+ self,
977
+ snapshot_name: str,
978
+ filters: dict[str, Any],
979
+ candidate_ids: set[int] | None,
980
+ *,
981
+ include_tag_filter: bool,
982
+ ) -> tuple[list[str], list[Any], bool]:
983
+ clauses = ["snapshot_name = ?"]
984
+ params: list[Any] = [snapshot_name]
985
+ fallback_tag_filter = False
986
+ mapping = {
987
+ "board_ids": "board_id",
988
+ "column_ids": "column_id",
989
+ "lane_ids": "lane_id",
990
+ "type_ids": "type_id",
991
+ "owner_ids": "owner_id",
992
+ "responsible_ids": "responsible_id",
993
+ "states": "state",
994
+ }
995
+ for filter_name, column_name in mapping.items():
996
+ values = _normalize_int_list(filters.get(filter_name))
997
+ if values:
998
+ placeholders = ",".join("?" for _ in values)
999
+ clauses.append(f"{column_name} IN ({placeholders})")
1000
+ params.extend(values)
1001
+ condition_values = _normalize_int_list(filters.get("condition"))
1002
+ if condition_values:
1003
+ placeholders = ",".join("?" for _ in condition_values)
1004
+ clauses.append(f"condition IN ({placeholders})")
1005
+ params.extend(condition_values)
1006
+ range_filters = (
1007
+ ("created_after", "created", ">="),
1008
+ ("created_before", "created", "<="),
1009
+ ("updated_after", "updated", ">="),
1010
+ ("updated_before", "updated", "<="),
1011
+ )
1012
+ for filter_name, column_name, operator in range_filters:
1013
+ value = filters.get(filter_name)
1014
+ if value:
1015
+ clauses.append(f"{column_name} {operator} ?")
1016
+ params.append(value)
1017
+ for filter_name, column_name in (
1018
+ ("has_children", "has_children"),
1019
+ ("has_comments", "has_comments"),
1020
+ ):
1021
+ value = _normalize_bool(filters.get(filter_name))
1022
+ if value is not None:
1023
+ clauses.append(f"{column_name} = ?")
1024
+ params.append(1 if value else 0)
1025
+ if candidate_ids is not None:
1026
+ if not candidate_ids:
1027
+ clauses.append("1 = 0")
1028
+ else:
1029
+ ordered_ids = sorted(candidate_ids)
1030
+ placeholders = ",".join("?" for _ in ordered_ids)
1031
+ clauses.append(f"card_id IN ({placeholders})")
1032
+ params.extend(ordered_ids)
1033
+ if include_tag_filter:
1034
+ tag_ids = _normalize_int_list(filters.get("tag_ids"))
1035
+ if tag_ids:
1036
+ placeholders = ",".join("?" for _ in tag_ids)
1037
+ clauses.append(
1038
+ "EXISTS (SELECT 1 FROM json_each(snapshot_cards.tag_ids_json) WHERE json_each.value IN "
1039
+ f"({placeholders}))"
1040
+ )
1041
+ params.extend(tag_ids)
1042
+ fallback_tag_filter = True
1043
+ return clauses, params, fallback_tag_filter
1044
+
1045
+ def query_card_records(
1046
+ self,
1047
+ snapshot_name: str,
1048
+ filters: dict[str, Any],
1049
+ candidate_ids: set[int] | None,
1050
+ *,
1051
+ limit: int,
1052
+ offset: int,
1053
+ ) -> tuple[list[dict[str, Any]], int]:
1054
+ self.get_snapshot(snapshot_name)
1055
+ clauses, params, fallback_tag_filter = self._build_card_query_parts(
1056
+ snapshot_name,
1057
+ filters,
1058
+ candidate_ids,
1059
+ include_tag_filter=True,
1060
+ )
1061
+ where_sql = " AND ".join(clauses)
1062
+ with self._connection(read_only=True) as conn:
1063
+ try:
1064
+ total = int(
1065
+ conn.execute(
1066
+ f"SELECT COUNT(*) AS total FROM snapshot_cards WHERE {where_sql}",
1067
+ params,
1068
+ ).fetchone()["total"]
1069
+ )
1070
+ rows = conn.execute(
1071
+ f"""
1072
+ SELECT *
1073
+ FROM snapshot_cards
1074
+ WHERE {where_sql}
1075
+ ORDER BY card_id
1076
+ LIMIT ? OFFSET ?
1077
+ """,
1078
+ [*params, limit, offset],
1079
+ ).fetchall()
1080
+ return [self._row_to_card_record(row) for row in rows], total
1081
+ except sqlite3.OperationalError:
1082
+ if not fallback_tag_filter:
1083
+ raise
1084
+ fallback_clauses, fallback_params, _ = self._build_card_query_parts(
1085
+ snapshot_name,
1086
+ {**filters, "tag_ids": None},
1087
+ candidate_ids,
1088
+ include_tag_filter=False,
1089
+ )
1090
+ fallback_where_sql = " AND ".join(fallback_clauses)
1091
+ rows = conn.execute(
1092
+ f"""
1093
+ SELECT *
1094
+ FROM snapshot_cards
1095
+ WHERE {fallback_where_sql}
1096
+ ORDER BY card_id
1097
+ """,
1098
+ fallback_params,
1099
+ ).fetchall()
1100
+ matched = [
1101
+ record
1102
+ for record in (self._row_to_card_record(row) for row in rows)
1103
+ if not _normalize_int_list(filters.get("tag_ids"))
1104
+ or (set(record["tag_ids"]) & set(_normalize_int_list(filters.get("tag_ids"))))
1105
+ ]
1106
+ return matched[offset : offset + limit], len(matched)
1107
+
1108
+ def load_metric_rows(
1109
+ self,
1110
+ snapshot_name: str,
1111
+ filters: dict[str, Any],
1112
+ candidate_ids: set[int] | None,
1113
+ columns: tuple[str, ...],
1114
+ ) -> list[sqlite3.Row]:
1115
+ self.get_snapshot(snapshot_name)
1116
+ clauses, params, fallback_tag_filter = self._build_card_query_parts(
1117
+ snapshot_name,
1118
+ filters,
1119
+ candidate_ids,
1120
+ include_tag_filter=True,
1121
+ )
1122
+ where_sql = " AND ".join(clauses)
1123
+ selected_columns = ", ".join(columns)
1124
+ with self._connection(read_only=True) as conn:
1125
+ try:
1126
+ return conn.execute(
1127
+ f"""
1128
+ SELECT {selected_columns}
1129
+ FROM snapshot_cards
1130
+ WHERE {where_sql}
1131
+ ORDER BY card_id
1132
+ """,
1133
+ params,
1134
+ ).fetchall()
1135
+ except sqlite3.OperationalError:
1136
+ if not fallback_tag_filter:
1137
+ raise
1138
+ fallback_clauses, fallback_params, _ = self._build_card_query_parts(
1139
+ snapshot_name,
1140
+ {**filters, "tag_ids": None},
1141
+ candidate_ids,
1142
+ include_tag_filter=False,
1143
+ )
1144
+ fallback_where_sql = " AND ".join(fallback_clauses)
1145
+ rows = conn.execute(
1146
+ f"""
1147
+ SELECT {selected_columns}, tag_ids_json
1148
+ FROM snapshot_cards
1149
+ WHERE {fallback_where_sql}
1150
+ ORDER BY card_id
1151
+ """,
1152
+ fallback_params,
1153
+ ).fetchall()
1154
+ tag_ids = set(_normalize_int_list(filters.get("tag_ids")))
1155
+ return [row for row in rows if set(json.loads(row["tag_ids_json"])) & tag_ids]
1156
+
1157
+ def text_candidate_card_ids(self, snapshot_name: str, field: str, text: str) -> set[int]:
1158
+ self.get_snapshot(snapshot_name)
1159
+ lowered = text.strip().lower()
1160
+ if not lowered:
1161
+ return set()
1162
+ column_map = {
1163
+ "search_text": "search_text",
1164
+ "child_text": "child_text",
1165
+ "comment_text": "comment_text",
1166
+ }
1167
+ column = column_map[field]
1168
+ with self._connection(read_only=True) as conn:
1169
+ try:
1170
+ tokens = " ".join(part for part in lowered.split() if part)
1171
+ if tokens:
1172
+ rows = conn.execute(
1173
+ f"""
1174
+ SELECT card_id
1175
+ FROM snapshot_card_search
1176
+ WHERE snapshot_name = ? AND {column} MATCH ?
1177
+ """,
1178
+ (snapshot_name, tokens),
1179
+ ).fetchall()
1180
+ if rows:
1181
+ return {int(row["card_id"]) for row in rows}
1182
+ except sqlite3.OperationalError:
1183
+ pass
1184
+ rows = conn.execute(
1185
+ f"""
1186
+ SELECT card_id
1187
+ FROM snapshot_cards
1188
+ WHERE snapshot_name = ? AND lower({column}) LIKE ?
1189
+ """,
1190
+ (snapshot_name, f"%{lowered}%"),
1191
+ ).fetchall()
1192
+ return {int(row["card_id"]) for row in rows}