@morroc/open-ledger 0.14.1

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 (81) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +191 -0
  3. package/datasets/defaults/cn.json +1 -0
  4. package/datasets/defaults/jp.json +1 -0
  5. package/datasets/defaults/th.json +1 -0
  6. package/datasets/defaults/us.json +1 -0
  7. package/datasets/institutions/cn.json +47 -0
  8. package/datasets/institutions/jp.json +59 -0
  9. package/datasets/institutions/th.json +99 -0
  10. package/datasets/institutions/us.json +70 -0
  11. package/dist/accounts/accounts.js +99 -0
  12. package/dist/accounts/balances.js +109 -0
  13. package/dist/accounts/matching.js +72 -0
  14. package/dist/accounts/resolve.js +118 -0
  15. package/dist/cli/commands/accounts.js +477 -0
  16. package/dist/cli/commands/config.js +175 -0
  17. package/dist/cli/commands/datasets.js +56 -0
  18. package/dist/cli/commands/doctor.js +153 -0
  19. package/dist/cli/commands/files.js +72 -0
  20. package/dist/cli/commands/ingest-commit.js +279 -0
  21. package/dist/cli/commands/ingest.js +203 -0
  22. package/dist/cli/commands/merchants.js +140 -0
  23. package/dist/cli/commands/notes.js +71 -0
  24. package/dist/cli/commands/open.js +54 -0
  25. package/dist/cli/commands/questions.js +119 -0
  26. package/dist/cli/commands/report.js +50 -0
  27. package/dist/cli/commands/setup.js +61 -0
  28. package/dist/cli/commands/status.js +187 -0
  29. package/dist/cli/commands/transactions.js +420 -0
  30. package/dist/cli/commands/vault.js +65 -0
  31. package/dist/cli/currency.js +28 -0
  32. package/dist/cli/db.js +5 -0
  33. package/dist/cli/format.js +71 -0
  34. package/dist/cli/index.js +19 -0
  35. package/dist/cli/output.js +304 -0
  36. package/dist/cli/program.js +140 -0
  37. package/dist/config.js +104 -0
  38. package/dist/context.js +30 -0
  39. package/dist/datasets/defaults.js +29 -0
  40. package/dist/datasets/index.js +33 -0
  41. package/dist/datasets/institutions.js +31 -0
  42. package/dist/datasets/loader.js +57 -0
  43. package/dist/db/connection.js +39 -0
  44. package/dist/db/encryption.js +42 -0
  45. package/dist/db/migrations/0001_baseline.js +121 -0
  46. package/dist/db/migrations/index.js +3 -0
  47. package/dist/db/queries/accounts.js +108 -0
  48. package/dist/db/queries/balances.js +61 -0
  49. package/dist/db/queries/files.js +72 -0
  50. package/dist/db/queries/merchants.js +163 -0
  51. package/dist/db/queries/notes.js +28 -0
  52. package/dist/db/queries/questions.js +117 -0
  53. package/dist/db/queries/transactions-dedup.js +80 -0
  54. package/dist/db/queries/transactions.js +319 -0
  55. package/dist/db/queries/vault.js +48 -0
  56. package/dist/db/schema.js +105 -0
  57. package/dist/extract/extract.js +89 -0
  58. package/dist/extract/ocr.js +164 -0
  59. package/dist/extract/pdf.js +127 -0
  60. package/dist/extract/presets/index.js +18 -0
  61. package/dist/extract/presets/lighton-ocr.js +12 -0
  62. package/dist/extract/presets/typhoon-ocr.js +23 -0
  63. package/dist/extract/route.js +40 -0
  64. package/dist/extract/source.js +84 -0
  65. package/dist/ingest/commit.js +313 -0
  66. package/dist/ingest/dedup.js +35 -0
  67. package/dist/ingest/prepare.js +241 -0
  68. package/dist/ingest/vault.js +56 -0
  69. package/dist/lib/date.js +6 -0
  70. package/dist/lib/ids.js +25 -0
  71. package/dist/lib/json.js +12 -0
  72. package/dist/lib/masked.js +49 -0
  73. package/dist/lib/money.js +35 -0
  74. package/dist/lib/patch.js +29 -0
  75. package/dist/lib/result.js +15 -0
  76. package/dist/lib/validate.js +154 -0
  77. package/dist/privacy/redactor.js +140 -0
  78. package/dist/setup/hosts.js +32 -0
  79. package/dist/setup/install.js +60 -0
  80. package/package.json +60 -0
  81. package/skills/SKILL.md +18 -0
