@srafis/zsync 0.1.3 → 0.1.5

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.
Files changed (3) hide show
  1. package/README.md +17 -7
  2. package/dist/zsync.js +169 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ Keep tracking time in Clockify and sync your entries to Zoho People when you're
4
4
 
5
5
  zsync is a terminal app for people who want to keep using their personal Clockify workspace while maintaining their work timesheet in Zoho. It offers another way to log time alongside the Zoho Chrome extension: use the Clockify interface you already know, then choose which completed entries to copy across.
6
6
 
7
- Each run lets you select a period, pick entries, map Clockify projects to Zoho jobs, and confirm the sync. Nothing runs in the background.
7
+ Each run lets you select a period, pick new, changed or deleted entries, map Clockify projects to Zoho jobs, and confirm the sync. Nothing runs in the background.
8
8
 
9
9
  ## Get started
10
10
 
@@ -90,11 +90,11 @@ bun run dev
90
90
  1. Choose Today, Yesterday, This week, Last week, or This month.
91
91
  2. Use the arrow keys to move through entries and Space to select or deselect them. Press Enter to continue.
92
92
  3. Choose a Zoho job for each unmapped Clockify project. zsync remembers your choices. An exact, unique match with a Zoho project or job name is selected automatically.
93
- 4. Review your selection and submit the final Yes/No prompt. Yes is selected initially, but you still have to confirm it.
93
+ 4. Review the create/update/delete counts and submit the final Yes/No prompt. It defaults to No when deletions are selected, otherwise Yes.
94
94
 
95
- New entries start selected. Previously synced entries, including changed ones, start unselected. Select a changed entry to update its existing Zoho log. An unchanged entry is skipped.
95
+ New, changed and deleted entries start selected. Changed rows show a yellow `[updated]` label before their description and update the existing Zoho log when confirmed. Unchanged synced entries start unselected and are skipped if selected.
96
96
 
97
- Choosing No or cancelling before the sync makes no time-log changes in Zoho. Job mappings may already have been saved locally.
97
+ The final confirmation lists how many Zoho logs will be created, updated and deleted. It defaults to No if any deletions are selected. Cancelling before confirmation makes no Zoho changes. Job mappings may already have been saved locally.
98
98
 
99
99
  ## What gets copied
100
100
 
@@ -115,17 +115,27 @@ zsync creates duration-based logs. The original start and end timestamps are ret
115
115
 
116
116
  ## Running sync again
117
117
 
118
- zsync writes readable YAML metadata in the Zoho log's Description and recognizes entries by a sync marker derived from their Clockify ID. Existing JSON descriptions are still recognized; selecting one updates it to YAML. Keep that metadata intact so later runs can find the existing log. Recognition works across machines and OAuth clients without a local sync ledger.
118
+ zsync writes readable YAML metadata in the Zoho log's Description and recognizes entries by a sync marker derived from their Clockify ID. Comparison includes the exact metadata text, excluding the sync marker. Differences in the log fields or metadata appear as updates. Keep that metadata intact so later runs can find the existing log. Recognition works across machines and OAuth clients without a local sync ledger.
119
119
 
120
- Selecting a changed entry overwrites differing Zoho fields with the Clockify values. Older marker-only logs are also recognized within their original account scope; selecting them updates their Work Item and metadata to the current format.
120
+ Selecting a changed entry overwrites differing Zoho fields with the Clockify values.
121
121
 
122
122
  A manually entered Zoho log without sync metadata is not treated as a match, even if its title and duration are identical. Deleting a synced Zoho log makes its Clockify entry appear new again.
123
123
 
124
124
  Before writing, zsync checks for changes in both services. It verifies each write afterward and attempts to reconcile an uncertain response without blindly repeating the write. If an entry remains `uncertain`, inspect it in Zoho before retrying. Failures are reported per entry, and failed or uncertain results produce a nonzero exit status. A run can partially succeed.
125
125
 
