@srafis/zsync 0.1.3 → 0.1.4

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 +14 -4
  2. package/dist/zsync.js +163 -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
 
@@ -92,9 +92,9 @@ bun run dev
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
93
  4. Review your selection and submit the final Yes/No prompt. Yes is selected initially, but you still have to confirm it.
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
 
@@ -123,9 +123,19 @@ A manually entered Zoho log without sync metadata is not treated as a match, eve
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. Older JSON and YAML logs without workspace/user IDs are checked against the currently configured Clockify workspace, so use the workspace you originally synced them from. 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";
@@ -7568,7 +7634,31 @@ function createClockify(config, options = {}) {
7568
7634
  }
7569
7635
  return entries;
7570
7636
  }
7571
- return { validate, listEntries };
7637
+ async function entryExists(id) {
7638
+ if (!id)
7639
+ throw new Error("Clockify entry ID is required");
7640
+ const path = `/workspaces/${encodeURIComponent(config.clockifyWorkspaceId)}/time-entries/${encodeURIComponent(id)}`;
7641
+ 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);
7642
+ if (result.status === 400 && isRecord(result.body) && result.body.message === "Time entry doesn't belong to Workspace") {
7643
+ await validate();
7644
+ const entries = await clockifyPages(fetcher, config, `/workspaces/${encodeURIComponent(config.clockifyWorkspaceId)}/user/${encodeURIComponent(config.clockifyUserId)}/time-entries`, {}, options);
7645
+ const ids = entries.map((entry) => {
7646
+ if (entry.userId !== undefined && String(entry.userId) !== config.clockifyUserId)
7647
+ throw new Error("Clockify returned an entry for a different user");
7648
+ return valueId(entry.id, "id", "Clockify entry");
7649
+ });
7650
+ return ids.includes(id);
7651
+ }
7652
+ if (result.status === 404) {
7653
+ await validate();
7654
+ return false;
7655
+ }
7656
+ const body = requireHttp(result, "Clockify entry lookup", [config.clockifyKey]);
7657
+ if (!isRecord(body) || body.id !== id)
7658
+ throw new Error("Clockify entry lookup returned an unexpected entry");
7659
+ return true;
7660
+ }
7661
+ return { validate, listEntries, entryExists };
7572
7662
  }
7573
7663
  function validDate(value, name) {
7574
7664
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
@@ -7882,9 +7972,17 @@ function createZoho(config, options = {}) {
7882
7972
  throw new Error("Zoho timelog id is required");
7883
7973
  await rateLimit(writesRate, ZOHO_WRITE_INTERVAL_MS, sleep, () => writeLog("/timetracker/edittimelog", { timeLogId: id, ...logFields(input, config) }, id));
7884
7974
  }
7975
+ async function deleteLog(id) {
7976
+ if (!id)
7977
+ throw new Error("Zoho timelog id is required");
7978
+ await rateLimit(writesRate, ZOHO_WRITE_INTERVAL_MS, sleep, async () => {
7979
+ const body = await zohoRequest(queryUrl("", "/timetracker/deletetimelog", { timeLogId: id }), {}, true);
7980
+ zohoStatus(body, "Zoho deletetimelog", secrets, true);
7981
+ });
7982
+ }
7885
7983
  return { validate: async () => {
7886
7984
  await listJobs();
7887
- }, listJobs, listLogs, getLog, createLog, updateLog };
7985
+ }, listJobs, listLogs, getLog, createLog, updateLog, deleteLog };
7888
7986
  }
7889
7987
 
7890
7988
  // src/auth.ts
@@ -7896,7 +7994,7 @@ function answer(value) {
7896
7994
  return value;
7897
7995
  }
7898
7996
  function authPath(config) {
7899
- const key = createHash("sha256").update(JSON.stringify([config.zohoClientId, config.clockifyWorkspaceId, config.clockifyUserId])).digest("hex");
7997
+ const key = createHash2("sha256").update(JSON.stringify([config.zohoClientId, config.clockifyWorkspaceId, config.clockifyUserId])).digest("hex");
7900
7998
  return join2(config.stateDir, `zoho-auth-${key}.json`);
7901
7999
  }
7902
8000
  async function readAuth(config) {
@@ -8147,7 +8245,7 @@ function entryTable(rows, columns) {
8147
8245
  if (columns >= 110)
8148
8246
  fields.push({ title: "Tags", size: 14, value: (row) => row.entry.tags.join("/") || "—" });
8149
8247
  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 || "—" });
8248
+ 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
8249
  const line = (cells) => fitCell(cells.join(" │ "), available).trimEnd();
