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