@@ -0,0 +1,72 @@
1
+ /** Missing status buckets are filled with 0 so callers get a stable shape without null checks. */
2
+ export function countFiles(db) {
3
+ const rows = db
4
+ .prepare(`SELECT status, COUNT(*) AS n FROM files GROUP BY status`)
5
+ .all();
6
+ const totals = { ingested: 0, pending: 0, failed: 0 };
7
+ for (const row of rows) {
8
+ if (row.status === "ingested" || row.status === "pending" || row.status === "failed") {
9
+ totals[row.status] = row.n;
10
+ }
11
+ }
12
+ return totals;
13
+ }
14
+ const FILE_SELECT = `SELECT id, path, file_hash, mime, status, ingested_at, source, error, created_at
15
+ FROM files`;
16
+ export function listFiles(db) {
17
+ return db
18
+ .prepare(`${FILE_SELECT} ORDER BY ingested_at DESC, created_at DESC`)
19
+ .all();
20
+ }
21
+ export function findFileById(db, id) {
22
+ const row = db.prepare(`${FILE_SELECT} WHERE id = ?`).get(id);
23
+ return row ?? null;
24
+ }
25
+ /** The row a file's bytes already registered as; `file_hash` is UNIQUE, so a content match is exactly one row. */
26
+ export function findFileByHash(db, hash) {
27
+ const row = db.prepare(`${FILE_SELECT} WHERE file_hash = ?`).get(hash);
28
+ return row ?? null;
29
+ }
30
+ export function insertPendingFile(db, file) {
31
+ db.prepare(`INSERT INTO files (id, path, file_hash, mime, status) VALUES (?, ?, ?, ?, 'pending')`).run(file.id, file.path, file.file_hash, file.mime);
32
+ }
33
+ /**
34
+ * Delete leads (file_hash is UNIQUE) then re-insert, atomically; the delete cascades away
35
+ * the prior row's transactions and questions (ON DELETE CASCADE).
36
+ */
37
+ export function replaceFile(db, priorId, file) {
38
+ const tx = db.transaction(() => {
39
+ deleteFile(db, priorId);
40
+ insertPendingFile(db, file);
41
+ });
42
+ tx();
43
+ }
44
+ /**
45
+ * Cascaded transaction/question counts are gathered before the DELETE runs —
46
+ * CASCADE would make them unrecoverable after.
47
+ */
48
+ export function deleteFile(db, id) {
49
+ const removed = findFileById(db, id);
50
+ if (!removed) {
51
+ return { removed: null, removedTransactions: 0, removedQuestions: 0 };
52
+ }
53
+ const removedTransactions = db
54
+ .prepare(`SELECT COUNT(*) AS n FROM transactions WHERE source_file_id = ?`)
55
+ .get(id).n;
56
+ const removedQuestions = db
57
+ .prepare(`SELECT COUNT(*) AS n FROM questions WHERE file_id = ?`)
58
+ .get(id).n;
59
+ db.prepare(`DELETE FROM files WHERE id = ?`).run(id);
60
+ return { removed, removedTransactions, removedQuestions };
61
+ }
62
+ export function markFileIngested(db, fileId, opts) {
63
+ return db
64
+ .prepare(`UPDATE files SET status = 'ingested', ingested_at = datetime('now'), source = ? WHERE id = ?`)
65
+ .run(opts.source ?? null, fileId).changes;
66
+ }
67
+ /** ingested_at is left untouched: a failed file was never successfully ingested. */
68
+ export function markFileFailed(db, fileId, opts) {
69
+ return db
70
+ .prepare(`UPDATE files SET status = 'failed', source = ?, error = ? WHERE id = ?`)
71
+ .run(opts.source ?? null, opts.error, fileId).changes;
72
+ }
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from "crypto";
2
+ /**
3
+ * Strips store ids, terminal codes, city tags, and transaction-type words so
4
+ * "STARBUCKS #1234 BKK CHARGE" and "Starbucks #5678 BANGKOK" both normalize
5
+ * to "starbucks" — the form `merchant_aliases.normalized_pattern` indexes.
6
+ */
7
+ const NOISE_TOKENS = new Set([
8
+ "bkk", "bangkok", "thailand", "th", "tha",
9
+ "charge", "purchase", "payment", "pmt", "ref", "txn", "trx", "tx",
10
+ "pos", "atm", "online", "web", "mobile", "app",
11
+ "co", "ltd", "company", "inc", "llc", "plc", "intl",
12
+ ]);
13
+ export function normalizeDescriptor(raw) {
14
+ if (!raw)
15
+ return "";
16
+ const lowered = raw.toLowerCase();
17
+ const stripped = lowered
18
+ .replace(/[#*][a-z0-9]+/gi, " ")
19
+ .replace(/\b\d{2,}\b/g, " ")
20
+ .replace(/[^a-z0-9\s]+/g, " ")
21
+ .replace(/\s+/g, " ")
22
+ .trim();
23
+ if (!stripped)
24
+ return "";
25
+ const tokens = stripped.split(" ").filter(t => t.length > 1 && !NOISE_TOKENS.has(t));
26
+ if (tokens.length === 0)
27
+ return stripped;
28
+ return tokens.join(" ");
29
+ }
30
+ /**
31
+ * Meant to run inside the same DB transaction as the write it serves, so a
32
+ * transaction never lands without its merchant.
33
+ */
34
+ export function upsertMerchant(db, input) {
35
+ const canonical = input.canonical_name.trim();
36
+ if (!canonical) {
37
+ throw new Error("merchant canonical_name is required");
38
+ }
39
+ const existing = db
40
+ .prepare(`SELECT id, canonical_name, default_account_id, created_at FROM merchants WHERE canonical_name = ?`)
41
+ .get(canonical);
42
+ let merchant;
43
+ if (existing) {
44
+ merchant = existing;
45
+ if (input.default_account_id && input.default_account_id !== existing.default_account_id) {
46
+ db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
47
+ .run(input.default_account_id, existing.id);
48
+ merchant = { ...existing, default_account_id: input.default_account_id };
49
+ }
50
+ }
51
+ else {
52
+ const id = `m:${randomUUID()}`;
53
+ db.prepare(`INSERT INTO merchants (id, canonical_name, default_account_id) VALUES (?, ?, ?)`).run(id, canonical, input.default_account_id ?? null);
54
+ merchant = {
55
+ id,
56
+ canonical_name: canonical,
57
+ default_account_id: input.default_account_id ?? null,
58
+ created_at: new Date().toISOString(),
59
+ };
60
+ }
61
+ let aliasConflict;
62
+ if (input.alias) {
63
+ const normalized = normalizeDescriptor(input.alias);
64
+ if (normalized) {
65
+ const existsAlias = db
66
+ .prepare(`SELECT merchant_id FROM merchant_aliases WHERE normalized_pattern = ?`)
67
+ .get(normalized);
68
+ if (!existsAlias) {
69
+ db.prepare(`INSERT INTO merchant_aliases (id, merchant_id, normalized_pattern) VALUES (?, ?, ?)`).run(`ma:${randomUUID()}`, merchant.id, normalized);
70
+ }
71
+ else if (existsAlias.merchant_id !== merchant.id) {
72
+ aliasConflict = { pattern: normalized, held_by: existsAlias.merchant_id };
73
+ }
74
+ }
75
+ }
76
+ return aliasConflict ? { ...merchant, alias_conflict: aliasConflict } : merchant;
77
+ }
78
+ export function findMerchantByAlias(db, rawDescriptor) {
79
+ const normalized = normalizeDescriptor(rawDescriptor);
80
+ if (!normalized)
81
+ return null;
82
+ const row = db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at
83
+ FROM merchant_aliases ma
84
+ JOIN merchants m ON m.id = ma.merchant_id
85
+ WHERE ma.normalized_pattern = ?`).get(normalized);
86
+ if (!row)
87
+ return null;
88
+ return { merchant: row, default_account_id: row.default_account_id };
89
+ }
90
+ export function listMerchants(db, opts = {}) {
91
+ const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
92
+ return db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at,
93
+ (SELECT COUNT(*) FROM merchant_aliases ma WHERE ma.merchant_id = m.id) AS alias_count
94
+ FROM merchants m
95
+ ORDER BY m.canonical_name
96
+ LIMIT ?`).all(limit);
97
+ }
98
+ export function countMerchants(db) {
99
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM merchants`).get();
100
+ return row.n;
101
+ }
102
+ export function merchantExists(db, id) {
103
+ return !!db.prepare(`SELECT 1 FROM merchants WHERE id = ?`).get(id);
104
+ }
105
+ export function findMerchantById(db, id) {
106
+ const row = db
107
+ .prepare(`SELECT id, canonical_name, default_account_id, created_at FROM merchants WHERE id = ?`)
108
+ .get(id);
109
+ return row ?? null;
110
+ }
111
+ export function setMerchantDefaultAccount(db, merchantId, accountId) {
112
+ const before = db
113
+ .prepare(`SELECT default_account_id FROM merchants WHERE id = ?`)
114
+ .get(merchantId);
115
+ if (!before)
116
+ throw new Error(`merchant not found: ${merchantId}`);
117
+ db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
118
+ .run(accountId, merchantId);
119
+ return { before: before.default_account_id, after: accountId };
120
+ }
121
+ export function clearMerchantDefaultAccount(db, merchantId) {
122
+ const row = db
123
+ .prepare(`SELECT default_account_id FROM merchants WHERE id = ?`)
124
+ .get(merchantId);
125
+ if (!row)
126
+ return null;
127
+ db.prepare(`UPDATE merchants SET default_account_id = NULL WHERE id = ?`).run(merchantId);
128
+ return { before: row.default_account_id };
129
+ }
130
+ /** One transaction, so a partial merge never persists. */
131
+ export function mergeMerchants(db, fromId, toId) {
132
+ if (fromId === toId)
133
+ throw new Error("Cannot merge a merchant into itself.");
134
+ const from = findMerchantById(db, fromId);
135
+ if (!from)
136
+ throw new Error(`Source merchant ${fromId} not found.`);
137
+ const to = findMerchantById(db, toId);
138
+ if (!to)
139
+ throw new Error(`Destination merchant ${toId} not found.`);
140
+ let movedTransactions = 0;
141
+ let movedAliases = 0;
142
+ let adoptedDefaultAccount;
143
+ const tx = db.transaction(() => {
144
+ movedTransactions = db
145
+ .prepare(`UPDATE transactions SET merchant_id = ? WHERE merchant_id = ?`)
146
+ .run(toId, fromId).changes;
147
+ movedAliases = db
148
+ .prepare(`UPDATE merchant_aliases SET merchant_id = ? WHERE merchant_id = ?`)
149
+ .run(toId, fromId).changes;
150
+ if (!to.default_account_id && from.default_account_id) {
151
+ db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
152
+ .run(from.default_account_id, toId);
153
+ adoptedDefaultAccount = from.default_account_id;
154
+ }
155
+ db.prepare(`DELETE FROM merchants WHERE id = ?`).run(fromId);
156
+ });
157
+ tx();
158
+ return {
159
+ moved_transactions: movedTransactions,
160
+ moved_aliases: movedAliases,
161
+ ...(adoptedDefaultAccount ? { adopted_default_account: adoptedDefaultAccount } : {}),
162
+ };
163
+ }
@@ -0,0 +1,28 @@
1
+ export function listNotes(db) {
2
+ return db.prepare(`SELECT id, content, category, created_at FROM notes ORDER BY created_at DESC`).all();
3
+ }
4
+ export function countNotes(db) {
5
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM notes`).get();
6
+ return row.n;
7
+ }
8
+ /**
9
+ * Idempotent on (category, content): a verbatim repeat is a no-op. Semantic
10
+ * dedup (different wording for the same note) is the calling agent's job.
11
+ */
12
+ export function addNote(db, content, category = "fact") {
13
+ const existing = db
14
+ .prepare(`SELECT 1 FROM notes WHERE category = ? AND content = ? LIMIT 1`)
15
+ .get(category, content);
16
+ if (existing)
17
+ return;
18
+ db.prepare(`INSERT INTO notes (content, category) VALUES (?, ?)`).run(content, category);
19
+ }
20
+ export function deleteNote(db, id) {
21
+ const row = db
22
+ .prepare(`SELECT id, content, category, created_at FROM notes WHERE id = ?`)
23
+ .get(id);
24
+ if (!row)
25
+ return null;
26
+ db.prepare(`DELETE FROM notes WHERE id = ?`).run(id);
27
+ return row;
28
+ }
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from "crypto";
2
+ import { parseJsonOrNull } from "../../lib/json.js";
3
+ /** The `cn:` id prefix is opaque - nothing else parses it. */
4
+ export function recordQuestion(db, input) {
5
+ const id = `cn:${randomUUID()}`;
6
+ db.prepare(`INSERT INTO questions (id, batch_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.batch_id ?? null, input.file_id, input.transaction_id ?? null, input.account_id, input.kind ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null, input.context ? JSON.stringify(input.context) : null);
8
+ if (input.transaction_id) {
9
+ db.prepare(`UPDATE transactions SET has_question = 1 WHERE id = ?`).run(input.transaction_id);
10
+ }
11
+ if (input.account_id) {
12
+ db.prepare(`UPDATE accounts SET has_question = 1 WHERE id = ?`).run(input.account_id);
13
+ }
14
+ return id;
15
+ }
16
+ /** Deletes the row outright (rather than marking it closed); null if the id doesn't exist. */
17
+ export function closeQuestion(db, id, answer) {
18
+ const row = db
19
+ .prepare(`SELECT prompt, kind, transaction_id, account_id, context_json FROM questions WHERE id = ?`)
20
+ .get(id);
21
+ if (!row)
22
+ return null;
23
+ db.prepare(`DELETE FROM questions WHERE id = ?`).run(id);
24
+ maybeClearHasQuestionFlags(db, {
25
+ transaction_id: row.transaction_id,
26
+ account_id: row.account_id,
27
+ });
28
+ return {
29
+ prompt: row.prompt,
30
+ kind: row.kind,
31
+ answer,
32
+ rule_key: extractRuleKey(row.context_json),
33
+ };
34
+ }
35
+ function extractRuleKey(contextJson) {
36
+ const parsed = parseJsonOrNull(contextJson);
37
+ return typeof parsed?.rule_key === "string" ? parsed.rule_key : null;
38
+ }
39
+ // Safe to call after any resolution; idempotent.
40
+ function maybeClearHasQuestionFlags(db, target) {
41
+ if (target.transaction_id) {
42
+ const open = db
43
+ .prepare(`SELECT 1 FROM questions WHERE transaction_id = ? LIMIT 1`)
44
+ .get(target.transaction_id);
45
+ if (!open)
46
+ db.prepare(`UPDATE transactions SET has_question = 0 WHERE id = ?`).run(target.transaction_id);
47
+ }
48
+ if (target.account_id) {
49
+ const open = db
50
+ .prepare(`SELECT 1 FROM questions WHERE account_id = ? LIMIT 1`)
51
+ .get(target.account_id);
52
+ if (!open)
53
+ db.prepare(`UPDATE accounts SET has_question = 0 WHERE id = ?`).run(target.account_id);
54
+ }
55
+ }
56
+ const ACTIVE_DEFERRED_CLAUSE = "(deferred_until IS NULL OR deferred_until <= datetime('now'))";
57
+ export function countQuestions(db, scope = {}) {
58
+ const conditions = [];
59
+ const params = [];
60
+ if (scope.file_id) {
61
+ conditions.push("file_id = ?");
62
+ params.push(scope.file_id);
63
+ }
64
+ if (scope.transaction_id) {
65
+ conditions.push("transaction_id = ?");
66
+ params.push(scope.transaction_id);
67
+ }
68
+ if (scope.account_id) {
69
+ conditions.push("account_id = ?");
70
+ params.push(scope.account_id);
71
+ }
72
+ if (scope.kind) {
73
+ conditions.push("kind = ?");
74
+ params.push(scope.kind);
75
+ }
76
+ if (scope.batch_id) {
77
+ conditions.push("batch_id = ?");
78
+ params.push(scope.batch_id);
79
+ }
80
+ if (!scope.includeDeferred)
81
+ conditions.push(ACTIVE_DEFERRED_CLAUSE);
82
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
83
+ const row = db
84
+ .prepare(`SELECT COUNT(*) AS n FROM questions ${where}`)
85
+ .get(...params);
86
+ return row.n;
87
+ }
88
+ const ROW_COLUMNS = "id, batch_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, deferred_until, created_at";
89
+ export function listQuestions(db, opts = {}) {
90
+ const capped = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
91
+ const conditions = [];
92
+ const params = [];
93
+ if (opts.batchId) {
94
+ conditions.push("batch_id = ?");
95
+ params.push(opts.batchId);
96
+ }
97
+ if (!opts.includeDeferred)
98
+ conditions.push(ACTIVE_DEFERRED_CLAUSE);
99
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
100
+ params.push(capped);
101
+ return db.prepare(`SELECT ${ROW_COLUMNS}
102
+ FROM questions
103
+ ${where}
104
+ ORDER BY created_at ASC
105
+ LIMIT ?`).all(...params);
106
+ }
107
+ /**
108
+ * `listQuestions`/`countQuestions` hide deferred rows by default until the
109
+ * timestamp passes; pass `includeDeferred: true` for an unfiltered view.
110
+ */
111
+ export function deferQuestion(db, id, days) {
112
+ const safeDays = Math.max(1, Math.floor(days));
113
+ const result = db
114
+ .prepare(`UPDATE questions SET deferred_until = datetime('now', ?) WHERE id = ?`)
115
+ .run(`+${safeDays} days`, id);
116
+ return result.changes > 0;
117
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Rows sharing a non-null group_id never match each other (a salary's legs aren't
3
+ * duplicates); returns components of size >= 2.
4
+ */
5
+ export function findDuplicateTransactions(db, opts = {}) {
6
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 2));
7
+ const minAmount = opts.minAmount ?? 0;
8
+ const rows = db
9
+ .prepare(`SELECT t.id, t.group_id, t.date, t.description, t.amount, t.currency,
10
+ t.source_file_id, t.merchant_id, t.debit_account_id, t.credit_account_id,
11
+ da.name AS debit_account_name, ca.name AS credit_account_name
12
+ FROM transactions t
13
+ LEFT JOIN accounts da ON da.id = t.debit_account_id
14
+ LEFT JOIN accounts ca ON ca.id = t.credit_account_id
15
+ WHERE t.void_of IS NULL`)
16
+ .all();
17
+ const buckets = new Map();
18
+ for (const r of rows) {
19
+ if (r.amount < minAmount)
20
+ continue;
21
+ const key = `${r.amount}|${r.debit_account_id}|${r.credit_account_id}`;
22
+ const arr = buckets.get(key) ?? [];
23
+ arr.push(r);
24
+ buckets.set(key, arr);
25
+ }
26
+ const groups = [];
27
+ for (const bucket of buckets.values()) {
28
+ if (bucket.length < 2)
29
+ continue;
30
+ for (const comp of proximityComponents(bucket, toleranceDays)) {
31
+ if (comp.length >= 2)
32
+ groups.push(comp);
33
+ }
34
+ }
35
+ return groups;
36
+ }
37
+ function proximityComponents(bucket, toleranceDays) {
38
+ const n = bucket.length;
39
+ const parent = Array.from({ length: n }, (_, i) => i);
40
+ const find = (x) => {
41
+ while (parent[x] !== x) {
42
+ parent[x] = parent[parent[x]];
43
+ x = parent[x];
44
+ }
45
+ return x;
46
+ };
47
+ const union = (a, b) => {
48
+ const ra = find(a);
49
+ const rb = find(b);
50
+ if (ra !== rb)
51
+ parent[ra] = rb;
52
+ };
53
+ for (let i = 0; i < n; i++) {
54
+ for (let j = i + 1; j < n; j++) {
55
+ const a = bucket[i];
56
+ const b = bucket[j];
57
+ if (dayDiff(a.date, b.date) > toleranceDays)
58
+ continue;
59
+ if (a.group_id && b.group_id && a.group_id === b.group_id)
60
+ continue;
61
+ union(i, j);
62
+ }
63
+ }
64
+ const comps = new Map();
65
+ for (let i = 0; i < n; i++) {
66
+ const root = find(i);
67
+ const arr = comps.get(root) ?? [];
68
+ arr.push(bucket[i]);
69
+ comps.set(root, arr);
70
+ }
71
+ return [...comps.values()];
72
+ }
73
+ /** Whole-day distance between two ISO dates; +Infinity on unparseable input. */
74
+ function dayDiff(a, b) {
75
+ const aDate = Date.parse(a);
76
+ const bDate = Date.parse(b);
77
+ if (Number.isNaN(aDate) || Number.isNaN(bDate))
78
+ return Number.POSITIVE_INFINITY;
79
+ return Math.abs(Math.round((bDate - aDate) / 86_400_000));
80
+ }