@context-vault/core 2.17.0 → 2.17.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.
- package/package.json +2 -3
- package/src/capture/file-ops.js +2 -0
- package/src/capture/index.js +14 -0
- package/src/core/categories.js +1 -0
- package/src/core/files.js +6 -29
- package/src/core/frontmatter.js +1 -0
- package/src/core/linking.js +161 -0
- package/src/core/migrate-dirs.js +196 -0
- package/src/core/temporal.js +146 -0
- package/src/index/db.js +178 -8
- package/src/index/index.js +89 -28
- package/src/index.js +5 -0
- package/src/retrieve/index.js +9 -136
- package/src/server/tools/create-snapshot.js +37 -68
- package/src/server/tools/get-context.js +108 -21
- package/src/server/tools/save-context.js +29 -6
- package/src/server/tools.js +0 -2
- package/src/server/tools/submit-feedback.js +0 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@context-vault/core",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"author": "Felix Hellstrom",
|
|
29
29
|
"repository": {
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@anthropic-ai/sdk": "^0.78.0",
|
|
40
39
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
41
40
|
"sqlite-vec": "^0.1.0"
|
|
42
41
|
},
|
package/src/capture/file-ops.js
CHANGED
|
@@ -38,6 +38,7 @@ export function writeEntryFile(
|
|
|
38
38
|
identity_key,
|
|
39
39
|
expires_at,
|
|
40
40
|
supersedes,
|
|
41
|
+
related_to,
|
|
41
42
|
},
|
|
42
43
|
) {
|
|
43
44
|
// P5: folder is now a top-level param; also accept from meta for backward compat
|
|
@@ -64,6 +65,7 @@ export function writeEntryFile(
|
|
|
64
65
|
if (identity_key) fmFields.identity_key = identity_key;
|
|
65
66
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
66
67
|
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
68
|
+
if (related_to?.length) fmFields.related_to = related_to;
|
|
67
69
|
fmFields.tags = tags || [];
|
|
68
70
|
fmFields.source = source || "claude-code";
|
|
69
71
|
fmFields.created = created;
|
package/src/capture/index.js
CHANGED
|
@@ -27,6 +27,7 @@ export function writeEntry(
|
|
|
27
27
|
identity_key,
|
|
28
28
|
expires_at,
|
|
29
29
|
supersedes,
|
|
30
|
+
related_to,
|
|
30
31
|
source_files,
|
|
31
32
|
tier,
|
|
32
33
|
userId,
|
|
@@ -88,6 +89,7 @@ export function writeEntry(
|
|
|
88
89
|
identity_key,
|
|
89
90
|
expires_at,
|
|
90
91
|
supersedes,
|
|
92
|
+
related_to,
|
|
91
93
|
});
|
|
92
94
|
|
|
93
95
|
return {
|
|
@@ -105,6 +107,7 @@ export function writeEntry(
|
|
|
105
107
|
identity_key,
|
|
106
108
|
expires_at,
|
|
107
109
|
supersedes,
|
|
110
|
+
related_to: related_to || null,
|
|
108
111
|
source_files: source_files || null,
|
|
109
112
|
tier: tier || null,
|
|
110
113
|
userId: userId || null,
|
|
@@ -126,6 +129,9 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
126
129
|
|
|
127
130
|
const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
|
|
128
131
|
const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
|
|
132
|
+
const existingRelatedTo = existing.related_to
|
|
133
|
+
? JSON.parse(existing.related_to)
|
|
134
|
+
: fmMeta.related_to || null;
|
|
129
135
|
|
|
130
136
|
const title = updates.title !== undefined ? updates.title : existing.title;
|
|
131
137
|
const body = updates.body !== undefined ? updates.body : existing.body;
|
|
@@ -138,6 +144,8 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
138
144
|
updates.supersedes !== undefined
|
|
139
145
|
? updates.supersedes
|
|
140
146
|
: fmMeta.supersedes || null;
|
|
147
|
+
const related_to =
|
|
148
|
+
updates.related_to !== undefined ? updates.related_to : existingRelatedTo;
|
|
141
149
|
const source_files =
|
|
142
150
|
updates.source_files !== undefined
|
|
143
151
|
? updates.source_files
|
|
@@ -162,6 +170,7 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
162
170
|
if (existing.identity_key) fmFields.identity_key = existing.identity_key;
|
|
163
171
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
164
172
|
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
173
|
+
if (related_to?.length) fmFields.related_to = related_to;
|
|
165
174
|
fmFields.tags = tags;
|
|
166
175
|
fmFields.source = source || "claude-code";
|
|
167
176
|
fmFields.created = fmMeta.created || existing.created_at;
|
|
@@ -189,6 +198,7 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
189
198
|
identity_key: existing.identity_key,
|
|
190
199
|
expires_at,
|
|
191
200
|
supersedes,
|
|
201
|
+
related_to: related_to || null,
|
|
192
202
|
source_files: source_files || null,
|
|
193
203
|
userId: existing.user_id || null,
|
|
194
204
|
};
|
|
@@ -217,6 +227,10 @@ export async function captureAndIndex(ctx, data) {
|
|
|
217
227
|
}
|
|
218
228
|
}
|
|
219
229
|
}
|
|
230
|
+
// Store related_to links in DB
|
|
231
|
+
if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
|
|
232
|
+
ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
|
|
233
|
+
}
|
|
220
234
|
return entry;
|
|
221
235
|
} catch (err) {
|
|
222
236
|
// Rollback: restore previous content for entity upserts, delete for new entries
|
package/src/core/categories.js
CHANGED
package/src/core/files.js
CHANGED
|
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
|
|
|
37
37
|
return slug;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
insight: "insights",
|
|
42
|
-
decision: "decisions",
|
|
43
|
-
pattern: "patterns",
|
|
44
|
-
status: "statuses",
|
|
45
|
-
analysis: "analyses",
|
|
46
|
-
contact: "contacts",
|
|
47
|
-
project: "projects",
|
|
48
|
-
tool: "tools",
|
|
49
|
-
source: "sources",
|
|
50
|
-
conversation: "conversations",
|
|
51
|
-
message: "messages",
|
|
52
|
-
session: "sessions",
|
|
53
|
-
log: "logs",
|
|
54
|
-
feedback: "feedbacks",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const SINGULAR_MAP = Object.fromEntries(
|
|
58
|
-
Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
|
|
59
|
-
);
|
|
60
|
-
|
|
40
|
+
/** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
|
|
61
41
|
export function kindToDir(kind) {
|
|
62
|
-
|
|
63
|
-
return kind.endsWith("s") ? kind : kind + "s";
|
|
42
|
+
return kind;
|
|
64
43
|
}
|
|
65
44
|
|
|
45
|
+
/** Map directory name back to kind name. Directory names equal kind names (identity). */
|
|
66
46
|
export function dirToKind(dirName) {
|
|
67
|
-
|
|
68
|
-
return dirName.replace(/s$/, "");
|
|
47
|
+
return dirName;
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
/** Normalize a kind input
|
|
50
|
+
/** Normalize a kind input to its canonical form. Kind names are returned as-is. */
|
|
72
51
|
export function normalizeKind(input) {
|
|
73
|
-
|
|
74
|
-
if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
|
|
75
|
-
return input; // Unknown — use as-is (don't strip 's')
|
|
52
|
+
return input;
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
/** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
|
package/src/core/frontmatter.js
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* linking.js — Pure graph traversal for related_to links.
|
|
3
|
+
*
|
|
4
|
+
* All functions accept a db handle and return data — no side effects.
|
|
5
|
+
* The calling layer (get-context handler) is responsible for I/O wiring.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a `related_to` JSON string from the DB into an array of ID strings.
|
|
10
|
+
* Returns an empty array on any parse failure or null input.
|
|
11
|
+
*
|
|
12
|
+
* @param {string|null|undefined} raw
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function parseRelatedTo(raw) {
|
|
16
|
+
if (!raw) return [];
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (!Array.isArray(parsed)) return [];
|
|
20
|
+
return parsed.filter((id) => typeof id === "string" && id.trim());
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fetch vault entries by their IDs, scoped to a user.
|
|
28
|
+
* Returns only entries that exist and are not expired or superseded.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
31
|
+
* @param {string[]} ids
|
|
32
|
+
* @param {string|null|undefined} userId
|
|
33
|
+
* @returns {object[]} Matching DB rows
|
|
34
|
+
*/
|
|
35
|
+
export function resolveLinks(db, ids, userId) {
|
|
36
|
+
if (!ids.length) return [];
|
|
37
|
+
const unique = [...new Set(ids)];
|
|
38
|
+
const placeholders = unique.map(() => "?").join(",");
|
|
39
|
+
// When userId is defined (hosted mode), scope to that user.
|
|
40
|
+
// When userId is undefined (local mode), no user scoping — all entries accessible.
|
|
41
|
+
const userClause =
|
|
42
|
+
userId !== undefined && userId !== null ? "AND user_id = ?" : "";
|
|
43
|
+
const params =
|
|
44
|
+
userId !== undefined && userId !== null ? [...unique, userId] : unique;
|
|
45
|
+
try {
|
|
46
|
+
return db
|
|
47
|
+
.prepare(
|
|
48
|
+
`SELECT * FROM vault
|
|
49
|
+
WHERE id IN (${placeholders})
|
|
50
|
+
${userClause}
|
|
51
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
52
|
+
AND superseded_by IS NULL`,
|
|
53
|
+
)
|
|
54
|
+
.all(...params);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find all entries that declare `entryId` in their `related_to` field
|
|
62
|
+
* (i.e. entries that point *to* this entry — backlinks).
|
|
63
|
+
* Scoped to the same user. Excludes expired and superseded entries.
|
|
64
|
+
*
|
|
65
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
66
|
+
* @param {string} entryId
|
|
67
|
+
* @param {string|null|undefined} userId
|
|
68
|
+
* @returns {object[]} Entries with a backlink to entryId
|
|
69
|
+
*/
|
|
70
|
+
export function resolveBacklinks(db, entryId, userId) {
|
|
71
|
+
if (!entryId) return [];
|
|
72
|
+
// When userId is defined (hosted mode), scope to that user.
|
|
73
|
+
// When userId is undefined (local mode), no user scoping — all entries accessible.
|
|
74
|
+
const userClause =
|
|
75
|
+
userId !== undefined && userId !== null ? "AND user_id = ?" : "";
|
|
76
|
+
const likePattern = `%"${entryId}"%`;
|
|
77
|
+
const params =
|
|
78
|
+
userId !== undefined && userId !== null
|
|
79
|
+
? [likePattern, userId]
|
|
80
|
+
: [likePattern];
|
|
81
|
+
try {
|
|
82
|
+
return db
|
|
83
|
+
.prepare(
|
|
84
|
+
`SELECT * FROM vault
|
|
85
|
+
WHERE related_to LIKE ?
|
|
86
|
+
${userClause}
|
|
87
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
88
|
+
AND superseded_by IS NULL`,
|
|
89
|
+
)
|
|
90
|
+
.all(...params);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* For a set of primary entry IDs, collect all forward links (entries pointed
|
|
98
|
+
* to by `related_to`) and backlinks (entries that point back to any primary).
|
|
99
|
+
*
|
|
100
|
+
* Returns a Map of id → entry row for all linked entries, excluding entries
|
|
101
|
+
* already present in `primaryIds`.
|
|
102
|
+
*
|
|
103
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
104
|
+
* @param {object[]} primaryEntries - Full entry rows (must have id + related_to fields)
|
|
105
|
+
* @param {string|null|undefined} userId
|
|
106
|
+
* @returns {{ forward: object[], backward: object[] }}
|
|
107
|
+
*/
|
|
108
|
+
export function collectLinkedEntries(db, primaryEntries, userId) {
|
|
109
|
+
const primaryIds = new Set(primaryEntries.map((e) => e.id));
|
|
110
|
+
|
|
111
|
+
// Forward: resolve all IDs from related_to fields
|
|
112
|
+
const forwardIds = [];
|
|
113
|
+
for (const entry of primaryEntries) {
|
|
114
|
+
const ids = parseRelatedTo(entry.related_to);
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
if (!primaryIds.has(id)) forwardIds.push(id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const forwardEntries = resolveLinks(db, forwardIds, userId).filter(
|
|
120
|
+
(e) => !primaryIds.has(e.id),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Backward: find all entries that link to any primary entry
|
|
124
|
+
const backwardSeen = new Set();
|
|
125
|
+
const backwardEntries = [];
|
|
126
|
+
const forwardIds2 = new Set(forwardEntries.map((e) => e.id));
|
|
127
|
+
for (const entry of primaryEntries) {
|
|
128
|
+
const backlinks = resolveBacklinks(db, entry.id, userId);
|
|
129
|
+
for (const bl of backlinks) {
|
|
130
|
+
if (!primaryIds.has(bl.id) && !backwardSeen.has(bl.id)) {
|
|
131
|
+
backwardSeen.add(bl.id);
|
|
132
|
+
backwardEntries.push(bl);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { forward: forwardEntries, backward: backwardEntries };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate a `related_to` value from user input.
|
|
142
|
+
* Must be an array of non-empty strings (ULID-like IDs).
|
|
143
|
+
* Returns an error message string if invalid, or null if valid.
|
|
144
|
+
*
|
|
145
|
+
* @param {unknown} relatedTo
|
|
146
|
+
* @returns {string|null}
|
|
147
|
+
*/
|
|
148
|
+
export function validateRelatedTo(relatedTo) {
|
|
149
|
+
if (relatedTo === undefined || relatedTo === null) return null;
|
|
150
|
+
if (!Array.isArray(relatedTo))
|
|
151
|
+
return "related_to must be an array of entry IDs";
|
|
152
|
+
for (const id of relatedTo) {
|
|
153
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
154
|
+
return "each related_to entry must be a non-empty string ID";
|
|
155
|
+
}
|
|
156
|
+
if (id.length > 32) {
|
|
157
|
+
return `related_to ID too long (max 32 chars): "${id.slice(0, 32)}..."`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-dirs.js — Rename plural vault directories to singular
|
|
3
|
+
*
|
|
4
|
+
* After context-vault >= 2.18.0, kindToDir() returns singular names.
|
|
5
|
+
* Existing vaults still have plural dirs (e.g. knowledge/decisions/).
|
|
6
|
+
* This module plans and executes the rename/merge migration.
|
|
7
|
+
*
|
|
8
|
+
* Architecture: pure planning function + I/O execution function.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
copyFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join, basename } from "node:path";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Complete plural→singular mapping for vault directory names.
|
|
24
|
+
* Covers the old PLURAL_MAP from files.js plus extended kinds seen in the wild.
|
|
25
|
+
*
|
|
26
|
+
* @type {Record<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export const PLURAL_TO_SINGULAR = {
|
|
29
|
+
// From old PLURAL_MAP in files.js
|
|
30
|
+
insights: "insight",
|
|
31
|
+
decisions: "decision",
|
|
32
|
+
patterns: "pattern",
|
|
33
|
+
statuses: "status",
|
|
34
|
+
analyses: "analysis",
|
|
35
|
+
contacts: "contact",
|
|
36
|
+
projects: "project",
|
|
37
|
+
tools: "tool",
|
|
38
|
+
sources: "source",
|
|
39
|
+
conversations: "conversation",
|
|
40
|
+
messages: "message",
|
|
41
|
+
sessions: "session",
|
|
42
|
+
logs: "log",
|
|
43
|
+
feedbacks: "feedback",
|
|
44
|
+
// Extended kinds from categories.js + observed in vaults
|
|
45
|
+
notes: "note",
|
|
46
|
+
prompts: "prompt",
|
|
47
|
+
documents: "document",
|
|
48
|
+
references: "reference",
|
|
49
|
+
tasks: "task",
|
|
50
|
+
buckets: "bucket",
|
|
51
|
+
architectures: "architecture",
|
|
52
|
+
briefs: "brief",
|
|
53
|
+
companies: "company",
|
|
54
|
+
discoveries: "discovery",
|
|
55
|
+
events: "event",
|
|
56
|
+
ideas: "idea",
|
|
57
|
+
issues: "issue",
|
|
58
|
+
agents: "agent",
|
|
59
|
+
"session-summaries": "session-summary",
|
|
60
|
+
"session-reviews": "session-review",
|
|
61
|
+
"user-prompts": "user-prompt",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Category directory names that are scanned for plural kind subdirectories.
|
|
66
|
+
*/
|
|
67
|
+
const CATEGORY_DIRS = ["knowledge", "entities", "events"];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Count .md files recursively in a directory.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dir
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
function countMdFiles(dir) {
|
|
76
|
+
let count = 0;
|
|
77
|
+
try {
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
count += countMdFiles(join(dir, entry.name));
|
|
81
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
82
|
+
count++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore unreadable dirs
|
|
87
|
+
}
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Plan migration operations: walk the vault and detect plural dirs that need renaming.
|
|
93
|
+
* Pure I/O read-only — does not modify any files.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} vaultDir - Absolute path to vault root
|
|
96
|
+
* @returns {MigrationOp[]} Array of planned operations
|
|
97
|
+
*
|
|
98
|
+
* @typedef {{ action: 'rename'|'merge', pluralDir: string, singularDir: string, pluralName: string, singularName: string, fileCount: number }} MigrationOp
|
|
99
|
+
*/
|
|
100
|
+
export function planMigration(vaultDir) {
|
|
101
|
+
const ops = [];
|
|
102
|
+
|
|
103
|
+
for (const catName of CATEGORY_DIRS) {
|
|
104
|
+
const catDir = join(vaultDir, catName);
|
|
105
|
+
if (!existsSync(catDir) || !statSync(catDir).isDirectory()) continue;
|
|
106
|
+
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(catDir, { withFileTypes: true });
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!entry.isDirectory()) continue;
|
|
116
|
+
const dirName = entry.name;
|
|
117
|
+
const singular = PLURAL_TO_SINGULAR[dirName];
|
|
118
|
+
|
|
119
|
+
// Not a known plural — skip (might already be singular or unknown kind)
|
|
120
|
+
if (!singular) continue;
|
|
121
|
+
|
|
122
|
+
const pluralDir = join(catDir, dirName);
|
|
123
|
+
const singularDir = join(catDir, singular);
|
|
124
|
+
|
|
125
|
+
// Guard: plural and singular are the same (shouldn't happen but be safe)
|
|
126
|
+
if (pluralDir === singularDir) continue;
|
|
127
|
+
|
|
128
|
+
const fileCount = countMdFiles(pluralDir);
|
|
129
|
+
const singularExists = existsSync(singularDir);
|
|
130
|
+
|
|
131
|
+
ops.push({
|
|
132
|
+
action: singularExists ? "merge" : "rename",
|
|
133
|
+
pluralDir,
|
|
134
|
+
singularDir,
|
|
135
|
+
pluralName: dirName,
|
|
136
|
+
singularName: singular,
|
|
137
|
+
fileCount,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return ops;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Copy all files and subdirectories from src into dst (non-overwriting).
|
|
147
|
+
* Mirrors `cp -rn src/* dst/`.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} src
|
|
150
|
+
* @param {string} dst
|
|
151
|
+
*/
|
|
152
|
+
function mergeDir(src, dst) {
|
|
153
|
+
mkdirSync(dst, { recursive: true });
|
|
154
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
155
|
+
const srcPath = join(src, entry.name);
|
|
156
|
+
const dstPath = join(dst, entry.name);
|
|
157
|
+
if (entry.isDirectory()) {
|
|
158
|
+
mergeDir(srcPath, dstPath);
|
|
159
|
+
} else if (entry.isFile()) {
|
|
160
|
+
if (!existsSync(dstPath)) {
|
|
161
|
+
copyFileSync(srcPath, dstPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Execute migration operations. Renames or merges plural dirs into singular dirs.
|
|
169
|
+
* Safe to call multiple times — already-renamed dirs produce no-op ops from planMigration.
|
|
170
|
+
*
|
|
171
|
+
* @param {MigrationOp[]} ops - Operations from planMigration()
|
|
172
|
+
* @returns {{ renamed: number, merged: number, errors: string[] }}
|
|
173
|
+
*/
|
|
174
|
+
export function executeMigration(ops) {
|
|
175
|
+
let renamed = 0;
|
|
176
|
+
let merged = 0;
|
|
177
|
+
const errors = [];
|
|
178
|
+
|
|
179
|
+
for (const op of ops) {
|
|
180
|
+
try {
|
|
181
|
+
if (op.action === "rename") {
|
|
182
|
+
renameSync(op.pluralDir, op.singularDir);
|
|
183
|
+
renamed++;
|
|
184
|
+
} else {
|
|
185
|
+
// merge: copy files from plural into singular, then remove plural
|
|
186
|
+
mergeDir(op.pluralDir, op.singularDir);
|
|
187
|
+
rmSync(op.pluralDir, { recursive: true, force: true });
|
|
188
|
+
merged++;
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
errors.push(`${op.pluralName} → ${op.singularName}: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { renamed, merged, errors };
|
|
196
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal shortcut resolver.
|
|
3
|
+
*
|
|
4
|
+
* Converts natural-language time expressions to ISO date strings.
|
|
5
|
+
* Fully pure — accepts an explicit `now` Date for deterministic testing.
|
|
6
|
+
*
|
|
7
|
+
* Supported shortcuts (case-insensitive, spaces or underscores):
|
|
8
|
+
* today → start of today (00:00:00 local midnight in UTC)
|
|
9
|
+
* yesterday → { since: start of yesterday, until: end of yesterday }
|
|
10
|
+
* this_week → start of current ISO week (Monday 00:00)
|
|
11
|
+
* last_N_days → N days ago (e.g. last_3_days, last 7 days)
|
|
12
|
+
* last_N_weeks → N weeks ago
|
|
13
|
+
* last_N_months → N calendar months ago (approximate: N × 30 days)
|
|
14
|
+
*
|
|
15
|
+
* Anything that doesn't match a shortcut is returned unchanged so that ISO
|
|
16
|
+
* date strings pass straight through without modification.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SHORTCUT_RE = /^last[_ ](\d+)[_ ](day|days|week|weeks|month|months)$/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start of today in UTC (i.e. the midnight boundary at which the local date begins,
|
|
23
|
+
* expressed as an ISO string). We operate in UTC throughout so tests are portable.
|
|
24
|
+
*
|
|
25
|
+
* @param {Date} now
|
|
26
|
+
* @returns {Date}
|
|
27
|
+
*/
|
|
28
|
+
function startOfToday(now) {
|
|
29
|
+
const d = new Date(now);
|
|
30
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a single temporal expression to one or two ISO date strings.
|
|
36
|
+
*
|
|
37
|
+
* @param {"since"|"until"} role - Which parameter we are resolving.
|
|
38
|
+
* @param {string} value - Raw parameter value from the caller.
|
|
39
|
+
* @param {Date} [now] - Override for "now" (defaults to `new Date()`).
|
|
40
|
+
* @returns {string} - Resolved ISO date string (or original value if unrecognised).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveTemporalShortcut(role, value, now = new Date()) {
|
|
43
|
+
if (!value || typeof value !== "string") return value;
|
|
44
|
+
|
|
45
|
+
const trimmed = value.trim().toLowerCase().replace(/\s+/g, "_");
|
|
46
|
+
|
|
47
|
+
if (trimmed === "today") {
|
|
48
|
+
const start = startOfToday(now);
|
|
49
|
+
if (role === "until") {
|
|
50
|
+
// "until today" means up to end-of-today
|
|
51
|
+
const end = new Date(start);
|
|
52
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
53
|
+
return end.toISOString();
|
|
54
|
+
}
|
|
55
|
+
return start.toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimmed === "yesterday") {
|
|
59
|
+
const todayStart = startOfToday(now);
|
|
60
|
+
const yesterdayStart = new Date(todayStart);
|
|
61
|
+
yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1);
|
|
62
|
+
if (role === "since") return yesterdayStart.toISOString();
|
|
63
|
+
// role === "until": end of yesterday = start of today
|
|
64
|
+
return todayStart.toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (trimmed === "this_week") {
|
|
68
|
+
// Monday 00:00 UTC of the current week
|
|
69
|
+
const todayStart = startOfToday(now);
|
|
70
|
+
const dayOfWeek = todayStart.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
|
71
|
+
const daysFromMonday = (dayOfWeek + 6) % 7; // 0 on Monday
|
|
72
|
+
const monday = new Date(todayStart);
|
|
73
|
+
monday.setUTCDate(monday.getUTCDate() - daysFromMonday);
|
|
74
|
+
if (role === "since") return monday.toISOString();
|
|
75
|
+
// "until this_week" means up to now (end of today)
|
|
76
|
+
const endOfToday = new Date(todayStart);
|
|
77
|
+
endOfToday.setUTCDate(endOfToday.getUTCDate() + 1);
|
|
78
|
+
return endOfToday.toISOString();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (trimmed === "this_month") {
|
|
82
|
+
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
83
|
+
if (role === "since") return d.toISOString();
|
|
84
|
+
const endOfMonth = new Date(
|
|
85
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1),
|
|
86
|
+
);
|
|
87
|
+
return endOfMonth.toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const m = SHORTCUT_RE.exec(trimmed);
|
|
91
|
+
if (m) {
|
|
92
|
+
const n = parseInt(m[1], 10);
|
|
93
|
+
const unit = m[2].replace(/s$/, ""); // normalise plural → singular
|
|
94
|
+
let ms;
|
|
95
|
+
if (unit === "day") {
|
|
96
|
+
ms = n * 86400000;
|
|
97
|
+
} else if (unit === "week") {
|
|
98
|
+
ms = n * 7 * 86400000;
|
|
99
|
+
} else {
|
|
100
|
+
// month → approximate as 30 days
|
|
101
|
+
ms = n * 30 * 86400000;
|
|
102
|
+
}
|
|
103
|
+
const target = new Date(now.getTime() - ms);
|
|
104
|
+
target.setUTCHours(0, 0, 0, 0);
|
|
105
|
+
return target.toISOString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Unrecognised — pass through unchanged (ISO dates, empty strings, etc.)
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve both `since` and `until` parameters, handling the special case where
|
|
114
|
+
* "yesterday" sets both bounds automatically when only `since` is specified.
|
|
115
|
+
*
|
|
116
|
+
* Returns `{ since, until }` with resolved ISO strings (or originals).
|
|
117
|
+
*
|
|
118
|
+
* @param {{ since?: string, until?: string }} params
|
|
119
|
+
* @param {Date} [now]
|
|
120
|
+
* @returns {{ since: string|undefined, until: string|undefined }}
|
|
121
|
+
*/
|
|
122
|
+
export function resolveTemporalParams(params, now = new Date()) {
|
|
123
|
+
let { since, until } = params;
|
|
124
|
+
|
|
125
|
+
// Special case: "yesterday" on `since` without an explicit `until`
|
|
126
|
+
// auto-fills `until` to the end of yesterday.
|
|
127
|
+
if (
|
|
128
|
+
since?.trim().toLowerCase() === "yesterday" &&
|
|
129
|
+
(until === undefined || until === null)
|
|
130
|
+
) {
|
|
131
|
+
since = resolveTemporalShortcut("since", since, now);
|
|
132
|
+
until = resolveTemporalShortcut("until", "yesterday", now);
|
|
133
|
+
return { since, until };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
since:
|
|
138
|
+
since !== undefined
|
|
139
|
+
? resolveTemporalShortcut("since", since, now)
|
|
140
|
+
: since,
|
|
141
|
+
until:
|
|
142
|
+
until !== undefined
|
|
143
|
+
? resolveTemporalShortcut("until", until, now)
|
|
144
|
+
: until,
|
|
145
|
+
};
|
|
146
|
+
}
|