@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.
- package/README.md +14 -4
- package/dist/zsync.js +163 -36
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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", "◼")} ${
|
|
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} ${
|
|
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
|
-
|
|
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
|
-
|
|
8298
|
-
|
|
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("
|
|
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: "
|
|
8356
|
-
initialValue:
|
|
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