@jlmarcos99/todos 1.0.1 → 1.1.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.
package/README.md CHANGED
@@ -79,7 +79,8 @@ Configuration is kept separately using each platform's config directory.
79
79
  - **History** shows tasks completed more than 24 hours ago.
80
80
 
81
81
  Press `a` to add a task, `s` to start it, `n` to add a progress note, and Space
82
- to complete or reopen it. Press `V` to create another vault. `Tab` switches
82
+ to complete or reopen it. Press `d` twice to delete the selected task and its
83
+ activity. Press `V` to create another vault. `Tab` switches
83
84
  vaults. Press `r` to synchronize; in local-only mode it prompts for a Git remote
84
85
  URL and links the current vault store. Changes are saved locally before Git is
85
86
  contacted; an offline remote leaves them saved and marked pending.
@@ -97,6 +98,7 @@ contacted; an offline remote leaves them saved and marked pending.
97
98
  | Enter | Read task activity |
98
99
  | `[` / `]` | Previous / next Daily window |
99
100
  | `t` | Today's Daily |
101
+ | `d` `d` | Delete selected task; the second `d` confirms |
100
102
  | `a` / `V` | Add task / create vault |
101
103
  | `/` | Search the current view |
102
104
  | `r` | Sync, or link a remote in local-only mode |
@@ -55,6 +55,32 @@ export class TaskService {
55
55
  await this.applyStatusChange(currentVault, currentTask, 'done', 'completed', date);
56
56
  });
57
57
  }
