@tashks/core 0.1.0 → 0.1.2
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/dist/src/hooks.d.ts +18 -0
- package/dist/src/hooks.d.ts.map +1 -0
- package/dist/src/hooks.js +140 -0
- package/dist/src/id.d.ts +2 -0
- package/dist/src/id.d.ts.map +1 -0
- package/dist/src/id.js +17 -0
- package/dist/src/proseql-repository.d.ts +12 -0
- package/dist/src/proseql-repository.d.ts.map +1 -0
- package/dist/src/proseql-repository.js +255 -0
- package/dist/src/query.d.ts +110 -269
- package/dist/src/query.test.d.ts +1 -1
- package/dist/src/recurrence.d.ts +21 -0
- package/dist/src/recurrence.d.ts.map +1 -0
- package/dist/src/recurrence.js +194 -0
- package/dist/src/repository.d.ts +44 -83
- package/dist/src/repository.d.ts.map +1 -1
- package/dist/src/repository.js +26 -338
- package/dist/src/repository.test.d.ts +1 -1
- package/dist/src/repository.test.js +4 -0
- package/dist/src/schema.d.ts +208 -434
- package/dist/src/schema.test.d.ts +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -8
package/dist/src/repository.d.ts
CHANGED
|
@@ -1,100 +1,61 @@
|
|
|
1
1
|
import * as Context from "effect/Context";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as Layer from "effect/Layer";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type WorkLogEntry,
|
|
10
|
-
type WorkLogPatch,
|
|
11
|
-
} from "./schema.js";
|
|
4
|
+
import { type Task, type TaskCreateInput, type TaskPatch, type WorkLogCreateInput, type WorkLogEntry, type WorkLogPatch } from "./schema.js";
|
|
5
|
+
export { discoverHooksForEvent } from "./hooks.js";
|
|
6
|
+
export type { HookEvent, HookDiscoveryOptions } from "./hooks.js";
|
|
7
|
+
export { generateTaskId } from "./id.js";
|
|
8
|
+
export declare const applyListTaskFilters: (tasks: Array<Task>, filters?: ListTasksFilters) => Array<Task>;
|
|
12
9
|
export interface ListTasksFilters {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
readonly status?: Task["status"];
|
|
11
|
+
readonly area?: Task["area"];
|
|
12
|
+
readonly project?: string;
|
|
13
|
+
readonly tags?: ReadonlyArray<string>;
|
|
14
|
+
readonly due_before?: string;
|
|
15
|
+
readonly due_after?: string;
|
|
16
|
+
readonly unblocked_only?: boolean;
|
|
17
|
+
readonly date?: string;
|
|
21
18
|
}
|
|
22
19
|
export interface ListWorkLogFilters {
|
|
23
|
-
|
|
20
|
+
readonly date?: string;
|
|
24
21
|
}
|
|
25
22
|
export interface DeleteResult {
|
|
26
|
-
|
|
23
|
+
readonly deleted: true;
|
|
27
24
|
}
|
|
28
|
-
export type HookEvent = "create" | "modify" | "complete" | "delete";
|
|
29
|
-
export interface HookDiscoveryOptions {
|
|
30
|
-
readonly hooksDir?: string;
|
|
31
|
-
readonly env?: NodeJS.ProcessEnv;
|
|
32
|
-
}
|
|
33
|
-
export declare const discoverHooksForEvent: (
|
|
34
|
-
event: HookEvent,
|
|
35
|
-
options?: HookDiscoveryOptions,
|
|
36
|
-
) => Effect.Effect<Array<string>, string>;
|
|
37
25
|
export interface TaskRepositoryService {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
filters?: ListWorkLogFilters,
|
|
61
|
-
) => Effect.Effect<Array<WorkLogEntry>, string>;
|
|
62
|
-
readonly createWorkLogEntry: (
|
|
63
|
-
input: WorkLogCreateInput,
|
|
64
|
-
) => Effect.Effect<WorkLogEntry, string>;
|
|
65
|
-
readonly updateWorkLogEntry: (
|
|
66
|
-
id: string,
|
|
67
|
-
patch: WorkLogPatch,
|
|
68
|
-
) => Effect.Effect<WorkLogEntry, string>;
|
|
69
|
-
readonly deleteWorkLogEntry: (
|
|
70
|
-
id: string,
|
|
71
|
-
) => Effect.Effect<DeleteResult, string>;
|
|
26
|
+
readonly listTasks: (filters?: ListTasksFilters) => Effect.Effect<Array<Task>, string>;
|
|
27
|
+
readonly getTask: (id: string) => Effect.Effect<Task, string>;
|
|
28
|
+
readonly createTask: (input: TaskCreateInput) => Effect.Effect<Task, string>;
|
|
29
|
+
readonly updateTask: (id: string, patch: TaskPatch) => Effect.Effect<Task, string>;
|
|
30
|
+
readonly completeTask: (id: string) => Effect.Effect<Task, string>;
|
|
31
|
+
readonly generateNextRecurrence: (id: string) => Effect.Effect<Task, string>;
|
|
32
|
+
readonly processDueRecurrences: (now: Date) => Effect.Effect<{
|
|
33
|
+
readonly created: Array<Task>;
|
|
34
|
+
readonly replaced: Array<string>;
|
|
35
|
+
}, string>;
|
|
36
|
+
readonly deleteTask: (id: string) => Effect.Effect<DeleteResult, string>;
|
|
37
|
+
readonly setDailyHighlight: (id: string) => Effect.Effect<Task, string>;
|
|
38
|
+
readonly listStale: (days: number) => Effect.Effect<Array<Task>, string>;
|
|
39
|
+
readonly listWorkLog: (filters?: ListWorkLogFilters) => Effect.Effect<Array<WorkLogEntry>, string>;
|
|
40
|
+
readonly createWorkLogEntry: (input: WorkLogCreateInput) => Effect.Effect<WorkLogEntry, string>;
|
|
41
|
+
readonly updateWorkLogEntry: (id: string, patch: WorkLogPatch) => Effect.Effect<WorkLogEntry, string>;
|
|
42
|
+
readonly deleteWorkLogEntry: (id: string) => Effect.Effect<DeleteResult, string>;
|
|
43
|
+
readonly importTask: (task: Task) => Effect.Effect<Task, string>;
|
|
44
|
+
readonly importWorkLogEntry: (entry: WorkLogEntry) => Effect.Effect<WorkLogEntry, string>;
|
|
45
|
+
}
|
|
46
|
+
declare const TaskRepository_base: Context.TagClass<TaskRepository, "TaskRepository", TaskRepositoryService>;
|
|
47
|
+
export declare class TaskRepository extends TaskRepository_base {
|
|
72
48
|
}
|
|
73
|
-
declare const TaskRepository_base: Context.TagClass<
|
|
74
|
-
TaskRepository,
|
|
75
|
-
"TaskRepository",
|
|
76
|
-
TaskRepositoryService
|
|
77
|
-
>;
|
|
78
|
-
export declare class TaskRepository extends TaskRepository_base {}
|
|
79
49
|
export interface TaskRepositoryLiveOptions {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
50
|
+
readonly dataDir?: string;
|
|
51
|
+
readonly hooksDir?: string;
|
|
52
|
+
readonly hookEnv?: NodeJS.ProcessEnv;
|
|
83
53
|
}
|
|
84
|
-
export declare const TaskRepositoryLive: (
|
|
85
|
-
options?: TaskRepositoryLiveOptions,
|
|
86
|
-
) => Layer.Layer<TaskRepository>;
|
|
87
|
-
export declare const generateTaskId: (title: string) => string;
|
|
54
|
+
export declare const TaskRepositoryLive: (options?: TaskRepositoryLiveOptions) => Layer.Layer<TaskRepository>;
|
|
88
55
|
export declare const todayIso: () => string;
|
|
89
56
|
export declare const parseTaskRecord: (record: unknown) => Task | null;
|
|
90
|
-
export declare const parseWorkLogRecord: (
|
|
91
|
-
record: unknown,
|
|
92
|
-
) => WorkLogEntry | null;
|
|
57
|
+
export declare const parseWorkLogRecord: (record: unknown) => WorkLogEntry | null;
|
|
93
58
|
export declare const createTaskFromInput: (input: TaskCreateInput) => Task;
|
|
94
59
|
export declare const applyTaskPatch: (task: Task, patch: TaskPatch) => Task;
|
|
95
|
-
export declare const applyWorkLogPatch: (
|
|
96
|
-
|
|
97
|
-
patch: WorkLogPatch,
|
|
98
|
-
) => WorkLogEntry;
|
|
99
|
-
export {};
|
|
100
|
-
//# sourceMappingURL=repository.d.ts.map
|
|
60
|
+
export declare const applyWorkLogPatch: (entry: WorkLogEntry, patch: WorkLogPatch) => WorkLogEntry;
|
|
61
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/repository.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/repository.ts"],"names":[],"mappings":"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"}
|
package/dist/src/repository.js
CHANGED
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { constants as fsConstants } from "node:fs";
|
|
4
|
-
import { access, mkdir, readdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
5
2
|
import { join } from "node:path";
|
|
6
3
|
import * as Context from "effect/Context";
|
|
7
4
|
import * as Either from "effect/Either";
|
|
8
5
|
import * as Effect from "effect/Effect";
|
|
9
6
|
import * as Layer from "effect/Layer";
|
|
10
7
|
import * as Schema from "effect/Schema";
|
|
11
|
-
import { Frequency, RRule, rrulestr } from "rrule";
|
|
12
8
|
import YAML from "yaml";
|
|
13
9
|
import { Task as TaskSchema, TaskCreateInput as TaskCreateInputSchema, TaskPatch as TaskPatchSchema, WorkLogCreateInput as WorkLogCreateInputSchema, WorkLogEntry as WorkLogEntrySchema, WorkLogPatch as WorkLogPatchSchema, } from "./schema.js";
|
|
14
10
|
import { byUpdatedDescThenTitle, isDueBefore, isStalerThan, isUnblocked, } from "./query.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.replace(/[\u0300-\u036f]/g, "")
|
|
22
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
23
|
-
.replace(/^-+|-+$/g, "");
|
|
24
|
-
return slug.length > 0 ? slug : "task";
|
|
25
|
-
};
|
|
26
|
-
const randomIdSuffix = () => {
|
|
27
|
-
const random = randomBytes(idSuffixLength);
|
|
28
|
-
return Array.from(random, (value) => idSuffixAlphabet[value % idSuffixAlphabet.length]).join("");
|
|
29
|
-
};
|
|
11
|
+
import { generateTaskId } from "./id.js";
|
|
12
|
+
import { runCreateHooks, runModifyHooks, runNonMutatingHooks, } from "./hooks.js";
|
|
13
|
+
import { buildCompletionRecurrenceTask, buildNextClockRecurrenceTask, isClockRecurrenceDue, } from "./recurrence.js";
|
|
14
|
+
// Re-export for backwards compatibility
|
|
15
|
+
export { discoverHooksForEvent } from "./hooks.js";
|
|
16
|
+
export { generateTaskId } from "./id.js";
|
|
30
17
|
const decodeTask = Schema.decodeUnknownSync(TaskSchema);
|
|
31
18
|
const decodeTaskEither = Schema.decodeUnknownEither(TaskSchema);
|
|
32
19
|
const decodeTaskCreateInput = Schema.decodeUnknownSync(TaskCreateInputSchema);
|
|
@@ -215,194 +202,17 @@ const toWorkLogDate = (startedAt) => Effect.try({
|
|
|
215
202
|
},
|
|
216
203
|
catch: (error) => `TaskRepository failed to derive work log date: ${toErrorMessage(error)}`,
|
|
217
204
|
});
|
|
218
|
-
const completionRecurrenceFrequencies = new Map([
|
|
219
|
-
[Frequency.DAILY, "DAILY"],
|
|
220
|
-
[Frequency.WEEKLY, "WEEKLY"],
|
|
221
|
-
[Frequency.MONTHLY, "MONTHLY"],
|
|
222
|
-
[Frequency.YEARLY, "YEARLY"],
|
|
223
|
-
]);
|
|
224
|
-
const parseCompletionRecurrenceInterval = (recurrence) => Effect.try({
|
|
225
|
-
try: () => {
|
|
226
|
-
const parsedRule = rrulestr(recurrence, { forceset: false });
|
|
227
|
-
if (!(parsedRule instanceof RRule)) {
|
|
228
|
-
throw new Error("Unsupported completion recurrence: expected a single RRULE");
|
|
229
|
-
}
|
|
230
|
-
const frequency = completionRecurrenceFrequencies.get(parsedRule.options.freq);
|
|
231
|
-
if (frequency === undefined) {
|
|
232
|
-
const frequencyLabel = Frequency[parsedRule.options.freq] ?? String(parsedRule.options.freq);
|
|
233
|
-
throw new Error(`Unsupported completion recurrence frequency: ${frequencyLabel}`);
|
|
234
|
-
}
|
|
235
|
-
const interval = parsedRule.options.interval;
|
|
236
|
-
if (!Number.isFinite(interval) || interval < 1) {
|
|
237
|
-
throw new Error(`Invalid recurrence interval: ${String(interval)}`);
|
|
238
|
-
}
|
|
239
|
-
return { frequency, interval };
|
|
240
|
-
},
|
|
241
|
-
catch: (error) => `TaskRepository failed to parse recurrence interval: ${toErrorMessage(error)}`,
|
|
242
|
-
});
|
|
243
|
-
const addRecurrenceInterval = (date, recurrenceInterval) => {
|
|
244
|
-
const next = new Date(date.getTime());
|
|
245
|
-
switch (recurrenceInterval.frequency) {
|
|
246
|
-
case "DAILY":
|
|
247
|
-
next.setUTCDate(next.getUTCDate() + recurrenceInterval.interval);
|
|
248
|
-
break;
|
|
249
|
-
case "WEEKLY":
|
|
250
|
-
next.setUTCDate(next.getUTCDate() + recurrenceInterval.interval * 7);
|
|
251
|
-
break;
|
|
252
|
-
case "MONTHLY":
|
|
253
|
-
next.setUTCMonth(next.getUTCMonth() + recurrenceInterval.interval);
|
|
254
|
-
break;
|
|
255
|
-
case "YEARLY":
|
|
256
|
-
next.setUTCFullYear(next.getUTCFullYear() + recurrenceInterval.interval);
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
return next;
|
|
260
|
-
};
|
|
261
|
-
const shiftIsoDateByRecurrenceInterval = (date, recurrenceInterval) => Effect.try({
|
|
262
|
-
try: () => {
|
|
263
|
-
const parsed = new Date(`${date}T00:00:00.000Z`);
|
|
264
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
265
|
-
throw new Error(`Invalid ISO date: ${date}`);
|
|
266
|
-
}
|
|
267
|
-
return addRecurrenceInterval(parsed, recurrenceInterval)
|
|
268
|
-
.toISOString()
|
|
269
|
-
.slice(0, 10);
|
|
270
|
-
},
|
|
271
|
-
catch: (error) => `TaskRepository failed to shift ISO date: ${toErrorMessage(error)}`,
|
|
272
|
-
});
|
|
273
|
-
const shiftIsoDateTimeToDateByRecurrenceInterval = (dateTime, recurrenceInterval) => Effect.try({
|
|
274
|
-
try: () => {
|
|
275
|
-
const parsed = new Date(dateTime);
|
|
276
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
277
|
-
throw new Error(`Invalid ISO datetime: ${dateTime}`);
|
|
278
|
-
}
|
|
279
|
-
return addRecurrenceInterval(parsed, recurrenceInterval)
|
|
280
|
-
.toISOString()
|
|
281
|
-
.slice(0, 10);
|
|
282
|
-
},
|
|
283
|
-
catch: (error) => `TaskRepository failed to shift ISO datetime: ${toErrorMessage(error)}`,
|
|
284
|
-
});
|
|
285
|
-
const buildCompletionRecurrenceTask = (completedTask, completedAt) => {
|
|
286
|
-
const recurrence = completedTask.recurrence;
|
|
287
|
-
if (recurrence === null ||
|
|
288
|
-
completedTask.recurrence_trigger !== "completion") {
|
|
289
|
-
return Effect.succeed(null);
|
|
290
|
-
}
|
|
291
|
-
return Effect.gen(function* () {
|
|
292
|
-
const recurrenceInterval = yield* parseCompletionRecurrenceInterval(recurrence);
|
|
293
|
-
const deferUntil = yield* shiftIsoDateTimeToDateByRecurrenceInterval(completedAt, recurrenceInterval);
|
|
294
|
-
const shiftedDue = completedTask.due === null
|
|
295
|
-
? null
|
|
296
|
-
: yield* shiftIsoDateByRecurrenceInterval(completedTask.due, recurrenceInterval);
|
|
297
|
-
const completedDate = completedAt.slice(0, 10);
|
|
298
|
-
return decodeTask({
|
|
299
|
-
...completedTask,
|
|
300
|
-
id: generateTaskId(completedTask.title),
|
|
301
|
-
status: "active",
|
|
302
|
-
created: completedDate,
|
|
303
|
-
updated: completedDate,
|
|
304
|
-
due: shiftedDue,
|
|
305
|
-
actual_minutes: null,
|
|
306
|
-
completed_at: null,
|
|
307
|
-
last_surfaced: null,
|
|
308
|
-
defer_until: deferUntil,
|
|
309
|
-
nudge_count: 0,
|
|
310
|
-
recurrence_last_generated: completedAt,
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
};
|
|
314
|
-
const toIsoDateTime = (value, label) => Effect.try({
|
|
315
|
-
try: () => {
|
|
316
|
-
const timestamp = value.getTime();
|
|
317
|
-
if (Number.isNaN(timestamp)) {
|
|
318
|
-
throw new Error(`Invalid ${label} datetime`);
|
|
319
|
-
}
|
|
320
|
-
return value.toISOString();
|
|
321
|
-
},
|
|
322
|
-
catch: (error) => `TaskRepository failed to normalize ${label} datetime: ${toErrorMessage(error)}`,
|
|
323
|
-
});
|
|
324
|
-
const parseIsoDateToUtcStart = (value, label) => Effect.try({
|
|
325
|
-
try: () => {
|
|
326
|
-
const parsed = new Date(`${value}T00:00:00.000Z`);
|
|
327
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
328
|
-
throw new Error(`Invalid ISO date: ${value}`);
|
|
329
|
-
}
|
|
330
|
-
return parsed;
|
|
331
|
-
},
|
|
332
|
-
catch: (error) => `TaskRepository failed to parse ${label} date: ${toErrorMessage(error)}`,
|
|
333
|
-
});
|
|
334
|
-
const parseIsoDateTime = (value, label) => Effect.try({
|
|
335
|
-
try: () => {
|
|
336
|
-
const parsed = new Date(value);
|
|
337
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
338
|
-
throw new Error(`Invalid ISO datetime: ${value}`);
|
|
339
|
-
}
|
|
340
|
-
return parsed;
|
|
341
|
-
},
|
|
342
|
-
catch: (error) => `TaskRepository failed to parse ${label} datetime: ${toErrorMessage(error)}`,
|
|
343
|
-
});
|
|
344
|
-
const isClockRecurrenceDue = (task, now) => Effect.gen(function* () {
|
|
345
|
-
const recurrence = task.recurrence;
|
|
346
|
-
if (recurrence === null || task.recurrence_trigger !== "clock") {
|
|
347
|
-
return false;
|
|
348
|
-
}
|
|
349
|
-
const nowIso = yield* toIsoDateTime(now, "recurrence check");
|
|
350
|
-
const nowDate = new Date(nowIso);
|
|
351
|
-
const createdAt = yield* parseIsoDateToUtcStart(task.created, "task created");
|
|
352
|
-
const lastGeneratedAt = task.recurrence_last_generated === null
|
|
353
|
-
? createdAt
|
|
354
|
-
: yield* parseIsoDateTime(task.recurrence_last_generated, "recurrence_last_generated");
|
|
355
|
-
const rule = yield* Effect.try({
|
|
356
|
-
try: () => rrulestr(recurrence, { dtstart: createdAt, forceset: false }),
|
|
357
|
-
catch: (error) => `TaskRepository failed to parse recurrence for ${task.id}: ${toErrorMessage(error)}`,
|
|
358
|
-
});
|
|
359
|
-
const nextOccurrence = rule.after(lastGeneratedAt, false);
|
|
360
|
-
return (nextOccurrence !== null && nextOccurrence.getTime() <= nowDate.getTime());
|
|
361
|
-
});
|
|
362
205
|
const generateNextClockRecurrence = (dataDir, existing, generatedAt) => Effect.gen(function* () {
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
const generatedAtIso = yield* toIsoDateTime(generatedAt, "recurrence generation");
|
|
368
|
-
const generatedDate = generatedAtIso.slice(0, 10);
|
|
369
|
-
const nextTask = decodeTask({
|
|
370
|
-
...existing.task,
|
|
371
|
-
id: generateTaskId(existing.task.title),
|
|
372
|
-
status: "active",
|
|
373
|
-
created: generatedDate,
|
|
374
|
-
updated: generatedDate,
|
|
375
|
-
actual_minutes: null,
|
|
376
|
-
completed_at: null,
|
|
377
|
-
last_surfaced: null,
|
|
378
|
-
defer_until: null,
|
|
379
|
-
nudge_count: 0,
|
|
380
|
-
recurrence_last_generated: generatedAtIso,
|
|
381
|
-
});
|
|
382
|
-
let replacedId = null;
|
|
383
|
-
if (existing.task.recurrence_strategy === "replace" &&
|
|
384
|
-
existing.task.status !== "done" &&
|
|
385
|
-
existing.task.status !== "dropped") {
|
|
386
|
-
const droppedTask = decodeTask({
|
|
387
|
-
...existing.task,
|
|
388
|
-
status: "dropped",
|
|
389
|
-
updated: generatedDate,
|
|
390
|
-
recurrence_last_generated: generatedAtIso,
|
|
391
|
-
});
|
|
392
|
-
yield* writeTaskToDisk(existing.path, droppedTask);
|
|
393
|
-
replacedId = existing.task.id;
|
|
394
|
-
}
|
|
395
|
-
if (existing.task.recurrence_strategy === "accumulate") {
|
|
396
|
-
const updatedCurrent = decodeTask({
|
|
397
|
-
...existing.task,
|
|
398
|
-
updated: generatedDate,
|
|
399
|
-
recurrence_last_generated: generatedAtIso,
|
|
400
|
-
});
|
|
401
|
-
yield* writeTaskToDisk(existing.path, updatedCurrent);
|
|
206
|
+
const result = yield* buildNextClockRecurrenceTask(existing.task, generatedAt);
|
|
207
|
+
if (result.updatedCurrent !== null) {
|
|
208
|
+
yield* writeTaskToDisk(existing.path, result.updatedCurrent);
|
|
402
209
|
}
|
|
403
210
|
yield* ensureTasksDir(dataDir);
|
|
404
|
-
yield* writeTaskToDisk(taskFilePath(dataDir, nextTask.id), nextTask);
|
|
405
|
-
|
|
211
|
+
yield* writeTaskToDisk(taskFilePath(dataDir, result.nextTask.id), result.nextTask);
|
|
212
|
+
const replacedId = result.shouldReplaceCurrent
|
|
213
|
+
? existing.task.id
|
|
214
|
+
: null;
|
|
215
|
+
return { nextTask: result.nextTask, replacedId };
|
|
406
216
|
});
|
|
407
217
|
const byStartedAtDescThenId = (a, b) => {
|
|
408
218
|
const byStartedAtDesc = b.started_at.localeCompare(a.started_at);
|
|
@@ -411,7 +221,7 @@ const byStartedAtDescThenId = (a, b) => {
|
|
|
411
221
|
}
|
|
412
222
|
return a.id.localeCompare(b.id);
|
|
413
223
|
};
|
|
414
|
-
const applyListTaskFilters = (tasks, filters = {}) => {
|
|
224
|
+
export const applyListTaskFilters = (tasks, filters = {}) => {
|
|
415
225
|
const dueBeforePredicate = filters.due_before !== undefined ? isDueBefore(filters.due_before) : null;
|
|
416
226
|
return tasks
|
|
417
227
|
.filter((task) => {
|
|
@@ -448,137 +258,6 @@ const applyListTaskFilters = (tasks, filters = {}) => {
|
|
|
448
258
|
})
|
|
449
259
|
.sort(byUpdatedDescThenTitle);
|
|
450
260
|
};
|
|
451
|
-
const defaultHooksDir = (env = process.env) => {
|
|
452
|
-
const xdgConfigHome = env.XDG_CONFIG_HOME;
|
|
453
|
-
if (xdgConfigHome !== undefined && xdgConfigHome.length > 0) {
|
|
454
|
-
return join(xdgConfigHome, "tashks", "hooks");
|
|
455
|
-
}
|
|
456
|
-
const home = env.HOME;
|
|
457
|
-
return home !== undefined && home.length > 0
|
|
458
|
-
? join(home, ".config", "tashks", "hooks")
|
|
459
|
-
: join(".config", "tashks", "hooks");
|
|
460
|
-
};
|
|
461
|
-
const hookNamePattern = (event) => new RegExp(`^on-${event}(?:\\..+)?$`);
|
|
462
|
-
const isHookCandidate = (event, fileName) => hookNamePattern(event).test(fileName);
|
|
463
|
-
const isExecutableFile = (path) => Effect.tryPromise({
|
|
464
|
-
try: async () => {
|
|
465
|
-
try {
|
|
466
|
-
await access(path, fsConstants.X_OK);
|
|
467
|
-
return true;
|
|
468
|
-
}
|
|
469
|
-
catch (error) {
|
|
470
|
-
if (error !== null &&
|
|
471
|
-
typeof error === "object" &&
|
|
472
|
-
"code" in error &&
|
|
473
|
-
(error.code === "EACCES" ||
|
|
474
|
-
error.code === "EPERM" ||
|
|
475
|
-
error.code === "ENOENT")) {
|
|
476
|
-
return false;
|
|
477
|
-
}
|
|
478
|
-
throw error;
|
|
479
|
-
}
|
|
480
|
-
},
|
|
481
|
-
catch: (error) => `TaskRepository failed to inspect hook executable bit for ${path}: ${toErrorMessage(error)}`,
|
|
482
|
-
});
|
|
483
|
-
export const discoverHooksForEvent = (event, options = {}) => {
|
|
484
|
-
const hooksDir = options.hooksDir ?? defaultHooksDir(options.env);
|
|
485
|
-
return Effect.gen(function* () {
|
|
486
|
-
const entries = yield* Effect.tryPromise({
|
|
487
|
-
try: () => readdir(hooksDir, { withFileTypes: true }).catch((error) => {
|
|
488
|
-
if (error !== null &&
|
|
489
|
-
typeof error === "object" &&
|
|
490
|
-
"code" in error &&
|
|
491
|
-
error.code === "ENOENT") {
|
|
492
|
-
return [];
|
|
493
|
-
}
|
|
494
|
-
throw error;
|
|
495
|
-
}),
|
|
496
|
-
catch: (error) => `TaskRepository failed to read hooks directory ${hooksDir}: ${toErrorMessage(error)}`,
|
|
497
|
-
});
|
|
498
|
-
const candidatePaths = entries
|
|
499
|
-
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) &&
|
|
500
|
-
isHookCandidate(event, entry.name))
|
|
501
|
-
.map((entry) => join(hooksDir, entry.name))
|
|
502
|
-
.sort((a, b) => a.localeCompare(b));
|
|
503
|
-
const discovered = [];
|
|
504
|
-
for (const candidatePath of candidatePaths) {
|
|
505
|
-
const executable = yield* isExecutableFile(candidatePath);
|
|
506
|
-
if (executable) {
|
|
507
|
-
discovered.push(candidatePath);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
return discovered;
|
|
511
|
-
});
|
|
512
|
-
};
|
|
513
|
-
const parseTaskFromHookStdout = (event, hookPath, stdout) => Effect.try({
|
|
514
|
-
try: () => decodeTask(JSON.parse(stdout)),
|
|
515
|
-
catch: (error) => `TaskRepository hook ${hookPath} returned invalid JSON for on-${event}: ${toErrorMessage(error)}`,
|
|
516
|
-
});
|
|
517
|
-
const buildHookEnv = (event, taskId, options) => ({
|
|
518
|
-
...process.env,
|
|
519
|
-
...options.env,
|
|
520
|
-
TASHKS_EVENT: event,
|
|
521
|
-
TASHKS_ID: taskId,
|
|
522
|
-
TASHKS_DATA_DIR: options.dataDir,
|
|
523
|
-
});
|
|
524
|
-
const runHookExecutable = (hookPath, stdin, env) => Effect.gen(function* () {
|
|
525
|
-
const result = yield* Effect.try({
|
|
526
|
-
try: () => spawnSync(hookPath, [], {
|
|
527
|
-
input: stdin,
|
|
528
|
-
encoding: "utf8",
|
|
529
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
530
|
-
env,
|
|
531
|
-
}),
|
|
532
|
-
catch: (error) => `TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(error)}`,
|
|
533
|
-
});
|
|
534
|
-
if (result.error !== undefined) {
|
|
535
|
-
return yield* Effect.fail(`TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(result.error)}`);
|
|
536
|
-
}
|
|
537
|
-
if (result.status !== 0) {
|
|
538
|
-
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
539
|
-
const signal = result.signal !== null ? `terminated by signal ${result.signal}` : null;
|
|
540
|
-
const status = result.status === null
|
|
541
|
-
? "unknown"
|
|
542
|
-
: `exited with code ${result.status}`;
|
|
543
|
-
const details = stderr.length > 0 ? stderr : (signal ?? status);
|
|
544
|
-
return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: ${details}`);
|
|
545
|
-
}
|
|
546
|
-
return typeof result.stdout === "string" ? result.stdout : "";
|
|
547
|
-
});
|
|
548
|
-
const runCreateHooks = (task, options) => Effect.gen(function* () {
|
|
549
|
-
const hooks = yield* discoverHooksForEvent("create", options);
|
|
550
|
-
let currentTask = task;
|
|
551
|
-
for (const hookPath of hooks) {
|
|
552
|
-
const stdout = yield* runHookExecutable(hookPath, JSON.stringify(currentTask), buildHookEnv("create", currentTask.id, options));
|
|
553
|
-
if (stdout.trim().length === 0) {
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
currentTask = yield* parseTaskFromHookStdout("create", hookPath, stdout);
|
|
557
|
-
}
|
|
558
|
-
return currentTask;
|
|
559
|
-
});
|
|
560
|
-
const runModifyHooks = (oldTask, newTask, options) => Effect.gen(function* () {
|
|
561
|
-
const hooks = yield* discoverHooksForEvent("modify", options);
|
|
562
|
-
let currentTask = newTask;
|
|
563
|
-
for (const hookPath of hooks) {
|
|
564
|
-
const stdout = yield* runHookExecutable(hookPath, JSON.stringify({ old: oldTask, new: currentTask }), buildHookEnv("modify", currentTask.id, options));
|
|
565
|
-
if (stdout.trim().length === 0) {
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
const hookedTask = yield* parseTaskFromHookStdout("modify", hookPath, stdout);
|
|
569
|
-
if (hookedTask.id !== oldTask.id) {
|
|
570
|
-
return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: on-modify hooks cannot change task id`);
|
|
571
|
-
}
|
|
572
|
-
currentTask = hookedTask;
|
|
573
|
-
}
|
|
574
|
-
return currentTask;
|
|
575
|
-
});
|
|
576
|
-
const runNonMutatingHooks = (event, task, options) => Effect.gen(function* () {
|
|
577
|
-
const hooks = yield* discoverHooksForEvent(event, options).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
578
|
-
for (const hookPath of hooks) {
|
|
579
|
-
yield* runHookExecutable(hookPath, JSON.stringify(task), buildHookEnv(event, task.id, options)).pipe(Effect.ignore);
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
261
|
export class TaskRepository extends Context.Tag("TaskRepository")() {
|
|
583
262
|
}
|
|
584
263
|
const defaultDataDir = () => {
|
|
@@ -703,10 +382,19 @@ const makeTaskRepositoryLive = (options = {}) => {
|
|
|
703
382
|
yield* deleteWorkLogEntryFromDisk(existing.path, id);
|
|
704
383
|
return { deleted: true };
|
|
705
384
|
}),
|
|
385
|
+
importTask: (task) => Effect.gen(function* () {
|
|
386
|
+
yield* ensureTasksDir(dataDir);
|
|
387
|
+
yield* writeTaskToDisk(taskFilePath(dataDir, task.id), task);
|
|
388
|
+
return task;
|
|
389
|
+
}),
|
|
390
|
+
importWorkLogEntry: (entry) => Effect.gen(function* () {
|
|
391
|
+
yield* ensureWorkLogDir(dataDir);
|
|
392
|
+
yield* writeWorkLogEntryToDisk(workLogFilePath(dataDir, entry.id), entry);
|
|
393
|
+
return entry;
|
|
394
|
+
}),
|
|
706
395
|
};
|
|
707
396
|
};
|
|
708
397
|
export const TaskRepositoryLive = (options = {}) => Layer.succeed(TaskRepository, makeTaskRepositoryLive(options));
|
|
709
|
-
export const generateTaskId = (title) => `${slugifyTitle(title)}-${randomIdSuffix()}`;
|
|
710
398
|
export const todayIso = () => new Date().toISOString().slice(0, 10);
|
|
711
399
|
export const parseTaskRecord = (record) => {
|
|
712
400
|
const result = decodeTaskEither(record);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export {};
|
|
2
|
-
//# sourceMappingURL=repository.test.d.ts.map
|
|
2
|
+
//# sourceMappingURL=repository.test.d.ts.map
|
|
@@ -61,6 +61,8 @@ const makeRepositoryService = (overrides = {}) => ({
|
|
|
61
61
|
createWorkLogEntry: () => unexpectedCall(),
|
|
62
62
|
updateWorkLogEntry: () => unexpectedCall(),
|
|
63
63
|
deleteWorkLogEntry: () => unexpectedCall(),
|
|
64
|
+
importTask: () => unexpectedCall(),
|
|
65
|
+
importWorkLogEntry: () => unexpectedCall(),
|
|
64
66
|
...overrides,
|
|
65
67
|
});
|
|
66
68
|
const runListTasks = (dataDir, filters) => Effect.runPromise(Effect.gen(function* () {
|
|
@@ -644,6 +646,8 @@ describe("TaskRepository service", () => {
|
|
|
644
646
|
"deleteWorkLogEntry",
|
|
645
647
|
"generateNextRecurrence",
|
|
646
648
|
"getTask",
|
|
649
|
+
"importTask",
|
|
650
|
+
"importWorkLogEntry",
|
|
647
651
|
"listStale",
|
|
648
652
|
"listTasks",
|
|
649
653
|
"listWorkLog",
|