@disco_trooper/apple-notes-mcp 1.7.0 → 1.8.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/README.md +67 -5
- package/package.json +1 -1
- package/src/config/constants.ts +5 -0
- package/src/config/env.test.ts +14 -0
- package/src/config/env.ts +2 -0
- package/src/db/lancedb.test.ts +14 -0
- package/src/db/lancedb.ts +37 -0
- package/src/index.ts +164 -16
- package/src/indexing/contracts.test.ts +13 -0
- package/src/indexing/contracts.ts +28 -0
- package/src/indexing/job-manager.test.ts +185 -0
- package/src/indexing/job-manager.ts +377 -0
- package/src/notes/crud.test.ts +33 -6
- package/src/notes/crud.ts +62 -7
- package/src/notes/read.test.ts +139 -5
- package/src/notes/read.ts +58 -5
- package/src/search/chunk-indexer.ts +69 -4
- package/src/search/indexer.progress.test.ts +75 -0
- package/src/search/indexer.ts +149 -38
- package/src/search/refresh-policy.test.ts +25 -0
- package/src/search/refresh-policy.ts +33 -0
- package/src/search/refresh.test.ts +146 -25
- package/src/search/refresh.ts +207 -47
- package/src/search/write-sync.test.ts +133 -0
- package/src/search/write-sync.ts +155 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createIndexJobManager } from "./job-manager.js";
|
|
3
|
+
import { IndexCancelledError } from "./contracts.js";
|
|
4
|
+
|
|
5
|
+
async function flushPromises(): Promise<void> {
|
|
6
|
+
await Promise.resolve();
|
|
7
|
+
await Promise.resolve();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("job-manager", () => {
|
|
11
|
+
it("starts a job and returns completed status", async () => {
|
|
12
|
+
const manager = createIndexJobManager({
|
|
13
|
+
indexNotes: vi.fn().mockImplementation(async (_mode, options) => {
|
|
14
|
+
options?.onProgress?.({
|
|
15
|
+
stage: "fetch",
|
|
16
|
+
current: 1,
|
|
17
|
+
total: 1,
|
|
18
|
+
message: "Fetched",
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
total: 1,
|
|
22
|
+
indexed: 1,
|
|
23
|
+
errors: 0,
|
|
24
|
+
timeMs: 50,
|
|
25
|
+
};
|
|
26
|
+
}),
|
|
27
|
+
fullChunkIndex: vi.fn().mockResolvedValue({
|
|
28
|
+
totalNotes: 1,
|
|
29
|
+
totalChunks: 2,
|
|
30
|
+
indexed: 2,
|
|
31
|
+
timeMs: 30,
|
|
32
|
+
}),
|
|
33
|
+
now: () => Date.now(),
|
|
34
|
+
newId: () => "job-1",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const job = manager.start({ mode: "incremental" });
|
|
38
|
+
const initial = manager.get(job.id);
|
|
39
|
+
expect(initial?.status).toBe("queued");
|
|
40
|
+
|
|
41
|
+
await flushPromises();
|
|
42
|
+
|
|
43
|
+
const final = manager.get(job.id);
|
|
44
|
+
expect(final?.status).toBe("completed");
|
|
45
|
+
expect(final?.progress.percent).toBe(100);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("deduplicates concurrent full jobs", async () => {
|
|
49
|
+
const unresolved = new Promise(() => {
|
|
50
|
+
// intentionally unresolved for dedupe check
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const manager = createIndexJobManager({
|
|
54
|
+
indexNotes: vi.fn().mockReturnValue(unresolved),
|
|
55
|
+
fullChunkIndex: vi.fn().mockResolvedValue({
|
|
56
|
+
totalNotes: 1,
|
|
57
|
+
totalChunks: 1,
|
|
58
|
+
indexed: 1,
|
|
59
|
+
timeMs: 1,
|
|
60
|
+
}),
|
|
61
|
+
now: () => Date.now(),
|
|
62
|
+
newId: () => crypto.randomUUID(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const first = manager.start({ mode: "full" });
|
|
66
|
+
const second = manager.start({ mode: "full" });
|
|
67
|
+
expect(first.id).toBe(second.id);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("deduplicates active jobs across modes", async () => {
|
|
71
|
+
const unresolved = new Promise(() => {
|
|
72
|
+
// keep running
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const manager = createIndexJobManager({
|
|
76
|
+
indexNotes: vi.fn().mockReturnValue(unresolved),
|
|
77
|
+
fullChunkIndex: vi.fn(),
|
|
78
|
+
now: () => Date.now(),
|
|
79
|
+
newId: () => crypto.randomUUID(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const full = manager.start({ mode: "full" });
|
|
83
|
+
const incremental = manager.start({ mode: "incremental" });
|
|
84
|
+
|
|
85
|
+
expect(incremental.id).toBe(full.id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("tracks failed job state and error message", async () => {
|
|
89
|
+
const manager = createIndexJobManager({
|
|
90
|
+
indexNotes: vi.fn().mockRejectedValue(new Error("boom")),
|
|
91
|
+
fullChunkIndex: vi.fn(),
|
|
92
|
+
now: () => Date.now(),
|
|
93
|
+
newId: () => "job-fail",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const job = manager.start({ mode: "incremental" });
|
|
97
|
+
await flushPromises();
|
|
98
|
+
|
|
99
|
+
const final = manager.get(job.id);
|
|
100
|
+
expect(final?.status).toBe("failed");
|
|
101
|
+
expect(final?.error).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("updates running progress beyond initial 10 percent", async () => {
|
|
105
|
+
const manager = createIndexJobManager({
|
|
106
|
+
indexNotes: vi.fn().mockImplementation((_mode, options) => {
|
|
107
|
+
options?.onProgress?.({
|
|
108
|
+
stage: "embed",
|
|
109
|
+
current: 1,
|
|
110
|
+
total: 2,
|
|
111
|
+
message: "Embedded batch 1/2",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return new Promise(() => {
|
|
115
|
+
// keep running so we can inspect intermediate progress
|
|
116
|
+
});
|
|
117
|
+
}),
|
|
118
|
+
fullChunkIndex: vi.fn(),
|
|
119
|
+
now: () => Date.now(),
|
|
120
|
+
newId: () => "job-progress",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const job = manager.start({ mode: "incremental" });
|
|
124
|
+
await flushPromises();
|
|
125
|
+
|
|
126
|
+
const running = manager.get(job.id);
|
|
127
|
+
expect(running?.status).toBe("running");
|
|
128
|
+
expect((running?.progress.percent ?? 0)).toBeGreaterThan(10);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("marks job as cancelled when cancel is requested", async () => {
|
|
132
|
+
const manager = createIndexJobManager({
|
|
133
|
+
indexNotes: vi.fn().mockImplementation((_mode, options) => {
|
|
134
|
+
return new Promise((_resolve, reject) => {
|
|
135
|
+
options?.signal?.addEventListener("abort", () => {
|
|
136
|
+
reject(new IndexCancelledError("cancelled"));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}),
|
|
140
|
+
fullChunkIndex: vi.fn(),
|
|
141
|
+
now: () => Date.now(),
|
|
142
|
+
newId: () => "job-cancel",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const started = manager.start({ mode: "incremental" });
|
|
146
|
+
await flushPromises();
|
|
147
|
+
|
|
148
|
+
const cancelling = manager.cancel(started.id);
|
|
149
|
+
expect(cancelling?.status).toBe("cancelling");
|
|
150
|
+
|
|
151
|
+
await flushPromises();
|
|
152
|
+
const cancelled = manager.get(started.id);
|
|
153
|
+
expect(cancelled?.status).toBe("cancelled");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns null when cancelling unknown job", async () => {
|
|
157
|
+
const manager = createIndexJobManager();
|
|
158
|
+
expect(manager.cancel("missing")).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does not start a queued job after cancel", async () => {
|
|
162
|
+
const indexNotes = vi.fn().mockResolvedValue({
|
|
163
|
+
total: 0,
|
|
164
|
+
indexed: 0,
|
|
165
|
+
errors: 0,
|
|
166
|
+
timeMs: 1,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const manager = createIndexJobManager({
|
|
170
|
+
indexNotes,
|
|
171
|
+
fullChunkIndex: vi.fn(),
|
|
172
|
+
now: () => Date.now(),
|
|
173
|
+
newId: () => "q1",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const started = manager.start({ mode: "incremental" });
|
|
177
|
+
const cancelled = manager.cancel(started.id);
|
|
178
|
+
expect(cancelled?.status).toBe("cancelled");
|
|
179
|
+
|
|
180
|
+
await flushPromises();
|
|
181
|
+
|
|
182
|
+
expect(indexNotes).not.toHaveBeenCalled();
|
|
183
|
+
expect(manager.get(started.id)?.status).toBe("cancelled");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_INDEX_JOB_RETENTION_SECONDS,
|
|
3
|
+
MAX_INDEX_JOB_HISTORY,
|
|
4
|
+
} from "../config/constants.js";
|
|
5
|
+
import { indexNotes, type IndexResult } from "../search/indexer.js";
|
|
6
|
+
import { fullChunkIndex, type ChunkIndexResult } from "../search/chunk-indexer.js";
|
|
7
|
+
import {
|
|
8
|
+
type IndexProgressEvent,
|
|
9
|
+
type IndexRunOptions,
|
|
10
|
+
isIndexCancelledError,
|
|
11
|
+
} from "./contracts.js";
|
|
12
|
+
import { sanitizeErrorMessage } from "../utils/errors.js";
|
|
13
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
14
|
+
|
|
15
|
+
const debug = createDebugLogger("INDEX-JOBS");
|
|
16
|
+
|
|
17
|
+
export type IndexJobStatus =
|
|
18
|
+
| "queued"
|
|
19
|
+
| "running"
|
|
20
|
+
| "cancelling"
|
|
21
|
+
| "completed"
|
|
22
|
+
| "failed"
|
|
23
|
+
| "cancelled";
|
|
24
|
+
|
|
25
|
+
export interface IndexJobProgress {
|
|
26
|
+
phase: string;
|
|
27
|
+
percent: number;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IndexJob {
|
|
32
|
+
id: string;
|
|
33
|
+
mode: "full" | "incremental";
|
|
34
|
+
status: IndexJobStatus;
|
|
35
|
+
progress: IndexJobProgress;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
finishedAt?: string;
|
|
39
|
+
result?: IndexResult;
|
|
40
|
+
chunkResult?: ChunkIndexResult;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StartIndexJobOptions {
|
|
45
|
+
mode: "full" | "incremental";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IndexJobManagerDeps {
|
|
49
|
+
indexNotes: (mode: "full" | "incremental", options?: IndexRunOptions) => Promise<IndexResult>;
|
|
50
|
+
fullChunkIndex: (options?: IndexRunOptions) => Promise<ChunkIndexResult>;
|
|
51
|
+
now: () => number;
|
|
52
|
+
newId: () => string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface IndexJobManager {
|
|
56
|
+
start: (options: StartIndexJobOptions) => IndexJob;
|
|
57
|
+
get: (jobId: string) => IndexJob | null;
|
|
58
|
+
list: (limit?: number) => IndexJob[];
|
|
59
|
+
cancel: (jobId: string) => IndexJob | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cloneJob(job: IndexJob): IndexJob {
|
|
63
|
+
return {
|
|
64
|
+
...job,
|
|
65
|
+
progress: { ...job.progress },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getRetentionSeconds(): number {
|
|
70
|
+
const raw = process.env.INDEX_JOB_RETENTION_SECONDS;
|
|
71
|
+
if (!raw) return DEFAULT_INDEX_JOB_RETENTION_SECONDS;
|
|
72
|
+
const parsed = Number.parseInt(raw, 10);
|
|
73
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
74
|
+
return DEFAULT_INDEX_JOB_RETENTION_SECONDS;
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function clampPercent(percent: number): number {
|
|
80
|
+
return Math.max(0, Math.min(100, Math.round(percent)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ratio(current: number, total: number): number {
|
|
84
|
+
if (total <= 0) return 0;
|
|
85
|
+
return Math.max(0, Math.min(1, current / total));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mapProgressForIncremental(event: IndexProgressEvent): number {
|
|
89
|
+
switch (event.stage) {
|
|
90
|
+
case "fetch":
|
|
91
|
+
return 5 + ratio(event.current, event.total) * 15;
|
|
92
|
+
case "prepare":
|
|
93
|
+
return 20 + ratio(event.current, event.total) * 10;
|
|
94
|
+
case "embed":
|
|
95
|
+
return 30 + ratio(event.current, event.total) * 35;
|
|
96
|
+
case "persist":
|
|
97
|
+
return 65 + ratio(event.current, event.total) * 25;
|
|
98
|
+
case "delete":
|
|
99
|
+
return 75 + ratio(event.current, event.total) * 10;
|
|
100
|
+
case "rebuild-fts":
|
|
101
|
+
return 90 + ratio(event.current, event.total) * 9;
|
|
102
|
+
case "done":
|
|
103
|
+
return 99;
|
|
104
|
+
default:
|
|
105
|
+
return 10;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mapProgressForFullNotes(event: IndexProgressEvent): number {
|
|
110
|
+
switch (event.stage) {
|
|
111
|
+
case "fetch":
|
|
112
|
+
return 5 + ratio(event.current, event.total) * 15;
|
|
113
|
+
case "prepare":
|
|
114
|
+
return 20 + ratio(event.current, event.total) * 10;
|
|
115
|
+
case "embed":
|
|
116
|
+
return 30 + ratio(event.current, event.total) * 20;
|
|
117
|
+
case "persist":
|
|
118
|
+
return 50 + ratio(event.current, event.total) * 15;
|
|
119
|
+
case "rebuild-fts":
|
|
120
|
+
return 65 + ratio(event.current, event.total) * 5;
|
|
121
|
+
case "done":
|
|
122
|
+
return 70;
|
|
123
|
+
default:
|
|
124
|
+
return 10;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mapProgressForFullChunks(event: IndexProgressEvent): number {
|
|
129
|
+
switch (event.stage) {
|
|
130
|
+
case "fetch":
|
|
131
|
+
return 70 + ratio(event.current, event.total) * 5;
|
|
132
|
+
case "prepare":
|
|
133
|
+
return 75 + ratio(event.current, event.total) * 5;
|
|
134
|
+
case "embed":
|
|
135
|
+
return 80 + ratio(event.current, event.total) * 10;
|
|
136
|
+
case "persist":
|
|
137
|
+
return 90 + ratio(event.current, event.total) * 5;
|
|
138
|
+
case "done":
|
|
139
|
+
return 95;
|
|
140
|
+
default:
|
|
141
|
+
return 75;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createIndexJobManager(
|
|
146
|
+
deps: Partial<IndexJobManagerDeps> = {}
|
|
147
|
+
): IndexJobManager {
|
|
148
|
+
const resolvedDeps: IndexJobManagerDeps = {
|
|
149
|
+
indexNotes,
|
|
150
|
+
fullChunkIndex,
|
|
151
|
+
now: () => Date.now(),
|
|
152
|
+
newId: () => crypto.randomUUID(),
|
|
153
|
+
...deps,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const jobs = new Map<string, IndexJob>();
|
|
157
|
+
const order: string[] = [];
|
|
158
|
+
const controllers = new Map<string, AbortController>();
|
|
159
|
+
|
|
160
|
+
function prune(): void {
|
|
161
|
+
const retentionMs = getRetentionSeconds() * 1000;
|
|
162
|
+
const now = resolvedDeps.now();
|
|
163
|
+
|
|
164
|
+
for (const jobId of [...order]) {
|
|
165
|
+
const job = jobs.get(jobId);
|
|
166
|
+
if (!job) continue;
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
(job.status === "completed" || job.status === "failed" || job.status === "cancelled") &&
|
|
170
|
+
job.finishedAt
|
|
171
|
+
) {
|
|
172
|
+
const ageMs = now - Date.parse(job.finishedAt);
|
|
173
|
+
if (ageMs > retentionMs) {
|
|
174
|
+
jobs.delete(jobId);
|
|
175
|
+
controllers.delete(jobId);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const alive = order.filter((id) => jobs.has(id));
|
|
181
|
+
order.length = 0;
|
|
182
|
+
order.push(...alive);
|
|
183
|
+
|
|
184
|
+
while (order.length > MAX_INDEX_JOB_HISTORY) {
|
|
185
|
+
const oldest = order.shift();
|
|
186
|
+
if (oldest) {
|
|
187
|
+
jobs.delete(oldest);
|
|
188
|
+
controllers.delete(oldest);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getActiveJob(): IndexJob | null {
|
|
194
|
+
for (let i = order.length - 1; i >= 0; i -= 1) {
|
|
195
|
+
const job = jobs.get(order[i]);
|
|
196
|
+
if (!job) continue;
|
|
197
|
+
if (
|
|
198
|
+
(job.status === "queued" || job.status === "running" || job.status === "cancelling")
|
|
199
|
+
) {
|
|
200
|
+
return job;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function setProgress(job: IndexJob, phase: string, percent: number, message: string): void {
|
|
207
|
+
job.progress = {
|
|
208
|
+
phase,
|
|
209
|
+
percent: clampPercent(percent),
|
|
210
|
+
message,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function noteProgressHandler(job: IndexJob) {
|
|
215
|
+
return (event: IndexProgressEvent): void => {
|
|
216
|
+
if (job.status === "cancelling") {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const percent =
|
|
220
|
+
job.mode === "incremental"
|
|
221
|
+
? mapProgressForIncremental(event)
|
|
222
|
+
: mapProgressForFullNotes(event);
|
|
223
|
+
setProgress(job, `indexing-notes/${event.stage}`, percent, event.message);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function chunkProgressHandler(job: IndexJob) {
|
|
228
|
+
return (event: IndexProgressEvent): void => {
|
|
229
|
+
if (job.status === "cancelling") {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const percent = mapProgressForFullChunks(event);
|
|
233
|
+
setProgress(job, `indexing-chunks/${event.stage}`, percent, event.message);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function run(jobId: string): Promise<void> {
|
|
238
|
+
const job = jobs.get(jobId);
|
|
239
|
+
if (!job) return;
|
|
240
|
+
|
|
241
|
+
if (job.status === "cancelled") {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
controllers.set(jobId, controller);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
job.status = "running";
|
|
250
|
+
job.startedAt = new Date(resolvedDeps.now()).toISOString();
|
|
251
|
+
setProgress(job, "indexing-notes", 5, `Running ${job.mode} index`);
|
|
252
|
+
|
|
253
|
+
const result = await resolvedDeps.indexNotes(job.mode, {
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
onProgress: noteProgressHandler(job),
|
|
256
|
+
});
|
|
257
|
+
job.result = result;
|
|
258
|
+
|
|
259
|
+
if (job.mode === "full") {
|
|
260
|
+
setProgress(job, "indexing-chunks", 70, "Building chunk index");
|
|
261
|
+
const chunkResult = await resolvedDeps.fullChunkIndex({
|
|
262
|
+
signal: controller.signal,
|
|
263
|
+
onProgress: chunkProgressHandler(job),
|
|
264
|
+
});
|
|
265
|
+
job.chunkResult = chunkResult;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
job.status = "completed";
|
|
269
|
+
job.finishedAt = new Date(resolvedDeps.now()).toISOString();
|
|
270
|
+
setProgress(job, "completed", 100, "Index job completed");
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const cancelled = controller.signal.aborted || isIndexCancelledError(error);
|
|
273
|
+
if (cancelled) {
|
|
274
|
+
job.status = "cancelled";
|
|
275
|
+
job.finishedAt = new Date(resolvedDeps.now()).toISOString();
|
|
276
|
+
setProgress(job, "cancelled", 100, "Index job cancelled");
|
|
277
|
+
} else {
|
|
278
|
+
job.status = "failed";
|
|
279
|
+
job.finishedAt = new Date(resolvedDeps.now()).toISOString();
|
|
280
|
+
job.error = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
|
281
|
+
setProgress(job, "failed", 100, "Index job failed");
|
|
282
|
+
debug("Background index job failed:", error);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
controllers.delete(jobId);
|
|
286
|
+
prune();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function start(options: StartIndexJobOptions): IndexJob {
|
|
291
|
+
prune();
|
|
292
|
+
|
|
293
|
+
const existing = getActiveJob();
|
|
294
|
+
if (existing) {
|
|
295
|
+
return cloneJob(existing);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const id = resolvedDeps.newId();
|
|
299
|
+
const job: IndexJob = {
|
|
300
|
+
id,
|
|
301
|
+
mode: options.mode,
|
|
302
|
+
status: "queued",
|
|
303
|
+
progress: {
|
|
304
|
+
phase: "queued",
|
|
305
|
+
percent: 0,
|
|
306
|
+
message: "Job queued",
|
|
307
|
+
},
|
|
308
|
+
createdAt: new Date(resolvedDeps.now()).toISOString(),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
jobs.set(id, job);
|
|
312
|
+
order.push(id);
|
|
313
|
+
|
|
314
|
+
queueMicrotask(() => {
|
|
315
|
+
void run(id);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return cloneJob(job);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function get(jobId: string): IndexJob | null {
|
|
322
|
+
prune();
|
|
323
|
+
const job = jobs.get(jobId);
|
|
324
|
+
return job ? cloneJob(job) : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function list(limit = 10): IndexJob[] {
|
|
328
|
+
prune();
|
|
329
|
+
const boundedLimit = Math.max(1, Math.min(limit, 50));
|
|
330
|
+
const ids = [...order].reverse().slice(0, boundedLimit);
|
|
331
|
+
return ids
|
|
332
|
+
.map((id) => jobs.get(id))
|
|
333
|
+
.filter((job): job is IndexJob => job !== undefined)
|
|
334
|
+
.map(cloneJob);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function cancel(jobId: string): IndexJob | null {
|
|
338
|
+
prune();
|
|
339
|
+
const job = jobs.get(jobId);
|
|
340
|
+
if (!job) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
|
|
345
|
+
return cloneJob(job);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (job.status === "queued") {
|
|
349
|
+
job.status = "cancelled";
|
|
350
|
+
job.finishedAt = new Date(resolvedDeps.now()).toISOString();
|
|
351
|
+
setProgress(job, "cancelled", 100, "Index job cancelled before start");
|
|
352
|
+
return cloneJob(job);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
job.status = "cancelling";
|
|
356
|
+
setProgress(job, "cancelling", job.progress.percent, "Cancellation requested");
|
|
357
|
+
controllers.get(jobId)?.abort();
|
|
358
|
+
|
|
359
|
+
return cloneJob(job);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
start,
|
|
364
|
+
get,
|
|
365
|
+
list,
|
|
366
|
+
cancel,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let defaultManager: IndexJobManager | null = null;
|
|
371
|
+
|
|
372
|
+
export function getIndexJobManager(): IndexJobManager {
|
|
373
|
+
if (!defaultManager) {
|
|
374
|
+
defaultManager = createIndexJobManager();
|
|
375
|
+
}
|
|
376
|
+
return defaultManager;
|
|
377
|
+
}
|
package/src/notes/crud.test.ts
CHANGED
|
@@ -74,16 +74,32 @@ describe("createNote", () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it("should create note successfully", async () => {
|
|
77
|
-
vi.mocked(runJxa).mockResolvedValueOnce(
|
|
77
|
+
vi.mocked(runJxa).mockResolvedValueOnce(
|
|
78
|
+
JSON.stringify({ id: "note-1", title: "New Note", folder: "Work" })
|
|
79
|
+
);
|
|
78
80
|
|
|
79
|
-
await expect(createNote("New Note", "Content", "Work")).resolves.
|
|
81
|
+
await expect(createNote("New Note", "Content", "Work")).resolves.toEqual({
|
|
82
|
+
id: "note-1",
|
|
83
|
+
title: "New Note",
|
|
84
|
+
folder: "Work",
|
|
85
|
+
requestedTitle: "New Note",
|
|
86
|
+
titleChanged: false,
|
|
87
|
+
});
|
|
80
88
|
});
|
|
81
89
|
|
|
82
90
|
it("should allow duplicate titles (Apple Notes uses IDs)", async () => {
|
|
83
|
-
vi.mocked(runJxa).mockResolvedValueOnce(
|
|
91
|
+
vi.mocked(runJxa).mockResolvedValueOnce(
|
|
92
|
+
JSON.stringify({ id: "note-2", title: "Existing Note", folder: "Notes" })
|
|
93
|
+
);
|
|
84
94
|
|
|
85
95
|
// Should not throw even if a note with same title exists
|
|
86
|
-
await expect(createNote("Existing Note", "Content")).resolves.
|
|
96
|
+
await expect(createNote("Existing Note", "Content")).resolves.toEqual({
|
|
97
|
+
id: "note-2",
|
|
98
|
+
title: "Existing Note",
|
|
99
|
+
folder: "Notes",
|
|
100
|
+
requestedTitle: "Existing Note",
|
|
101
|
+
titleChanged: false,
|
|
102
|
+
});
|
|
87
103
|
});
|
|
88
104
|
});
|
|
89
105
|
|
|
@@ -116,6 +132,7 @@ describe("updateNote", () => {
|
|
|
116
132
|
|
|
117
133
|
const result = await updateNote("Test", "New Content");
|
|
118
134
|
expect(result).toEqual({
|
|
135
|
+
id: "123",
|
|
119
136
|
originalTitle: "Test",
|
|
120
137
|
newTitle: "Test",
|
|
121
138
|
folder: "Work",
|
|
@@ -133,6 +150,7 @@ describe("updateNote", () => {
|
|
|
133
150
|
|
|
134
151
|
const result = await updateNote("Original Title", "# New Heading\n\nContent");
|
|
135
152
|
expect(result).toEqual({
|
|
153
|
+
id: "123",
|
|
136
154
|
originalTitle: "Original Title",
|
|
137
155
|
newTitle: "New Heading",
|
|
138
156
|
folder: "Work",
|
|
@@ -179,7 +197,11 @@ describe("deleteNote", () => {
|
|
|
179
197
|
});
|
|
180
198
|
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
181
199
|
|
|
182
|
-
await expect(deleteNote("Test")).resolves.
|
|
200
|
+
await expect(deleteNote("Test")).resolves.toEqual({
|
|
201
|
+
id: "123",
|
|
202
|
+
title: "Test",
|
|
203
|
+
folder: "Work",
|
|
204
|
+
});
|
|
183
205
|
});
|
|
184
206
|
|
|
185
207
|
it("should include suggestions in error when multiple notes found", async () => {
|
|
@@ -221,7 +243,12 @@ describe("moveNote", () => {
|
|
|
221
243
|
});
|
|
222
244
|
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
223
245
|
|
|
224
|
-
await expect(moveNote("Test", "Personal")).resolves.
|
|
246
|
+
await expect(moveNote("Test", "Personal")).resolves.toEqual({
|
|
247
|
+
id: "123",
|
|
248
|
+
title: "Test",
|
|
249
|
+
fromFolder: "Work",
|
|
250
|
+
toFolder: "Personal",
|
|
251
|
+
});
|
|
225
252
|
});
|
|
226
253
|
|
|
227
254
|
it("should include suggestions in error when multiple notes found", async () => {
|