@srafis/zsync 0.1.5 → 0.1.7
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 +143 -96
- package/dist/zsync.js +153 -56
- package/docs/clockify-setup.md +40 -0
- package/docs/zoho-setup.md +38 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,43 +1,55 @@
|
|
|
1
1
|
# zsync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Copy selected Clockify time entries to Zoho People.
|
|
4
4
|
|
|
5
|
-
zsync
|
|
5
|
+
zsync runs in an interactive terminal. It reads completed entries for one Clockify user and workspace. You choose the entries to create or update in Zoho People. It can also find entries deleted from Clockify and offer the related Zoho logs for deletion. It runs only when you start it.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
You need:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- Node.js 22 or newer
|
|
12
|
+
- An interactive terminal
|
|
13
|
+
- A Clockify account with time entries
|
|
14
|
+
- A Zoho People account with Time Tracker access, at least one assigned project, and permission to create and assign jobs
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Install the published package:
|
|
14
17
|
|
|
15
18
|
```sh
|
|
16
|
-
|
|
17
|
-
bun run dev --demo
|
|
19
|
+
npm i -g @srafis/zsync
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
Check the installation:
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
```sh
|
|
25
|
+
zsync --help
|
|
26
|
+
```
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
To try zsync without credentials, run:
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
```sh
|
|
31
|
+
zsync --demo
|
|
32
|
+
```
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
The demo uses fictional entries and sends no requests to Clockify or Zoho People.
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
## Set up your accounts
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
Follow the [Clockify setup guide](docs/clockify-setup.md) and the [Zoho People setup guide](docs/zoho-setup.md) to create the required credentials.
|
|
39
|
+
|
|
40
|
+
Before you configure zsync, collect these values:
|
|
41
|
+
|
|
42
|
+
- A Clockify API key, user ID, and workspace ID
|
|
43
|
+
- A Zoho client ID and client secret
|
|
44
|
+
- Access to Zoho People Time Tracker
|
|
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
|
|
35
47
|
|
|
36
|
-
|
|
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.
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
## Configure zsync
|
|
39
51
|
|
|
40
|
-
|
|
52
|
+
Set the required values in the terminal where you will run zsync:
|
|
41
53
|
|
|
42
54
|
```sh
|
|
43
55
|
export CLOCKIFY_API_KEY='your-clockify-api-key'
|
|
@@ -47,143 +59,178 @@ export ZOHO_CLIENT_ID='your-zoho-client-id'
|
|
|
47
59
|
export ZOHO_CLIENT_SECRET='your-zoho-client-secret'
|
|
48
60
|
```
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
The example uses a POSIX shell. In PowerShell, set each variable with `$env:NAME = 'value'`.
|
|
63
|
+
|
|
64
|
+
Keep these values private. Do not commit them to a repository. The published command reads environment variables from its process. It does not read a project `.env` file.
|
|
65
|
+
|
|
66
|
+
### Optional variables
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
|
|
69
|
+
| Variable | Use | Default |
|
|
70
|
+
| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
71
|
+
| `ZOHO_REGION` | Zoho data center code: `com`, `eu`, `in`, `au`, `cn`, `jp`, `ca`, `sa`, or `uk` | Saved region, otherwise `com` |
|
|
72
|
+
| `ZSYNC_TIMEZONE` | Time zone used for entry dates, such as `Asia/Kolkata` | System time zone |
|
|
73
|
+
| `ZOHO_DATE_FORMAT` | Date format used by your Zoho organization | `yyyy-MM-dd` |
|
|
74
|
+
| `ZSYNC_STATE_DIR` | Folder for saved authentication and project mappings | See [Saved files](#saved-files) |
|
|
75
|
+
| `ZOHO_REFRESH_TOKEN` | Use an existing Zoho refresh token | Saved token |
|
|
76
|
+
| `ZOHO_EMPLOYEE_ID` | Set the Zoho employee record ID, also called `ERECNO` | Saved ID, otherwise looked up after authorization |
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Supported values for `ZOHO_DATE_FORMAT` are `yyyy-MM-dd`, `dd-MM-yyyy`, `MM-dd-yyyy`, `yyyy/MM/dd`, `dd/MM/yyyy`, and `MM/dd/yyyy`.
|
|
80
|
+
|
|
81
|
+
Set `ZOHO_REGION` for a non-US Zoho data center. For example:
|
|
53
82
|
|
|
54
83
|
```sh
|
|
55
84
|
export ZOHO_REGION='in'
|
|
56
85
|
```
|
|
57
86
|
|
|
58
|
-
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## Authorize Zoho People
|
|
90
|
+
|
|
91
|
+
Run zsync after you set the required variables:
|
|
59
92
|
|
|
60
93
|
```sh
|
|
61
|
-
|
|
94
|
+
zsync
|
|
62
95
|
```
|
|
63
96
|
|
|
64
|
-
On the first run, zsync
|
|
97
|
+
On the first run, zsync:
|
|
65
98
|
|
|
66
|
-
|
|
99
|
+
1. Opens your browser for Zoho authorization.
|
|
100
|
+
2. Waits for Zoho to send the result to `http://localhost:8765/callback`.
|
|
101
|
+
3. Finds your Zoho People employee record from your email address.
|
|
102
|
+
4. Asks for your numeric employee record ID, `ERECNO`, if the lookup fails.
|
|
103
|
+
5. Saves the refresh token and employee record ID for later runs.
|
|
67
104
|
|
|
68
|
-
|
|
69
|
-
- `ZOHOPEOPLE.forms.READ`
|
|
70
|
-
- `AaaServer.profile.READ`
|
|
105
|
+
If the browser does not open, copy the URL shown in the terminal and open it yourself. Port 8765 must be free. The authorization step times out after five minutes.
|
|
71
106
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
The refresh token and employee record ID are saved locally for later runs. To authorize again:
|
|
107
|
+
To authorize again, run:
|
|
75
108
|
|
|
76
109
|
```sh
|
|
77
|
-
|
|
110
|
+
zsync --connect
|
|
78
111
|
```
|
|
79
112
|
|
|
80
|
-
Reconnecting
|
|
113
|
+
Reconnecting keeps saved project mappings for the same account.
|
|
81
114
|
|
|
82
115
|
## Sync your time
|
|
83
116
|
|
|
84
|
-
Track time in Clockify
|
|
117
|
+
Track time in Clockify and stop any timers you want to sync. Then run:
|
|
85
118
|
|
|
86
119
|
```sh
|
|
87
|
-
|
|
120
|
+
zsync
|
|
88
121
|
```
|
|
89
122
|
|
|
123
|
+
Use the prompts in this order:
|
|
124
|
+
|
|
90
125
|
1. Choose Today, Yesterday, This week, Last week, or This month.
|
|
91
|
-
2. Use the arrow keys to move through entries
|
|
92
|
-
3. Choose a Zoho
|
|
93
|
-
4.
|
|
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.
|
|
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.
|
|
131
|
+
|
|
132
|
+
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]`.
|
|
94
133
|
|
|
95
|
-
|
|
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.
|
|
96
135
|
|
|
97
|
-
|
|
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.
|
|
98
137
|
|
|
99
|
-
## What
|
|
138
|
+
## What zsync copies
|
|
100
139
|
|
|
101
|
-
| Clockify field | Zoho field or behavior |
|
|
102
|
-
| ------------------------------------------------------ | --------------------------------------------------------- |
|
|
103
|
-
| Description | Work Item |
|
|
104
|
-
| Project | The Zoho job you selected or matched |
|
|
105
|
-
| Duration | Hours, rounded to the nearest minute |
|
|
106
|
-
| Start date | Work date in your configured timezone |
|
|
107
|
-
| Billable flag | Billing status |
|
|
108
|
-
| Entry ID, project, tags, start/end times, billing flag | Readable YAML metadata in Description, with a sync marker |
|
|
109
140
|
|
|
110
|
-
|
|
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 |
|
|
111
150
|
|
|
112
|
-
Entries belong to their local start date. A timer that crosses midnight is copied whole to that date. Entries longer than 24 hours or shorter than the duration that rounds to one minute must be corrected in Clockify first.
|
|
113
151
|
|
|
114
|
-
zsync creates duration-based logs
|
|
152
|
+
Only completed entries are included. zsync creates duration-based logs and missing tag jobs. It does not create Zoho projects.
|
|
115
153
|
|
|
116
|
-
|
|
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.
|
|
117
155
|
|
|
118
|
-
|
|
156
|
+
## Run a sync again
|
|
119
157
|
|
|
120
|
-
|
|
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.
|
|
121
159
|
|
|
122
|
-
|
|
160
|
+
If a Clockify entry or its tags changed, select its `[updated]` 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.
|
|
123
161
|
|
|
124
|
-
Before
|
|
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.
|
|
125
163
|
|
|
126
164
|
## Review deleted entries
|
|
127
165
|
|
|
128
|
-
Before
|
|
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 `[deleted]` and is selected by default.
|
|
167
|
+
|
|
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.
|
|
169
|
+
|
|
170
|
+
An authentication or lookup error stops deletion review. zsync does not count that error as a deletion. Deletion review also runs when the selected period has no completed Clockify entries.
|
|
171
|
+
|
|
172
|
+
zsync applies creates and updates before deletions. A failed operation does not undo operations that already succeeded.
|
|
173
|
+
|
|
174
|
+
## Limits and safety
|
|
129
175
|
|
|
130
|
-
|
|
176
|
+
- Sync works from Clockify to Zoho People. It does not copy Zoho edits back to Clockify or submit or approve timesheets.
|
|
177
|
+
- Locked or approved Zoho logs, and multiple Zoho logs for one Clockify entry, are conflicts. Clear those entries or fix the logs before you continue.
|
|
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.
|
|
179
|
+
- Run one sync at a time. zsync does not coordinate runs from different terminals or machines.
|
|
180
|
+
- zsync uses `api.clockify.me`. Clockify regional API endpoints are not supported.
|
|
181
|
+
- Zoho attendance rules, date rules, project assignments, and job permissions can reject a write.
|
|
131
182
|
|
|
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
183
|
|
|
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
184
|
|
|
136
|
-
##
|
|
185
|
+
## Saved files
|
|
137
186
|
|
|
138
|
-
|
|
139
|
-
- Locked or approved logs and multiple Zoho logs identifying the same Clockify entry are conflicts. Resolve them or deselect those entries before continuing.
|
|
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.
|
|
141
|
-
- Run one sync at a time. There is no protection against simultaneous runs across terminals or machines.
|
|
142
|
-
- Clockify regional API endpoints are not supported. The app uses `api.clockify.me`.
|
|
143
|
-
- Zoho attendance, leave, date restrictions, and job permissions still apply and may cause a write to be rejected.
|
|
187
|
+
zsync stores authentication and project mappings on your computer. It stores them per account.
|
|
144
188
|
|
|
145
|
-
For your first real sync, select a few entries and check the resulting logs in Zoho.
|
|
146
189
|
|
|
147
|
-
|
|
190
|
+
| System | Default folder |
|
|
191
|
+
| ------- | ------------------------------------------------- |
|
|
192
|
+
| Linux | `$XDG_DATA_HOME/zsync`, or `~/.local/share/zsync` |
|
|
193
|
+
| macOS | `~/Library/Application Support/zsync` |
|
|
194
|
+
| Windows | `%APPDATA%/zsync`, with a local AppData fallback |
|
|
148
195
|
|
|
149
|
-
| Variable | Purpose | Default |
|
|
150
|
-
| -------------------- | -------------------------------------------------------------------------- | --------------------------------- |
|
|
151
|
-
| `ZOHO_REGION` | Zoho data center: `com`, `eu`, `in`, `au`, `cn`, `jp`, `ca`, `sa`, or `uk` | Saved region, otherwise `com` |
|
|
152
|
-
| `ZSYNC_TIMEZONE` | IANA timezone, such as `Asia/Kolkata` | System timezone |
|
|
153
|
-
| `ZOHO_DATE_FORMAT` | Date format used by your Zoho organization | `yyyy-MM-dd` |
|
|
154
|
-
| `ZSYNC_STATE_DIR` | Directory for saved authentication and job mappings | Platform-specific directory below |
|
|
155
|
-
| `ZOHO_REFRESH_TOKEN` | Override the saved refresh token | Saved token |
|
|
156
|
-
| `ZOHO_EMPLOYEE_ID` | Override the saved employee record ID, `ERECNO` | Saved employee ID |
|
|
157
196
|
|
|
158
|
-
|
|
197
|
+
The authentication file contains a refresh token and employee record ID. zsync creates it with owner-only permissions (`0600`). It does not save the Zoho client secret. Set `ZSYNC_STATE_DIR` to use another folder.
|
|
159
198
|
|
|
160
|
-
|
|
199
|
+
## Troubleshooting
|
|
200
|
+
|
|
201
|
+
- `Missing required environment variable`: set all five required variables in the current terminal.
|
|
202
|
+
- `Clockify API key does not belong to CLOCKIFY_USER_ID`: use the user ID that belongs to the API key.
|
|
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.
|
|
205
|
+
- `Port 8765 may be in use`: stop the other local process and run zsync again.
|
|
206
|
+
- `Zoho authorization expired or was revoked`: run `zsync --connect`.
|
|
161
207
|
|
|
162
|
-
Default storage locations:
|
|
163
208
|
|
|
164
|
-
- Linux: `$XDG_DATA_HOME/zsync`, or `~/.local/share/zsync`
|
|
165
|
-
- macOS: `~/Library/Application Support/zsync`
|
|
166
|
-
- Windows: `%APPDATA%/zsync`, with a local AppData fallback
|
|
167
209
|
|
|
168
210
|
## Development
|
|
169
211
|
|
|
170
|
-
Use Bun
|
|
212
|
+
The published package does not need Bun. Use Bun only when you run the source checkout.
|
|
171
213
|
|
|
172
214
|
```sh
|
|
215
|
+
git clone https://github.com/srafis/zoho-clockify-sync.git
|
|
216
|
+
cd zoho-clockify-sync
|
|
173
217
|
bun install
|
|
174
|
-
bun run dev --
|
|
175
|
-
bun run typecheck
|
|
176
|
-
bun test
|
|
177
|
-
bun run build
|
|
178
|
-
bun pm pack
|
|
218
|
+
bun run dev --demo
|
|
179
219
|
```
|
|
180
220
|
|
|
181
|
-
|
|
221
|
+
For a real source run, copy `.env.example` to `.env`, fill in the values, and run:
|
|
182
222
|
|
|
183
|
-
|
|
223
|
+
```sh
|
|
224
|
+
bun run dev
|
|
225
|
+
```
|
|
184
226
|
|
|
185
|
-
|
|
227
|
+
Run the checks with:
|
|
186
228
|
|
|
187
|
-
|
|
229
|
+
```sh
|
|
230
|
+
bun run typecheck
|
|
231
|
+
bun test
|
|
232
|
+
bun run build
|
|
233
|
+
bun pm pack
|
|
234
|
+
```
|
|
188
235
|
|
|
189
|
-
|
|
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
|
|
5824
|
-
return
|
|
5825
|
-
return sameFields(remote,
|
|
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
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
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
|
|
5896
|
+
throw new Error(`Invalid project preferences at ${path}`);
|
|
5894
5897
|
}
|
|
5895
|
-
if (data.scope !== scope || !isRecord2(data.
|
|
5896
|
-
throw new Error(`Invalid
|
|
5897
|
-
|
|
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
|
-
|
|
5904
|
+
projectMappings,
|
|
5903
5905
|
saveMappings: async () => {
|
|
5904
5906
|
contextFor(store);
|
|
5905
|
-
await writeAtomic(path, { scope,
|
|
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
|
|
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 = {
|
|
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))
|
|
@@ -8349,11 +8404,22 @@ async function busy(message, action) {
|
|
|
8349
8404
|
clearLine2(process.stdout, 0);
|
|
8350
8405
|
}
|
|
8351
8406
|
}
|
|
8352
|
-
|
|
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
|
|
8356
|
-
|
|
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
|
|
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
|
|
8415
|
-
return
|
|
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 ?? (!
|
|
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
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
const
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
hint:
|
|
8465
|
-
}))
|
|
8466
|
-
}
|
|
8467
|
-
|
|
8468
|
-
|
|
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}
|
|
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,
|
|
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));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Set up Clockify
|
|
2
|
+
|
|
3
|
+
zsync needs a Clockify API key, user ID, and workspace ID.
|
|
4
|
+
|
|
5
|
+
Use the same user, API key, and workspace. zsync reads entries for that user only.
|
|
6
|
+
|
|
7
|
+
## Create an API key
|
|
8
|
+
|
|
9
|
+
1. Sign in to Clockify.
|
|
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.
|
|
20
|
+
|
|
21
|
+
See Clockify's [API and webhook settings](https://clockify.me/help/administration/api-webhook-settings) for the current menu names.
|
|
22
|
+
|
|
23
|
+
## Find your user ID and workspace ID
|
|
24
|
+
|
|
25
|
+
Make sure `CLOCKIFY_API_KEY` is set in the current shell. This command also requires `jq`:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
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)\""'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Example expected output:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
export CLOCKIFY_USER_ID="..."
|
|
37
|
+
export CLOCKIFY_WORKSPACE_ID="..."
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Store these in your shell configuration file (`~/.bashrc`/`~/.zshrc`).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Set up Zoho People
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Create an OAuth client
|
|
6
|
+
|
|
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="..."
|
|
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.
|
|
21
|
+
|
|
22
|
+
## Request the required scopes
|
|
23
|
+
|
|
24
|
+
zsync requests these scopes during authorization:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
ZOHOPEOPLE.timetracker.ALL,ZOHOPEOPLE.forms.READ,ZOHOPEOPLE.forms.CREATE,AaaServer.profile.READ
|
|
28
|
+
```
|
|
29
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
See Zoho's [OAuth scopes](https://www.zoho.com/people/api/scopes.html) page for scope details.
|
|
33
|
+
|
|
34
|
+
## Check your Zoho access
|
|
35
|
+
|
|
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.
|
|
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`.
|
package/package.json
CHANGED
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
"@js-temporal/polyfill": "^0.5.1",
|
|
13
13
|
"fast-string-width": "3.0.2"
|
|
14
14
|
},
|
|
15
|
-
"version": "0.1.
|
|
15
|
+
"version": "0.1.7",
|
|
16
16
|
"description": "Interactively sync Clockify entries to Zoho People",
|
|
17
17
|
"bin": {
|
|
18
18
|
"zsync": "dist/zsync.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist/zsync.js",
|
|
22
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"docs"
|
|
23
24
|
],
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=22"
|