@srafis/zsync 0.1.6 → 0.1.8

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/LICENCE ADDED
@@ -0,0 +1,9 @@
1
+ # MIT Licence
2
+
3
+ Copyright 2026 Kamaljot Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -11,7 +11,7 @@ You need:
11
11
  - Node.js 22 or newer
12
12
  - An interactive terminal
13
13
  - A Clockify account with time entries
14
- - A Zoho People account with Time Tracker access and at least one assigned job
14
+ - A Zoho People account with Time Tracker access, at least one assigned project, and permission to create and assign jobs
15
15
 
16
16
  Install the published package:
17
17
 
@@ -42,9 +42,10 @@ Before you configure zsync, collect these values:
42
42
  - A Clockify API key, user ID, and workspace ID
43
43
  - A Zoho client ID and client secret
44
44
  - Access to Zoho People Time Tracker
45
- - A Zoho job assigned to your employee record
45
+ - Zoho projects assigned to your employee record. Matching names help zsync map them automatically.
46
+ - Permission to create and assign jobs in Zoho People
46
47
 
47
- Use the same Clockify user and workspace that own the entries you want to sync. zsync checks both values before it reads your time entries.
48
+ Use the Clockify user and workspace that contain the entries you want to sync. zsync checks both values before it reads your time entries.
48
49
 
49
50
  ## Configure zsync
50
51
 
@@ -70,7 +71,7 @@ Keep these values private. Do not commit them to a repository. The published com
70
71
  | `ZOHO_REGION` | Zoho data center code: `com`, `eu`, `in`, `au`, `cn`, `jp`, `ca`, `sa`, or `uk` | Saved region, otherwise `com` |
71
72
  | `ZSYNC_TIMEZONE` | Time zone used for entry dates, such as `Asia/Kolkata` | System time zone |
72
73
  | `ZOHO_DATE_FORMAT` | Date format used by your Zoho organization | `yyyy-MM-dd` |