126
+ ## Review deleted entries
127
+
128
+ Before showing the combined sync table, zsync checks synced Zoho logs dated within your selected period. For each eligible log, it looks up the Clockify entry by ID, regardless of date. A moved entry or running timer that still exists is not offered for deletion. For a Clockify workspace-mismatch response, zsync checks the complete paginated user entry list without date filters. Only confirmed absence becomes a deletion candidate. Authentication errors and other failed lookups stop discovery rather than counting as deletions.
129
+
130
+ If Clockify confirms an entry is absent, its Zoho log appears in the same checkbox table as new and changed entries, labelled with a red `[deleted]` before its description. Deletion rows start checked. Deselect any logs you want to keep and review the create/update/delete counts at the final confirmation. The app rechecks each selected log and its Clockify source before deleting, then verifies that the Zoho log is gone. Deletions are not retried automatically after an uncertain response.
131
+
132
+ Logs need Clockify source metadata, an entry ID and a valid sync marker. Explicit workspace/user IDs must match your configuration. Deletion checks use the configured Clockify workspace. Use the same workspace for tracking and syncing. Manual logs and locked logs are excluded.
133
+
134
+ Deletion review also runs when the selected period has no completed Clockify entries. After confirmation, the app applies creations and updates, then deletions. A failure does not roll back successful operations. A deletion failure is reported per entry and produces a nonzero exit status.
135
+
126
136
  ## Limits to know
127
137
 
128
- - Sync runs one way, from Clockify to Zoho. It does not copy Zoho edits back, delete Zoho logs, or submit or approve timesheets.
138
+ - Sync runs one way, from Clockify to Zoho. It does not copy Zoho edits back or submit or approve timesheets.
129
139
  - Locked or approved logs and multiple Zoho logs identifying the same Clockify entry are conflicts. Resolve them or deselect those entries before continuing.
130
140
  - Lookup covers the selected entries' date span. If you move a previously synced entry to a date outside that span, reconcile its old Zoho log before syncing again.
131
141
  - Run one sync at a time. There is no protection against simultaneous runs across terminals or machines.
package/dist/zsync.js CHANGED
@@ -44,6 +44,16 @@ var __esm = (fn, res, err) => () => {
44
44
  return res;
45
45
  };
46
46
 
47
+ // src/types.ts
48
+ function accountScope(config) {
49
+ return JSON.stringify([config.clockifyWorkspaceId, config.clockifyUserId, config.zohoRegion, config.zohoClientId, config.zohoEmployeeId]);
50
+ }
51
+ var RejectedWriteError;
52
+ var init_types = __esm(() => {
53
+ RejectedWriteError = class RejectedWriteError extends Error {
54
+ };
55
+ });
56
+
47
57
  // node_modules/sisteransi/src/index.js