8152
8250
  return {
8153
8251
  header: line(fields.map((field) => fitCell(field.title, field.size))),
@@ -8155,10 +8253,13 @@ function entryTable(rows, columns) {
8155
8253
  labels: rows.map((row) => line(fields.map((field) => fitCell(field.value(row), field.size))))
8156
8254
  };
8157
8255
  }
8256
+ function colorEntryLabel(label, dim = false) {
8257
+ 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("");
8258
+ }
8158
8259
  function pickEntries(rows) {
8159
8260
  return new a({
8160
8261
  options: rows.map((row) => ({ value: row.entry.id })),
8161
- initialValues: rows.filter((row) => row.status === "new").map((row) => row.entry.id),
8262
+ initialValues: rows.filter((row) => row.status === "new" || row.status === "changed" || row.status === "deleted").map((row) => row.entry.id),
8162
8263
  required: false,
8163
8264
  render() {
8164
8265
  const columns = process.stdout.columns || 80;
@@ -8180,7 +8281,7 @@ function pickEntries(rows) {
8180
8281
  gap,
8181
8282
  mutedLine(`│ ${table.header}`),
8182
8283
  mutedLine(`│ ${table.separator}`),
8183
- ...table.labels.map((label) => `${gap} ${styleText3("green", "◼")} ${styleText3("dim", label)}`)
8284
+ ...table.labels.map((label) => `${gap} ${styleText3("green", "◼")} ${colorEntryLabel(label, true)}`)
8184
8285
  ].join(`
8185
8286
  `);
8186
8287
  }
@@ -8196,7 +8297,7 @@ ${heading("Selection cancelled")}`;
8196
8297
  const checked = selected.has(row.entry.id);
8197
8298
  const check = styleText3(checked ? "green" : focused ? "cyan" : "dim", checked ? "◼" : "◻");
8198
8299
  const label = table.labels[index + start];
8199
- return `${gap} ${focused ? styleText3("cyan", "›") : " "} ${check} ${focused ? label : styleText3("dim", label)}`;
8300
+ return `${gap} ${focused ? styleText3("cyan", "›") : " "} ${check} ${colorEntryLabel(label, !focused)}`;
8200
8301
  });
8201
8302
  const focused = rows[this.cursor];
8202
8303
  return [
@@ -8208,6 +8309,7 @@ ${heading("Selection cancelled")}`;
8208
8309
  ...visible,
8209
8310
  gap,
8210
8311
  line(`│ ${selected.size}/${rows.length} selected · ${start + 1}–${Math.min(start + count, rows.length)} shown`),
8312
+ ...focused?.status === "changed" ? [line("│ Changed · updates the existing Zoho entry")] : [],
8211
8313
  ...focused?.reason ? [line(`│ ${focused.reason}`)] : [],
8212
8314
  mutedLine("└ ↑↓ move · Space toggle · Enter confirm · Esc cancel")
8213
8315
  ].join(`
@@ -8254,7 +8356,7 @@ async function main(argv = process.argv.slice(2)) {
8254
8356
  Usage: zsync [--demo | --connect | --help | --version]
8255
8357
 
8256
8358
  Choose a date range, select entries, review mappings, then confirm.
8257
- Existing logs are unchecked by default. No background automation.
8359
+ New, changed and deleted entries are checked by default. No background automation.
8258
8360
 
8259
8361
  Required environment:
8260
8362
  CLOCKIFY_API_KEY, CLOCKIFY_USER_ID, CLOCKIFY_WORKSPACE_ID
@@ -8294,26 +8396,25 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8294
8396
  await zoho.validate();
8295
8397
  });
8296
8398
  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.");
8399
+ const deletions = await busy("Checking for deleted Clockify entries", async () => findDeletions(await zoho.listLogs(range.firstDate, range.lastDate), clockify, config));
8400
+ if (!entries.length && !deletions.length) {
8401
+ outro("No entries to sync in this period.");
8299
8402
  return;
8300
8403
  }
8301
8404
  const scope = accountScope(config);
8302
8405
  store = await openStore(config.stateDir, scope);
8303
8406
  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
8407
  const jobFor = (entry) => {
8307
8408
  const saved = store.mappings[entry.projectId ?? "(no project)"];
8308
8409
  return jobs.some((job) => job.id === saved) ? saved : automaticJob(entry, jobs);
8309
8410
  };
8310
8411
  const makeInputs = (items) => items.map((entry) => ({
8311
8412
  key: entry.id,
8312
- input: entryInput(entry, jobFor(entry) ?? "__unmapped__", config.zohoEmployeeId, config.timezone)
8413
+ input: entryInput(entry, jobFor(entry) ?? "__unmapped__", config.zohoEmployeeId, config.timezone, { workspaceId: config.clockifyWorkspaceId, userId: config.clockifyUserId })
8313
8414
  }));
8314
8415
  const initialPlan = await busy("Checking sync status", () => prepare(store, zoho, makeInputs(entries)));
8315
8416
  const initialByKey = new Map(initialPlan.map((item) => [item.key, item]));
8316
- const selectedIds = answer2(await pickEntries(entries.map((entry) => {
8417
+ const selectedIds = answer2(await pickEntries([...entries.map((entry) => {
8317
8418
  const item = initialByKey.get(entry.id);
8318
8419
  return {
8319
8420
  entry,
@@ -8321,14 +8422,31 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8321
8422
  status: item.status === "create" ? "new" : item.status === "skip" ? "synced" : item.status === "update" ? "changed" : "conflict",
8322
8423
  reason: item.reason ?? (!jobFor(entry) ? "Choose a Zoho job after selection" : undefined)
8323
8424
  };
8324
- })));
8425
+ }), ...deletions.map(({ log: log2, entryId }) => ({
8426
+ entry: {
8427
+ id: `delete:${log2.id}`,
8428
+ projectId: null,
8429
+ projectName: jobs.find((job) => job.id === log2.jobId)?.projectName || jobs.find((job) => job.id === log2.jobId)?.name || `Job ${log2.jobId}`,
8430
+ tags: [],
8431
+ description: log2.workItem || entryId,
8432
+ start: "",
8433
+ end: "",
8434
+ billable: log2.billable
8435
+ },
8436
+ input: log2,
8437
+ status: "deleted",
8438
+ reason: `Deletes Zoho entry · ${log2.date} · ${log2.billable ? "billable" : "non-billable"}`
8439
+ }))]));
8440
+ const selectedDeletions = deletions.filter((item) => selectedIds.includes(`delete:${item.log.id}`));
8325
8441
  const selected = entries.filter((entry) => selectedIds.includes(entry.id));
8326
- if (!selected.length) {
8442
+ if (!selected.length && !selectedDeletions.length) {
8327
8443
  moveCursor2(process.stdout, 0, -1);
8328
8444
  clearLine2(process.stdout, 0);
8329
- outro("\uD83C\uDF2A️ Nothing selected. No Zoho changes.");
8445
+ outro("Nothing selected. No Zoho changes.");
8330
8446
  return;
8331
8447
  }
8448
+ if (selected.length && !jobs.length)
8449
+ throw new Error("No eligible Zoho jobs. Ask your People administrator to assign a job first.");
8332
8450
  for (const entry of selected) {
8333
8451
  const key = entry.projectId ?? "(no project)";
8334
8452
  if (!jobFor(entry)) {
@@ -8352,8 +8470,8 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8352
8470
  throw new Error("Resolve the conflicts or deselect those entries, then rerun. No selected logs were written.");
8353
8471
  }
8354
8472
  const confirmed = answer2(await select({
8355
- message: "Do you want to commit?",
8356
- initialValue: true,
8473
+ message: `Create ${plan.filter((item) => item.status === "create").length}, update ${plan.filter((item) => item.status === "update").length}, delete ${selectedDeletions.length} Zoho entries?`,
8474
+ initialValue: selectedDeletions.length === 0,
8357
8475
  options: [
8358
8476
  { value: true, label: "Yes" },
8359
8477
  { value: false, label: "No" }
@@ -8370,6 +8488,14 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8370
8488
  throw new Error(`Clockify entry ${entry.id} changed. Rerun to review the updated plan; no writes made.`);
8371
8489
  }
8372
8490
  const results = await busy("Syncing selected entries", () => commit(store, zoho, plan));
8491
+ for (const item of selectedDeletions) {
8492
+ try {
8493
+ await busy("Deleting selected Zoho entry", () => deleteConfirmed(item, clockify, zoho));
8494
+ results.push({ key: item.log.id, status: "deleted" });
8495
+ } catch (error) {
8496
+ results.push({ key: item.log.id, status: "failed", message: error instanceof Error ? error.message : String(error) });
8497
+ }
8498
+ }
8373
8499
  for (const result of results.filter((item) => item.status === "failed" || item.status === "uncertain")) {
8374
8500
  log.error(`${result.key}: ${result.status} — ${cleanText(result.message ?? "Check sync state before retrying.")}`);
8375
8501
  }
@@ -8377,6 +8503,7 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8377
8503
  process.exitCode = 1;
8378
8504
  const icons = {
8379
8505
  created: "✅",
8506
+ deleted: "\uD83D\uDDD1️",
8380
8507
  updated: "\uD83D\uDD04",
8381
8508
  skipped: "⏭️",
8382
8509
  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.4",
16
16
  "description": "Interactively sync Clockify entries to Zoho People",
17
17
  "bin": {
18
18
  "zsync": "dist/zsync.js"