@goodandready/dsh-task-tracker 0.0.0-stage → 0.2.14

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/lib/storage.js ADDED
@@ -0,0 +1,472 @@
1
+ import { readFileSync, writeFileSync, renameSync, copyFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
2
+ import { dirname, resolve, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { createRequire } from 'node:module';
5
+
6
+ export function getDefaultFilePath() {
7
+ if (process.env.DSH_TASK_TRACKER_FILE) {
8
+ return resolve(process.env.DSH_TASK_TRACKER_FILE);
9
+ }
10
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
11
+ return join(dshHome, 'task-tracker', 'tasks.json');
12
+ }
13
+
14
+ export function generateTaskId() {
15
+ const ts = Date.now().toString(36);
16
+ const rand = Math.random().toString(36).slice(2, 6);
17
+ return `${ts}${rand}`;
18
+ }
19
+
20
+ function loadSqlite() {
21
+ try {
22
+ const require = createRequire(import.meta.url);
23
+ return require('node:sqlite');
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function normalizeTask(raw) {
30
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
31
+ return null;
32
+ }
33
+ const id = raw.id != null ? String(raw.id).trim() : '';
34
+ if (!id) return null;
35
+
36
+ return {
37
+ ...raw,
38
+ id,
39
+ title: raw.title != null ? String(raw.title) : '',
40
+ description: raw.description != null ? String(raw.description) : '',
41
+ status: String(raw.status || 'pending').toLowerCase() === 'completed' ? 'completed' : 'pending',
42
+ priority: Number.isFinite(Number(raw.priority)) ? Number(raw.priority) : 0,
43
+ folder: raw.folder != null ? (String(raw.folder).trim() || 'Inbox') : 'Inbox',
44
+ author: raw.author != null ? (String(raw.author).trim() || 'User') : 'User',
45
+ createdAt: raw.createdAt != null ? String(raw.createdAt) : new Date().toISOString(),
46
+ updatedAt: raw.updatedAt != null ? String(raw.updatedAt) : (raw.createdAt != null ? String(raw.createdAt) : new Date().toISOString()),
47
+ completedAt: raw.completedAt != null ? String(raw.completedAt) : null,
48
+ };
49
+ }
50
+
51
+ export class TaskStore {
52
+ constructor(options = {}) {
53
+ const optPath = options.filePath;
54
+ let cleanPath = '';
55
+ if (typeof optPath === 'string') {
56
+ cleanPath = optPath.trim();
57
+ } else if (optPath && typeof optPath === 'object') {
58
+ if (typeof optPath.value === 'string') cleanPath = optPath.value.trim();
59
+ else if (typeof optPath.valueOf === 'function' && typeof optPath.valueOf() === 'string') cleanPath = optPath.valueOf().trim();
60
+ else if (typeof optPath.toString === 'function' && String(optPath) !== '[object Object]') cleanPath = String(optPath).trim();
61
+ }
62
+ this.filePath = cleanPath ? resolve(cleanPath) : getDefaultFilePath();
63
+ this.legacyDbPath = options.legacyDbPath !== undefined ? options.legacyDbPath : (process.env.DSH_TASK_TRACKER_LEGACY_DB || null);
64
+ this.tasks = [];
65
+ this.folders = [];
66
+ this.lastMtime = 0;
67
+ this.corrupted = false;
68
+ this.corruptError = null;
69
+ this.quarantinePath = null;
70
+ this.init();
71
+ }
72
+
73
+ init() {
74
+ if (!existsSync(this.filePath)) {
75
+ // Check if we can migrate from legacy sqlite DB
76
+ const imported = this.tryImportLegacy();
77
+ if (!imported) {
78
+ this.tasks = [];
79
+ this.save();
80
+ }
81
+ } else {
82
+ this.load();
83
+ }
84
+ }
85
+
86
+ tryImportLegacy() {
87
+ if (!this.legacyDbPath || !existsSync(this.legacyDbPath)) {
88
+ return false;
89
+ }
90
+ try {
91
+ const sqlite = loadSqlite();
92
+ if (!sqlite || !sqlite.DatabaseSync) return false;
93
+ const db = new sqlite.DatabaseSync(this.legacyDbPath, { open: true, readOnly: true });
94
+ const rows = db.prepare('SELECT id, title, description, status, priority, created_at, updated_at, completed_at FROM tasks ORDER BY created_at ASC').all();
95
+ this.tasks = rows.map((r) => ({
96
+ id: String(r.id),
97
+ title: String(r.title || ''),
98
+ description: String(r.description || ''),
99
+ status: (r.status === 'completed' || r.status === 'resolved') ? 'completed' : 'pending',
100
+ priority: Number(r.priority) || 0,
101
+ createdAt: r.created_at || new Date().toISOString(),
102
+ updatedAt: r.updated_at || r.created_at || new Date().toISOString(),
103
+ completedAt: r.completed_at || null,
104
+ }));
105
+ this.save();
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ load(providedStat = null) {
113
+ try {
114
+ const stat = providedStat || statSync(this.filePath);
115
+ if (stat.mtimeMs === this.lastMtime) {
116
+ return;
117
+ }
118
+ const raw = readFileSync(this.filePath, 'utf8');
119
+ const parsed = JSON.parse(raw);
120
+ if (Array.isArray(parsed)) {
121
+ this.tasks = parsed.map(normalizeTask).filter(Boolean);
122
+ this.folders = [];
123
+ this.lastMtime = stat.mtimeMs;
124
+ } else if (parsed && typeof parsed === 'object') {
125
+ const rawTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
126
+ this.tasks = rawTasks.map(normalizeTask).filter(Boolean);
127
+ const rawFolders = Array.isArray(parsed.folders) ? parsed.folders : [];
128
+ this.folders = rawFolders.map((f) => {
129
+ if (typeof f === 'string') {
130
+ return { name: f.trim(), icon: f.trim() === 'Inbox' ? '📥' : '📁' };
131
+ }
132
+ if (f && typeof f === 'object' && f.name) {
133
+ return { name: String(f.name).trim(), icon: String(f.icon || '📁').trim() || '📁' };
134
+ }
135
+ return null;
136
+ }).filter(Boolean);
137
+ this.lastMtime = stat.mtimeMs;
138
+ } else {
139
+ this.tasks = [];
140
+ this.folders = [];
141
+ this.lastMtime = stat.mtimeMs;
142
+ }
143
+ this.ensureDefaultFolders();
144
+ this.corrupted = false;
145
+ this.corruptError = null;
146
+ } catch (err) {
147
+ if (err.code !== 'ENOENT') {
148
+ try {
149
+ if (existsSync(this.filePath)) {
150
+ const ts = Date.now();
151
+ const qPath = `${this.filePath}.corrupt.${ts}`;
152
+ copyFileSync(this.filePath, qPath);
153
+ this.quarantinePath = qPath;
154
+ }
155
+ } catch (_) {
156
+ /* bestEffort */
157
+ }
158
+ this.corrupted = true;
159
+ this.corruptError = err;
160
+ }
161
+ }
162
+ }
163
+
164
+ resetStorage() {
165
+ this.tasks = [];
166
+ this.folders = [];
167
+ this.corrupted = false;
168
+ this.corruptError = null;
169
+ this.ensureDefaultFolders();
170
+ this.save();
171
+ }
172
+
173
+
174
+
175
+ ensureDefaultFolders() {
176
+ if (!Array.isArray(this.folders)) this.folders = [];
177
+ if (!this.folders.some((f) => f.name === 'Inbox')) {
178
+ this.folders.unshift({ name: 'Inbox', icon: '📥' });
179
+ }
180
+ // Also discover any folders referenced by existing tasks
181
+ for (const t of this.tasks) {
182
+ if (t.folder) {
183
+ const fn = String(t.folder).trim();
184
+ if (fn && !this.folders.some((f) => f.name === fn)) {
185
+ this.folders.push({ name: fn, icon: '📁' });
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ save() {
192
+ if (this.corrupted) {
193
+ throw new Error('Storage is corrupted and quarantined. Refusing to overwrite corrupt file.');
194
+ }
195
+ mkdirSync(dirname(this.filePath), { recursive: true });
196
+ const tmp = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
197
+ const payload = JSON.stringify({
198
+ version: 2,
199
+ folders: this.folders || [],
200
+ tasks: this.tasks || [],
201
+ }, null, 2);
202
+ writeFileSync(tmp, payload, 'utf8');
203
+ renameSync(tmp, this.filePath);
204
+ try {
205
+ this.lastMtime = statSync(this.filePath).mtimeMs;
206
+ } catch {
207
+ this.lastMtime = Date.now();
208
+ }
209
+ }
210
+
211
+ ensureFresh() {
212
+ try {
213
+ const stat = statSync(this.filePath);
214
+ if (stat.mtimeMs !== this.lastMtime) {
215
+ this.load(stat);
216
+ }
217
+ } catch {
218
+ // ignore
219
+ }
220
+ }
221
+
222
+ list(filter = {}) {
223
+ this.ensureFresh();
224
+ let res = [...this.tasks];
225
+
226
+ if (filter.status) {
227
+ const s = String(filter.status).toLowerCase();
228
+ res = res.filter((t) => (t.status || 'pending').toLowerCase() === s);
229
+ }
230
+
231
+ if (filter.folder !== undefined && filter.folder !== '') {
232
+ const f = String(filter.folder);
233
+ res = res.filter((t) => (t.folder || 'Inbox') === f);
234
+ }
235
+
236
+ if (filter.query) {
237
+ const q = String(filter.query).toLowerCase();
238
+ res = res.filter((t) =>
239
+ (t.title && String(t.title).toLowerCase().includes(q)) ||
240
+ (t.description && String(t.description).toLowerCase().includes(q))
241
+ );
242
+ }
243
+
244
+ // Default sorting: pending first, then by priority desc, then by createdAt desc
245
+ res.sort((a, b) => {
246
+ const aDone = a.status === 'completed' ? 1 : 0;
247
+ const bDone = b.status === 'completed' ? 1 : 0;
248
+ if (aDone !== bDone) return aDone - bDone;
249
+ const prioDiff = (Number(b.priority) || 0) - (Number(a.priority) || 0);
250
+ if (prioDiff !== 0) return prioDiff;
251
+ return String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
252
+ });
253
+
254
+ let offset = 0;
255
+ if (filter.offset !== undefined) {
256
+ const parsedOffset = Number(filter.offset);
257
+ offset = Number.isFinite(parsedOffset) && parsedOffset > 0 ? Math.floor(parsedOffset) : 0;
258
+ }
259
+
260
+ let limit = undefined;
261
+ if (filter.limit !== undefined) {
262
+ const parsedLimit = Number(filter.limit);
263
+ if (Number.isFinite(parsedLimit)) {
264
+ limit = Math.max(0, Math.floor(parsedLimit));
265
+ }
266
+ }
267
+
268
+ if (offset > 0) {
269
+ res = res.slice(offset);
270
+ }
271
+ if (limit !== undefined) {
272
+ res = res.slice(0, limit);
273
+ }
274
+
275
+ return res;
276
+ }
277
+
278
+ get(id) {
279
+ this.ensureFresh();
280
+ const targetId = String(id);
281
+ return this.tasks.find((t) => t.id === targetId) || null;
282
+ }
283
+
284
+ create({ title, description = '', priority = 0, status = 'pending', folder = 'Inbox', author = 'User' }) {
285
+ this.ensureFresh();
286
+ const cleanTitle = String(title || '').trim();
287
+ if (!cleanTitle) {
288
+ throw new Error('Title is required');
289
+ }
290
+
291
+ const cleanFolder = String(folder || 'Inbox').trim() || 'Inbox';
292
+ if (!this.folders.some((f) => f.name === cleanFolder)) {
293
+ this.folders.push({ name: cleanFolder, icon: '📁' });
294
+ }
295
+
296
+ const now = new Date().toISOString();
297
+ const task = {
298
+ id: generateTaskId(),
299
+ title: cleanTitle,
300
+ description: String(description || '').trim(),
301
+ priority: Number(priority) || 0,
302
+ status: status === 'completed' ? 'completed' : 'pending',
303
+ folder: cleanFolder,
304
+ author: String(author || 'User').trim() || 'User',
305
+ createdAt: now,
306
+ updatedAt: now,
307
+ completedAt: status === 'completed' ? now : null,
308
+ };
309
+
310
+ this.tasks.push(task);
311
+ this.save();
312
+ return task;
313
+ }
314
+
315
+ update(id, patch = {}) {
316
+ this.ensureFresh();
317
+ const targetId = String(id);
318
+ const task = this.tasks.find((t) => t.id === targetId);
319
+ if (!task) {
320
+ return null;
321
+ }
322
+
323
+ const now = new Date().toISOString();
324
+ if (patch.title !== undefined) {
325
+ const t = String(patch.title).trim();
326
+ if (!t) throw new Error('Title cannot be empty');
327
+ task.title = t;
328
+ }
329
+ if (patch.folder !== undefined) {
330
+ const f = String(patch.folder || 'Inbox').trim() || 'Inbox';
331
+ task.folder = f;
332
+ if (!this.folders.some((item) => item.name === f)) {
333
+ this.folders.push({ name: f, icon: '📁' });
334
+ }
335
+ }
336
+ if (patch.author !== undefined) {
337
+ task.author = String(patch.author || 'User').trim() || 'User';
338
+ }
339
+ if (patch.description !== undefined) {
340
+ task.description = String(patch.description).trim();
341
+ }
342
+ if (patch.priority !== undefined) {
343
+ task.priority = Number(patch.priority) || 0;
344
+ }
345
+ if (patch.status !== undefined) {
346
+ const prev = task.status;
347
+ const next = patch.status === 'completed' ? 'completed' : 'pending';
348
+ task.status = next;
349
+ if (next === 'completed' && prev !== 'completed') {
350
+ task.completedAt = now;
351
+ } else if (next !== 'completed') {
352
+ task.completedAt = null;
353
+ }
354
+ }
355
+
356
+ task.updatedAt = now;
357
+ this.save();
358
+ return task;
359
+ }
360
+
361
+ delete(id) {
362
+ this.ensureFresh();
363
+ const targetId = String(id);
364
+ const idx = this.tasks.findIndex((t) => t.id === targetId);
365
+ if (idx === -1) {
366
+ return false;
367
+ }
368
+ this.tasks.splice(idx, 1);
369
+ this.save();
370
+ return true;
371
+ }
372
+
373
+ listFolders() {
374
+ this.ensureFresh();
375
+ this.ensureDefaultFolders();
376
+ return this.folders.map((f) => f.name);
377
+ }
378
+
379
+ getFolders() {
380
+ this.ensureFresh();
381
+ this.ensureDefaultFolders();
382
+ const counts = {};
383
+ for (const t of this.tasks) {
384
+ if (t.status !== 'completed') {
385
+ const fn = String(t.folder || 'Inbox').trim() || 'Inbox';
386
+ counts[fn] = (counts[fn] || 0) + 1;
387
+ }
388
+ }
389
+ return this.folders.map((f) => ({
390
+ name: f.name,
391
+ icon: f.icon || (f.name === 'Inbox' ? '📥' : '📁'),
392
+ count: counts[f.name] || 0,
393
+ }));
394
+ }
395
+
396
+ addFolder(name, icon = undefined) {
397
+ this.ensureFresh();
398
+ this.ensureDefaultFolders();
399
+ const clean = String(name || '').trim();
400
+ if (!clean) throw new Error('Folder name is required');
401
+ const existing = this.folders.find((f) => f.name.toLowerCase() === clean.toLowerCase());
402
+ if (!existing) {
403
+ const defaultIcon = clean === 'Inbox' ? '📥' : '📁';
404
+ const cleanIcon = (icon !== undefined && String(icon).trim()) ? String(icon).trim() : defaultIcon;
405
+ this.folders.push({ name: clean, icon: cleanIcon });
406
+ this.save();
407
+ } else if (icon !== undefined && String(icon).trim()) {
408
+ const cleanIcon = String(icon).trim();
409
+ if (existing.icon !== cleanIcon) {
410
+ existing.icon = cleanIcon;
411
+ this.save();
412
+ }
413
+ }
414
+ return this.listFolders();
415
+ }
416
+
417
+ updateFolder(name, patch = {}) {
418
+ this.ensureFresh();
419
+ this.ensureDefaultFolders();
420
+ const clean = String(name || '').trim();
421
+ const folder = this.folders.find((f) => f.name === clean);
422
+ if (!folder) return null;
423
+ if (patch.icon !== undefined) {
424
+ folder.icon = String(patch.icon || '📁').trim() || '📁';
425
+ }
426
+ if (patch.name !== undefined) {
427
+ const newName = String(patch.name || '').trim();
428
+ if (newName && newName !== clean) {
429
+ folder.name = newName;
430
+ for (const t of this.tasks) {
431
+ if (t.folder === clean) {
432
+ t.folder = newName;
433
+ t.updatedAt = new Date().toISOString();
434
+ }
435
+ }
436
+ }
437
+ }
438
+ this.save();
439
+ return folder;
440
+ }
441
+
442
+ removeFolder(name) {
443
+ this.ensureFresh();
444
+ this.ensureDefaultFolders();
445
+ const clean = String(name || '').trim();
446
+ if (!clean || clean === 'Inbox') return false;
447
+ this.folders = this.folders.filter((f) => f.name !== clean);
448
+ for (const t of this.tasks) {
449
+ if (t.folder === clean) {
450
+ t.folder = 'Inbox';
451
+ t.updatedAt = new Date().toISOString();
452
+ }
453
+ }
454
+ this.save();
455
+ return true;
456
+ }
457
+
458
+ stats() {
459
+ this.ensureFresh();
460
+ let total = this.tasks.length;
461
+ let pending = 0;
462
+ let completed = 0;
463
+ for (const t of this.tasks) {
464
+ if (t.status === 'completed') {
465
+ completed++;
466
+ } else {
467
+ pending++;
468
+ }
469
+ }
470
+ return { total, pending, completed };
471
+ }
472
+ }