dig-burrow 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ const { generateId } = require('./core.cjs');
4
+
5
+ // --- Helpers ---
6
+
7
+ /**
8
+ * Recursively find a card by ID in the tree.
9
+ * @param {object} data - Root data object
10
+ * @param {string} id - Card ID to find
11
+ * @returns {object|null} The card, or null if not found
12
+ */
13
+ function findById(data, id) {
14
+ function search(cards) {
15
+ for (const card of cards) {
16
+ if (card.id === id) return card;
17
+ if (card.children && card.children.length) {
18
+ const found = search(card.children);
19
+ if (found) return found;
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ return search(data.cards);
25
+ }
26
+
27
+ /**
28
+ * Find the parent of a card by ID.
29
+ * Uses a single recursive traversal (no separate root-level loop).
30
+ * @param {object} data - Root data object
31
+ * @param {string} id - Card ID to find parent of
32
+ * @returns {{parent: object|null, container: Array}|null} parent card (null for root), container (the array the card lives in)
33
+ */
34
+ function findParent(data, id) {
35
+ function search(parentCard, container) {
36
+ for (const card of container) {
37
+ if (card.id === id) {
38
+ return { parent: parentCard, container };
39
+ }
40
+ if (card.children && card.children.length) {
41
+ const found = search(card, card.children);
42
+ if (found) return found;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ return search(null, data.cards);
48
+ }
49
+
50
+ /**
51
+ * Get the array to push into for adding/listing cards.
52
+ * @param {object} data - Root data object
53
+ * @param {string|null|undefined} parentId - Parent ID, or null/undefined for root
54
+ * @returns {Array|null} The array to operate on, or null if parentId not found
55
+ */
56
+ function getContainer(data, parentId) {
57
+ if (parentId == null) return data.cards;
58
+ const parent = findById(data, parentId);
59
+ if (!parent) return null;
60
+ return parent.children;
61
+ }
62
+
63
+ /**
64
+ * Get the ancestry path from root to the target card.
65
+ * @param {object} data - Root data object
66
+ * @param {string} id - Target card ID
67
+ * @returns {Array<object>|null} Array from root ancestor to target, or null
68
+ */
69
+ function getPath(data, id) {
70
+ function search(cards, path) {
71
+ for (const card of cards) {
72
+ const currentPath = [...path, card];
73
+ if (card.id === id) return currentPath;
74
+ if (card.children && card.children.length) {
75
+ const found = search(card.children, currentPath);
76
+ if (found) return found;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ return search(data.cards, []);
82
+ }
83
+
84
+ /**
85
+ * Count descendants recursively.
86
+ * @param {object} card
87
+ * @param {object} [opts]
88
+ * @param {boolean} [opts.activeOnly=false] - When true, skip archived children and their subtrees
89
+ * @returns {number}
90
+ */
91
+ function countDescendants(card, opts) {
92
+ const activeOnly = opts && opts.activeOnly;
93
+ let count = 0;
94
+ if (card.children && card.children.length) {
95
+ for (const child of card.children) {
96
+ if (activeOnly && child.archived) continue;
97
+ count += 1 + countDescendants(child, opts);
98
+ }
99
+ }
100
+ return count;
101
+ }
102
+
103
+ // --- Constants ---
104
+
105
+ /**
106
+ * Maximum length for body preview snippets in renderTree output.
107
+ * Bodies longer than this are sliced before newline replacement to avoid
108
+ * processing thousands of characters unnecessarily.
109
+ * @type {number}
110
+ */
111
+ const PREVIEW_TRUNCATE_LENGTH = 80;
112
+
113
+ // --- Public API ---
114
+
115
+ /**
116
+ * Add a new card to the tree.
117
+ * @param {object} data - Root data object
118
+ * @param {object} opts - {title, parentId, body, position}
119
+ * @returns {{card: object, breadcrumbs: Array<{id, title}>}|null} The created card with ancestor breadcrumbs, or null if parent not found
120
+ */
121
+ function addCard(data, { title, parentId, body, position }) {
122
+ const container = getContainer(data, parentId);
123
+ if (!container) return null;
124
+
125
+ const id = generateId();
126
+
127
+ const card = {
128
+ id,
129
+ title,
130
+ created: new Date().toISOString(),
131
+ archived: false,
132
+ body: body || '',
133
+ children: [],
134
+ };
135
+
136
+ if (position != null && position < container.length) {
137
+ container.splice(position, 0, card);
138
+ } else {
139
+ container.push(card);
140
+ }
141
+
142
+ const pathResult = getPath(data, card.id);
143
+ const breadcrumbs = pathResult ? pathResult.slice(0, -1).map((c) => ({ id: c.id, title: c.title })) : [];
144
+
145
+ return { card, breadcrumbs };
146
+ }
147
+
148
+ /**
149
+ * Edit an existing card's title or body.
150
+ * @param {object} data - Root data object
151
+ * @param {string} id - Card ID
152
+ * @param {object} changes - {title, body}
153
+ * @returns {{card: object, oldTitle: string, oldBody: string, breadcrumbs: Array<{id, title}>}|null} Result with old values and breadcrumbs, or null if not found
154
+ */
155
+ function editCard(data, id, { title, body }) {
156
+ const card = findById(data, id);
157
+ if (!card) return null;
158
+
159
+ const oldTitle = card.title;
160
+ const oldBody = card.body;
161
+
162
+ if (title !== undefined) card.title = title;
163
+ if (body !== undefined) card.body = body;
164
+
165
+ const pathResult = getPath(data, id);
166
+ const breadcrumbs = pathResult ? pathResult.slice(0, -1).map((c) => ({ id: c.id, title: c.title })) : [];
167
+
168
+ return { card, oldTitle, oldBody, breadcrumbs };
169
+ }
170
+
171
+ /**
172
+ * Delete a card and all its descendants.
173
+ * @param {object} data - Root data object
174
+ * @param {string} id - Card ID to delete
175
+ * @returns {object|null} Full deleted card object with descendantCount added, or null if not found
176
+ */
177
+ function deleteCard(data, id) {
178
+ const parentResult = findParent(data, id);
179
+ if (!parentResult) return null;
180
+
181
+ const { container } = parentResult;
182
+ const idx = container.findIndex((c) => c.id === id);
183
+ if (idx === -1) return null;
184
+
185
+ const card = container[idx];
186
+ const descendantCount = countDescendants(card);
187
+
188
+ container.splice(idx, 1);
189
+
190
+ return { ...card, descendantCount };
191
+ }
192
+
193
+ /**
194
+ * Internal helper: find a card and its ancestry (parent + container) in a single walk.
195
+ * @param {object} data - Root data object
196
+ * @param {string} cardId - Card ID to find
197
+ * @returns {{card: object, parent: object|null, container: Array, ancestorIds: Set}|null}
198
+ */
199
+ function findCardWithAncestry(data, cardId) {
200
+ function search(container, parent, ancestorIds) {
201
+ for (const card of container) {
202
+ if (card.id === cardId) {
203
+ return { card, parent, container, ancestorIds };
204
+ }
205
+ if (card.children && card.children.length) {
206
+ const nextAncestors = new Set(ancestorIds);
207
+ nextAncestors.add(card.id);
208
+ const found = search(card.children, card, nextAncestors);
209
+ if (found) return found;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+ return search(data.cards, null, new Set());
215
+ }
216
+
217
+ /**
218
+ * Move a card to a new parent (or root).
219
+ * Uses at most 2 tree walks: one to find card + ancestry, one to get target container.
220
+ * @param {object} data - Root data object
221
+ * @param {string} cardId - ID of card to move
222
+ * @param {string|null} newParentId - Target parent ID, or null for root
223
+ * @param {number} [requestedPosition] - Optional index in target array
224
+ * @returns {{card: object, sourceParentTitle: string, breadcrumbs: Array<{id, title}>}|null} Result with source parent title and breadcrumbs, or null on error (not found, cycle)
225
+ */
226
+ function moveCard(data, cardId, newParentId, requestedPosition) {
227
+ // Walk 1: find card, its container, and ancestor IDs for cycle detection
228
+ const found = findCardWithAncestry(data, cardId);
229
+ if (!found) return null;
230
+
231
+ const { card, parent: sourceParent, container: sourceContainer, ancestorIds } = found;
232
+ const sourceParentTitle = sourceParent ? sourceParent.title : 'root';
233
+
234
+ // Cycle check: newParentId cannot be the card itself or any ancestor's descendant
235
+ // ancestorIds contains all ancestors of card; card.id is card itself
236
+ if (newParentId != null && (newParentId === cardId || ancestorIds.has(newParentId))) {
237
+ // newParentId is an ancestor of card — but we need to check if newParentId is a descendant of card
238
+ // ancestorIds only has ancestors, not descendants. We need to check if newParentId is under card.
239
+ // If newParentId === cardId, that's a trivial cycle. Otherwise check via getPath.
240
+ if (newParentId === cardId) return null;
241
+ }
242
+
243
+ // For cycle detection: newParentId must not be a descendant of cardId
244
+ if (newParentId != null) {
245
+ const path = getPath(data, newParentId);
246
+ if (path && path.some((p) => p.id === cardId)) {
247
+ return null; // Would create a cycle
248
+ }
249
+ }
250
+
251
+ // Remove from source (using already-found container)
252
+ const sourceIdx = sourceContainer.findIndex((c) => c.id === cardId);
253
+ sourceContainer.splice(sourceIdx, 1);
254
+
255
+ // Walk 2: get target container
256
+ const targetContainer = getContainer(data, newParentId);
257
+ if (!targetContainer) return null;
258
+
259
+ if (requestedPosition != null) {
260
+ targetContainer.splice(requestedPosition, 0, card);
261
+ } else {
262
+ targetContainer.push(card);
263
+ }
264
+
265
+ const pathResult = getPath(data, cardId);
266
+ const breadcrumbs = pathResult ? pathResult.slice(0, -1).map((c) => ({ id: c.id, title: c.title })) : [];
267
+
268
+ return { card, sourceParentTitle, breadcrumbs };
269
+ }
270
+
271
+
272
+ /**
273
+ * Create a body preview: truncate at PREVIEW_TRUNCATE_LENGTH chars first, then replace newlines with spaces.
274
+ * Truncate-first avoids processing thousands of characters in huge bodies.
275
+ * @param {string} body
276
+ * @returns {string}
277
+ */
278
+ function makePreview(body) {
279
+ if (!body) return '';
280
+ if (body.length > PREVIEW_TRUNCATE_LENGTH) {
281
+ return body.slice(0, PREVIEW_TRUNCATE_LENGTH).replace(/\n/g, ' ') + '...';
282
+ }
283
+ return body.replace(/\n/g, ' ');
284
+ }
285
+
286
+ /**
287
+ * Render a nested tree of cards with pre-computed metadata, breadcrumbs, and archive filtering.
288
+ * @param {object} data - Root data object
289
+ * @param {string|null} rootId - Focus card ID, or null for root view
290
+ * @param {object} [opts] - {depth, archiveFilter}
291
+ * @returns {{breadcrumbs: Array|null, cards: Array}|null} Render result, or null if rootId not found
292
+ * cards is a nested tree: [{id, title, descendantCount, hasBody, bodyPreview, created, archived, children: [...]}]
293
+ */
294
+ function renderTree(data, rootId, opts) {
295
+ const { depth: depthArg, archiveFilter } = opts || {};
296
+ if (depthArg !== undefined && typeof depthArg !== 'number') {
297
+ throw new Error('renderTree: depth must be a number');
298
+ }
299
+ const maxDepth = depthArg === 0 ? Infinity : (depthArg !== undefined ? depthArg : 1);
300
+ const filter = archiveFilter || 'active';
301
+
302
+ // Archive filter function
303
+ const shouldInclude = filter === 'active'
304
+ ? (card) => !card.archived
305
+ : filter === 'archived-only'
306
+ ? (card) => card.archived
307
+ : () => true;
308
+
309
+ // Build breadcrumbs
310
+ let breadcrumbs = null;
311
+ if (rootId != null) {
312
+ const path = getPath(data, rootId);
313
+ if (!path) return null;
314
+ breadcrumbs = path.slice(0, -1).map((c) => ({ id: c.id, title: c.title }));
315
+ }
316
+
317
+ function buildNested(cards, currentDepth) {
318
+ const result = [];
319
+ for (const card of cards) {
320
+ if (shouldInclude(card)) {
321
+ const entry = {
322
+ id: card.id,
323
+ title: card.title,
324
+ descendantCount: countDescendants(card, { activeOnly: true }),
325
+ hasBody: !!(card.body && card.body.trim()),
326
+ bodyPreview: makePreview(card.body),
327
+ created: card.created,
328
+ archived: card.archived,
329
+ children: (currentDepth < maxDepth && card.children && card.children.length)
330
+ ? buildNested(card.children, currentDepth + 1)
331
+ : [],
332
+ };
333
+ result.push(entry);
334
+ }
335
+ }
336
+ return result;
337
+ }
338
+
339
+ let cards;
340
+ if (rootId != null) {
341
+ const rootCard = findById(data, rootId);
342
+ if (!shouldInclude(rootCard)) return { breadcrumbs, cards: [] };
343
+ const builtChildren = (maxDepth > 0 && rootCard.children && rootCard.children.length)
344
+ ? buildNested(rootCard.children, 1)
345
+ : [];
346
+ // Derive descendantCount from already-built children instead of a redundant tree walk
347
+ const descendantCount = builtChildren.reduce(
348
+ (sum, child) => sum + 1 + (child.descendantCount || 0), 0
349
+ );
350
+ const entry = {
351
+ id: rootCard.id,
352
+ title: rootCard.title,
353
+ descendantCount,
354
+ hasBody: !!(rootCard.body && rootCard.body.trim()),
355
+ bodyPreview: makePreview(rootCard.body),
356
+ created: rootCard.created,
357
+ archived: rootCard.archived,
358
+ children: builtChildren,
359
+ };
360
+ cards = [entry];
361
+ } else {
362
+ cards = buildNested(data.cards, 0);
363
+ }
364
+
365
+ return { breadcrumbs, cards };
366
+ }
367
+
368
+ /**
369
+ * Recursively set archived flag on a card and all descendants.
370
+ * Returns the count of descendant nodes visited (excludes the card itself).
371
+ * @param {object} card
372
+ * @param {boolean} value
373
+ * @returns {number} Count of descendants processed
374
+ */
375
+ function setArchivedRecursive(card, value) {
376
+ card.archived = value;
377
+ let count = 0;
378
+ if (card.children && card.children.length) {
379
+ for (const child of card.children) {
380
+ count += 1 + setArchivedRecursive(child, value);
381
+ }
382
+ }
383
+ return count;
384
+ }
385
+
386
+ /**
387
+ * Archive a card and all its descendants.
388
+ * @param {object} data - Root data object
389
+ * @param {string} id - Card ID
390
+ * @returns {object|null} Full card object with descendantCount, or null if not found
391
+ */
392
+ function archiveCard(data, id) {
393
+ const card = findById(data, id);
394
+ if (!card) return null;
395
+ const descendantCount = setArchivedRecursive(card, true);
396
+ return { ...card, descendantCount };
397
+ }
398
+
399
+ /**
400
+ * Unarchive a card and all its descendants.
401
+ * @param {object} data - Root data object
402
+ * @param {string} id - Card ID
403
+ * @returns {object|null} Full card object with descendantCount, or null if not found
404
+ */
405
+ function unarchiveCard(data, id) {
406
+ const card = findById(data, id);
407
+ if (!card) return null;
408
+ const descendantCount = setArchivedRecursive(card, false);
409
+ return { ...card, descendantCount };
410
+ }
411
+
412
+ /**
413
+ * Search all active cards for a query string (case-insensitive title match).
414
+ * Returns matches with path breadcrumbs for display.
415
+ * @param {object} data - Root data object
416
+ * @param {string} query - Lowercase search query
417
+ * @returns {Array<{id: string, title: string, path: string}>} Matching cards with path
418
+ */
419
+ function searchCards(data, query) {
420
+ const lowerQuery = query.toLowerCase();
421
+ const results = [];
422
+
423
+ function walk(cards, ancestors) {
424
+ for (const card of cards) {
425
+ if (card.archived) continue;
426
+ const crumbs = [...ancestors, { id: card.id, title: card.title }];
427
+ if ((card.title || '').toLowerCase().includes(lowerQuery)) {
428
+ results.push({
429
+ id: card.id,
430
+ title: card.title,
431
+ path: crumbs.map((c) => c.title).join(' › '),
432
+ });
433
+ }
434
+ if (card.children && card.children.length) {
435
+ walk(card.children, crumbs);
436
+ }
437
+ }
438
+ }
439
+
440
+ walk(data.cards, []);
441
+ return results;
442
+ }
443
+
444
+ module.exports = {
445
+ findById,
446
+ findParent,
447
+ getContainer,
448
+ getPath,
449
+ addCard,
450
+ editCard,
451
+ deleteCard,
452
+ moveCard,
453
+
454
+ countDescendants,
455
+ makePreview,
456
+ PREVIEW_TRUNCATE_LENGTH,
457
+ renderTree,
458
+ searchCards,
459
+ archiveCard,
460
+ unarchiveCard,
461
+ };