58
+ async deleteTask(vault, task) {
59
+ await this.runChange(`Deleted task: ${task.name}`, async () => {
60
+ const currentVault = this.resolveVault(vault);
61
+ const currentTask = this.resolveTask(currentVault, task);
62
+ const related = currentVault.activity.filter(item => item.taskId === currentTask.id);
63
+ const deletedActivity = [];
64
+ try {
65
+ if (this.store) {
66
+ for (const item of related) {
67
+ await this.store.deleteActivity(currentVault.id, item.id);
68
+ deletedActivity.push(item);
69
+ }
70
+ await this.store.deleteTask(currentVault.id, currentTask.id);
71
+ }
72
+ currentVault.tasks = currentVault.tasks.filter(item => item.id !== currentTask.id);
73
+ currentVault.activity = currentVault.activity.filter(item => item.taskId !== currentTask.id);
74
+ }
75
+ catch (error) {
76
+ if (this.store) {
77
+ for (const item of deletedActivity)
78
+ await this.store.saveActivity(currentVault.id, item).catch(() => undefined);
79
+ }
80
+ throw error;
81
+ }
82
+ });
83
+ }
58
84
  async addProgressNote(vault, task, note, date) {
59
85
  const cleanNote = note.trim();
60
86
  if (!cleanNote)
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { chmod, lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { chmod, lstat, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { writeJsonAtomically } from "./atomic-file.js";
5
5
  import { decodeActivity, decodeTask, decodeVault, encodeActivity, encodeTask } from "./record-codec.js";
6
6
  const FORMAT_VERSION = 1;
7
7
  const OWNER_MARKER = '.jmtodos-store';
8
8
  const STORE_FILE = 'store.json';
9
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
9
10
  export class StoreRepository {
10
11
  root;
11
12
  constructor(root) {
@@ -111,6 +112,12 @@ export class StoreRepository {
111
112
  async saveActivity(vaultId, activity) {
112
113
  await writeJsonAtomically(path.join(this.root, 'vaults', vaultId, 'activity', `${activity.id}.json`), encodeActivity(activity));
113
114
  }
115
+ async deleteTask(vaultId, taskId) {
116
+ await this.deleteRecord(this.recordPath(vaultId, 'tasks', taskId));
117
+ }
118
+ async deleteActivity(vaultId, activityId) {
119
+ await this.deleteRecord(this.recordPath(vaultId, 'activity', activityId));
120
+ }
114
121
  async assertStoreVersion() {
115
122
  const data = await this.readJson(path.join(this.root, STORE_FILE));
116
123
  if (typeof data !== 'object' || data === null || !('formatVersion' in data) || data.formatVersion !== FORMAT_VERSION) {
@@ -135,6 +142,28 @@ export class StoreRepository {
135
142
  throw new Error(`Could not read Todos data at ${file}: ${error.message}`);
136
143
  }
137
144
  }
145
+ recordPath(vaultId, folder, id) {
146
+ return path.join(this.root, 'vaults', this.assertRecordId(vaultId), folder, `${this.assertRecordId(id)}.json`);
147
+ }
148
+ assertRecordId(value) {
149
+ if (!RECORD_ID.test(value))
150
+ throw new Error('Refusing to delete a record with an invalid id.');
151
+ return value;
152
+ }
153
+ async deleteRecord(file) {
154
+ let info;
155
+ try {
156
+ info = await lstat(file);
157
+ }
158
+ catch (error) {
159
+ if (error.code === 'ENOENT')
160
+ return;
161
+ throw error;
162
+ }
163
+ if (!info.isFile() || info.isSymbolicLink())
164
+ throw new Error(`Refusing to delete a non-regular file: ${file}`);
165
+ await rm(file);
166
+ }
138
167
  async readRecords(directory, decode) {
139
168
  let files;
140
169
  try {
@@ -28,7 +28,11 @@ export class GitClient {
28
28
  }
29
29
  runOnce(args, allowFailure) {
30
30
  return new Promise((resolve, reject) => {
31
- const child = spawn('git', ['-C', this.directory, ...args], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
31
+ const child = spawn('git', ['-C', this.directory, ...args], {
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ windowsHide: true,
34
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: '0' },
35
+ });
32
36
  let stdout = '';
33
37
  let stderr = '';
34
38
  child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
@@ -19,6 +19,7 @@ export class TodoScreen {
19
19
  noteTaskId;
20
20
  viewportWidth = 80;
21
21
  previousGTime = 0;
22
+ pendingDeleteId;
22
23
  unsubscribeSync;
23
24
  constructor(options) {
24
25
  this.options = options;
@@ -54,6 +55,11 @@ export class TodoScreen {
54
55
  this.options.redraw();
55
56
  }
56
57
  handleCommand(command) {
58
+ const confirmDelete = this.pendingDeleteId !== undefined && command === 'd'
59
+ && this.session.selectedTask?.id === this.pendingDeleteId;
60
+ if (this.pendingDeleteId !== undefined && !confirmDelete)
61
+ this.session.message = 'Delete cancelled.';
62
+ this.pendingDeleteId = undefined;
57
63
  if (command !== 'ctrl+d' && command !== 'ctrl+u')
58
64
  this.followDailySelection = true;
59
65
  if (command === 'q') {
@@ -73,6 +79,10 @@ export class TodoScreen {
73
79
  this.handleActivityCommand(command);
74
80
  return;
75
81
  }
82
+ if (confirmDelete) {
83
+ this.session.deleteSelectedTask();
84
+ return;
85
+ }
76
86
  switch (command) {
77
87
  case 'tab':
78
88
  this.session.switchVault(1);
@@ -145,6 +155,14 @@ export class TodoScreen {
145
155
  this.activityOffset = 0;
146
156
  }
147
157
  break;
158
+ case 'd': {
159
+ const task = this.session.selectedTask;
160
+ if (!task)
161
+ break;
162
+ this.pendingDeleteId = task.id;
163
+ this.session.message = `Delete "${task.name}"? Press d again to confirm.`;
164
+ break;
165
+ }
148
166
  case 'a':
149
167
  this.openInput('add');
150
168
  break;
@@ -79,6 +79,15 @@ export class TodoSession {
79
79
  void this.runMutation(() => this.tasksService.startTask(this.vault, task, this.now()));
80
80
  this.clampSelection();
81
81
  }
82
+ deleteSelectedTask() {
83
+ const task = this.selectedTask;
84
+ if (!task)
85
+ return;
86
+ const name = task.name;
87
+ void this.runMutation(() => this.tasksService.deleteTask(this.vault, task));
88
+ this.message = `Deleted "${name}".`;
89
+ this.clampSelection();
90
+ }
82
91
  toggleSelectedTask() {
83
92
  const task = this.selectedTask;
84
93
  if (!task)
@@ -223,6 +223,7 @@ function renderHelp() {
223
223
  ' [ / ] Previous / next two-day window',
224
224
  ' Ctrl+d/u Scroll daily notes',
225
225
  ' t Today’s daily',
226
+ ' d Delete task; press d again to confirm',
226
227
  ' a · V · / Add task · vault · search',
227
228
  ' r Sync, or link a Git remote if local-only',
228
229
  ' Esc · ? Back / clear · help',
@@ -273,6 +274,6 @@ function renderFooter(session, inputLine, showActivity) {
273
274
  return [
274
275
  inputLine ?? message,
275
276
  ` ${theme.accent('j/k')} move ${theme.accent('Tab')} vault ${theme.accent('h/l')} views ${theme.accent('space')} done`,
276
- ` ${theme.accent('s')} start ${theme.accent('n')} note ${theme.accent('V')} vault ${theme.accent('r')} sync/link ${theme.accent('?')} help`,
277
+ ` ${theme.accent('s')} start ${theme.accent('n')} note ${theme.accent('d')} delete ${theme.accent('V')} vault ${theme.accent('r')} sync ${theme.accent('?')} help`,
277
278
  ];
278
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jlmarcos99/todos",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A keyboard-first task manager and daily work log for the terminal",
5
5
  "repository": {
6
6
  "type": "git",