48
58
  var require_src = __commonJS(function(exports, module) {
49
59
  var ESC = "\x1B";
@@ -102,16 +112,6 @@ var require_src = __commonJS(function(exports, module) {
102
112
  module.exports = { cursor, scroll, erase, beep };
103
113
  });
104
114
 
105
- // src/types.ts
106
- function accountScope(config) {
107
- return JSON.stringify([config.clockifyWorkspaceId, config.clockifyUserId, config.zohoRegion, config.zohoClientId, config.zohoEmployeeId]);
108
- }
109
- var RejectedWriteError;
110
- var init_types = __esm(() => {
111
- RejectedWriteError = class RejectedWriteError extends Error {
112
- };
113
- });
114
-
115
115
  // node_modules/jsbi/dist/jsbi-cjs.js
116
116
  var require_jsbi_cjs = __commonJS(function(exports, module) {
117
117
  class JSBI extends Array {
@@ -5701,7 +5701,7 @@ function dateRange(name, zone, now = qi.Now.instant().toString()) {
5701
5701
  lastDate: qi.PlainDate.compare(first, end.subtract({ nanoseconds: 1 }).toZonedDateTimeISO(zone).toPlainDate()) > 0 ? first.toString() : end.subtract({ nanoseconds: 1 }).toZonedDateTimeISO(zone).toPlainDate().toString()
5702
5702
  };
5703
5703
  }
5704
- function entryInput(entry, jobId, employeeId, zone) {
5704
+ function entryInput(entry, jobId, employeeId, zone, source) {
5705
5705
  const start = qi.Instant.from(entry.start);
5706
5706
  const end = qi.Instant.from(entry.end);
5707
5707
  const seconds = Number(end.epochNanoseconds - start.epochNanoseconds) / 1e9;
@@ -5719,6 +5719,7 @@ function entryInput(entry, jobId, employeeId, zone) {
5719
5719
  workItem: entry.description,
5720
5720
  description: [
5721
5721
  "source: Clockify",
5722
+ ...source ? [`workspaceId: ${JSON.stringify(source.workspaceId)}`, `userId: ${JSON.stringify(source.userId)}`] : [],
5722
5723
  `entryId: ${JSON.stringify(entry.id)}`,
5723
5724
  "project:",
5724
5725
  ` id: ${JSON.stringify(entry.projectId)}`,
@@ -5755,7 +5756,7 @@ var init_dates = __esm(() => {
5755
5756
  });
5756
5757
 
5757
5758
  // src/sync.ts
5758
- import { createHash as createHash2, randomUUID } from "node:crypto";
5759
+ import { createHash as createHash3, randomUUID } from "node:crypto";
5759
5760
  import { open, mkdir as mkdir2, readFile as readFile2, rename as rename2, unlink } from "node:fs/promises";
5760
5761
  import { join as join3 } from "node:path";
5761
5762
  function isRecord2(value) {
@@ -5791,7 +5792,7 @@ function assertKey(key) {
5791
5792
  throw new Error("Sync entry keys must be non-empty strings");
5792
5793
  }
5793
5794
  function markerFor(scope, key) {
5794
- return createHash2("sha256").update(scope).update("\x00").update(key).digest("hex");
5795
+ return createHash3("sha256").update(scope).update("\x00").update(key).digest("hex");
5795
5796
  }
5796
5797
  function markerIn(description) {
5797
5798
  return description.match(MARKER_RE)?.[1];
@@ -5873,7 +5874,7 @@ function contextFor(store) {
5873
5874
  }
5874
5875
  async function openStore(directory, scope) {
5875
5876
  await mkdir2(directory, { recursive: true, mode: 448 });
5876
- const hash = createHash2("sha256").update(scope).digest("hex").slice(0, 32);
5877
+ const hash = createHash3("sha256").update(scope).digest("hex").slice(0, 32);
5877
5878
  const path = join3(directory, `zsync-preferences-${hash}.json`);
5878
5879
  let mappings = {};
5879
5880
  for (const candidate of [path, join3(directory, `zsync-state-${hash}.json`)]) {
@@ -6148,6 +6149,9 @@ async function demoServices() {
6148
6149
  entries.push({ ...entries[0], id: "demo-entry-2", description: "Already synced example", tags: [] });
6149
6150
  const logs = new Map;
6150
6151
  const zoho = {
6152
+ async deleteLog(id) {
6153
+ logs.delete(id);
6154
+ },
6151
6155
  async validate() {},
6152
6156
  async listJobs() {
6153
6157
  return [{ id: "demo-job", name: "Example project" }];
@@ -6170,14 +6174,18 @@ async function demoServices() {
6170
6174
  };
6171
6175
  const store = await openStore(stateDir, accountScope(config));
6172
6176
  try {
6173
- await commit(store, zoho, await prepare(store, zoho, [{ key: entries[1].id, input: entryInput(entries[1], "demo-job", "demo", "UTC") }]));
6177
+ await commit(store, zoho, await prepare(store, zoho, [{ key: entries[1].id, input: entryInput(entries[1], "demo-job", "demo", "UTC", { workspaceId: "demo", userId: "demo" }) }]));
6178
+ const deleted = { ...entries[0], id: "demo-deleted", description: "Deleted Clockify example" };
6179
+ await commit(store, zoho, await prepare(store, zoho, [{ key: deleted.id, input: entryInput(deleted, "demo-job", "demo", "UTC", { workspaceId: "demo", userId: "demo" }) }]));
6174
6180
  } finally {
6175
6181
  await store.close();
6176
6182
  }
6177
6183
  return {
6178
6184
  config,
6179
6185
  zoho,
6180
- clockify: { async validate() {}, async listEntries() {
6186
+ clockify: { async entryExists(id) {
6187
+ return entries.some((entry) => entry.id === id);
6188
+ }, async validate() {}, async listEntries() {
6181
6189
  return entries.map((entry) => ({ ...entry }));
6182
6190
  } },
6183
6191
  cleanup: () => rm(stateDir, { recursive: true, force: true })
@@ -6190,6 +6198,64 @@ var init_demo = __esm(() => {
6190
6198
  init_dates();
6191
6199
  });
6192
6200
 
6201
+ // src/deletions.ts
6202
+ init_types();
6203
+ import { createHash } from "node:crypto";
6204
+ function field(description, name) {
6205
+ try {
6206
+ const metadata = JSON.parse(description.replace(/\n\n\[zsync-source:[a-f0-9]{64}\]$/, ""));
6207
+ return typeof metadata?.[name] === "string" ? metadata[name] : undefined;
6208
+ } catch {}
6209
+ if (name === "source" && description.startsWith(`source: Clockify
6210
+ `))
6211
+ return "Clockify";
6212
+ const lines = description.split(`
6213
+ `).filter((line) => line.startsWith(`${name}: `));
6214
+ if (lines.length !== 1)
6215
+ return;
6216
+ try {
6217
+ const value = JSON.parse(lines[0].slice(name.length + 2));
6218
+ return typeof value === "string" && value ? value : undefined;
6219
+ } catch {
6220
+ return;
6221
+ }
6222
+ }
6223
+ async function findDeletions(logs, source, config) {
6224
+ const candidates = [];
6225
+ for (const log of logs) {
6226
+ if (log.locked || log.employeeId !== config.zohoEmployeeId || field(log.description, "source") !== "Clockify" || field(log.description, "workspaceId") !== undefined && field(log.description, "workspaceId") !== config.clockifyWorkspaceId || field(log.description, "userId") !== undefined && field(log.description, "userId") !== config.clockifyUserId)
6227
+ continue;
6228
+ const entryId = field(log.description, "entryId");
6229
+ if (!entryId)
6230
+ continue;
6231
+ const markers = ["", accountScope(config)].map((scope) => createHash("sha256").update(scope).update("\x00").update(entryId).digest("hex"));
6232
+ if (!markers.some((marker) => log.description.endsWith(`
6233
+
6234
+ [zsync-source:${marker}]`)))
6235
+ continue;
6236
+ if (await source.entryExists(entryId) === false)
6237
+ candidates.push({ log: { ...log }, entryId });
6238
+ }
6239
+ return candidates;
6240
+ }
6241
+ async function deleteConfirmed(item, source, destination) {
6242
+ const current = await destination.getLog(item.log.id);
6243
+ if (!current)
6244
+ throw new Error("Zoho entry is already absent; refresh the list.");
6245
+ if (current.locked || JSON.stringify(current) !== JSON.stringify(item.log))
6246
+ throw new Error("Zoho entry changed after review; refresh the list.");
6247
+ if (await source.entryExists(item.entryId) !== false)
6248
+ throw new Error("Clockify entry exists or its deletion cannot be confirmed; deletion cancelled.");
6249
+ let failure;
6250
+ try {
6251
+ await destination.deleteLog(item.log.id);
6252
+ } catch (error) {
6253
+ failure = error;
6254
+ }
6255
+ if (await destination.getLog(item.log.id) !== null)
6256
+ throw failure ?? new Error("Deletion was not verified. Check Zoho before retrying.");
6257
+ }
6258
+
6193
6259
  // node_modules/@clack/core/dist/index.mjs
6194
6260
  import { styleText } from "node:util";
6195
6261
  import { stdout, stdin } from "node:process";
@@ -7318,7 +7384,7 @@ ${i}
7318
7384
  }).prompt();
7319
7385
 
7320
7386
  // src/auth.ts
7321
- import { createHash, randomBytes } from "node:crypto";
7387
+ import { createHash as createHash2, randomBytes } from "node:crypto";
7322
7388
  import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
7323
7389
  import { join as join2 } from "node:path";
7324
7390
  import { spawn } from "node:child_process";
@@ -7375,6 +7441,10 @@ function responseMessage(body) {
7375
7441
  const parts = [];
7376
7442
  if (typeof response.message === "string")
7377
7443
  parts.push(response.message);
7444
+ if (typeof response.error === "string")
7445
+ parts.push(response.error);
7446
+ if (typeof response.error_description === "string")
7447
+ parts.push(response.error_description);
7378
7448
  if (Array.isArray(response.errors)) {
7379
7449
  for (const error of response.errors) {
7380
7450
  if (!isRecord(error))
@@ -7568,7 +7638,31 @@ function createClockify(config, options = {}) {
7568
7638
  }
7569
7639
  return entries;
7570
7640
  }
7571
- return { validate, listEntries };
7641
+ async function entryExists(id) {
7642
+ if (!id)
7643
+ throw new Error("Clockify entry ID is required");
7644
+ const path = `/workspaces/${encodeURIComponent(config.clockifyWorkspaceId)}/time-entries/${encodeURIComponent(id)}`;
7645
+ const result = await fetchRaw(fetcher, `${CLOCKIFY_API}${path}`, { headers: { "X-Api-Key": config.clockifyKey } }, "Clockify entry lookup", [config.clockifyKey], options.timeoutMs ?? REQUEST_TIMEOUT_MS, false);
7646
+ if (result.status === 400 && isRecord(result.body) && result.body.message === "Time entry doesn't belong to Workspace") {
7647
+ await validate();
7648
+ const entries = await clockifyPages(fetcher, config, `/workspaces/${encodeURIComponent(config.clockifyWorkspaceId)}/user/${encodeURIComponent(config.clockifyUserId)}/time-entries`, {}, options);
7649
+ const ids = entries.map((entry) => {
7650
+ if (entry.userId !== undefined && String(entry.userId) !== config.clockifyUserId)
7651
+ throw new Error("Clockify returned an entry for a different user");
7652
+ return valueId(entry.id, "id", "Clockify entry");
7653
+ });
7654
+ return ids.includes(id);
7655
+ }
7656
+ if (result.status === 404) {
7657
+ await validate();
7658
+ return false;
7659
+ }
7660
+ const body = requireHttp(result, "Clockify entry lookup", [config.clockifyKey]);
7661
+ if (!isRecord(body) || body.id !== id)
7662
+ throw new Error("Clockify entry lookup returned an unexpected entry");
7663
+ return true;
7664
+ }
7665
+ return { validate, listEntries, entryExists };
7572
7666
  }
7573
7667
  function validDate(value, name) {
7574
7668
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
@@ -7747,6 +7841,8 @@ function createZoho(config, options = {}) {
7747
7841
  const body = requireHttp(result, "Zoho OAuth token refresh", secrets);
7748
7842
  if (!isRecord(body))
7749
7843
  throw new Error("Zoho OAuth token refresh returned malformed data");
7844
+ if (typeof body.error === "string")
7845
+ throw new Error(`Zoho OAuth token refresh failed: ${redact(responseMessage(body), secrets)}`);
7750
7846
  const token = valueString(body.access_token, "access_token", "Zoho OAuth token refresh");
7751
7847
  secrets.push(token);
7752
7848
  const expiresIn = Number(body.expires_in ?? 3600);
@@ -7882,9 +7978,17 @@ function createZoho(config, options = {}) {
7882
7978
  throw new Error("Zoho timelog id is required");
7883
7979
  await rateLimit(writesRate, ZOHO_WRITE_INTERVAL_MS, sleep, () => writeLog("/timetracker/edittimelog", { timeLogId: id, ...logFields(input, config) }, id));
7884
7980
  }
7981
+ async function deleteLog(id) {
7982
+ if (!id)
7983
+ throw new Error("Zoho timelog id is required");
7984
+ await rateLimit(writesRate, ZOHO_WRITE_INTERVAL_MS, sleep, async () => {
7985
+ const body = await zohoRequest(queryUrl("", "/timetracker/deletetimelog", { timeLogId: id }), {}, true);
7986
+ zohoStatus(body, "Zoho deletetimelog", secrets, true);
7987
+ });
7988
+ }
7885
7989
  return { validate: async () => {
7886
7990
  await listJobs();
7887
- }, listJobs, listLogs, getLog, createLog, updateLog };
7991
+ }, listJobs, listLogs, getLog, createLog, updateLog, deleteLog };
7888
7992
  }
7889
7993
 
7890
7994
  // src/auth.ts
@@ -7896,7 +8000,7 @@ function answer(value) {
7896
8000
  return value;
7897
8001
  }
7898
8002
  function authPath(config) {
7899
- const key = createHash("sha256").update(JSON.stringify([config.zohoClientId, config.clockifyWorkspaceId, config.clockifyUserId])).digest("hex");
8003
+ const key = createHash2("sha256").update(JSON.stringify([config.zohoClientId, config.clockifyWorkspaceId, config.clockifyUserId])).digest("hex");
7900
8004
  return join2(config.stateDir, `zoho-auth-${key}.json`);
7901
8005
  }
7902
8006
  async function readAuth(config) {
@@ -8147,7 +8251,7 @@ function entryTable(rows, columns) {
8147
8251
  if (columns >= 110)
8148
8252
  fields.push({ title: "Tags", size: 14, value: (row) => row.entry.tags.join("/") || "—" });
8149
8253
  const used = fields.reduce((sum, field) => sum + field.size + 3, 0);
8150
- fields.push({ title: "Description", size: Math.max(1, available - used), value: (row) => row.entry.description || "—" });
8254
+ fields.push({ title: "Description", size: Math.max(1, available - used), value: (row) => `${row.status === "changed" || row.status === "deleted" ? `[${row.status === "changed" ? "updated" : "deleted"}] ` : ""}${row.entry.description || "—"}` });
8151
8255
  const line = (cells) => fitCell(cells.join(" │ "), available).trimEnd();
8152
8256
  return {
8153
8257
  header: line(fields.map((field) => fitCell(field.title, field.size))),
@@ -8155,10 +8259,13 @@ function entryTable(rows, columns) {
8155
8259
  labels: rows.map((row) => line(fields.map((field) => fitCell(field.value(row), field.size))))
8156
8260
  };
8157
8261
  }
8262
+ function colorEntryLabel(label, dim = false) {
8263
+ return label.split(/(\[updated\]|\[deleted\])/).map((part) => part === "[updated]" ? styleText3(["bold", "yellow"], part) : part === "[deleted]" ? styleText3(["bold", "red"], part) : dim ? styleText3("dim", part) : part).join("");
8264
+ }
8158
8265
  function pickEntries(rows) {
8159
8266
  return new a({
8160
8267
  options: rows.map((row) => ({ value: row.entry.id })),
8161
- initialValues: rows.filter((row) => row.status === "new").map((row) => row.entry.id),
8268
+ initialValues: rows.filter((row) => row.status === "new" || row.status === "changed" || row.status === "deleted").map((row) => row.entry.id),
8162
8269
  required: false,
8163
8270
  render() {
8164
8271
  const columns = process.stdout.columns || 80;
@@ -8180,7 +8287,7 @@ function pickEntries(rows) {
8180
8287
  gap,
8181
8288
  mutedLine(`│ ${table.header}`),
8182
8289
  mutedLine(`│ ${table.separator}`),
8183
- ...table.labels.map((label) => `${gap} ${styleText3("green", "◼")} ${styleText3("dim", label)}`)
8290
+ ...table.labels.map((label) => `${gap} ${styleText3("green", "◼")} ${colorEntryLabel(label, true)}`)
8184
8291
  ].join(`
8185
8292
  `);
8186
8293
  }
@@ -8196,7 +8303,7 @@ ${heading("Selection cancelled")}`;
8196
8303
  const checked = selected.has(row.entry.id);
8197
8304
  const check = styleText3(checked ? "green" : focused ? "cyan" : "dim", checked ? "◼" : "◻");
8198
8305
  const label = table.labels[index + start];
8199
- return `${gap} ${focused ? styleText3("cyan", "›") : " "} ${check} ${focused ? label : styleText3("dim", label)}`;
8306
+ return `${gap} ${focused ? styleText3("cyan", "›") : " "} ${check} ${colorEntryLabel(label, !focused)}`;
8200
8307
  });
8201
8308
  const focused = rows[this.cursor];
8202
8309
  return [
@@ -8208,6 +8315,7 @@ ${heading("Selection cancelled")}`;
8208
8315
  ...visible,
8209
8316
  gap,
8210
8317
  line(`│ ${selected.size}/${rows.length} selected · ${start + 1}–${Math.min(start + count, rows.length)} shown`),
8318
+ ...focused?.status === "changed" ? [line("│ Changed · updates the existing Zoho entry")] : [],
8211
8319
  ...focused?.reason ? [line(`│ ${focused.reason}`)] : [],
8212
8320
  mutedLine("└ ↑↓ move · Space toggle · Enter confirm · Esc cancel")
8213
8321
  ].join(`
@@ -8254,7 +8362,7 @@ async function main(argv = process.argv.slice(2)) {
8254
8362
  Usage: zsync [--demo | --connect | --help | --version]
8255
8363
 
8256
8364
  Choose a date range, select entries, review mappings, then confirm.
8257
- Existing logs are unchecked by default. No background automation.
8365
+ New, changed and deleted entries are checked by default. No background automation.
8258
8366
 
8259
8367
  Required environment:
8260
8368
  CLOCKIFY_API_KEY, CLOCKIFY_USER_ID, CLOCKIFY_WORKSPACE_ID
@@ -8294,26 +8402,25 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8294
8402
  await zoho.validate();
8295
8403
  });
8296
8404
  const entries = (await busy("Fetching Clockify entries", () => clockify.listEntries(range.start, range.end))).filter((entry) => inRange(entry, range)).sort((a, b) => a.start.localeCompare(b.start) || a.id.localeCompare(b.id));
8297
- if (!entries.length) {
8298
- outro("No completed entries in this period.");
8405
+ const deletions = await busy("Checking for deleted Clockify entries", async () => findDeletions(await zoho.listLogs(range.firstDate, range.lastDate), clockify, config));
8406
+ if (!entries.length && !deletions.length) {
8407
+ outro("No entries to sync in this period.");
8299
8408
  return;
8300
8409
  }
8301
8410
  const scope = accountScope(config);
8302
8411
  store = await openStore(config.stateDir, scope);
8303
8412
  const jobs = await busy("Fetching Zoho People jobs", () => zoho.listJobs());
8304
- if (!jobs.length)
8305
- throw new Error("No eligible Zoho jobs. Ask your People administrator to assign a job first.");
8306
8413
  const jobFor = (entry) => {
8307
8414
  const saved = store.mappings[entry.projectId ?? "(no project)"];
8308
8415
  return jobs.some((job) => job.id === saved) ? saved : automaticJob(entry, jobs);
8309
8416
  };
8310
8417
  const makeInputs = (items) => items.map((entry) => ({
8311
8418
  key: entry.id,
8312
- input: entryInput(entry, jobFor(entry) ?? "__unmapped__", config.zohoEmployeeId, config.timezone)
8419
+ input: entryInput(entry, jobFor(entry) ?? "__unmapped__", config.zohoEmployeeId, config.timezone, { workspaceId: config.clockifyWorkspaceId, userId: config.clockifyUserId })
8313
8420
  }));
8314
8421
  const initialPlan = await busy("Checking sync status", () => prepare(store, zoho, makeInputs(entries)));
8315
8422
  const initialByKey = new Map(initialPlan.map((item) => [item.key, item]));
8316
- const selectedIds = answer2(await pickEntries(entries.map((entry) => {
8423
+ const selectedIds = answer2(await pickEntries([...entries.map((entry) => {
8317
8424
  const item = initialByKey.get(entry.id);
8318
8425
  return {
8319
8426
  entry,
@@ -8321,14 +8428,31 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8321
8428
  status: item.status === "create" ? "new" : item.status === "skip" ? "synced" : item.status === "update" ? "changed" : "conflict",
8322
8429
  reason: item.reason ?? (!jobFor(entry) ? "Choose a Zoho job after selection" : undefined)
8323
8430
  };
8324
- })));
8431
+ }), ...deletions.map(({ log: log2, entryId }) => ({
8432
+ entry: {
8433
+ id: `delete:${log2.id}`,
8434
+ projectId: null,
8435
+ projectName: jobs.find((job) => job.id === log2.jobId)?.projectName || jobs.find((job) => job.id === log2.jobId)?.name || `Job ${log2.jobId}`,
8436
+ tags: [],
8437
+ description: log2.workItem || entryId,
8438
+ start: "",
8439
+ end: "",
8440
+ billable: log2.billable
8441
+ },
8442
+ input: log2,
8443
+ status: "deleted",
8444
+ reason: `Deletes Zoho entry · ${log2.date} · ${log2.billable ? "billable" : "non-billable"}`
8445
+ }))]));
8446
+ const selectedDeletions = deletions.filter((item) => selectedIds.includes(`delete:${item.log.id}`));
8325
8447
  const selected = entries.filter((entry) => selectedIds.includes(entry.id));
8326
- if (!selected.length) {
8448
+ if (!selected.length && !selectedDeletions.length) {
8327
8449
  moveCursor2(process.stdout, 0, -1);
8328
8450
  clearLine2(process.stdout, 0);
8329
- outro("\uD83C\uDF2A️ Nothing selected. No Zoho changes.");
8451
+ outro("Nothing selected. No Zoho changes.");
8330
8452
  return;
8331
8453
  }
8454
+ if (selected.length && !jobs.length)
8455
+ throw new Error("No eligible Zoho jobs. Ask your People administrator to assign a job first.");
8332
8456
  for (const entry of selected) {
8333
8457
  const key = entry.projectId ?? "(no project)";
8334
8458
  if (!jobFor(entry)) {
@@ -8352,8 +8476,8 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8352
8476
  throw new Error("Resolve the conflicts or deselect those entries, then rerun. No selected logs were written.");
8353
8477
  }
8354
8478
  const confirmed = answer2(await select({
8355
- message: "Do you want to commit?",
8356
- initialValue: true,
8479
+ message: `Create ${plan.filter((item) => item.status === "create").length}, update ${plan.filter((item) => item.status === "update").length}, delete ${selectedDeletions.length} Zoho entries?`,
8480
+ initialValue: selectedDeletions.length === 0,
8357
8481
  options: [
8358
8482
  { value: true, label: "Yes" },
8359
8483
  { value: false, label: "No" }
@@ -8370,6 +8494,14 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8370
8494
  throw new Error(`Clockify entry ${entry.id} changed. Rerun to review the updated plan; no writes made.`);
8371
8495
  }
8372
8496
  const results = await busy("Syncing selected entries", () => commit(store, zoho, plan));
8497
+ for (const item of selectedDeletions) {
8498
+ try {
8499
+ await busy("Deleting selected Zoho entry", () => deleteConfirmed(item, clockify, zoho));
8500
+ results.push({ key: item.log.id, status: "deleted" });
8501
+ } catch (error) {
8502
+ results.push({ key: item.log.id, status: "failed", message: error instanceof Error ? error.message : String(error) });
8503
+ }
8504
+ }
8373
8505
  for (const result of results.filter((item) => item.status === "failed" || item.status === "uncertain")) {
8374
8506
  log.error(`${result.key}: ${result.status} — ${cleanText(result.message ?? "Check sync state before retrying.")}`);
8375
8507
  }
@@ -8377,6 +8509,7 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8377
8509
  process.exitCode = 1;
8378
8510
  const icons = {
8379
8511
  created: "✅",
8512
+ deleted: "\uD83D\uDDD1️",
8380
8513
  updated: "\uD83D\uDD04",
8381
8514
  skipped: "⏭️",
8382
8515
  failed: "❌",
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "@js-temporal/polyfill": "^0.5.1",
13
13
  "fast-string-width": "3.0.2"
14
14
  },
15
- "version": "0.1.3",
15
+ "version": "0.1.5",
16
16
  "description": "Interactively sync Clockify entries to Zoho People",
17
17
  "bin": {
18
18
  "zsync": "dist/zsync.js"