shared-things-daemon 1.1.0 → 2.0.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 +12 -8
- package/dist/cli.js +782 -210
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,12 +54,14 @@ shared-things init
|
|
|
54
54
|
? Things auth token: ****
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
> **Note:** The Things project must be empty when you run `init`.
|
|
58
|
+
|
|
57
59
|
> **Things auth token:** Required for syncing updates to existing todos. Find it in Things → Settings → General → Things URLs → Enable Things URLs.
|
|
58
60
|
|
|
59
61
|
### After Setup
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
|
-
shared-things
|
|
64
|
+
shared-things start # Start daemon (auto-runs on login)
|
|
63
65
|
shared-things status # Check sync status
|
|
64
66
|
shared-things logs -f # Follow sync logs
|
|
65
67
|
```
|
|
@@ -69,13 +71,16 @@ shared-things logs -f # Follow sync logs
|
|
|
69
71
|
| Command | Description |
|
|
70
72
|
|---------|-------------|
|
|
71
73
|
| `init` | Setup wizard |
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
+
| `start` | Start launchd daemon (auto-starts on login) |
|
|
75
|
+
| `stop` | Stop launchd daemon |
|
|
74
76
|
| `status` | Show sync status & last sync time |
|
|
75
77
|
| `sync` | Force immediate sync |
|
|
76
78
|
| `logs [-f]` | Show logs (`-f` to follow) |
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
+
| `conflicts [--all]` | Show conflict history |
|
|
80
|
+
| `repair` | Diagnose state issues (no auto-fix) |
|
|
81
|
+
| `reset --local` | Clear local state |
|
|
82
|
+
| `reset --server` | Clear server data for this user |
|
|
83
|
+
| `doctor` | Comprehensive health check |
|
|
79
84
|
|
|
80
85
|
## Server Setup
|
|
81
86
|
|
|
@@ -143,9 +148,8 @@ things.yourdomain.com {
|
|
|
143
148
|
|
|
144
149
|
| Synced | Not Synced |
|
|
145
150
|
|--------|------------|
|
|
146
|
-
| Todo title, notes, due date, tags |
|
|
147
|
-
| Headings
|
|
148
|
-
| | Areas |
|
|
151
|
+
| Todo title, notes, due date, tags, status | Checklist items |
|
|
152
|
+
| | Headings, Areas |
|
|
149
153
|
|
|
150
154
|
> **Note:** The project must exist in each user's Things app. Only items within that project sync.
|
|
151
155
|
|
package/dist/cli.js
CHANGED
|
@@ -10,11 +10,12 @@ import { Command } from "commander";
|
|
|
10
10
|
import updateNotifier from "update-notifier";
|
|
11
11
|
|
|
12
12
|
// src/api.ts
|
|
13
|
-
var ApiClient = class {
|
|
13
|
+
var ApiClient = class _ApiClient {
|
|
14
14
|
constructor(serverUrl, apiKey) {
|
|
15
15
|
this.serverUrl = serverUrl;
|
|
16
16
|
this.apiKey = apiKey;
|
|
17
17
|
}
|
|
18
|
+
static TIMEOUT_MS = 3e4;
|
|
18
19
|
async request(path6, options = {}) {
|
|
19
20
|
const url = `${this.serverUrl}${path6}`;
|
|
20
21
|
const headers = {
|
|
@@ -22,7 +23,23 @@ var ApiClient = class {
|
|
|
22
23
|
"Content-Type": "application/json",
|
|
23
24
|
...options.headers
|
|
24
25
|
};
|
|
25
|
-
const
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timeout = setTimeout(() => controller.abort(), _ApiClient.TIMEOUT_MS);
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await fetch(url, {
|
|
31
|
+
...options,
|
|
32
|
+
headers,
|
|
33
|
+
signal: controller.signal
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.name === "AbortError") {
|
|
37
|
+
throw new Error(`API timeout after ${_ApiClient.TIMEOUT_MS}ms`);
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(timeout);
|
|
42
|
+
}
|
|
26
43
|
if (!response.ok) {
|
|
27
44
|
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
28
45
|
throw new Error(`API error: ${error.error || response.statusText}`);
|
|
@@ -118,7 +135,7 @@ function installLaunchAgent() {
|
|
|
118
135
|
const npmPrefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
|
|
119
136
|
binPath = path2.join(npmPrefix, "bin", "shared-things");
|
|
120
137
|
}
|
|
121
|
-
const logPath = path2.join(os2.homedir(), ".shared-things", "
|
|
138
|
+
const logPath = path2.join(os2.homedir(), ".shared-things", "sync.log");
|
|
122
139
|
let nodeBinDir;
|
|
123
140
|
try {
|
|
124
141
|
const nodePath = execSync("which node", { encoding: "utf-8" }).trim();
|
|
@@ -180,6 +197,30 @@ function installLaunchAgent() {
|
|
|
180
197
|
console.warn("You may need to manually load it or restart your Mac.");
|
|
181
198
|
}
|
|
182
199
|
}
|
|
200
|
+
function startLaunchAgent() {
|
|
201
|
+
if (!fs2.existsSync(PLIST_PATH)) {
|
|
202
|
+
installLaunchAgent();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
execSync(`launchctl load "${PLIST_PATH}"`);
|
|
207
|
+
console.log("LaunchAgent started.");
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.warn(`Warning: Could not start LaunchAgent: ${error}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function stopLaunchAgent() {
|
|
213
|
+
if (!fs2.existsSync(PLIST_PATH)) {
|
|
214
|
+
console.log("LaunchAgent not installed.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
execSync(`launchctl unload "${PLIST_PATH}"`);
|
|
219
|
+
console.log("LaunchAgent stopped.");
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.warn(`Warning: Could not stop LaunchAgent: ${error}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
183
224
|
function uninstallLaunchAgent() {
|
|
184
225
|
if (!fs2.existsSync(PLIST_PATH)) {
|
|
185
226
|
console.log("LaunchAgent not installed.");
|
|
@@ -212,7 +253,7 @@ function getLaunchAgentStatus() {
|
|
|
212
253
|
// src/logger.ts
|
|
213
254
|
import * as fs3 from "fs";
|
|
214
255
|
import * as path3 from "path";
|
|
215
|
-
var LOG_FILE = "
|
|
256
|
+
var LOG_FILE = "sync.log";
|
|
216
257
|
var MAX_LOG_SIZE = 1024 * 1024;
|
|
217
258
|
function getLogPath() {
|
|
218
259
|
return path3.join(getConfigDir(), LOG_FILE);
|
|
@@ -229,49 +270,60 @@ function rotateIfNeeded() {
|
|
|
229
270
|
fs3.renameSync(logPath, oldLog);
|
|
230
271
|
}
|
|
231
272
|
}
|
|
232
|
-
function log(message) {
|
|
273
|
+
function log(level, message) {
|
|
233
274
|
ensureConfigDir();
|
|
234
275
|
rotateIfNeeded();
|
|
235
276
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
236
|
-
const line = `[${timestamp}] ${message}
|
|
277
|
+
const line = `[${timestamp}] [${level}] ${message}
|
|
237
278
|
`;
|
|
238
279
|
fs3.appendFileSync(getLogPath(), line);
|
|
239
280
|
}
|
|
240
281
|
function logError(message, error) {
|
|
241
282
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
242
|
-
log(
|
|
283
|
+
log("ERROR", `${message}${error ? ` - ${errorMsg}` : ""}`);
|
|
284
|
+
}
|
|
285
|
+
function logWarn(message) {
|
|
286
|
+
log("WARN", message);
|
|
287
|
+
}
|
|
288
|
+
function logInfo(message) {
|
|
289
|
+
log("INFO", message);
|
|
243
290
|
}
|
|
244
|
-
function
|
|
291
|
+
function logDebug(message) {
|
|
292
|
+
log("DEBUG", message);
|
|
293
|
+
}
|
|
294
|
+
function logSync(pushed, pulled, isFirstSync, conflicts) {
|
|
245
295
|
if (isFirstSync) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
296
|
+
logInfo(
|
|
297
|
+
`Sync complete (first): pushed=${pushed}, pulled=${pulled}, conflicts=${conflicts}`
|
|
298
|
+
);
|
|
299
|
+
} else if (pushed > 0 || pulled > 0 || conflicts > 0) {
|
|
300
|
+
logInfo(
|
|
301
|
+
`Sync complete: pushed=${pushed}, pulled=${pulled}, conflicts=${conflicts}`
|
|
302
|
+
);
|
|
249
303
|
} else {
|
|
250
|
-
|
|
304
|
+
logDebug("Sync: no changes");
|
|
251
305
|
}
|
|
252
306
|
}
|
|
253
307
|
function logMapping(serverId, thingsId) {
|
|
254
|
-
|
|
308
|
+
logDebug(`Mapped server ${serverId} -> local ${thingsId}`);
|
|
255
309
|
}
|
|
256
310
|
function logTodoCreated(title) {
|
|
257
|
-
|
|
311
|
+
logInfo(`Created todo: "${title}"`);
|
|
258
312
|
}
|
|
259
313
|
function logTodoUpdated(thingsId, title) {
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
function logDaemonStart() {
|
|
263
|
-
log("Daemon started");
|
|
264
|
-
}
|
|
265
|
-
function logDaemonStop() {
|
|
266
|
-
log("Daemon stopped");
|
|
314
|
+
logInfo(`Updated todo ${thingsId}: "${title}"`);
|
|
267
315
|
}
|
|
268
316
|
|
|
269
317
|
// src/sync.ts
|
|
318
|
+
import { execSync as execSync3 } from "child_process";
|
|
270
319
|
import * as fs4 from "fs";
|
|
271
320
|
import * as path4 from "path";
|
|
272
321
|
|
|
273
322
|
// src/things.ts
|
|
274
323
|
import { execSync as execSync2 } from "child_process";
|
|
324
|
+
var PIPE_TOKEN = "{{PIPE}}";
|
|
325
|
+
var CARET_TOKEN = "{{CARET}}";
|
|
326
|
+
var MAX_URL_LENGTH = 2e3;
|
|
275
327
|
function runAppleScript(script) {
|
|
276
328
|
try {
|
|
277
329
|
return execSync2(`osascript -e '${script.replace(/'/g, `'"'"'`)}'`, {
|
|
@@ -285,22 +337,34 @@ function runAppleScript(script) {
|
|
|
285
337
|
}
|
|
286
338
|
function getTodosFromProject(projectName) {
|
|
287
339
|
const script = `
|
|
340
|
+
on replaceText(findText, replaceText, theText)
|
|
341
|
+
set AppleScript's text item delimiters to findText
|
|
342
|
+
set theItems to every text item of theText
|
|
343
|
+
set AppleScript's text item delimiters to replaceText
|
|
344
|
+
set theText to theItems as string
|
|
345
|
+
set AppleScript's text item delimiters to ""
|
|
346
|
+
return theText
|
|
347
|
+
end replaceText
|
|
348
|
+
|
|
349
|
+
on escapeText(t)
|
|
350
|
+
if t is missing value then return ""
|
|
351
|
+
set t to my replaceText("|||", "${PIPE_TOKEN}", t as string)
|
|
352
|
+
set t to my replaceText("^^^", "${CARET_TOKEN}", t as string)
|
|
353
|
+
return t
|
|
354
|
+
end escapeText
|
|
355
|
+
|
|
288
356
|
tell application "Things3"
|
|
289
357
|
set todoList to {}
|
|
290
358
|
set proj to project "${projectName}"
|
|
291
359
|
repeat with t in to dos of proj
|
|
292
360
|
set todoId to id of t
|
|
293
|
-
set todoTitle to name of t
|
|
294
|
-
set todoNotes to notes of t
|
|
361
|
+
set todoTitle to my escapeText(name of t)
|
|
362
|
+
set todoNotes to my escapeText(notes of t)
|
|
295
363
|
set todoStatus to status of t
|
|
296
364
|
set todoDue to due date of t
|
|
297
|
-
set
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
set todoHeading to ""
|
|
301
|
-
try
|
|
302
|
-
set todoHeading to id of (get area of t)
|
|
303
|
-
end try
|
|
365
|
+
set AppleScript's text item delimiters to ", "
|
|
366
|
+
set todoTags to my escapeText((tag names of t) as string)
|
|
367
|
+
set AppleScript's text item delimiters to ""
|
|
304
368
|
|
|
305
369
|
-- Format due date
|
|
306
370
|
set dueDateStr to ""
|
|
@@ -318,7 +382,7 @@ function getTodosFromProject(projectName) {
|
|
|
318
382
|
set statusStr to "canceled"
|
|
319
383
|
end if
|
|
320
384
|
|
|
321
|
-
set end of todoList to todoId & "|||" & todoTitle & "|||" & todoNotes & "|||" & dueDateStr & "|||" & todoTags & "|||" & statusStr
|
|
385
|
+
set end of todoList to todoId & "|||" & todoTitle & "|||" & todoNotes & "|||" & dueDateStr & "|||" & todoTags & "|||" & statusStr
|
|
322
386
|
end repeat
|
|
323
387
|
|
|
324
388
|
set AppleScript's text item delimiters to "^^^"
|
|
@@ -328,18 +392,24 @@ function getTodosFromProject(projectName) {
|
|
|
328
392
|
const result = runAppleScript(script);
|
|
329
393
|
if (!result) return [];
|
|
330
394
|
return result.split("^^^").map((line) => {
|
|
331
|
-
const [thingsId, title, notes, dueDate, tags, status
|
|
395
|
+
const [thingsId, title, notes, dueDate, tags, status] = line.split("|||");
|
|
396
|
+
const decodedTitle = unescapeField(title);
|
|
397
|
+
const decodedNotes = unescapeField(notes);
|
|
398
|
+
const decodedTags = unescapeField(tags);
|
|
332
399
|
return {
|
|
333
400
|
thingsId,
|
|
334
|
-
title:
|
|
335
|
-
notes:
|
|
401
|
+
title: decodedTitle || "",
|
|
402
|
+
notes: decodedNotes || "",
|
|
336
403
|
dueDate: dueDate || null,
|
|
337
|
-
tags:
|
|
338
|
-
status: status || "open"
|
|
339
|
-
headingThingsId: headingThingsId || null
|
|
404
|
+
tags: decodedTags ? decodedTags.split(", ").filter(Boolean) : [],
|
|
405
|
+
status: status || "open"
|
|
340
406
|
};
|
|
341
407
|
});
|
|
342
408
|
}
|
|
409
|
+
function unescapeField(value) {
|
|
410
|
+
if (!value) return "";
|
|
411
|
+
return value.replaceAll(PIPE_TOKEN, "|||").replaceAll(CARET_TOKEN, "^^^");
|
|
412
|
+
}
|
|
343
413
|
function createTodo(projectName, todo) {
|
|
344
414
|
const params = new URLSearchParams();
|
|
345
415
|
params.set("title", todo.title);
|
|
@@ -347,7 +417,19 @@ function createTodo(projectName, todo) {
|
|
|
347
417
|
if (todo.dueDate) params.set("when", todo.dueDate);
|
|
348
418
|
if (todo.tags?.length) params.set("tags", todo.tags.join(","));
|
|
349
419
|
params.set("list", projectName);
|
|
350
|
-
|
|
420
|
+
let url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
|
|
421
|
+
if (url.length > MAX_URL_LENGTH && todo.notes) {
|
|
422
|
+
const originalNotes = todo.notes;
|
|
423
|
+
let truncated = originalNotes;
|
|
424
|
+
while (truncated.length > 0 && url.length > MAX_URL_LENGTH) {
|
|
425
|
+
truncated = truncated.slice(0, Math.max(0, truncated.length - 200));
|
|
426
|
+
params.set("notes", truncated);
|
|
427
|
+
url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
|
|
428
|
+
}
|
|
429
|
+
logWarn(
|
|
430
|
+
`Create URL exceeded ${MAX_URL_LENGTH} chars; notes truncated from ${originalNotes.length} to ${truncated.length}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
351
433
|
execSync2(`open -g "${url}"`);
|
|
352
434
|
}
|
|
353
435
|
function updateTodo(authToken, thingsId, updates) {
|
|
@@ -393,139 +475,411 @@ function listProjects() {
|
|
|
393
475
|
|
|
394
476
|
// src/sync.ts
|
|
395
477
|
var STATE_FILE = path4.join(getConfigDir(), "state.json");
|
|
478
|
+
var STATE_BAK_FILE = path4.join(getConfigDir(), "state.json.bak");
|
|
479
|
+
var LOCK_FILE = path4.join(getConfigDir(), "sync.lock");
|
|
480
|
+
var CONFLICTS_FILE = path4.join(getConfigDir(), "conflicts.json");
|
|
396
481
|
function loadLocalState() {
|
|
397
482
|
if (!fs4.existsSync(STATE_FILE)) {
|
|
398
|
-
|
|
399
|
-
lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
400
|
-
todos: /* @__PURE__ */ new Map(),
|
|
401
|
-
serverIdToThingsId: /* @__PURE__ */ new Map()
|
|
402
|
-
};
|
|
483
|
+
throw new Error('State file missing. Run "shared-things init" first.');
|
|
403
484
|
}
|
|
485
|
+
ensureConfigDir();
|
|
486
|
+
fs4.copyFileSync(STATE_FILE, STATE_BAK_FILE);
|
|
487
|
+
const raw = fs4.readFileSync(STATE_FILE, "utf-8");
|
|
488
|
+
let data;
|
|
404
489
|
try {
|
|
405
|
-
|
|
406
|
-
const data = JSON.parse(content);
|
|
407
|
-
return {
|
|
408
|
-
lastSyncedAt: data.lastSyncedAt,
|
|
409
|
-
todos: new Map(Object.entries(data.todos || {})),
|
|
410
|
-
serverIdToThingsId: new Map(
|
|
411
|
-
Object.entries(data.serverIdToThingsId || {})
|
|
412
|
-
)
|
|
413
|
-
};
|
|
490
|
+
data = JSON.parse(raw);
|
|
414
491
|
} catch {
|
|
415
|
-
|
|
416
|
-
lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
417
|
-
todos: /* @__PURE__ */ new Map(),
|
|
418
|
-
serverIdToThingsId: /* @__PURE__ */ new Map()
|
|
419
|
-
};
|
|
492
|
+
throw new Error("State file is corrupted (invalid JSON).");
|
|
420
493
|
}
|
|
494
|
+
if (typeof data !== "object" || data === null) {
|
|
495
|
+
throw new Error("State file is corrupted (invalid structure).");
|
|
496
|
+
}
|
|
497
|
+
const obj = data;
|
|
498
|
+
const lastSyncedAt = typeof obj.lastSyncedAt === "string" ? obj.lastSyncedAt : null;
|
|
499
|
+
const todos = typeof obj.todos === "object" && obj.todos !== null ? obj.todos : null;
|
|
500
|
+
const serverIdToThingsId = typeof obj.serverIdToThingsId === "object" && obj.serverIdToThingsId !== null ? obj.serverIdToThingsId : null;
|
|
501
|
+
const dirtyObj = typeof obj.dirty === "object" && obj.dirty !== null ? obj.dirty : null;
|
|
502
|
+
if (!lastSyncedAt || !todos || !serverIdToThingsId) {
|
|
503
|
+
throw new Error("State file is corrupted (missing fields).");
|
|
504
|
+
}
|
|
505
|
+
const dirty = {
|
|
506
|
+
upserted: Array.isArray(dirtyObj?.upserted) ? dirtyObj?.upserted : [],
|
|
507
|
+
deleted: {}
|
|
508
|
+
};
|
|
509
|
+
if (dirtyObj?.deleted) {
|
|
510
|
+
if (Array.isArray(dirtyObj.deleted)) {
|
|
511
|
+
for (const serverId of dirtyObj.deleted) {
|
|
512
|
+
if (typeof serverId === "string") {
|
|
513
|
+
dirty.deleted[serverId] = (/* @__PURE__ */ new Date()).toISOString();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} else if (typeof dirtyObj.deleted === "object" && dirtyObj.deleted !== null) {
|
|
517
|
+
for (const [serverId, deletedAt] of Object.entries(
|
|
518
|
+
dirtyObj.deleted
|
|
519
|
+
)) {
|
|
520
|
+
if (typeof deletedAt === "string") {
|
|
521
|
+
dirty.deleted[serverId] = deletedAt;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const [thingsId, todo] of Object.entries(todos)) {
|
|
527
|
+
if (!todo.editedAt) {
|
|
528
|
+
todos[thingsId] = {
|
|
529
|
+
thingsId,
|
|
530
|
+
title: todo.title || "",
|
|
531
|
+
notes: todo.notes || "",
|
|
532
|
+
dueDate: todo.dueDate ?? null,
|
|
533
|
+
tags: Array.isArray(todo.tags) ? todo.tags : [],
|
|
534
|
+
status: todo.status || "open",
|
|
535
|
+
position: typeof todo.position === "number" && Number.isFinite(todo.position) ? todo.position : 0,
|
|
536
|
+
editedAt: lastSyncedAt
|
|
537
|
+
};
|
|
538
|
+
} else if (typeof todo.position !== "number" || !Number.isFinite(todo.position)) {
|
|
539
|
+
todos[thingsId] = {
|
|
540
|
+
...todo,
|
|
541
|
+
position: 0
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
validateMapping(todos, serverIdToThingsId);
|
|
546
|
+
return {
|
|
547
|
+
lastSyncedAt,
|
|
548
|
+
todos,
|
|
549
|
+
serverIdToThingsId,
|
|
550
|
+
dirty
|
|
551
|
+
};
|
|
421
552
|
}
|
|
422
553
|
function saveLocalState(state) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
554
|
+
ensureConfigDir();
|
|
555
|
+
const tempFile = `${STATE_FILE}.tmp-${process.pid}`;
|
|
556
|
+
const data = JSON.stringify(state, null, 2);
|
|
557
|
+
fs4.writeFileSync(tempFile, data);
|
|
558
|
+
fs4.renameSync(tempFile, STATE_FILE);
|
|
559
|
+
}
|
|
560
|
+
function acquireLock() {
|
|
561
|
+
ensureConfigDir();
|
|
562
|
+
if (fs4.existsSync(LOCK_FILE)) {
|
|
563
|
+
const pid = parseInt(fs4.readFileSync(LOCK_FILE, "utf-8"), 10);
|
|
564
|
+
if (pid) {
|
|
565
|
+
try {
|
|
566
|
+
process.kill(pid, 0);
|
|
567
|
+
return false;
|
|
568
|
+
} catch {
|
|
569
|
+
fs4.unlinkSync(LOCK_FILE);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
fs4.writeFileSync(LOCK_FILE, String(process.pid));
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
function releaseLock() {
|
|
577
|
+
if (fs4.existsSync(LOCK_FILE)) {
|
|
578
|
+
fs4.unlinkSync(LOCK_FILE);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function appendConflicts(entries) {
|
|
582
|
+
if (entries.length === 0) return;
|
|
583
|
+
ensureConfigDir();
|
|
584
|
+
let existing = [];
|
|
585
|
+
if (fs4.existsSync(CONFLICTS_FILE)) {
|
|
586
|
+
try {
|
|
587
|
+
const raw = fs4.readFileSync(CONFLICTS_FILE, "utf-8");
|
|
588
|
+
existing = JSON.parse(raw);
|
|
589
|
+
} catch {
|
|
590
|
+
existing = [];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const next = [...existing, ...entries];
|
|
594
|
+
const tempFile = `${CONFLICTS_FILE}.tmp-${process.pid}`;
|
|
595
|
+
fs4.writeFileSync(tempFile, JSON.stringify(next, null, 2));
|
|
596
|
+
fs4.renameSync(tempFile, CONFLICTS_FILE);
|
|
597
|
+
}
|
|
598
|
+
function notifyConflicts(count) {
|
|
599
|
+
if (count <= 0) return;
|
|
600
|
+
try {
|
|
601
|
+
const message = `Sync complete. ${count} conflict${count === 1 ? "" : "s"} resolved (your edits were older).`;
|
|
602
|
+
const safeMessage = message.replace(/"/g, '\\"');
|
|
603
|
+
const cmd = `osascript -e 'display notification "${safeMessage}" with title "shared-things"'`;
|
|
604
|
+
execSync3(cmd);
|
|
605
|
+
} catch {
|
|
606
|
+
}
|
|
429
607
|
}
|
|
430
608
|
async function runSync() {
|
|
431
609
|
const config = loadConfig();
|
|
432
610
|
if (!config) {
|
|
433
611
|
throw new Error('Not configured. Run "shared-things init" first.');
|
|
434
612
|
}
|
|
613
|
+
if (!acquireLock()) {
|
|
614
|
+
logWarn("Sync skipped: another sync process is running.");
|
|
615
|
+
return {
|
|
616
|
+
pushed: 0,
|
|
617
|
+
pulled: 0,
|
|
618
|
+
isFirstSync: false,
|
|
619
|
+
skipped: true,
|
|
620
|
+
conflicts: 0
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
let localState;
|
|
624
|
+
try {
|
|
625
|
+
localState = loadLocalState();
|
|
626
|
+
} catch (error) {
|
|
627
|
+
releaseLock();
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
logDebug(
|
|
631
|
+
`Loaded state: todos=${Object.keys(localState.todos).length}, mappings=${Object.keys(localState.serverIdToThingsId).length}, dirtyUpserted=${localState.dirty.upserted.length}, dirtyDeleted=${Object.keys(localState.dirty.deleted).length}`
|
|
632
|
+
);
|
|
633
|
+
logInfo("Sync started");
|
|
435
634
|
const api = new ApiClient(config.serverUrl, config.apiKey);
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
635
|
+
const isFirstSync = Object.keys(localState.todos).length === 0 && Object.keys(localState.serverIdToThingsId).length === 0;
|
|
636
|
+
let pushed = 0;
|
|
637
|
+
let pulled = 0;
|
|
638
|
+
let conflictCount = 0;
|
|
639
|
+
try {
|
|
640
|
+
const currentTodos = getTodosFromProject(config.projectName);
|
|
641
|
+
logDebug(`Read Things: todos=${currentTodos.length}`);
|
|
642
|
+
const positionMap = new Map(
|
|
643
|
+
currentTodos.map((todo, idx) => [todo.thingsId, idx])
|
|
644
|
+
);
|
|
645
|
+
const currentTodosMap = new Map(currentTodos.map((t) => [t.thingsId, t]));
|
|
646
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
647
|
+
const dirtyUpserted = new Set(localState.dirty.upserted);
|
|
648
|
+
for (const [thingsId, todo] of currentTodosMap) {
|
|
649
|
+
const prev = localState.todos[thingsId];
|
|
650
|
+
const position = positionMap.get(thingsId) ?? 0;
|
|
651
|
+
if (!prev) {
|
|
652
|
+
localState.todos[thingsId] = {
|
|
653
|
+
thingsId,
|
|
654
|
+
title: todo.title,
|
|
655
|
+
notes: todo.notes,
|
|
656
|
+
dueDate: todo.dueDate,
|
|
657
|
+
tags: todo.tags,
|
|
658
|
+
status: todo.status,
|
|
659
|
+
position,
|
|
660
|
+
editedAt: now
|
|
661
|
+
};
|
|
662
|
+
dirtyUpserted.add(thingsId);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (hasChanged(prev, todo, position)) {
|
|
666
|
+
localState.todos[thingsId] = {
|
|
667
|
+
...prev,
|
|
668
|
+
title: todo.title,
|
|
669
|
+
notes: todo.notes,
|
|
670
|
+
dueDate: todo.dueDate,
|
|
671
|
+
tags: todo.tags,
|
|
672
|
+
status: todo.status,
|
|
673
|
+
position,
|
|
674
|
+
editedAt: now
|
|
675
|
+
};
|
|
676
|
+
dirtyUpserted.add(thingsId);
|
|
677
|
+
}
|
|
448
678
|
}
|
|
679
|
+
for (const thingsId of Object.keys(localState.todos)) {
|
|
680
|
+
if (!currentTodosMap.has(thingsId)) {
|
|
681
|
+
const serverId = findServerId(localState.serverIdToThingsId, thingsId);
|
|
682
|
+
if (serverId) {
|
|
683
|
+
if (!localState.dirty.deleted[serverId]) {
|
|
684
|
+
localState.dirty.deleted[serverId] = now;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
delete localState.todos[thingsId];
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
localState.dirty.upserted = Array.from(dirtyUpserted);
|
|
691
|
+
localState.dirty.upserted = localState.dirty.upserted.filter(
|
|
692
|
+
(id) => currentTodosMap.has(id)
|
|
693
|
+
);
|
|
694
|
+
logDebug(
|
|
695
|
+
`Local changes: upserted=${localState.dirty.upserted.length}, deleted=${Object.keys(localState.dirty.deleted).length}`
|
|
696
|
+
);
|
|
697
|
+
const pushUpserts = buildUpserts(currentTodosMap, localState);
|
|
698
|
+
const pushDeletes = buildDeletes(currentTodosMap, localState);
|
|
699
|
+
logDebug(
|
|
700
|
+
`Push payload: upserted=${pushUpserts.length}, deleted=${pushDeletes.length}`
|
|
701
|
+
);
|
|
702
|
+
if (pushUpserts.length > 0 || pushDeletes.length > 0) {
|
|
703
|
+
logDebug(
|
|
704
|
+
`Push payload body: ${JSON.stringify({
|
|
705
|
+
todos: { upserted: pushUpserts, deleted: pushDeletes },
|
|
706
|
+
lastSyncedAt: localState.lastSyncedAt
|
|
707
|
+
})}`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
if (pushUpserts.length > 0 || pushDeletes.length > 0) {
|
|
711
|
+
const pushResponse = await api.push({
|
|
712
|
+
todos: {
|
|
713
|
+
upserted: pushUpserts,
|
|
714
|
+
deleted: pushDeletes
|
|
715
|
+
},
|
|
716
|
+
lastSyncedAt: localState.lastSyncedAt
|
|
717
|
+
});
|
|
718
|
+
pushed = pushUpserts.length + pushDeletes.length;
|
|
719
|
+
processPushMappings(localState, pushResponse);
|
|
720
|
+
const conflictEntries = conflictsFromPush(pushResponse);
|
|
721
|
+
logDebug(
|
|
722
|
+
`Push response: conflicts=${conflictEntries.length}, mappings=${pushResponse.mappings?.length ?? 0}`
|
|
723
|
+
);
|
|
724
|
+
conflictCount += conflictEntries.length;
|
|
725
|
+
appendConflicts(conflictEntries);
|
|
726
|
+
localState.dirty = { upserted: [], deleted: {} };
|
|
727
|
+
}
|
|
728
|
+
const delta = await getServerDelta(api, localState, currentTodos);
|
|
729
|
+
logDebug(
|
|
730
|
+
`Delta response: upserted=${delta.todos.upserted.length}, deleted=${delta.todos.deleted.length}, syncedAt=${delta.syncedAt}`
|
|
731
|
+
);
|
|
732
|
+
const remoteResult = await applyRemoteChanges(
|
|
733
|
+
config.thingsAuthToken,
|
|
734
|
+
config.projectName,
|
|
735
|
+
delta.todos.upserted,
|
|
736
|
+
delta.todos.deleted,
|
|
737
|
+
currentTodosMap,
|
|
738
|
+
localState
|
|
739
|
+
);
|
|
740
|
+
pulled += remoteResult.applied;
|
|
741
|
+
conflictCount += remoteResult.conflicts.length;
|
|
742
|
+
appendConflicts(remoteResult.conflicts);
|
|
743
|
+
localState.lastSyncedAt = delta.syncedAt;
|
|
744
|
+
saveLocalState(localState);
|
|
745
|
+
logSync(pushed, pulled, isFirstSync, conflictCount);
|
|
746
|
+
if (conflictCount > 0) {
|
|
747
|
+
notifyConflicts(conflictCount);
|
|
748
|
+
}
|
|
749
|
+
} catch (error) {
|
|
750
|
+
logError("Sync failed", error);
|
|
751
|
+
saveLocalState(localState);
|
|
752
|
+
throw error;
|
|
753
|
+
} finally {
|
|
754
|
+
releaseLock();
|
|
755
|
+
}
|
|
756
|
+
return { pushed, pulled, isFirstSync, conflicts: conflictCount };
|
|
757
|
+
}
|
|
758
|
+
function hasChanged(prev, curr, position) {
|
|
759
|
+
return prev.title !== curr.title || prev.notes !== curr.notes || prev.dueDate !== curr.dueDate || prev.status !== curr.status || prev.position !== position || JSON.stringify(prev.tags) !== JSON.stringify(curr.tags);
|
|
760
|
+
}
|
|
761
|
+
function buildUpserts(currentTodosMap, state) {
|
|
762
|
+
const thingsIdToServerId = invertMapping(state.serverIdToThingsId);
|
|
763
|
+
const upserts = [];
|
|
764
|
+
for (const thingsId of state.dirty.upserted) {
|
|
765
|
+
const todo = currentTodosMap.get(thingsId);
|
|
766
|
+
const stored = state.todos[thingsId];
|
|
767
|
+
if (!todo || !stored) continue;
|
|
768
|
+
upserts.push({
|
|
769
|
+
serverId: thingsIdToServerId.get(thingsId),
|
|
770
|
+
clientId: thingsId,
|
|
771
|
+
title: stored.title,
|
|
772
|
+
notes: stored.notes,
|
|
773
|
+
dueDate: stored.dueDate,
|
|
774
|
+
tags: stored.tags,
|
|
775
|
+
status: stored.status,
|
|
776
|
+
position: stored.position,
|
|
777
|
+
editedAt: stored.editedAt
|
|
778
|
+
});
|
|
449
779
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
780
|
+
return upserts;
|
|
781
|
+
}
|
|
782
|
+
function buildDeletes(currentTodosMap, state) {
|
|
783
|
+
const deletes = [];
|
|
784
|
+
for (const [serverId, deletedAt] of Object.entries(state.dirty.deleted)) {
|
|
785
|
+
const thingsId = state.serverIdToThingsId[serverId];
|
|
786
|
+
if (thingsId && currentTodosMap.has(thingsId)) {
|
|
787
|
+
delete state.dirty.deleted[serverId];
|
|
788
|
+
continue;
|
|
453
789
|
}
|
|
790
|
+
deletes.push({ serverId, deletedAt });
|
|
454
791
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
792
|
+
return deletes;
|
|
793
|
+
}
|
|
794
|
+
function processPushMappings(state, response) {
|
|
795
|
+
if (!response.mappings) return;
|
|
796
|
+
for (const mapping of response.mappings) {
|
|
797
|
+
if (!mapping.clientId) continue;
|
|
798
|
+
setMapping(state, mapping.serverId, mapping.clientId);
|
|
458
799
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
800
|
+
}
|
|
801
|
+
function conflictsFromPush(response) {
|
|
802
|
+
if (!response.conflicts || response.conflicts.length === 0) return [];
|
|
803
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
804
|
+
return response.conflicts.map((conflict) => ({
|
|
805
|
+
timestamp,
|
|
806
|
+
serverId: conflict.serverId,
|
|
807
|
+
title: conflict.clientTodo?.title || conflict.serverTodo?.title || "Unknown",
|
|
808
|
+
yourVersion: conflict.clientTodo ? {
|
|
809
|
+
title: conflict.clientTodo.title,
|
|
810
|
+
editedAt: conflict.clientTodo.editedAt
|
|
811
|
+
} : conflict.clientDeletedAt ? { deletedAt: conflict.clientDeletedAt } : {},
|
|
812
|
+
winningVersion: conflict.serverTodo ? {
|
|
813
|
+
title: conflict.serverTodo.title,
|
|
814
|
+
editedAt: conflict.serverTodo.editedAt
|
|
815
|
+
} : conflict.clientDeletedAt ? { deletedAt: conflict.clientDeletedAt } : {},
|
|
816
|
+
reason: conflict.reason
|
|
817
|
+
}));
|
|
818
|
+
}
|
|
819
|
+
async function getServerDelta(api, state, currentTodos) {
|
|
820
|
+
const shouldBootstrap = Object.keys(state.todos).length === 0 && Object.keys(state.serverIdToThingsId).length === 0 && currentTodos.length === 0;
|
|
821
|
+
if (shouldBootstrap) {
|
|
822
|
+
const fullState = await api.getState();
|
|
823
|
+
return {
|
|
463
824
|
todos: {
|
|
464
|
-
upserted:
|
|
465
|
-
|
|
466
|
-
serverId: thingsIdToServerId.get(t.thingsId),
|
|
467
|
-
thingsId: t.thingsId,
|
|
468
|
-
title: t.title,
|
|
469
|
-
notes: t.notes,
|
|
470
|
-
dueDate: t.dueDate,
|
|
471
|
-
tags: t.tags,
|
|
472
|
-
status: t.status,
|
|
473
|
-
headingId: t.headingThingsId,
|
|
474
|
-
position: idx
|
|
475
|
-
})),
|
|
476
|
-
// Convert thingsIds to serverIds for deletions
|
|
477
|
-
deleted: localChanges.deleted.map((thingsId) => thingsIdToServerId.get(thingsId)).filter((id) => id !== void 0)
|
|
825
|
+
upserted: fullState.todos,
|
|
826
|
+
deleted: []
|
|
478
827
|
},
|
|
479
|
-
|
|
828
|
+
syncedAt: fullState.syncedAt
|
|
480
829
|
};
|
|
481
|
-
const pushResponse = await api.push(pushRequest);
|
|
482
|
-
pushed = localChanges.upserted.length + localChanges.deleted.length;
|
|
483
|
-
for (const serverTodo of pushResponse.state.todos) {
|
|
484
|
-
const localTodo = currentTodosMap.get(serverTodo.thingsId);
|
|
485
|
-
if (localTodo) {
|
|
486
|
-
localState.serverIdToThingsId.set(serverTodo.id, localTodo.thingsId);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
830
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
831
|
+
return api.getDelta(state.lastSyncedAt);
|
|
832
|
+
}
|
|
833
|
+
async function applyRemoteChanges(authToken, projectName, upserted, deleted, currentTodosMap, state) {
|
|
834
|
+
let applied = 0;
|
|
835
|
+
const conflicts = [];
|
|
836
|
+
for (const remoteTodo of upserted) {
|
|
837
|
+
const localThingsId = state.serverIdToThingsId[remoteTodo.id];
|
|
838
|
+
const localTodo = localThingsId ? currentTodosMap.get(localThingsId) : void 0;
|
|
839
|
+
const localStateTodo = localThingsId ? state.todos[localThingsId] : void 0;
|
|
840
|
+
if (!localTodo || !localThingsId) {
|
|
841
|
+
const before = new Set(currentTodosMap.keys());
|
|
842
|
+
createTodo(projectName, {
|
|
498
843
|
title: remoteTodo.title,
|
|
499
844
|
notes: remoteTodo.notes,
|
|
500
845
|
dueDate: remoteTodo.dueDate || void 0,
|
|
501
846
|
tags: remoteTodo.tags
|
|
502
847
|
});
|
|
503
848
|
logTodoCreated(remoteTodo.title);
|
|
504
|
-
|
|
505
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
506
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
507
|
-
const afterTodos = getTodosFromProject(config.projectName);
|
|
508
|
-
newTodo = afterTodos.find(
|
|
509
|
-
(t) => !beforeTodos.has(t.thingsId) && t.title === remoteTodo.title
|
|
510
|
-
);
|
|
511
|
-
if (!newTodo) {
|
|
512
|
-
newTodo = afterTodos.find((t) => !beforeTodos.has(t.thingsId));
|
|
513
|
-
}
|
|
514
|
-
if (newTodo) break;
|
|
515
|
-
}
|
|
849
|
+
const newTodo = await findNewTodo(projectName, before, remoteTodo.title);
|
|
516
850
|
if (newTodo) {
|
|
517
|
-
|
|
851
|
+
setMapping(state, remoteTodo.id, newTodo.thingsId);
|
|
852
|
+
state.todos[newTodo.thingsId] = {
|
|
853
|
+
thingsId: newTodo.thingsId,
|
|
854
|
+
title: remoteTodo.title,
|
|
855
|
+
notes: remoteTodo.notes,
|
|
856
|
+
dueDate: remoteTodo.dueDate,
|
|
857
|
+
tags: remoteTodo.tags,
|
|
858
|
+
status: remoteTodo.status,
|
|
859
|
+
position: remoteTodo.position,
|
|
860
|
+
editedAt: remoteTodo.editedAt
|
|
861
|
+
};
|
|
518
862
|
currentTodosMap.set(newTodo.thingsId, newTodo);
|
|
519
|
-
|
|
863
|
+
if (remoteTodo.status !== "open") {
|
|
864
|
+
try {
|
|
865
|
+
updateTodo(authToken, newTodo.thingsId, {
|
|
866
|
+
completed: remoteTodo.status === "completed",
|
|
867
|
+
canceled: remoteTodo.status === "canceled"
|
|
868
|
+
});
|
|
869
|
+
} catch (error) {
|
|
870
|
+
logWarn(
|
|
871
|
+
`Failed to set status for ${newTodo.thingsId}: ${String(error)}`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
520
875
|
} else {
|
|
521
|
-
|
|
522
|
-
`Failed to find newly created todo for server ${remoteTodo.id}`,
|
|
523
|
-
remoteTodo.title
|
|
524
|
-
);
|
|
876
|
+
logWarn(`Failed to locate created todo for server ${remoteTodo.id}`);
|
|
525
877
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
878
|
+
applied += 1;
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (!localStateTodo || compareIso(remoteTodo.editedAt, localStateTodo.editedAt) >= 0) {
|
|
882
|
+
updateTodo(authToken, localTodo.thingsId, {
|
|
529
883
|
title: remoteTodo.title,
|
|
530
884
|
notes: remoteTodo.notes,
|
|
531
885
|
dueDate: remoteTodo.dueDate || void 0,
|
|
@@ -533,25 +887,101 @@ async function runSync() {
|
|
|
533
887
|
canceled: remoteTodo.status === "canceled"
|
|
534
888
|
});
|
|
535
889
|
logTodoUpdated(localTodo.thingsId, remoteTodo.title);
|
|
536
|
-
|
|
890
|
+
state.todos[localTodo.thingsId] = {
|
|
891
|
+
thingsId: localTodo.thingsId,
|
|
892
|
+
title: remoteTodo.title,
|
|
893
|
+
notes: remoteTodo.notes,
|
|
894
|
+
dueDate: remoteTodo.dueDate,
|
|
895
|
+
tags: remoteTodo.tags,
|
|
896
|
+
status: remoteTodo.status,
|
|
897
|
+
position: remoteTodo.position,
|
|
898
|
+
editedAt: remoteTodo.editedAt
|
|
899
|
+
};
|
|
900
|
+
applied += 1;
|
|
537
901
|
}
|
|
538
902
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
);
|
|
903
|
+
for (const deletion of deleted) {
|
|
904
|
+
const localThingsId = state.serverIdToThingsId[deletion.serverId];
|
|
905
|
+
if (!localThingsId) continue;
|
|
906
|
+
const existsInThings = currentTodosMap.has(localThingsId);
|
|
907
|
+
const localStateTodo = state.todos[localThingsId];
|
|
908
|
+
if (!existsInThings && !localStateTodo) {
|
|
909
|
+
delete state.serverIdToThingsId[deletion.serverId];
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (!localStateTodo) continue;
|
|
913
|
+
if (compareIso(deletion.deletedAt, localStateTodo.editedAt) < 0) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
conflicts.push({
|
|
917
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
918
|
+
serverId: deletion.serverId,
|
|
919
|
+
title: localStateTodo.title,
|
|
920
|
+
yourVersion: {
|
|
921
|
+
title: localStateTodo.title,
|
|
922
|
+
editedAt: localStateTodo.editedAt
|
|
923
|
+
},
|
|
924
|
+
winningVersion: { deletedAt: deletion.deletedAt },
|
|
925
|
+
reason: "Remote delete was newer (manual delete required)"
|
|
926
|
+
});
|
|
543
927
|
}
|
|
544
|
-
|
|
545
|
-
localState.todos = currentTodosMap;
|
|
546
|
-
saveLocalState(localState);
|
|
547
|
-
logSync(pushed, pulled, isFirstSync);
|
|
548
|
-
return { pushed, pulled, isFirstSync };
|
|
928
|
+
return { applied, conflicts };
|
|
549
929
|
}
|
|
550
|
-
function
|
|
551
|
-
|
|
930
|
+
function invertMapping(mapping) {
|
|
931
|
+
const result = /* @__PURE__ */ new Map();
|
|
932
|
+
for (const [serverId, thingsId] of Object.entries(mapping)) {
|
|
933
|
+
result.set(thingsId, serverId);
|
|
934
|
+
}
|
|
935
|
+
return result;
|
|
552
936
|
}
|
|
553
|
-
function
|
|
554
|
-
|
|
937
|
+
function findServerId(mapping, thingsId) {
|
|
938
|
+
for (const [serverId, mappedThingsId] of Object.entries(mapping)) {
|
|
939
|
+
if (mappedThingsId === thingsId) return serverId;
|
|
940
|
+
}
|
|
941
|
+
return void 0;
|
|
942
|
+
}
|
|
943
|
+
function setMapping(state, serverId, thingsId) {
|
|
944
|
+
const existing = state.serverIdToThingsId[serverId];
|
|
945
|
+
if (existing && existing !== thingsId) {
|
|
946
|
+
if (state.todos[existing]) {
|
|
947
|
+
throw new Error(
|
|
948
|
+
`Duplicate mapping detected for serverId ${serverId} (${existing} vs ${thingsId})`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
for (const [sid, tid] of Object.entries(state.serverIdToThingsId)) {
|
|
953
|
+
if (tid === thingsId && sid !== serverId) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`Duplicate mapping detected for thingsId ${thingsId} (${sid} vs ${serverId})`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
state.serverIdToThingsId[serverId] = thingsId;
|
|
960
|
+
logMapping(serverId, thingsId);
|
|
961
|
+
}
|
|
962
|
+
function validateMapping(todos, mapping) {
|
|
963
|
+
const thingsIds = new Set(Object.values(mapping));
|
|
964
|
+
if (thingsIds.size !== Object.values(mapping).length) {
|
|
965
|
+
throw new Error("Invalid mapping detected: duplicate thingsId entries.");
|
|
966
|
+
}
|
|
967
|
+
for (const thingsId of Object.values(mapping)) {
|
|
968
|
+
if (!todos[thingsId]) {
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function compareIso(a, b) {
|
|
973
|
+
return new Date(a).getTime() - new Date(b).getTime();
|
|
974
|
+
}
|
|
975
|
+
async function findNewTodo(projectName, before, title) {
|
|
976
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
977
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
978
|
+
const after = getTodosFromProject(projectName);
|
|
979
|
+
const found = after.find(
|
|
980
|
+
(t) => !before.has(t.thingsId) && t.title === title
|
|
981
|
+
);
|
|
982
|
+
if (found) return found;
|
|
983
|
+
}
|
|
984
|
+
return void 0;
|
|
555
985
|
}
|
|
556
986
|
|
|
557
987
|
// src/cli.ts
|
|
@@ -664,10 +1094,14 @@ program.command("init").description("Setup wizard").action(async () => {
|
|
|
664
1094
|
});
|
|
665
1095
|
console.log("\n\u23F3 Checking Things project...");
|
|
666
1096
|
const todos = getTodosFromProject(projectName);
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1097
|
+
if (todos.length > 0) {
|
|
1098
|
+
console.error(
|
|
1099
|
+
`\u274C Project "${projectName}" must be empty for first sync (found ${todos.length}).`
|
|
1100
|
+
);
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
console.log(`\u2705 Project "${projectName}" is empty.
|
|
1104
|
+
`);
|
|
671
1105
|
console.log("\u{1F4CB} Find your Things Auth Token in:");
|
|
672
1106
|
console.log(" Things \u2192 Settings \u2192 General \u2192 Things URLs \u2192 Manage\n");
|
|
673
1107
|
const thingsAuthToken = await password({
|
|
@@ -685,19 +1119,42 @@ program.command("init").description("Setup wizard").action(async () => {
|
|
|
685
1119
|
pollInterval: 30,
|
|
686
1120
|
thingsAuthToken
|
|
687
1121
|
});
|
|
1122
|
+
writeState({
|
|
1123
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1124
|
+
todos: {},
|
|
1125
|
+
serverIdToThingsId: {},
|
|
1126
|
+
dirty: { upserted: [], deleted: {} }
|
|
1127
|
+
});
|
|
688
1128
|
console.log("\n\u2705 Configuration saved!\n");
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1129
|
+
const install = await confirm({
|
|
1130
|
+
message: "Install LaunchAgent to run sync automatically on login?",
|
|
1131
|
+
default: true
|
|
1132
|
+
});
|
|
1133
|
+
if (install) {
|
|
1134
|
+
installLaunchAgent();
|
|
1135
|
+
}
|
|
1136
|
+
console.log("\nNext steps:");
|
|
1137
|
+
console.log(' 1. Run "shared-things sync" for a one-time sync');
|
|
1138
|
+
console.log(' 2. Or run "shared-things start" to run the daemon\n');
|
|
1139
|
+
});
|
|
1140
|
+
program.command("start").description("Start the sync daemon (launchd)").action(() => {
|
|
1141
|
+
if (!configExists()) {
|
|
1142
|
+
console.error('Not configured. Run "shared-things init" first.');
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
}
|
|
1145
|
+
startLaunchAgent();
|
|
1146
|
+
});
|
|
1147
|
+
program.command("stop").description("Stop the sync daemon (launchd)").action(() => {
|
|
1148
|
+
stopLaunchAgent();
|
|
692
1149
|
});
|
|
693
|
-
program.command("install").description("Install launchd daemon (
|
|
1150
|
+
program.command("install").description("Install launchd daemon (deprecated)").action(() => {
|
|
694
1151
|
if (!configExists()) {
|
|
695
1152
|
console.error('Not configured. Run "shared-things init" first.');
|
|
696
1153
|
process.exit(1);
|
|
697
1154
|
}
|
|
698
1155
|
installLaunchAgent();
|
|
699
1156
|
});
|
|
700
|
-
program.command("uninstall").description("Remove launchd daemon").action(() => {
|
|
1157
|
+
program.command("uninstall").description("Remove launchd daemon (deprecated)").action(() => {
|
|
701
1158
|
uninstallLaunchAgent();
|
|
702
1159
|
});
|
|
703
1160
|
program.command("status").description("Show sync status").action(async () => {
|
|
@@ -728,15 +1185,23 @@ program.command("status").description("Show sync status").action(async () => {
|
|
|
728
1185
|
console.log(
|
|
729
1186
|
`${chalk.dim("Daemon:")} ${isRunning ? chalk.green("\u25CF running") : chalk.red("\u25CB stopped")}`
|
|
730
1187
|
);
|
|
731
|
-
const
|
|
732
|
-
if (
|
|
733
|
-
const state = JSON.parse(fs5.readFileSync(statePath, "utf-8"));
|
|
1188
|
+
const state = readState();
|
|
1189
|
+
if (state) {
|
|
734
1190
|
const lastSync = new Date(state.lastSyncedAt);
|
|
735
1191
|
const ago = formatTimeAgo(lastSync);
|
|
736
1192
|
console.log(`${chalk.dim("Last sync:")} ${ago}`);
|
|
1193
|
+
console.log(
|
|
1194
|
+
`${chalk.dim("Dirty:")} upserted=${state.dirty.upserted.length}, deleted=${Object.keys(state.dirty.deleted).length}`
|
|
1195
|
+
);
|
|
737
1196
|
} else {
|
|
738
1197
|
console.log(`${chalk.dim("Last sync:")} ${chalk.yellow("never")}`);
|
|
739
1198
|
}
|
|
1199
|
+
const conflicts = readConflicts();
|
|
1200
|
+
if (conflicts.length > 0) {
|
|
1201
|
+
console.log(
|
|
1202
|
+
`${chalk.dim("Conflicts:")} ${chalk.yellow(conflicts.length)} (run "shared-things conflicts")`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
740
1205
|
console.log();
|
|
741
1206
|
});
|
|
742
1207
|
function formatTimeAgo(date) {
|
|
@@ -756,12 +1221,15 @@ program.command("sync").description("Manual one-time sync").action(async () => {
|
|
|
756
1221
|
}
|
|
757
1222
|
try {
|
|
758
1223
|
const result = await runSync();
|
|
759
|
-
if (result.
|
|
760
|
-
console.log(chalk.
|
|
1224
|
+
if (result.skipped) {
|
|
1225
|
+
console.log(chalk.yellow("\u26A0\uFE0F Sync skipped (another sync is running)."));
|
|
1226
|
+
return;
|
|
761
1227
|
}
|
|
1228
|
+
if (result.isFirstSync)
|
|
1229
|
+
console.log(chalk.cyan("\u{1F4E5} First sync completed!"));
|
|
762
1230
|
console.log(
|
|
763
1231
|
chalk.green(
|
|
764
|
-
`\u2705 Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}`
|
|
1232
|
+
`\u2705 Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}, Conflicts: ${result.conflicts}`
|
|
765
1233
|
)
|
|
766
1234
|
);
|
|
767
1235
|
} catch (error) {
|
|
@@ -776,18 +1244,18 @@ program.command("daemon").description("Run sync daemon (used by launchd)").actio
|
|
|
776
1244
|
process.exit(1);
|
|
777
1245
|
}
|
|
778
1246
|
const config = loadConfig();
|
|
779
|
-
|
|
780
|
-
|
|
1247
|
+
logInfo("Daemon started");
|
|
1248
|
+
logInfo(`Polling interval: ${config.pollInterval}s`);
|
|
781
1249
|
console.log(`Daemon started. Syncing every ${config.pollInterval}s...`);
|
|
782
1250
|
const shutdown = () => {
|
|
783
|
-
|
|
1251
|
+
logInfo("Daemon stopped");
|
|
784
1252
|
process.exit(0);
|
|
785
1253
|
};
|
|
786
1254
|
process.on("SIGTERM", shutdown);
|
|
787
1255
|
process.on("SIGINT", shutdown);
|
|
788
1256
|
try {
|
|
789
1257
|
await runSync();
|
|
790
|
-
|
|
1258
|
+
logInfo("Initial sync complete");
|
|
791
1259
|
} catch (error) {
|
|
792
1260
|
logError("Initial sync failed", error);
|
|
793
1261
|
}
|
|
@@ -799,8 +1267,8 @@ program.command("daemon").description("Run sync daemon (used by launchd)").actio
|
|
|
799
1267
|
}
|
|
800
1268
|
}, config.pollInterval * 1e3);
|
|
801
1269
|
});
|
|
802
|
-
program.command("logs").description("Show
|
|
803
|
-
const logPath = path5.join(getConfigDir(), "
|
|
1270
|
+
program.command("logs").description("Show sync logs").option("-f, --follow", "Follow log output").action((options) => {
|
|
1271
|
+
const logPath = path5.join(getConfigDir(), "sync.log");
|
|
804
1272
|
if (!fs5.existsSync(logPath)) {
|
|
805
1273
|
console.log("No logs yet.");
|
|
806
1274
|
return;
|
|
@@ -812,24 +1280,34 @@ program.command("logs").description("Show daemon logs").option("-f, --follow", "
|
|
|
812
1280
|
console.log(logs);
|
|
813
1281
|
}
|
|
814
1282
|
});
|
|
815
|
-
program.command("reset").description("Reset sync state
|
|
1283
|
+
program.command("reset").description("Reset sync state").option("--local", "Clear local state (re-sync from server)").option("--server", "Clear server data for this user").action(async (options) => {
|
|
816
1284
|
if (!configExists()) {
|
|
817
1285
|
console.log(chalk.yellow("Not configured. Nothing to reset."));
|
|
818
1286
|
return;
|
|
819
1287
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if (!hasLocalState && !options.server) {
|
|
823
|
-
console.log(chalk.yellow("No sync state to reset."));
|
|
1288
|
+
if (!options.local && !options.server) {
|
|
1289
|
+
console.log(chalk.yellow("Specify --local and/or --server to reset."));
|
|
824
1290
|
return;
|
|
825
1291
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1292
|
+
const config = loadConfig();
|
|
1293
|
+
const statePath = path5.join(getConfigDir(), "state.json");
|
|
1294
|
+
const hasLocalState = fs5.existsSync(statePath);
|
|
1295
|
+
if (options.local) {
|
|
1296
|
+
try {
|
|
1297
|
+
const todos = getTodosFromProject(config.projectName);
|
|
1298
|
+
if (todos.length > 0) {
|
|
1299
|
+
console.error(
|
|
1300
|
+
`\u274C Project "${config.projectName}" must be empty to reset local state.`
|
|
1301
|
+
);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
console.error(`\u274C Failed to read Things project: ${error}`);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
829
1308
|
}
|
|
830
|
-
message += " Continue?";
|
|
831
1309
|
const confirmed = await confirm({
|
|
832
|
-
message,
|
|
1310
|
+
message: "This action cannot be undone. Continue?",
|
|
833
1311
|
default: false
|
|
834
1312
|
});
|
|
835
1313
|
if (!confirmed) {
|
|
@@ -837,18 +1315,13 @@ program.command("reset").description("Reset sync state (next sync will be a fres
|
|
|
837
1315
|
return;
|
|
838
1316
|
}
|
|
839
1317
|
if (options.server) {
|
|
840
|
-
const config = loadConfig();
|
|
841
1318
|
const api = new ApiClient(config.serverUrl, config.apiKey);
|
|
842
1319
|
try {
|
|
843
1320
|
console.log(chalk.dim("Deleting server data..."));
|
|
844
1321
|
const result = await api.reset();
|
|
845
|
-
|
|
846
|
-
`Server reset: deleted ${result.deleted.todos} todos, ${result.deleted.headings} headings`
|
|
847
|
-
);
|
|
1322
|
+
logInfo(`Server reset: deleted ${result.deleted.todos} todos`);
|
|
848
1323
|
console.log(
|
|
849
|
-
chalk.green(
|
|
850
|
-
`\u2705 Server data deleted (${result.deleted.todos} todos, ${result.deleted.headings} headings)`
|
|
851
|
-
)
|
|
1324
|
+
chalk.green(`\u2705 Server data deleted (${result.deleted.todos} todos)`)
|
|
852
1325
|
);
|
|
853
1326
|
} catch (error) {
|
|
854
1327
|
logError("Server reset failed", error);
|
|
@@ -856,9 +1329,19 @@ program.command("reset").description("Reset sync state (next sync will be a fres
|
|
|
856
1329
|
return;
|
|
857
1330
|
}
|
|
858
1331
|
}
|
|
859
|
-
if (
|
|
860
|
-
|
|
861
|
-
|
|
1332
|
+
if (options.local) {
|
|
1333
|
+
if (hasLocalState) {
|
|
1334
|
+
fs5.unlinkSync(statePath);
|
|
1335
|
+
}
|
|
1336
|
+
const conflictsPath = path5.join(getConfigDir(), "conflicts.json");
|
|
1337
|
+
if (fs5.existsSync(conflictsPath)) fs5.unlinkSync(conflictsPath);
|
|
1338
|
+
writeState({
|
|
1339
|
+
lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1340
|
+
todos: {},
|
|
1341
|
+
serverIdToThingsId: {},
|
|
1342
|
+
dirty: { upserted: [], deleted: {} }
|
|
1343
|
+
});
|
|
1344
|
+
logInfo("Local sync state reset by user");
|
|
862
1345
|
}
|
|
863
1346
|
console.log(
|
|
864
1347
|
chalk.green(
|
|
@@ -866,32 +1349,121 @@ program.command("reset").description("Reset sync state (next sync will be a fres
|
|
|
866
1349
|
)
|
|
867
1350
|
);
|
|
868
1351
|
});
|
|
869
|
-
program.command("
|
|
870
|
-
const
|
|
871
|
-
if (
|
|
872
|
-
console.log(
|
|
1352
|
+
program.command("conflicts").description("Show conflict history").option("--all", "Show all conflicts").action((options) => {
|
|
1353
|
+
const conflicts = readConflicts();
|
|
1354
|
+
if (conflicts.length === 0) {
|
|
1355
|
+
console.log("No conflicts recorded.");
|
|
873
1356
|
return;
|
|
874
1357
|
}
|
|
875
|
-
const
|
|
876
|
-
|
|
1358
|
+
const shown = options.all ? conflicts : conflicts.slice(-10);
|
|
1359
|
+
console.log(chalk.bold(`
|
|
1360
|
+
\u26A0\uFE0F Conflicts (${shown.length})
|
|
1361
|
+
`));
|
|
1362
|
+
for (const conflict of shown) {
|
|
877
1363
|
console.log(
|
|
878
|
-
chalk.
|
|
1364
|
+
`${chalk.dim(conflict.timestamp)} ${chalk.white(conflict.title)} (${conflict.serverId})`
|
|
879
1365
|
);
|
|
880
|
-
|
|
1366
|
+
console.log(` ${chalk.dim("Reason:")} ${conflict.reason}`);
|
|
1367
|
+
if (conflict.yourVersion.editedAt || conflict.yourVersion.deletedAt) {
|
|
1368
|
+
console.log(
|
|
1369
|
+
` ${chalk.dim("Yours:")} ${formatConflictVersion(conflict.yourVersion)}`
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
if (conflict.winningVersion.editedAt || conflict.winningVersion.deletedAt) {
|
|
1373
|
+
console.log(
|
|
1374
|
+
` ${chalk.dim("Winner:")} ${formatConflictVersion(conflict.winningVersion)}`
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
console.log();
|
|
881
1378
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1379
|
+
});
|
|
1380
|
+
program.command("repair").description("Diagnose state issues (no auto-fix)").action(() => {
|
|
1381
|
+
const issues = [];
|
|
1382
|
+
if (!configExists()) {
|
|
1383
|
+
issues.push("Missing config.json (run init)");
|
|
1384
|
+
}
|
|
1385
|
+
const state = readState();
|
|
1386
|
+
if (!state) {
|
|
1387
|
+
issues.push("Missing or invalid state.json");
|
|
1388
|
+
}
|
|
1389
|
+
if (issues.length === 0) {
|
|
1390
|
+
console.log(chalk.green("No issues detected."));
|
|
1391
|
+
} else {
|
|
1392
|
+
console.log(chalk.yellow("Issues detected:"));
|
|
1393
|
+
for (const issue of issues) {
|
|
1394
|
+
console.log(`- ${issue}`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
program.command("doctor").description("Comprehensive health check").action(async () => {
|
|
1399
|
+
console.log(chalk.bold("\n\u{1FA7A} shared-things Doctor\n"));
|
|
1400
|
+
if (!configExists()) {
|
|
1401
|
+
console.log(chalk.red("Config: missing (run init)"));
|
|
888
1402
|
return;
|
|
889
1403
|
}
|
|
890
|
-
|
|
1404
|
+
const config = loadConfig();
|
|
1405
|
+
console.log(chalk.green("Config: ok"));
|
|
1406
|
+
const state = readState();
|
|
891
1407
|
console.log(
|
|
892
|
-
chalk.green(
|
|
893
|
-
'\u2705 All local data removed. Run "shared-things init" to start fresh.'
|
|
894
|
-
)
|
|
1408
|
+
state ? chalk.green("State: ok") : chalk.red("State: missing/invalid")
|
|
895
1409
|
);
|
|
1410
|
+
if (!isThingsRunning()) {
|
|
1411
|
+
console.log(chalk.yellow("Things 3: not running"));
|
|
1412
|
+
} else {
|
|
1413
|
+
console.log(chalk.green("Things 3: running"));
|
|
1414
|
+
}
|
|
1415
|
+
const projects = listProjects();
|
|
1416
|
+
if (!projects.includes(config.projectName)) {
|
|
1417
|
+
console.log(chalk.red(`Project: "${config.projectName}" not found`));
|
|
1418
|
+
} else {
|
|
1419
|
+
console.log(chalk.green(`Project: ${config.projectName}`));
|
|
1420
|
+
}
|
|
1421
|
+
const api = new ApiClient(config.serverUrl, config.apiKey);
|
|
1422
|
+
try {
|
|
1423
|
+
await api.health();
|
|
1424
|
+
console.log(chalk.green("Server: reachable"));
|
|
1425
|
+
} catch {
|
|
1426
|
+
console.log(chalk.red("Server: unreachable"));
|
|
1427
|
+
}
|
|
1428
|
+
const daemonStatus = getLaunchAgentStatus();
|
|
1429
|
+
console.log(chalk.green(`Daemon: ${daemonStatus}`));
|
|
1430
|
+
console.log();
|
|
896
1431
|
});
|
|
1432
|
+
function readState() {
|
|
1433
|
+
const statePath = path5.join(getConfigDir(), "state.json");
|
|
1434
|
+
if (!fs5.existsSync(statePath)) return null;
|
|
1435
|
+
try {
|
|
1436
|
+
const raw = fs5.readFileSync(statePath, "utf-8");
|
|
1437
|
+
const data = JSON.parse(raw);
|
|
1438
|
+
return {
|
|
1439
|
+
lastSyncedAt: data.lastSyncedAt,
|
|
1440
|
+
dirty: {
|
|
1441
|
+
upserted: data.dirty?.upserted ?? [],
|
|
1442
|
+
deleted: data.dirty?.deleted ?? {}
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
} catch {
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
function readConflicts() {
|
|
1450
|
+
const conflictsPath = path5.join(getConfigDir(), "conflicts.json");
|
|
1451
|
+
if (!fs5.existsSync(conflictsPath)) return [];
|
|
1452
|
+
try {
|
|
1453
|
+
return JSON.parse(fs5.readFileSync(conflictsPath, "utf-8"));
|
|
1454
|
+
} catch {
|
|
1455
|
+
return [];
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function formatConflictVersion(version) {
|
|
1459
|
+
if (version.deletedAt) return `deletedAt=${version.deletedAt}`;
|
|
1460
|
+
if (version.editedAt) return `editedAt=${version.editedAt}`;
|
|
1461
|
+
return "unknown";
|
|
1462
|
+
}
|
|
1463
|
+
function writeState(state) {
|
|
1464
|
+
const statePath = path5.join(getConfigDir(), "state.json");
|
|
1465
|
+
const temp = `${statePath}.tmp-${process.pid}`;
|
|
1466
|
+
fs5.writeFileSync(temp, JSON.stringify(state, null, 2));
|
|
1467
|
+
fs5.renameSync(temp, statePath);
|
|
1468
|
+
}
|
|
897
1469
|
program.parse();
|