73
- | `ZSYNC_STATE_DIR` | Folder for saved authentication and job mappings | See [Saved files](#saved-files) |
74
+ | `ZSYNC_STATE_DIR` | Folder for saved authentication and project mappings | See [Saved files](#saved-files) |
74
75
  | `ZOHO_REFRESH_TOKEN` | Use an existing Zoho refresh token | Saved token |
75
76
  | `ZOHO_EMPLOYEE_ID` | Set the Zoho employee record ID, also called `ERECNO` | Saved ID, otherwise looked up after authorization |
76
77
 
@@ -109,7 +110,7 @@ To authorize again, run:
109
110
  zsync --connect
110
111
  ```
111
112
 
112
- Reconnecting keeps saved job mappings for the same account.
113
+ Reconnecting keeps saved project mappings for the same account.
113
114
 
114
115
  ## Sync your time
115
116
 
@@ -123,44 +124,46 @@ Use the prompts in this order:
123
124
 
124
125
  1. Choose Today, Yesterday, This week, Last week, or This month.
125
126
  2. Use the arrow keys to move through the entries. Press Space to select or clear an entry. Press Enter to continue. Press Esc to cancel.
126
- 3. Choose a Zoho job for each Clockify project that has no saved match. zsync selects a unique project or job with the same name when it can.
127
- 4. Review the create, update, and delete counts.
128
- 5. Choose Yes to write the changes.
127
+ 3. Choose a Zoho project for each Clockify project that has neither a saved mapping nor a unique case-insensitive name match.
128
+ 4. zsync uses the alphabetically first Clockify tag as the Zoho job, reusing a case-insensitive match or creating the job under the selected project. Entries without tags use the `N/A` job.
129
+ 5. Review the create, update, job creation, and delete counts.
130
+ 6. Choose Yes to write the changes.
129
131
 
130
- New, changed, and deleted entries start selected. Unchanged entries that zsync already synced are not selected. Changed entries show `[updated]` before the description. Deleted entries show `[deleted]`.
132
+ New, changed, and deleted entries start selected. Unchanged entries that zsync already synced are not selected. Changed entries show `[U]` before the description. Deleted entries show `[D]`.
131
133
 
132
- The final prompt defaults to Yes when it only creates or updates entries. It defaults to No when it includes a deletion. zsync makes no Zoho changes before this final confirmation. It may save job mappings before you confirm.
134
+ The final prompt defaults to Yes when it only creates or updates entries. It defaults to No when it includes a deletion. zsync makes no Zoho changes before this final confirmation. It may save project mappings before you confirm.
133
135
 
134
136
  Weeks start on Monday. Today, This week, and This month end at the time you start zsync. zsync uses `ZSYNC_TIMEZONE` for local dates.
135
137
 
136
138
  ## What zsync copies
137
139
 
138
140
 
139
- | Clockify data | Zoho People result |
140
- | ---------------------------------------------------------------------------- | ---------------------------------------------------- |
141
- | Description | Work Item |
142
- | Project | The Zoho job you select or zsync matches |
143
- | Duration | Hours, rounded to the nearest minute |
144
- | Start date | Work date in your configured time zone |
145
- | Billable flag | Billing status |
146
- | Entry ID, project, tags, timestamps, billing flag, workspace ID, and user ID | Readable metadata in Description, with a sync marker |
141
+ | Clockify data | Zoho People result |
142
+ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
143
+ | Description | Work Item |
144
+ | Project | The Zoho project you select or zsync matches |
145
+ | Tags | A Zoho job under that project; alphabetically first tag, or `N/A` when empty |
146
+ | Duration | Hours, rounded to the nearest minute |
147
+ | Start date | Work date in your configured time zone |
148
+ | Billable flag | Billing status |
149
+ | Entry ID, project, tags, timestamps, billing flag, workspace ID, and user ID | Readable metadata in the Description, with a sync marker |
147
150
 
148
151
 
149
- Only completed entries are included. zsync creates duration-based logs. It does not create Zoho projects or jobs.
152
+ Only completed entries are included. zsync creates duration-based logs and missing tag jobs. It does not create Zoho projects.
150
153
 
151
154
  An entry belongs to the local date of its start time. If an entry crosses midnight, zsync copies the full duration to that start date. Entries longer than 24 hours or entries that round to less than one minute must be corrected in Clockify first.
152
155
 
153
156
  ## Run a sync again
154
157
 
155
- zsync writes readable source metadata and a marker such as `[zsync-source:...]` in each Zoho log description. It uses this data to find the log that belongs to a Clockify entry. Keep the metadata and marker in the description.
158
+ zsync writes readable source metadata and a marker such as `[zsync-source:...]` in each Zoho log description. It uses this data to find the log that belongs to a Clockify entry. Keep the metadata and marker in the description. All Clockify tags remain in the metadata even when only one tag selects the Zoho job.
156
159
 
157
- If a Clockify entry changed, select its `[updated]` row. zsync then writes the current Clockify values to the matching Zoho log. A Zoho log that you entered by hand has no source metadata, so zsync does not treat it as a match. If you delete a synced Zoho log, its Clockify entry appears as new on the next run.
160
+ If a Clockify entry or its tags changed, select its `[U]` row. zsync then writes the current Clockify values and selected Zoho project and job to the matching Zoho log. A Zoho log that you entered by hand has no source metadata, so zsync does not treat it as a match. If you delete a synced Zoho log, its Clockify entry appears as new on the next run.
158
161
 
159
162
  Before each write, zsync checks both services again. It verifies the result after the write. A run can finish with both successful and failed entries. If a result is uncertain, inspect Zoho People before you retry it.
160
163
 
161
164
  ## Review deleted entries
162
165
 
163
- Before it shows the selection table, zsync checks synced Zoho logs in the selected date range. If the source entry no longer exists in Clockify, the Zoho log appears with `[deleted]` and is selected by default.
166
+ Before it shows the selection table, zsync checks synced Zoho logs in the selected date range. If the source entry no longer exists in Clockify, the Zoho log appears with `[D]` and is selected by default.
164
167
 
165
168
  You can clear a deletion to keep the Zoho log. zsync checks each selected deletion and its Clockify source again before it deletes the log. It does not offer an entry that still exists in Clockify, even if the entry moved to another date. It also excludes manual, locked, and ambiguous Zoho logs.
166
169
 
@@ -175,13 +178,13 @@ zsync applies creates and updates before deletions. A failed operation does not
175
178
  - If you move a synced entry outside the selected date range, include the old Zoho log date in a later run when you clean it up.
176
179
  - Run one sync at a time. zsync does not coordinate runs from different terminals or machines.
177
180
  - zsync uses `api.clockify.me`. Clockify regional API endpoints are not supported.
178
- - Zoho attendance rules, date rules, and job permissions can reject a write.
181
+ - Zoho attendance rules, date rules, project assignments, and job permissions can reject a write.
179
182
 
180
183
 
181
184
 
182
185
  ## Saved files
183
186
 
184
- zsync stores authentication and job mappings on your computer. It stores them per account.
187
+ zsync stores authentication and project mappings on your computer. It stores them per account.
185
188
 
186
189
 
187
190
  | System | Default folder |
@@ -197,7 +200,8 @@ The authentication file contains a refresh token and employee record ID. zsync c
197
200
 
198
201
  - `Missing required environment variable`: set all five required variables in the current terminal.
199
202
  - `Clockify API key does not belong to CLOCKIFY_USER_ID`: use the user ID that belongs to the API key.
200
- - `No eligible Zoho jobs`: ask your Zoho People administrator to assign an active job to your employee record.
203
+ - `No eligible Zoho projects`: ask your Zoho People administrator to create or assign an active project.
204
+ - Job creation errors: ask your Zoho People administrator for permission to create and assign jobs.
201
205
  - `Port 8765 may be in use`: stop the other local process and run zsync again.
202
206
  - `Zoho authorization expired or was revoked`: run `zsync --connect`.
203
207
 
@@ -229,4 +233,4 @@ bun run build
229
233
  bun pm pack
230
234
  ```
231
235
 
232
- The build creates `dist/zsync.js`, a Node.js 22 or newer executable. Tests use fictional API responses and do not create real time logs.
236
+ The build creates `dist/zsync.js`, a Node.js 22 or newer executable. Tests use fictional API responses and do not create real time logs.
package/dist/zsync.js CHANGED
@@ -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, source) {
5704
+ function entryInput(entry, projectId, 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;
@@ -5712,6 +5712,7 @@ function entryInput(entry, jobId, employeeId, zone, source) {
5712
5712
  if (minutes < 1)
5713
5713
  throw new Error(`Entry ${entry.id} rounds to zero minutes. Adjust it in Clockify before syncing.`);
5714
5714
  return {
5715
+ projectId,
5715
5716
  jobId,
5716
5717
  employeeId,
5717
5718
  date: start.toZonedDateTimeISO(zone).toPlainDate().toString(),
@@ -5773,7 +5774,7 @@ function isDate(value) {
5773
5774
  return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
5774
5775
  }
5775
5776
  function validateInput(value, label, minimumMinutes = 1) {
5776
- if (!isRecord2(value) || typeof value.jobId !== "string" || !value.jobId || typeof value.employeeId !== "string" || !value.employeeId || !isDate(value.date) || typeof value.minutes !== "number" || !Number.isInteger(value.minutes) || value.minutes < minimumMinutes || value.minutes > 1440 || value.workItem !== undefined && typeof value.workItem !== "string" || typeof value.description !== "string" || typeof value.billable !== "boolean") {
5777
+ if (!isRecord2(value) || typeof value.projectId !== "string" || !value.projectId || typeof value.jobId !== "string" || !value.jobId || typeof value.employeeId !== "string" || !value.employeeId || !isDate(value.date) || typeof value.minutes !== "number" || !Number.isInteger(value.minutes) || value.minutes < minimumMinutes || value.minutes > 1440 || value.workItem !== undefined && typeof value.workItem !== "string" || typeof value.description !== "string" || typeof value.billable !== "boolean") {
5777
5778
  throw new Error(`${label} is not a valid log input`);
5778
5779
  }
5779
5780
  }
@@ -5807,6 +5808,7 @@ function markedInput(input, marker) {
5807
5808
  }
5808
5809
  function comparable(input) {
5809
5810
  return {
5811
+ projectId: input.projectId,
5810
5812
  jobId: input.jobId,
5811
5813
  employeeId: input.employeeId,
5812
5814
  date: input.date,
@@ -5820,12 +5822,13 @@ function sameFields(left, right) {
5820
5822
  return JSON.stringify(comparable(left)) === JSON.stringify(comparable(right));
5821
5823
  }
5822
5824
  function samePlannedFields(remote, input) {
5823
- if (input.jobId !== "__unmapped__")
5824
- return sameFields(remote, input);
5825
- return sameFields(remote, { ...input, jobId: remote.jobId });
5825
+ if (input.projectId === "__unmapped__" || input.jobId === "__unmapped__")
5826
+ return false;
5827
+ return sameFields(remote, input);
5826
5828
  }
5827
5829
  function remoteSnapshot(logs) {
5828
5830
  return JSON.stringify([...logs].map((log) => ({
5831
+ projectId: log.projectId,
5829
5832
  jobId: log.jobId,
5830
5833
  employeeId: log.employeeId,
5831
5834
  date: log.date,
@@ -5875,34 +5878,33 @@ function contextFor(store) {
5875
5878
  async function openStore(directory, scope) {
5876
5879
  await mkdir2(directory, { recursive: true, mode: 448 });
5877
5880
  const hash = createHash3("sha256").update(scope).digest("hex").slice(0, 32);
5878
- const path = join3(directory, `zsync-preferences-${hash}.json`);
5879
- let mappings = {};
5880
- for (const candidate of [path, join3(directory, `zsync-state-${hash}.json`)]) {
5881
- let text;
5882
- try {
5883
- text = await readFile2(candidate, "utf8");
5884
- } catch (error) {
5885
- if (error.code === "ENOENT")
5886
- continue;
5881
+ const path = join3(directory, `zsync-project-preferences-${hash}.json`);
5882
+ let projectMappings = {};
5883
+ let text;
5884
+ try {
5885
+ text = await readFile2(path, "utf8");
5886
+ } catch (error) {
5887
+ if (error.code !== "ENOENT")
5887
5888
  throw error;
5888
- }
5889
+ text = "";
5890
+ }
5891
+ if (text) {
5889
5892
  let data;
5890
5893
  try {
5891
5894
  data = JSON.parse(text);
5892
5895
  } catch {
5893
- throw new Error(`Invalid job preferences at ${candidate}`);
5896
+ throw new Error(`Invalid project preferences at ${path}`);
5894
5897
  }
5895
- if (data.scope !== scope || !isRecord2(data.mappings) || Object.values(data.mappings).some((v) => typeof v !== "string" || !v))
5896
- throw new Error(`Invalid job preferences at ${candidate}`);
5897
- mappings = { ...data.mappings };
5898
- break;
5898
+ if (data.scope !== scope || !isRecord2(data.projectMappings) || Object.values(data.projectMappings).some((v) => typeof v !== "string" || !v))
5899
+ throw new Error(`Invalid project preferences at ${path}`);
5900
+ projectMappings = { ...data.projectMappings };
5899
5901
  }
5900
5902
  const context = { scope, closed: false };
5901
5903
  const store = {
5902
- mappings,
5904
+ projectMappings,
5903
5905
  saveMappings: async () => {
5904
5906
  contextFor(store);
5905
- await writeAtomic(path, { scope, mappings: store.mappings });
5907
+ await writeAtomic(path, { scope, projectMappings: store.projectMappings });
5906
5908
  },
5907
5909
  close: async () => {
5908
5910
  context.closed = true;
@@ -6148,13 +6150,26 @@ async function demoServices() {
6148
6150
  }];
6149
6151
  entries.push({ ...entries[0], id: "demo-entry-2", description: "Already synced example", tags: [] });
6150
6152
  const logs = new Map;
6153
+ const projects = [{ id: "demo-zoho-project", name: "Example project" }];
6154
+ const jobs = [
6155
+ { id: "demo-meeting-job", name: "Meeting", projectId: "demo-zoho-project", projectName: "Example project" },
6156
+ { id: "demo-na-job", name: "N/A", projectId: "demo-zoho-project", projectName: "Example project" }
6157
+ ];
6151
6158
  const zoho = {
6152
6159
  async deleteLog(id) {
6153
6160
  logs.delete(id);
6154
6161
  },
6155
6162
  async validate() {},
6163
+ async listProjects() {
6164
+ return projects.map((project) => ({ ...project }));
6165
+ },
6156
6166
  async listJobs() {
6157
- return [{ id: "demo-job", name: "Example project" }];
6167
+ return jobs.map((job) => ({ ...job }));
6168
+ },
6169
+ async createJob(name, projectId) {
6170
+ const job = { id: `demo-job-${jobs.length + 1}`, name, projectId };
6171
+ jobs.push(job);
6172
+ return job;
6158
6173
  },
6159
6174
  async listLogs() {
6160
6175
  return [...logs.values()].map((log) => ({ ...log }));
@@ -6174,9 +6189,9 @@ async function demoServices() {
6174
6189
  };
6175
6190
  const store = await openStore(stateDir, accountScope(config));
6176
6191
  try {
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" }) }]));
6192
+ await commit(store, zoho, await prepare(store, zoho, [{ key: entries[1].id, input: entryInput(entries[1], "demo-zoho-project", "demo-na-job", "demo", "UTC", { workspaceId: "demo", userId: "demo" }) }]));
6178
6193
  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" }) }]));
6194
+ await commit(store, zoho, await prepare(store, zoho, [{ key: deleted.id, input: entryInput(deleted, "demo-zoho-project", "demo-meeting-job", "demo", "UTC", { workspaceId: "demo", userId: "demo" }) }]));
6180
6195
  } finally {
6181
6196
  await store.close();
6182
6197
  }
@@ -7784,6 +7799,7 @@ function remoteLog(raw, config, context) {
7784
7799
  const locked = raw.locked === true || raw.locked === "true" || approvalStatus === "approved" || approvalStatus === "pending" || editAllowed === false || editAllowed === "false";
7785
7800
  return {
7786
7801
  id: valueId(raw.timelogId, "timelogId", context),
7802
+ projectId: raw.projectId === undefined || raw.projectId === null ? "__unmapped__" : valueId(raw.projectId, "projectId", context),
7787
7803
  jobId: valueId(raw.jobId, "jobId", context),
7788
7804
  employeeId,
7789
7805
  date: parseZohoDate(raw.workDate, config.zohoDateFormat ?? "yyyy-MM-dd", context, raw.db_workDate),
@@ -7798,8 +7814,8 @@ function logFields(input, config) {
7798
7814
  if (input.employeeId !== config.zohoEmployeeId)
7799
7815
  throw new Error("Zoho log employeeId does not match configured employee");
7800
7816
  validDate(input.date, "log date");
7801
- if (!input.jobId || !Number.isInteger(input.minutes) || input.minutes < 1 || input.minutes > MAX_MINUTES) {
7802
- throw new Error("Zoho log requires a job and 1-1440 whole minutes");
7817
+ if (!input.projectId || !input.jobId || !Number.isInteger(input.minutes) || input.minutes < 1 || input.minutes > MAX_MINUTES) {
7818
+ throw new Error("Zoho log requires a project, job, and 1-1440 whole minutes");
7803
7819
  }
7804
7820
  if (typeof input.description !== "string" || input.description.length > 15000)
7805
7821
  throw new Error("Zoho log description is malformed or too long");
@@ -7810,6 +7826,7 @@ function logFields(input, config) {
7810
7826
  const format = dateFormat(config.zohoDateFormat ?? "yyyy-MM-dd");
7811
7827
  return {
7812
7828
  user: input.employeeId,
7829
+ projectId: input.projectId,
7813
7830
  jobId: input.jobId,
7814
7831
  workDate: formatDate(input.date, format),
7815
7832
  dateFormat: format,
@@ -7880,8 +7897,42 @@ function createZoho(config, options = {}) {
7880
7897
  const body = await zohoRequest(queryUrl("", path, params));
7881
7898
  return zohoStatus(body, `Zoho ${path}`, secrets);
7882
7899
  }
7900
+ const projectsRate = { lastAt: 0, queue: Promise.resolve() };
7883
7901
  const jobsRate = { lastAt: 0, queue: Promise.resolve() };
7884
7902
  const logsRate = { lastAt: 0, queue: Promise.resolve() };
7903
+ async function listProjects() {
7904
+ const projects = [];
7905
+ for (let index = 0;; ) {
7906
+ const response = await rateLimit(projectsRate, ZOHO_WRITE_INTERVAL_MS, sleep, () => zohoPage("/timetracker/getprojects", {
7907
+ assignedTo: config.zohoEmployeeId,
7908
+ projectStatus: "inprogress",
7909
+ sIndex: String(index),
7910
+ limit: String(PAGE_SIZE)
7911
+ }));
7912
+ if (!Array.isArray(response.result) || !response.result.every(isRecord))
7913
+ throw new Error("Zoho getprojects returned malformed result");
7914
+ for (const raw of response.result)
7915
+ projects.push({
7916
+ id: valueId(raw.projectId, "projectId", "Zoho project"),
7917
+ name: valueString(raw.projectName, "projectName", "Zoho project")
7918
+ });
7919
+ if (!boolValue(response.isNextAvailable) || response.result.length === 0)
7920
+ return projects;
7921
+ index += response.result.length;
7922
+ }
7923
+ }
7924
+ async function createJob(name, projectId) {
7925
+ const jobName = name.trim();
7926
+ if (!jobName || !projectId)
7927
+ throw new Error("Zoho job requires a name and project");
7928
+ const inputData = JSON.stringify({ Job_Name: jobName, Project: projectId, Assignees: config.zohoEmployeeId });
7929
+ const body = await rateLimit(jobsRate, ZOHO_WRITE_INTERVAL_MS, sleep, () => zohoRequest("/forms/json/P_TimesheetJob/insertRecord", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formBody({ inputData }) }, true));
7930
+ const response = zohoStatus(body, "Zoho create job", secrets, true);
7931
+ const result = Array.isArray(response.result) ? response.result[0] : response.result;
7932
+ if (!isRecord(result))
7933
+ throw new Error("Zoho create job returned malformed result; write outcome may be uncertain");
7934
+ return { id: valueId(result.pkId, "pkId", "Zoho create job"), name: jobName, projectId };
7935
+ }
7885
7936
  async function listJobs() {
7886
7937
  const jobs = [];
7887
7938
  for (let index = 0;; ) {
@@ -7898,7 +7949,11 @@ function createZoho(config, options = {}) {
7898
7949
  const status = typeof raw.jobStatus === "string" ? raw.jobStatus.toLowerCase() : "";
7899
7950
  if (status.includes("completed") || status.includes("inactive"))
7900
7951
  continue;
7901
- const job = { id: valueId(raw.jobId, "jobId", "Zoho job"), name: valueString(raw.jobName, "jobName", "Zoho job") };
7952
+ const job = {
7953
+ id: valueId(raw.jobId, "jobId", "Zoho job"),
7954
+ name: valueString(raw.jobName, "jobName", "Zoho job"),
7955
+ projectId: raw.projectId === undefined || raw.projectId === null ? "__unmapped__" : valueId(raw.projectId, "projectId", "Zoho job")
7956
+ };
7902
7957
  if (raw.projectName !== undefined) {
7903
7958
  if (typeof raw.projectName !== "string")
7904
7959
  throw new Error("Zoho job projectName is malformed");
@@ -7987,12 +8042,12 @@ function createZoho(config, options = {}) {
7987
8042
  });
7988
8043
  }
7989
8044
  return { validate: async () => {
7990
- await listJobs();
7991
- }, listJobs, listLogs, getLog, createLog, updateLog, deleteLog };
8045
+ await Promise.all([listProjects(), listJobs()]);
8046
+ }, listProjects, listJobs, createJob, listLogs, getLog, createLog, updateLog, deleteLog };
7992
8047
  }
7993
8048
 
7994
8049
  // src/auth.ts
7995
- var scopes = "ZOHOPEOPLE.timetracker.ALL,ZOHOPEOPLE.forms.READ,AaaServer.profile.READ";
8050
+ var scopes = "ZOHOPEOPLE.timetracker.ALL,ZOHOPEOPLE.forms.READ,ZOHOPEOPLE.forms.CREATE,AaaServer.profile.READ";
7996
8051
  var redirect = "http://localhost:8765/callback";
7997
8052
  function answer(value) {
7998
8053
  if (isCancel(value))
@@ -8251,7 +8306,7 @@ function entryTable(rows, columns) {
8251
8306
  if (columns >= 110)
8252
8307
  fields.push({ title: "Tags", size: 14, value: (row) => row.entry.tags.join("/") || "—" });
8253
8308
  const used = fields.reduce((sum, field) => sum + field.size + 3, 0);
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 || "—"}` });
8309
+ fields.push({ title: "Description", size: Math.max(1, available - used), value: (row) => `${row.status === "changed" || row.status === "deleted" ? `[${row.status === "changed" ? "U" : "D"}] ` : ""}${row.entry.description || "—"}` });
8255
8310
  const line = (cells) => fitCell(cells.join(" │ "), available).trimEnd();
8256
8311
  return {
8257
8312
  header: line(fields.map((field) => fitCell(field.title, field.size))),
@@ -8260,7 +8315,7 @@ function entryTable(rows, columns) {
8260
8315
  };
8261
8316
  }
8262
8317
  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("");
8318
+ return label.split(/(\[U\]|\[D\])/).map((part) => part === "[U]" ? styleText3(["bold", "yellow"], part) : part === "[D]" ? styleText3(["bold", "red"], part) : dim ? styleText3("dim", part) : part).join("");
8264
8319
  }
8265
8320
  function pickEntries(rows) {
8266
8321
  return new a({
@@ -8349,11 +8404,22 @@ async function busy(message, action) {
8349
8404
  clearLine2(process.stdout, 0);
8350
8405
  }
8351
8406
  }
8352
- function automaticJob(entry, jobs) {
8407
+ var FALLBACK_JOB = "N/A";
8408
+ function tagJobName(tags) {
8409
+ const names = tags.map((tag) => tag.trim()).filter(Boolean);
8410
+ names.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b));
8411
+ return names[0] ?? FALLBACK_JOB;
8412
+ }
8413
+ function matchingJob(projectId, name, jobs) {
8414
+ const wanted = name.trim().toLowerCase();
8415
+ return jobs.find((job) => job.projectId === projectId && job.name.trim().toLowerCase() === wanted);
8416
+ }
8417
+ function automaticProject(entry, projects) {
8353
8418
  if (!entry.projectId)
8354
8419
  return;
8355
- const matches = jobs.filter((job) => job.projectName === entry.projectName || job.name === entry.projectName);
8356
- return matches.length === 1 ? matches[0].id : undefined;
8420
+ const wanted = entry.projectName.trim().toLowerCase();
8421
+ const matches = projects.filter((project) => project.name.trim().toLowerCase() === wanted);
8422
+ return matches.length === 1 ? matches[0] : undefined;
8357
8423
  }
8358
8424
  async function main(argv = process.argv.slice(2)) {
8359
8425
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -8409,14 +8475,21 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8409
8475
  }
8410
8476
  const scope = accountScope(config);
8411
8477
  store = await openStore(config.stateDir, scope);
8412
- const jobs = await busy("Fetching Zoho People jobs", () => zoho.listJobs());
8478
+ const projects = await busy("Fetching Zoho People projects", () => zoho.listProjects());
8479
+ let jobs = await busy("Fetching Zoho People jobs", () => zoho.listJobs());
8480
+ const projectFor = (entry) => {
8481
+ if (!entry.projectId)
8482
+ return;
8483
+ const saved = store.projectMappings[entry.projectId];
8484
+ return projects.find((project) => project.id === saved) ?? automaticProject(entry, projects);
8485
+ };
8413
8486
  const jobFor = (entry) => {
8414
- const saved = store.mappings[entry.projectId ?? "(no project)"];
8415
- return jobs.some((job) => job.id === saved) ? saved : automaticJob(entry, jobs);
8487
+ const project = projectFor(entry);
8488
+ return project ? matchingJob(project.id, tagJobName(entry.tags), jobs) : undefined;
8416
8489
  };
8417
8490
  const makeInputs = (items) => items.map((entry) => ({
8418
8491
  key: entry.id,
8419
- input: entryInput(entry, jobFor(entry) ?? "__unmapped__", config.zohoEmployeeId, config.timezone, { workspaceId: config.clockifyWorkspaceId, userId: config.clockifyUserId })
8492
+ input: entryInput(entry, projectFor(entry)?.id ?? "__unmapped__", jobFor(entry)?.id ?? "__unmapped__", config.zohoEmployeeId, config.timezone, { workspaceId: config.clockifyWorkspaceId, userId: config.clockifyUserId })
8420
8493
  }));
8421
8494
  const initialPlan = await busy("Checking sync status", () => prepare(store, zoho, makeInputs(entries)));
8422
8495
  const initialByKey = new Map(initialPlan.map((item) => [item.key, item]));
@@ -8426,7 +8499,7 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8426
8499
  entry,
8427
8500
  input: item.input,
8428
8501
  status: item.status === "create" ? "new" : item.status === "skip" ? "synced" : item.status === "update" ? "changed" : "conflict",
8429
- reason: item.reason ?? (!jobFor(entry) ? "Choose a Zoho job after selection" : undefined)
8502
+ reason: item.reason ?? (!entry.projectId ? "Assign a Clockify project before syncing" : !projectFor(entry) ? "Choose a Zoho project after selection" : !jobFor(entry) ? `Creates Zoho job "${tagJobName(entry.tags)}" after confirmation` : undefined)
8430
8503
  };
8431
8504
  }), ...deletions.map(({ log: log2, entryId }) => ({
8432
8505
  entry: {
@@ -8451,23 +8524,30 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8451
8524
  outro("Nothing selected. No Zoho changes.");
8452
8525
  return;
8453
8526
  }
8454
- if (selected.length && !jobs.length)
8455
- throw new Error("No eligible Zoho jobs. Ask your People administrator to assign a job first.");
8456
- for (const entry of selected) {
8457
- const key = entry.projectId ?? "(no project)";
8458
- if (!jobFor(entry)) {
8459
- store.mappings[key] = answer2(await select({
8460
- message: `Zoho job for ${cleanText(entry.projectName || "entries without a project")}?`,
8461
- options: jobs.map((job) => ({
8462
- value: job.id,
8463
- label: cleanText(`${job.projectName ? job.projectName + " / " : ""}${job.name}`),
8464
- hint: job.id
8465
- }))
8466
- }));
8467
- } else
8468
- store.mappings[key] = jobFor(entry);
8527
+ if (selected.length) {
8528
+ if (!projects.length)
8529
+ throw new Error("No eligible Zoho projects. Ask your People administrator to create or assign a project first.");
8530
+ for (const entry of selected) {
8531
+ if (!entry.projectId)
8532
+ throw new Error(`Clockify entry ${entry.id} has no project. Assign it in Clockify before syncing.`);
8533
+ const project = projectFor(entry);
8534
+ if (!project) {
8535
+ store.projectMappings[entry.projectId] = answer2(await select({
8536
+ message: `Zoho project for ${cleanText(entry.projectName)}?`,
8537
+ options: projects.map((project) => ({ value: project.id, label: cleanText(project.name), hint: project.id }))
8538
+ }));
8539
+ } else
8540
+ store.projectMappings[entry.projectId] = project.id;
8541
+ }
8469
8542
  }
8470
8543
  await store.saveMappings();
8544
+ const jobsToCreate = new Map;
8545
+ for (const entry of selected) {
8546
+ const project = projectFor(entry);
8547
+ const name = tagJobName(entry.tags);
8548
+ if (!matchingJob(project.id, name, jobs))
8549
+ jobsToCreate.set(`${project.id}\x00${name.toLowerCase()}`, { name, projectId: project.id });
8550
+ }
8471
8551
  const plan = await busy("Preparing commit", () => prepare(store, zoho, makeInputs(selected)));
8472
8552
  const conflicts = plan.filter((item) => item.status === "conflict");
8473
8553
  if (conflicts.length) {
@@ -8476,7 +8556,7 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8476
8556
  throw new Error("Resolve the conflicts or deselect those entries, then rerun. No selected logs were written.");
8477
8557
  }
8478
8558
  const confirmed = answer2(await select({
8479
- message: `Create ${plan.filter((item) => item.status === "create").length}, update ${plan.filter((item) => item.status === "update").length}, delete ${selectedDeletions.length} Zoho entries?`,
8559
+ message: `Create ${plan.filter((item) => item.status === "create").length}, update ${plan.filter((item) => item.status === "update").length}, create ${jobsToCreate.size} Zoho jobs, delete ${selectedDeletions.length} entries?`,
8480
8560
  initialValue: selectedDeletions.length === 0,
8481
8561
  options: [
8482
8562
  { value: true, label: "Yes" },
@@ -8487,13 +8567,30 @@ Optional: ZOHO_REFRESH_TOKEN, ZOHO_EMPLOYEE_ID, ZOHO_REGION, ZSYNC_TIMEZONE, ZSY
8487
8567
  outro("Cancelled. No Zoho changes.");
8488
8568
  return;
8489
8569
  }
8570
+ if (jobsToCreate.size) {
8571
+ await busy("Creating Zoho jobs", async () => {
8572
+ for (const request of jobsToCreate.values()) {
8573
+ const existing = matchingJob(request.projectId, request.name, jobs);
8574
+ if (existing)
8575
+ continue;
8576
+ jobs.push(await zoho.createJob(request.name, request.projectId));
8577
+ }
8578
+ });
8579
+ }
8580
+ const finalPlan = await busy("Preparing commit", () => prepare(store, zoho, makeInputs(selected)));
8581
+ const finalConflicts = finalPlan.filter((item) => item.status === "conflict");
8582
+ if (finalConflicts.length) {
8583
+ for (const item of finalConflicts)
8584
+ log.error(`${item.key}: ${cleanText(item.reason ?? "Needs reconciliation")}`);
8585
+ throw new Error("Resolve the conflicts or rerun. No selected logs were written.");
8586
+ }
8490
8587
  const fresh = await busy("Rechecking Clockify entries", () => clockify.listEntries(range.start, range.end));
8491
8588
  for (const entry of selected) {
8492
8589
  const current = fresh.find((item) => item.id === entry.id);
8493
8590
  if (!current || JSON.stringify(current) !== JSON.stringify(entry))
8494
8591
  throw new Error(`Clockify entry ${entry.id} changed. Rerun to review the updated plan; no writes made.`);
8495
8592
  }
8496
- const results = await busy("Syncing selected entries", () => commit(store, zoho, plan));
8593
+ const results = await busy("Syncing selected entries", () => commit(store, zoho, finalPlan));
8497
8594
  for (const item of selectedDeletions) {
8498
8595
  try {
8499
8596
  await busy("Deleting selected Zoho entry", () => deleteConfirmed(item, clockify, zoho));
@@ -2,34 +2,39 @@
2
2
 
3
3
  zsync needs a Clockify API key, user ID, and workspace ID.
4
4
 
5
- Use the same user, API key, and workspace together. zsync reads entries for that user only.
5
+ Use the same user, API key, and workspace. zsync reads entries for that user only.
6
6
 
7
7
  ## Create an API key
8
8
 
9
9
  1. Sign in to Clockify.
10
- 2. Open your account menu.
11
- 3. Select Preferences, then Advanced.
12
- 4. In the API Key section, select Generate.
13
- 5. Copy the key and store it in a safe place. Clockify does not show it again after you close the window.
10
+ 2. Open [Manage API keys](https://app.clockify.me/manage-api-keys).
11
+ 3. Click the `GENERATE NEW` button, give it a name, and click `GENERATE`.
12
+ 4. Copy the key and save it in your shell configuration file:
13
+
14
+ ```sh
15
+ export CLOCKIFY_API_KEY="..."
16
+ ```
17
+
18
+ > [!NOTE]
19
+ > Clockify does not show the API key again after you close the window.
14
20
 
15
21
  See Clockify's [API and webhook settings](https://clockify.me/help/administration/api-webhook-settings) for the current menu names.
16
22
 
17
- ## Find your user ID
23
+ ## Find your user ID and workspace ID
18
24
 
19
- The user ID is different from the API key. The endpoint below returns the user for the API key:
25
+ Make sure `CLOCKIFY_API_KEY` is set in the current shell. This command also requires `jq`:
20
26
 
21
27
  ```sh
22
- curl -sS -H "X-Api-Key: $CLOCKIFY_API_KEY" https://api.clockify.me/api/v1/user
28
+ curl -sS -H "X-Api-Key: $CLOCKIFY_API_KEY" \
29
+ https://api.clockify.me/api/v1/user |
30
+ jq -r '"export CLOCKIFY_USER_ID=\"\(.id)\"\nexport CLOCKIFY_WORKSPACE_ID=\"\(.defaultWorkspace)\""'
23
31
  ```
24
32
 
25
- Use the `id` value in the response as `CLOCKIFY_USER_ID`. See Clockify's [user ID instructions](https://clockify.me/help/troubleshooting/how-to-find-userid-in-the-api) for other ways to find it.
26
-
27
- ## Find your workspace ID
28
-
29
- Open the workspace in Clockify. Its URL contains `/workspaces/<workspace-id>`. Copy the value after `/workspaces/` and use it as `CLOCKIFY_WORKSPACE_ID`.
33
+ Example expected output:
30
34
 
31
- Use the workspace where you track the time. zsync uses the default Clockify API endpoint, `https://api.clockify.me/api/v1`. Clockify regional API endpoints are not supported.
32
-
33
- ## Keep the key safe
35
+ ```sh
36
+ export CLOCKIFY_USER_ID="..."
37
+ export CLOCKIFY_WORKSPACE_ID="..."
38
+ ```
34
39
 
35
- Do not commit the API key to a repository. If you delete the key in Clockify, zsync cannot access your entries until you create and configure a new one.
40
+ Store these in your shell configuration file (`~/.bashrc`/`~/.zshrc`).
@@ -1,21 +1,22 @@
1
1
  # Set up Zoho People
2
2
 
3
- zsync needs a Zoho OAuth client, Time Tracker API access, and a job assigned to your employee record.
3
+ zsync needs a Zoho OAuth client, Time Tracker API access, an active project assigned to your employee record, and permission to create and assign jobs.
4
4
 
5
5
  ## Create an OAuth client
6
6
 
7
- 1. Open the [Zoho People OAuth instructions](https://www.zoho.com/people/api/oauth-steps.html).
8
- 2. Open the Zoho API Console from that page.
9
- 3. Create a server-based application. Zoho may call this client type Web-based.
10
- 4. Choose the data center that contains your Zoho People account.
11
- 5. Register this redirect URI exactly:
12
-
13
- ```text
14
- http://localhost:8765/callback
7
+ 1. Open the [Zoho API Console](https://api-console.zoho.com/add#web) in a browser where you are logged in to Zoho People.
8
+ 2. Enter `@srafis/zsync` as the client name.
9
+ 3. Set `http://localhost:8765` as the Homepage URL.
10
+ 4. Set `http://localhost:8765/callback` as the Authorized Redirect URI.
11
+ 5. Click the Create button.
12
+ 6. Copy the client ID and client secret from the Client Secret tab.
13
+ 7. Save them in your shell configuration file (`~/.bashrc` or `~/.zshrc`):
14
+
15
+ ```sh
16
+ export ZOHO_CLIENT_ID="..."
17
+ export ZOHO_CLIENT_SECRET="..."
15
18
  ```
16
19
 
17
- 6. Copy the client ID and client secret. Use them as `ZOHO_CLIENT_ID` and `ZOHO_CLIENT_SECRET`.
18
-
19
20
  zsync opens a local callback on port 8765 during the first authorization. The redirect URI must match exactly, including the scheme, port, and path.
20
21
 
21
22
  ## Request the required scopes
@@ -23,19 +24,15 @@ zsync opens a local callback on port 8765 during the first authorization. The re
23
24
  zsync requests these scopes during authorization:
24
25
 
25
26
  ```text
26
- ZOHOPEOPLE.timetracker.ALL,ZOHOPEOPLE.forms.READ,AaaServer.profile.READ
27
+ ZOHOPEOPLE.timetracker.ALL,ZOHOPEOPLE.forms.READ,ZOHOPEOPLE.forms.CREATE,AaaServer.profile.READ
27
28
  ```
28
29
 
29
- `ZOHOPEOPLE.timetracker.ALL` lets zsync read and change time logs and list assigned jobs. The forms and profile scopes let it find your employee record from your email address.
30
+ `ZOHOPEOPLE.timetracker.ALL` lets zsync read and change time logs and list assigned projects and jobs. `ZOHOPEOPLE.forms.CREATE` lets it create missing tag jobs under the selected project. The `ZOHOPEOPLE.forms.READ` and `AaaServer.profile.READ` scopes let it find your employee record from your email address.
30
31
 
31
32
  See Zoho's [OAuth scopes](https://www.zoho.com/people/api/scopes.html) page for scope details.
32
33
 
33
34
  ## Check your Zoho access
34
35
 
35
- Your Zoho People role must allow Time Tracker API access. Your employee record must have an eligible, assigned job. Ask your People administrator if zsync reports that no jobs are available.
36
+ Your Zoho People role must allow Time Tracker API access. Your employee record must have an eligible, assigned project and permission to create and assign jobs. Ask your People administrator if zsync reports that no projects are available or job creation is denied.
36
37
 
37
38
  If zsync cannot find your employee record by email, it asks for the numeric `ERECNO`. This is the employee record ID. It is different from the employee number shown in the People interface. You can also set it with `ZOHO_EMPLOYEE_ID`.
38
-
39
- ## Keep credentials safe
40
-
41
- Do not commit the client ID, client secret, or refresh token to a repository. zsync saves the Zoho refresh token locally after authorization. It does not save the client secret.
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
1
  {
2
- "name": "@srafis/zsync",
3
- "type": "module",
4
- "devDependencies": {
5
- "@types/bun": "latest",
6
- "@types/node": "^22.20.2",
7
- "typescript": "^7.0.2"
8
- },
9
- "dependencies": {
10
- "@clack/core": "1.5.0",
11
- "@clack/prompts": "^1.8.0",
12
- "@js-temporal/polyfill": "^0.5.1",
13
- "fast-string-width": "3.0.2"
14
- },
15
- "version": "0.1.6",
16
- "description": "Interactively sync Clockify entries to Zoho People",
17
- "bin": {
18
- "zsync": "dist/zsync.js"
19
- },
20
- "files": [
21
- "dist/zsync.js",
22
- "README.md",
23
- "docs"
24
- ],
25
- "engines": {
26
- "node": ">=22"
27
- },
28
- "scripts": {
29
- "dev": "bun app.ts",
30
- "build": "bun build app.ts --target=node --outfile=dist/zsync.js",
31
- "test": "bun test",
32
- "typecheck": "tsc --noEmit",
33
- "prepack": "bun run build"
34
- },
35
- "publishConfig": {
36
- "access": "public",
37
- "registry": "https://registry.npmjs.org/"
38
- },
39
- "repository": {
40
- "type": "git",
41
- "url": "git+https://github.com/srafis/zoho-clockify-sync.git"
42
- }
2
+ "name": "@srafis/zsync",
3
+ "type": "module",
4
+ "devDependencies": {
5
+ "@types/bun": "latest",
6
+ "@types/node": "^22.20.2",
7
+ "typescript": "^7.0.2"
8
+ },
9
+ "dependencies": {
10
+ "@clack/core": "1.5.0",
11
+ "@clack/prompts": "^1.8.0",
12
+ "@js-temporal/polyfill": "^0.5.1",
13
+ "fast-string-width": "3.0.2"
14
+ },
15
+ "version": "0.1.8",
16
+ "description": "Interactively sync Clockify entries to Zoho People",
17
+ "bin": {
18
+ "zsync": "dist/zsync.js"
19
+ },
20
+ "files": [
21
+ "dist/zsync.js",
22
+ "README.md",
23
+ "docs"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "dev": "bun app.ts",
30
+ "build": "bun build app.ts --target=node --outfile=dist/zsync.js",
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit",
33
+ "prepack": "bun run build"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/srafis/zoho-clockify-sync.git"
42
+ }
43
43
  }