viviscape-mcp 2.1.0 → 2.3.0
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 +9 -1
- package/dist/api-client.d.ts +57 -0
- package/dist/api-client.js +212 -0
- package/dist/index.js +164 -8
- package/dist/projection.d.ts +29 -1
- package/dist/projection.js +54 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,7 +111,9 @@ The server exposes tools across these domains:
|
|
|
111
111
|
- **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
|
|
112
112
|
- **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
|
|
113
113
|
- **Time logs** - `timelog_add`, `timelog_update`
|
|
114
|
-
- **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`
|
|
114
|
+
- **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`, `notes_account`, `note_revisions`
|
|
115
|
+
- **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
|
|
116
|
+
- **Files** - `project_files` (list/upload/update/delete/download), `task_files`
|
|
115
117
|
- **Insights** - hours by person/service/project, AI summary, person stats, time totals
|
|
116
118
|
- **Account** - `account_info`, `account_services`, `account_users`
|
|
117
119
|
- **Auth** - `auth_login`, `auth_status`, `auth_logout`
|
|
@@ -142,6 +144,12 @@ work, so each list tool accepts:
|
|
|
142
144
|
- `fields` - columns to return; omit for a curated default, or pass `["all"]`
|
|
143
145
|
- `limit` / `offset` - page window, default 50 rows
|
|
144
146
|
|
|
147
|
+
Task rows also carry computed `due_date`, `due_in_days` (negative when
|
|
148
|
+
overdue), and `overdue`, derived from the task's `end` date. Read those rather
|
|
149
|
+
than the API's `deadline` string, which does not track the due date -- it
|
|
150
|
+
reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
|
|
151
|
+
default field set; ask for it explicitly if you need to see what the UI shows.
|
|
152
|
+
|
|
145
153
|
Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
|
|
146
154
|
so a caller can tell when more rows exist. Task lists also accept
|
|
147
155
|
`company_id`, `project_id`, `assignee_id`, `status`, `priority`,
|
package/dist/api-client.d.ts
CHANGED
|
@@ -21,6 +21,16 @@ export declare class ViviScapeClient {
|
|
|
21
21
|
get session(): Credentials;
|
|
22
22
|
private get ctx();
|
|
23
23
|
private request;
|
|
24
|
+
/**
|
|
25
|
+
* Shared response handling for the binary upload paths, which cannot go
|
|
26
|
+
* through request() because that always sends JSON.
|
|
27
|
+
*/
|
|
28
|
+
private send;
|
|
29
|
+
private authHeaders;
|
|
30
|
+
/** multipart/form-data upload; fetch sets the boundary, so no Content-Type here. */
|
|
31
|
+
private postMultipart;
|
|
32
|
+
/** Raw-body upload with metadata in headers (the groups/file/add contract). */
|
|
33
|
+
private postBytes;
|
|
24
34
|
private get;
|
|
25
35
|
private post;
|
|
26
36
|
addProspect(data: Record<string, unknown>): Promise<unknown>;
|
|
@@ -162,6 +172,53 @@ export declare class ViviScapeClient {
|
|
|
162
172
|
updateNote(data: Record<string, unknown>): Promise<unknown>;
|
|
163
173
|
removeNote(noteId: number | string): Promise<unknown>;
|
|
164
174
|
queryNotes(data: Record<string, unknown>): Promise<unknown>;
|
|
175
|
+
getAccountNotes(companyId?: number, userId?: number): Promise<unknown>;
|
|
176
|
+
getNoteRevisions(noteId: string): Promise<unknown>;
|
|
177
|
+
getNoteCompanies(noteId: string): Promise<unknown>;
|
|
178
|
+
addNoteCompany(noteId: string, companyId: number): Promise<unknown>;
|
|
179
|
+
removeNoteCompany(assignmentId: string): Promise<unknown>;
|
|
180
|
+
getNoteUsers(noteId: string): Promise<unknown>;
|
|
181
|
+
addNoteUser(noteId: string, userId: number, permission?: string): Promise<unknown>;
|
|
182
|
+
updateNoteUser(assignmentId: string, noteId: string, userId: number, permission: string): Promise<unknown>;
|
|
183
|
+
removeNoteUser(assignmentId: string): Promise<unknown>;
|
|
184
|
+
getNoteTags(noteId: string): Promise<unknown>;
|
|
185
|
+
addNoteTag(noteId: string, tag: string): Promise<unknown>;
|
|
186
|
+
removeNoteTag(tagId: string): Promise<unknown>;
|
|
187
|
+
getNoteAttachments(noteId: string): Promise<unknown>;
|
|
188
|
+
/** Uploads a local file as a note attachment. knowledge=true marks it for extraction. */
|
|
189
|
+
uploadNoteAttachment(noteId: string, filePath: string, knowledge?: boolean): Promise<unknown>;
|
|
190
|
+
removeNoteAttachment(attachmentId: string): Promise<unknown>;
|
|
191
|
+
/** Flag an attachment as knowledge-base material (drives text extraction). */
|
|
192
|
+
setNoteAttachmentKnowledge(attachment: Record<string, unknown>): Promise<unknown>;
|
|
193
|
+
getNotebooks(userId?: number): Promise<unknown>;
|
|
194
|
+
getNotebookNotes(bookId: string): Promise<unknown>;
|
|
195
|
+
addNotebook(name: string, description?: string): Promise<unknown>;
|
|
196
|
+
updateNotebook(bookId: string, changes: Record<string, unknown>): Promise<unknown>;
|
|
197
|
+
removeNotebook(bookId: string): Promise<unknown>;
|
|
198
|
+
/** Add or remove notes from a notebook in one call. */
|
|
199
|
+
assignNotesToBook(bookId: string, noteIds: string[], assign?: boolean): Promise<unknown>;
|
|
200
|
+
getNotebookUsers(bookId: string): Promise<unknown>;
|
|
201
|
+
addNotebookUser(bookId: string, userId: number, permission?: string): Promise<unknown>;
|
|
202
|
+
updateNotebookUser(assignmentId: string, bookId: string, userId: number, permission: string): Promise<unknown>;
|
|
203
|
+
removeNotebookUser(assignmentId: string): Promise<unknown>;
|
|
204
|
+
getProjectFiles(projectId: number, userId?: number): Promise<unknown>;
|
|
205
|
+
getTaskFiles(taskId: number): Promise<unknown>;
|
|
206
|
+
/**
|
|
207
|
+
* Upload a file to a project. This route takes the raw bytes as the body and
|
|
208
|
+
* every piece of metadata as a header, and the handler reads each header
|
|
209
|
+
* without a null check -- so all of them must be sent, even when empty.
|
|
210
|
+
*/
|
|
211
|
+
uploadProjectFile(opts: {
|
|
212
|
+
project_id: number;
|
|
213
|
+
file_path: string;
|
|
214
|
+
title?: string;
|
|
215
|
+
task_id?: number;
|
|
216
|
+
notify?: boolean;
|
|
217
|
+
}): Promise<unknown>;
|
|
218
|
+
updateProjectFile(file: Record<string, unknown>): Promise<unknown>;
|
|
219
|
+
deleteProjectFile(fileId: number): Promise<unknown>;
|
|
220
|
+
/** Returns whatever the download route serves; usually a redirect target or URL. */
|
|
221
|
+
downloadProjectFile(fileId: number): Promise<unknown>;
|
|
165
222
|
getAccountInfo(): Promise<unknown>;
|
|
166
223
|
getActiveServices(): Promise<unknown>;
|
|
167
224
|
getAllServices(): Promise<unknown>;
|
package/dist/api-client.js
CHANGED
|
@@ -255,6 +255,51 @@ export class ViviScapeClient {
|
|
|
255
255
|
return text;
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Shared response handling for the binary upload paths, which cannot go
|
|
260
|
+
* through request() because that always sends JSON.
|
|
261
|
+
*/
|
|
262
|
+
async send(method, path, init) {
|
|
263
|
+
requireFeature(featureForPath(path), this.creds);
|
|
264
|
+
const url = `${this.baseUrl}${API_PREFIX}/${path.replace(/^\//, '')}`;
|
|
265
|
+
const res = await fetch(url, { method, redirect: 'manual', ...init });
|
|
266
|
+
const text = await res.text();
|
|
267
|
+
if (res.status === 401 || res.status === 403)
|
|
268
|
+
throw new SessionExpiredError();
|
|
269
|
+
if (res.status >= 300 && res.status < 400)
|
|
270
|
+
throw new SessionExpiredError();
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const snippet = text.length > 400 ? `${text.slice(0, 400)}...` : text;
|
|
273
|
+
throw new ApiError(res.status, `API ${method} ${path} returned ${res.status}: ${snippet}`);
|
|
274
|
+
}
|
|
275
|
+
if (!text.trim())
|
|
276
|
+
return null;
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(text);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return text;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
authHeaders(extra = {}) {
|
|
285
|
+
return {
|
|
286
|
+
'Authorization': `Bearer ${this.creds.access_token}`,
|
|
287
|
+
'user_id': String(this.creds.user_id),
|
|
288
|
+
'pid': String(this.creds.pid),
|
|
289
|
+
...extra,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** multipart/form-data upload; fetch sets the boundary, so no Content-Type here. */
|
|
293
|
+
async postMultipart(path, form) {
|
|
294
|
+
return this.send('POST', path, { headers: this.authHeaders(), body: form });
|
|
295
|
+
}
|
|
296
|
+
/** Raw-body upload with metadata in headers (the groups/file/add contract). */
|
|
297
|
+
async postBytes(path, bytes, extra) {
|
|
298
|
+
return this.send('POST', path, {
|
|
299
|
+
headers: this.authHeaders({ 'Content-Type': 'application/octet-stream', ...extra }),
|
|
300
|
+
body: bytes,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
258
303
|
get(path) {
|
|
259
304
|
return this.request('GET', path);
|
|
260
305
|
}
|
|
@@ -683,6 +728,173 @@ export class ViviScapeClient {
|
|
|
683
728
|
module: 'notes',
|
|
684
729
|
});
|
|
685
730
|
}
|
|
731
|
+
// -- Note relations, tags, revisions -------------------------
|
|
732
|
+
async getAccountNotes(companyId = 0, userId = 0) {
|
|
733
|
+
return this.post('notes/account', {
|
|
734
|
+
account_id: this.accountId,
|
|
735
|
+
company_id: companyId,
|
|
736
|
+
user_id: userId,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
async getNoteRevisions(noteId) {
|
|
740
|
+
return this.get(`note/revisions/${noteId}`);
|
|
741
|
+
}
|
|
742
|
+
async getNoteCompanies(noteId) {
|
|
743
|
+
return this.get(`note/companies/${noteId}`);
|
|
744
|
+
}
|
|
745
|
+
async addNoteCompany(noteId, companyId) {
|
|
746
|
+
return this.post('note/company/add', {
|
|
747
|
+
assignment_id: EMPTY_GUID,
|
|
748
|
+
note_id: noteId,
|
|
749
|
+
assign_user_id: this.userId,
|
|
750
|
+
company_id: companyId,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async removeNoteCompany(assignmentId) {
|
|
754
|
+
return this.get(`note/company/remove/${assignmentId}`);
|
|
755
|
+
}
|
|
756
|
+
async getNoteUsers(noteId) {
|
|
757
|
+
return this.get(`note/users/${noteId}`);
|
|
758
|
+
}
|
|
759
|
+
async addNoteUser(noteId, userId, permission = 'read') {
|
|
760
|
+
return this.post('note/user/add', {
|
|
761
|
+
assignment_id: EMPTY_GUID,
|
|
762
|
+
note_id: noteId,
|
|
763
|
+
user_id: userId,
|
|
764
|
+
permission,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
async updateNoteUser(assignmentId, noteId, userId, permission) {
|
|
768
|
+
return this.post('note/user/update', {
|
|
769
|
+
assignment_id: assignmentId,
|
|
770
|
+
note_id: noteId,
|
|
771
|
+
user_id: userId,
|
|
772
|
+
permission,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async removeNoteUser(assignmentId) {
|
|
776
|
+
return this.get(`note/user/remove/${assignmentId}`);
|
|
777
|
+
}
|
|
778
|
+
async getNoteTags(noteId) {
|
|
779
|
+
return this.get(`note/tags/${noteId}`);
|
|
780
|
+
}
|
|
781
|
+
async addNoteTag(noteId, tag) {
|
|
782
|
+
return this.post('note/tags/add', { tag_id: EMPTY_GUID, note_id: noteId, tag });
|
|
783
|
+
}
|
|
784
|
+
async removeNoteTag(tagId) {
|
|
785
|
+
return this.get(`note/tag/remove/${tagId}`);
|
|
786
|
+
}
|
|
787
|
+
// -- Note attachments ----------------------------------------
|
|
788
|
+
async getNoteAttachments(noteId) {
|
|
789
|
+
return this.get(`note/attachments/${noteId}`);
|
|
790
|
+
}
|
|
791
|
+
/** Uploads a local file as a note attachment. knowledge=true marks it for extraction. */
|
|
792
|
+
async uploadNoteAttachment(noteId, filePath, knowledge = false) {
|
|
793
|
+
const { readFileSync } = await import('node:fs');
|
|
794
|
+
const { basename } = await import('node:path');
|
|
795
|
+
const form = new FormData();
|
|
796
|
+
form.append('knowledge', String(knowledge));
|
|
797
|
+
form.append('file', new Blob([readFileSync(filePath)]), basename(filePath));
|
|
798
|
+
return this.postMultipart(`note/attachment/upload/${noteId}`, form);
|
|
799
|
+
}
|
|
800
|
+
async removeNoteAttachment(attachmentId) {
|
|
801
|
+
return this.get(`note/attachment/remove/${attachmentId}`);
|
|
802
|
+
}
|
|
803
|
+
/** Flag an attachment as knowledge-base material (drives text extraction). */
|
|
804
|
+
async setNoteAttachmentKnowledge(attachment) {
|
|
805
|
+
return this.post('note/attachment/knowledge', attachment);
|
|
806
|
+
}
|
|
807
|
+
// -- Notebooks -----------------------------------------------
|
|
808
|
+
async getNotebooks(userId = 0) {
|
|
809
|
+
return this.get(`note/books/user/${userId || this.userId}`);
|
|
810
|
+
}
|
|
811
|
+
async getNotebookNotes(bookId) {
|
|
812
|
+
return this.get(`note/book/notes/${bookId}`);
|
|
813
|
+
}
|
|
814
|
+
async addNotebook(name, description = '') {
|
|
815
|
+
return this.post('note/book/add', {
|
|
816
|
+
book_id: EMPTY_GUID,
|
|
817
|
+
name,
|
|
818
|
+
description,
|
|
819
|
+
creator_id: this.userId,
|
|
820
|
+
account_id: this.accountId,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
async updateNotebook(bookId, changes) {
|
|
824
|
+
return this.post('note/book/update', {
|
|
825
|
+
book_id: bookId,
|
|
826
|
+
creator_id: this.userId,
|
|
827
|
+
account_id: this.accountId,
|
|
828
|
+
...changes,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async removeNotebook(bookId) {
|
|
832
|
+
return this.get(`note/book/remove/${bookId}`);
|
|
833
|
+
}
|
|
834
|
+
/** Add or remove notes from a notebook in one call. */
|
|
835
|
+
async assignNotesToBook(bookId, noteIds, assign = true) {
|
|
836
|
+
return this.post('note/book/assign', { book_id: bookId, note_ids: noteIds, assign });
|
|
837
|
+
}
|
|
838
|
+
async getNotebookUsers(bookId) {
|
|
839
|
+
return this.get(`note/book/users/${bookId}`);
|
|
840
|
+
}
|
|
841
|
+
async addNotebookUser(bookId, userId, permission = 'read') {
|
|
842
|
+
return this.post('note/book/user/add', {
|
|
843
|
+
assignment_id: EMPTY_GUID,
|
|
844
|
+
book_id: bookId,
|
|
845
|
+
user_id: userId,
|
|
846
|
+
permission,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
async updateNotebookUser(assignmentId, bookId, userId, permission) {
|
|
850
|
+
return this.post('note/book/user/update', {
|
|
851
|
+
assignment_id: assignmentId,
|
|
852
|
+
book_id: bookId,
|
|
853
|
+
user_id: userId,
|
|
854
|
+
permission,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async removeNotebookUser(assignmentId) {
|
|
858
|
+
return this.get(`note/book/user/remove/${assignmentId}`);
|
|
859
|
+
}
|
|
860
|
+
// -- Project and task files ----------------------------------
|
|
861
|
+
async getProjectFiles(projectId, userId = 0) {
|
|
862
|
+
return this.get(`groups/files?groupid=${projectId}&userid=${userId || this.userId}`);
|
|
863
|
+
}
|
|
864
|
+
async getTaskFiles(taskId) {
|
|
865
|
+
return this.get(`groups/task/files?taskid=${taskId}`);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Upload a file to a project. This route takes the raw bytes as the body and
|
|
869
|
+
* every piece of metadata as a header, and the handler reads each header
|
|
870
|
+
* without a null check -- so all of them must be sent, even when empty.
|
|
871
|
+
*/
|
|
872
|
+
async uploadProjectFile(opts) {
|
|
873
|
+
const { readFileSync } = await import('node:fs');
|
|
874
|
+
const { basename } = await import('node:path');
|
|
875
|
+
const name = basename(opts.file_path);
|
|
876
|
+
return this.postBytes('groups/file/add', readFileSync(opts.file_path), {
|
|
877
|
+
userid: String(this.userId),
|
|
878
|
+
groupid: String(opts.project_id),
|
|
879
|
+
filename: name,
|
|
880
|
+
title: opts.title || name,
|
|
881
|
+
youtube: '',
|
|
882
|
+
notify: String(opts.notify ?? false),
|
|
883
|
+
taskid: String(opts.task_id ?? 0),
|
|
884
|
+
lat: '0',
|
|
885
|
+
lng: '0',
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
async updateProjectFile(file) {
|
|
889
|
+
return this.post('groups/file/update', file);
|
|
890
|
+
}
|
|
891
|
+
async deleteProjectFile(fileId) {
|
|
892
|
+
return this.get(`groups/file/delete?fileid=${fileId}`);
|
|
893
|
+
}
|
|
894
|
+
/** Returns whatever the download route serves; usually a redirect target or URL. */
|
|
895
|
+
async downloadProjectFile(fileId) {
|
|
896
|
+
return this.get(`groups/file/download/${fileId}`);
|
|
897
|
+
}
|
|
686
898
|
// -- Account ------------------------------------------------
|
|
687
899
|
async getAccountInfo() {
|
|
688
900
|
return this.get(`account/id/${this.accountId}`);
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { ViviScapeClient } from './api-client.js';
|
|
|
7
7
|
import { AuthService } from './auth/auth-service.js';
|
|
8
8
|
import { describe } from './auth/credentials.js';
|
|
9
9
|
import { PRIORITIES, PROJECT_STATUSES, PROSPECT_STATUSES, TASK_STATUSES, enumReference, } from './enums.js';
|
|
10
|
-
import { PROJECT_FIELDS, TASK_FIELDS, filterTasks, shape, } from './projection.js';
|
|
10
|
+
import { FILE_FIELDS, NOTE_FIELDS, PROJECT_FIELDS, TASK_FIELDS, annotateTasks, filterTasks, shape, } from './projection.js';
|
|
11
11
|
import { baseUrl } from './config.js';
|
|
12
12
|
// quiet: dotenv's banner goes to stdout, which is the MCP protocol channel.
|
|
13
13
|
config({ quiet: true });
|
|
@@ -336,11 +336,11 @@ server.tool('project_tasks', 'Get all tasks for a project. Returns a page of tri
|
|
|
336
336
|
// project_id is the query, not a filter -- re-filtering on it would drop
|
|
337
337
|
// every row if the response names the column differently.
|
|
338
338
|
const rows = Array.isArray(result)
|
|
339
|
-
? filterTasks(result, { ...params, project_id: undefined })
|
|
339
|
+
? annotateTasks(filterTasks(result, { ...params, project_id: undefined }))
|
|
340
340
|
: result;
|
|
341
341
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
342
342
|
});
|
|
343
|
-
server.tool('tasks_open', 'Get open tasks across projects. company_id and only_mine filter server-side; the rest filter in-process. Returns a page of trimmed rows.', {
|
|
343
|
+
server.tool('tasks_open', 'Get open tasks across projects. company_id and only_mine filter server-side; the rest filter in-process. Returns a page of trimmed rows with computed due_date / due_in_days / overdue -- read those, not the API deadline string, which is unreliable.', {
|
|
344
344
|
only_mine: z.boolean().optional().describe('Only tasks assigned to the signed-in user (server-side)'),
|
|
345
345
|
team: z.string().optional().describe('Team filter passed through to the API'),
|
|
346
346
|
...taskFilterArgs,
|
|
@@ -351,7 +351,7 @@ server.tool('tasks_open', 'Get open tasks across projects. company_id and only_m
|
|
|
351
351
|
only_mine: params.only_mine,
|
|
352
352
|
team: params.team,
|
|
353
353
|
});
|
|
354
|
-
const rows = Array.isArray(result) ? filterTasks(result, params) : result;
|
|
354
|
+
const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
|
|
355
355
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
356
356
|
});
|
|
357
357
|
server.tool('task_add', 'Add a task to a project. The API requires a full Group_Task payload; this tool builds it from simplified params.', {
|
|
@@ -394,7 +394,7 @@ server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of tr
|
|
|
394
394
|
...taskFilterArgs,
|
|
395
395
|
}, async (params) => {
|
|
396
396
|
const result = await requireClient().getPendingTasks(params.user_id);
|
|
397
|
-
const rows = Array.isArray(result) ? filterTasks(result, params) : result;
|
|
397
|
+
const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
|
|
398
398
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
399
399
|
});
|
|
400
400
|
server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of trimmed rows; see fields/limit/offset.', {
|
|
@@ -403,7 +403,7 @@ server.tool('tasks_by_milestone', 'Get tasks for a milestone. Returns a page of
|
|
|
403
403
|
...taskFilterArgs,
|
|
404
404
|
}, async (params) => {
|
|
405
405
|
const result = await requireClient().getTasksByMilestone(params.milestone_id);
|
|
406
|
-
const rows = Array.isArray(result) ? filterTasks(result, params) : result;
|
|
406
|
+
const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
|
|
407
407
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
408
408
|
});
|
|
409
409
|
server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns a page of trimmed rows; see fields/limit/offset.', {
|
|
@@ -413,7 +413,7 @@ server.tool('tasks_by_group_user', 'Get tasks by project group and user. Returns
|
|
|
413
413
|
...taskFilterArgs,
|
|
414
414
|
}, async (params) => {
|
|
415
415
|
const result = await requireClient().getTasksByGroupAndUser(params.group_id, params.user_id);
|
|
416
|
-
const rows = Array.isArray(result) ? filterTasks(result, params) : result;
|
|
416
|
+
const rows = Array.isArray(result) ? annotateTasks(filterTasks(result, params)) : result;
|
|
417
417
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
418
418
|
});
|
|
419
419
|
server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trimmed rows; see fields/limit/offset.', {
|
|
@@ -423,7 +423,7 @@ server.tool('tasks_by_company', 'Get tasks for a company. Returns a page of trim
|
|
|
423
423
|
}, async (params) => {
|
|
424
424
|
const result = await requireClient().getCompanyTasks(params.company_id);
|
|
425
425
|
const rows = Array.isArray(result)
|
|
426
|
-
? filterTasks(result, { ...params, company_id: undefined })
|
|
426
|
+
? annotateTasks(filterTasks(result, { ...params, company_id: undefined }))
|
|
427
427
|
: result;
|
|
428
428
|
return { content: [{ type: 'text', text: json(shape(rows, TASK_FIELDS, paging(params))) }] };
|
|
429
429
|
});
|
|
@@ -443,6 +443,162 @@ server.tool('projects_active_by_user', 'Get active projects for a specific user.
|
|
|
443
443
|
return { content: [{ type: 'text', text: json(shape(result, PROJECT_FIELDS, paging(params))) }] };
|
|
444
444
|
});
|
|
445
445
|
// ============================================================
|
|
446
|
+
// DOCUMENTATION: NOTE RELATIONS, TAGS, REVISIONS, ATTACHMENTS
|
|
447
|
+
// ============================================================
|
|
448
|
+
//
|
|
449
|
+
// These tools take an `action` rather than splitting every verb into its own
|
|
450
|
+
// tool: the server already registers ~90 tools, and past roughly 100 the tool
|
|
451
|
+
// list itself starts costing more than it returns. Each action names exactly
|
|
452
|
+
// which arguments it needs, and a missing one is reported by name.
|
|
453
|
+
/** Fail with a message that names the action and the argument it wanted. */
|
|
454
|
+
function need(action, name, value) {
|
|
455
|
+
if (value === undefined || value === null || value === '') {
|
|
456
|
+
throw new Error(`action "${action}" requires ${name}`);
|
|
457
|
+
}
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
server.tool('notes_account', 'List notes across the account, newest-first as the API returns them. Bodies are omitted by default (hundreds of rows); pass fields:["note_id","title","note"] to read content.', {
|
|
461
|
+
company_id: z.number().optional().describe('Restrict to notes linked to this company'),
|
|
462
|
+
user_id: z.number().optional().describe('Restrict to notes for this user'),
|
|
463
|
+
...pageArgs,
|
|
464
|
+
}, async (params) => {
|
|
465
|
+
const result = await requireClient().getAccountNotes(params.company_id ?? 0, params.user_id ?? 0);
|
|
466
|
+
return { content: [{ type: 'text', text: json(shape(result, NOTE_FIELDS, paging(params))) }] };
|
|
467
|
+
});
|
|
468
|
+
server.tool('note_revisions', 'Get the edit history of a note. Use before rewriting a shared note so human edits are not silently clobbered.', { note_id: z.string().describe('Note ID (GUID)') }, async ({ note_id }) => {
|
|
469
|
+
const result = await requireClient().getNoteRevisions(note_id);
|
|
470
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
471
|
+
});
|
|
472
|
+
server.tool('note_companies', 'Link a note to a company, list its links, or remove one. This is how a generated document gets filed against a client. NOTE: add returns an echo with zeroed ids, so call list afterward to get the assignment_id you need for remove.', {
|
|
473
|
+
action: z.enum(['list', 'add', 'remove']).describe('What to do'),
|
|
474
|
+
note_id: z.string().optional().describe('Note ID (list, add)'),
|
|
475
|
+
company_id: z.number().optional().describe('Company ID (add)'),
|
|
476
|
+
assignment_id: z.string().optional().describe('Link ID from list (remove)'),
|
|
477
|
+
}, async ({ action, note_id, company_id, assignment_id }) => {
|
|
478
|
+
const c = requireClient();
|
|
479
|
+
const result = action === 'list' ? await c.getNoteCompanies(need(action, 'note_id', note_id))
|
|
480
|
+
: action === 'add' ? await c.addNoteCompany(need(action, 'note_id', note_id), need(action, 'company_id', company_id))
|
|
481
|
+
: await c.removeNoteCompany(need(action, 'assignment_id', assignment_id));
|
|
482
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
483
|
+
});
|
|
484
|
+
server.tool('note_users', 'Share a note with a user, list who it is shared with, change a permission, or unshare. NOTE: add returns an echo with zeroed ids; call list to get the assignment_id.', {
|
|
485
|
+
action: z.enum(['list', 'add', 'update', 'remove']).describe('What to do'),
|
|
486
|
+
note_id: z.string().optional().describe('Note ID (list, add, update)'),
|
|
487
|
+
user_id: z.number().optional().describe('User ID (add, update)'),
|
|
488
|
+
permission: z.string().optional().describe('Permission, e.g. read or write (add, update)'),
|
|
489
|
+
assignment_id: z.string().optional().describe('Share ID from list (update, remove)'),
|
|
490
|
+
}, async ({ action, note_id, user_id, permission, assignment_id }) => {
|
|
491
|
+
const c = requireClient();
|
|
492
|
+
const result = action === 'list' ? await c.getNoteUsers(need(action, 'note_id', note_id))
|
|
493
|
+
: action === 'add' ? await c.addNoteUser(need(action, 'note_id', note_id), need(action, 'user_id', user_id), permission || 'read')
|
|
494
|
+
: action === 'update' ? await c.updateNoteUser(need(action, 'assignment_id', assignment_id), need(action, 'note_id', note_id), need(action, 'user_id', user_id), need(action, 'permission', permission))
|
|
495
|
+
: await c.removeNoteUser(need(action, 'assignment_id', assignment_id));
|
|
496
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
497
|
+
});
|
|
498
|
+
server.tool('note_tags', 'List, add, or remove tags on a note -- the only way to categorise notes for later retrieval. NOTE: remove answers false even when it succeeds; re-list to confirm.', {
|
|
499
|
+
action: z.enum(['list', 'add', 'remove']).describe('What to do'),
|
|
500
|
+
note_id: z.string().optional().describe('Note ID (list, add)'),
|
|
501
|
+
tag: z.string().optional().describe('Tag text (add)'),
|
|
502
|
+
tag_id: z.string().optional().describe('Tag ID from list (remove)'),
|
|
503
|
+
}, async ({ action, note_id, tag, tag_id }) => {
|
|
504
|
+
const c = requireClient();
|
|
505
|
+
const result = action === 'list' ? await c.getNoteTags(need(action, 'note_id', note_id))
|
|
506
|
+
: action === 'add' ? await c.addNoteTag(need(action, 'note_id', note_id), need(action, 'tag', tag))
|
|
507
|
+
: await c.removeNoteTag(need(action, 'tag_id', tag_id));
|
|
508
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
509
|
+
});
|
|
510
|
+
server.tool('note_attachments', 'List, upload, or remove note attachments, or flag one as knowledge-base material. upload takes a path to a local file.', {
|
|
511
|
+
action: z.enum(['list', 'upload', 'remove', 'set_knowledge']).describe('What to do'),
|
|
512
|
+
note_id: z.string().optional().describe('Note ID (list, upload)'),
|
|
513
|
+
file_path: z.string().optional().describe('Local file to upload (upload)'),
|
|
514
|
+
knowledge: z.boolean().optional().describe('Mark for text extraction (upload, set_knowledge)'),
|
|
515
|
+
attachment_id: z.string().optional().describe('Attachment ID from list (remove, set_knowledge)'),
|
|
516
|
+
}, async ({ action, note_id, file_path, knowledge, attachment_id }) => {
|
|
517
|
+
const c = requireClient();
|
|
518
|
+
const result = action === 'list' ? await c.getNoteAttachments(need(action, 'note_id', note_id))
|
|
519
|
+
: action === 'upload' ? await c.uploadNoteAttachment(need(action, 'note_id', note_id), need(action, 'file_path', file_path), knowledge ?? false)
|
|
520
|
+
: action === 'remove' ? await c.removeNoteAttachment(need(action, 'attachment_id', attachment_id))
|
|
521
|
+
: await c.setNoteAttachmentKnowledge({
|
|
522
|
+
attachment_id: need(action, 'attachment_id', attachment_id),
|
|
523
|
+
note_id: note_id ?? undefined,
|
|
524
|
+
knowledge: knowledge ?? true,
|
|
525
|
+
});
|
|
526
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
527
|
+
});
|
|
528
|
+
server.tool('notebook', 'Notebooks group notes into a per-client or per-project documentation space. Actions: list, add, update, remove, notes (list a book contents), assign / unassign notes. WARNING: remove is broken server-side (400 even for an empty book) -- delete notebooks in the ViviScape Work UI.', {
|
|
529
|
+
action: z.enum(['list', 'add', 'update', 'remove', 'notes', 'assign', 'unassign']).describe('What to do'),
|
|
530
|
+
book_id: z.string().optional().describe('Notebook ID (update, remove, notes, assign, unassign)'),
|
|
531
|
+
name: z.string().optional().describe('Notebook name (add, update)'),
|
|
532
|
+
description: z.string().optional().describe('Notebook description (add, update)'),
|
|
533
|
+
note_ids: z.array(z.string()).optional().describe('Note IDs (assign, unassign)'),
|
|
534
|
+
user_id: z.number().optional().describe('Owner to list for (list; defaults to signed-in user)'),
|
|
535
|
+
}, async ({ action, book_id, name, description, note_ids, user_id }) => {
|
|
536
|
+
const c = requireClient();
|
|
537
|
+
const result = action === 'list' ? await c.getNotebooks(user_id ?? 0)
|
|
538
|
+
: action === 'add' ? await c.addNotebook(need(action, 'name', name), description || '')
|
|
539
|
+
: action === 'update' ? await c.updateNotebook(need(action, 'book_id', book_id), {
|
|
540
|
+
...(name ? { name } : {}),
|
|
541
|
+
...(description !== undefined ? { description } : {}),
|
|
542
|
+
})
|
|
543
|
+
: action === 'remove' ? await c.removeNotebook(need(action, 'book_id', book_id))
|
|
544
|
+
: action === 'notes' ? await c.getNotebookNotes(need(action, 'book_id', book_id))
|
|
545
|
+
: await c.assignNotesToBook(need(action, 'book_id', book_id), need(action, 'note_ids', note_ids), action === 'assign');
|
|
546
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
547
|
+
});
|
|
548
|
+
server.tool('notebook_users', 'List, add, update, or remove the users a notebook is shared with', {
|
|
549
|
+
action: z.enum(['list', 'add', 'update', 'remove']).describe('What to do'),
|
|
550
|
+
book_id: z.string().optional().describe('Notebook ID (list, add, update)'),
|
|
551
|
+
user_id: z.number().optional().describe('User ID (add, update)'),
|
|
552
|
+
permission: z.string().optional().describe('Permission, e.g. read, write, owner (add, update)'),
|
|
553
|
+
assignment_id: z.string().optional().describe('Share ID from list (update, remove)'),
|
|
554
|
+
}, async ({ action, book_id, user_id, permission, assignment_id }) => {
|
|
555
|
+
const c = requireClient();
|
|
556
|
+
const result = action === 'list' ? await c.getNotebookUsers(need(action, 'book_id', book_id))
|
|
557
|
+
: action === 'add' ? await c.addNotebookUser(need(action, 'book_id', book_id), need(action, 'user_id', user_id), permission || 'read')
|
|
558
|
+
: action === 'update' ? await c.updateNotebookUser(need(action, 'assignment_id', assignment_id), need(action, 'book_id', book_id), need(action, 'user_id', user_id), need(action, 'permission', permission))
|
|
559
|
+
: await c.removeNotebookUser(need(action, 'assignment_id', assignment_id));
|
|
560
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
561
|
+
});
|
|
562
|
+
// ============================================================
|
|
563
|
+
// PROJECT AND TASK FILES
|
|
564
|
+
// ============================================================
|
|
565
|
+
server.tool('project_files', 'List, upload, update, delete, or resolve the download URL of files attached to a project. upload takes a path to a local file; delete is by file_id from list.', {
|
|
566
|
+
action: z.enum(['list', 'upload', 'update', 'delete', 'download']).describe('What to do'),
|
|
567
|
+
project_id: z.number().optional().describe('Project ID (list, upload)'),
|
|
568
|
+
file_path: z.string().optional().describe('Local file to upload (upload)'),
|
|
569
|
+
title: z.string().optional().describe('Title for the uploaded file (upload; defaults to the filename)'),
|
|
570
|
+
task_id: z.number().optional().describe('Attach the upload to a task as well (upload)'),
|
|
571
|
+
notify: z.boolean().optional().describe('Notify project members (upload; default false)'),
|
|
572
|
+
file_id: z.number().optional().describe('File ID from list (delete, download)'),
|
|
573
|
+
file: z.record(z.string(), z.unknown()).optional().describe('Full file record with changes applied (update)'),
|
|
574
|
+
...pageArgs,
|
|
575
|
+
}, async (params) => {
|
|
576
|
+
const c = requireClient();
|
|
577
|
+
const { action } = params;
|
|
578
|
+
if (action === 'list') {
|
|
579
|
+
const result = await c.getProjectFiles(need(action, 'project_id', params.project_id));
|
|
580
|
+
return { content: [{ type: 'text', text: json(shape(result, FILE_FIELDS, paging(params))) }] };
|
|
581
|
+
}
|
|
582
|
+
const result = action === 'upload' ? await c.uploadProjectFile({
|
|
583
|
+
project_id: need(action, 'project_id', params.project_id),
|
|
584
|
+
file_path: need(action, 'file_path', params.file_path),
|
|
585
|
+
title: params.title,
|
|
586
|
+
task_id: params.task_id,
|
|
587
|
+
notify: params.notify,
|
|
588
|
+
})
|
|
589
|
+
: action === 'update' ? await c.updateProjectFile(need(action, 'file', params.file))
|
|
590
|
+
: action === 'delete' ? await c.deleteProjectFile(need(action, 'file_id', params.file_id))
|
|
591
|
+
: await c.downloadProjectFile(need(action, 'file_id', params.file_id));
|
|
592
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
593
|
+
});
|
|
594
|
+
server.tool('task_files', 'List files attached to a task. Use this to read context humans already attached before generating anything new.', {
|
|
595
|
+
task_id: z.number().describe('Task ID'),
|
|
596
|
+
...pageArgs,
|
|
597
|
+
}, async (params) => {
|
|
598
|
+
const result = await requireClient().getTaskFiles(params.task_id);
|
|
599
|
+
return { content: [{ type: 'text', text: json(shape(result, FILE_FIELDS, paging(params))) }] };
|
|
600
|
+
});
|
|
601
|
+
// ============================================================
|
|
446
602
|
// REFERENCE
|
|
447
603
|
// ============================================================
|
|
448
604
|
server.tool('enums', 'List the status, priority, and group-type values the platform actually accepts. Read this before setting a status or priority; tasks complete as "completed" while projects complete as "complete", and priority is low/moderate/important/urgent/critical.', {}, async () => ({ content: [{ type: 'text', text: json(enumReference()) }] }));
|
package/dist/projection.d.ts
CHANGED
|
@@ -8,10 +8,27 @@
|
|
|
8
8
|
* read. These helpers trim to a PM-relevant default, page the result, and tell
|
|
9
9
|
* the caller what was left out so it can ask for more deliberately.
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Task fields worth returning by default, of roughly 90 available.
|
|
13
|
+
*
|
|
14
|
+
* `deadline` is deliberately excluded. It is a server-rendered string that does
|
|
15
|
+
* not track the task's own due date: measured against six live tasks it read
|
|
16
|
+
* "In 2 Days" for one 39 days overdue, and gave "In 4 Days" for tasks at both
|
|
17
|
+
* -9 and +2 days. `end` is the real due date, and `pastdue` agrees with it on
|
|
18
|
+
* every row, so annotateTasks() derives due_date / due_in_days / overdue from
|
|
19
|
+
* `end` and those are what callers should read.
|
|
20
|
+
*/
|
|
12
21
|
export declare const TASK_FIELDS: string[];
|
|
13
22
|
/** Project (group) fields worth returning by default, of 87 available. */
|
|
14
23
|
export declare const PROJECT_FIELDS: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Note fields worth returning by default. Excludes `note` (the full body) and
|
|
26
|
+
* `firefly_transcript_id`: the account list runs to hundreds of rows and the
|
|
27
|
+
* bodies dominate the payload. Ask for fields:["note"] to read one.
|
|
28
|
+
*/
|
|
29
|
+
export declare const NOTE_FIELDS: string[];
|
|
30
|
+
/** Project/task file fields worth returning by default. */
|
|
31
|
+
export declare const FILE_FIELDS: string[];
|
|
15
32
|
export declare const DEFAULT_LIMIT = 50;
|
|
16
33
|
export interface ShapeOpts {
|
|
17
34
|
fields?: string[];
|
|
@@ -30,6 +47,17 @@ export interface TaskFilters {
|
|
|
30
47
|
}
|
|
31
48
|
/** Apply the filters the backend does not support, in-process. */
|
|
32
49
|
export declare function filterTasks(rows: unknown[], f: TaskFilters): unknown[];
|
|
50
|
+
/**
|
|
51
|
+
* Add an unambiguous due-date signal to task rows.
|
|
52
|
+
*
|
|
53
|
+
* The API ships three overlapping fields: `end` (the real due date), `pastdue`
|
|
54
|
+
* (a boolean that matches it), and `deadline` (a human string that does not).
|
|
55
|
+
* Rather than make every caller know which to trust, derive the answer from
|
|
56
|
+
* `end` and pass `deadline` through only when explicitly requested.
|
|
57
|
+
*
|
|
58
|
+
* due_in_days is negative for overdue work, 0 for due today.
|
|
59
|
+
*/
|
|
60
|
+
export declare function annotateTasks(rows: unknown[], now?: number): unknown[];
|
|
33
61
|
/**
|
|
34
62
|
* Trim, page, and annotate a list response. Non-array payloads (a single record,
|
|
35
63
|
* or an error object) pass through untouched.
|
package/dist/projection.js
CHANGED
|
@@ -8,12 +8,22 @@
|
|
|
8
8
|
* read. These helpers trim to a PM-relevant default, page the result, and tell
|
|
9
9
|
* the caller what was left out so it can ask for more deliberately.
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Task fields worth returning by default, of roughly 90 available.
|
|
13
|
+
*
|
|
14
|
+
* `deadline` is deliberately excluded. It is a server-rendered string that does
|
|
15
|
+
* not track the task's own due date: measured against six live tasks it read
|
|
16
|
+
* "In 2 Days" for one 39 days overdue, and gave "In 4 Days" for tasks at both
|
|
17
|
+
* -9 and +2 days. `end` is the real due date, and `pastdue` agrees with it on
|
|
18
|
+
* every row, so annotateTasks() derives due_date / due_in_days / overdue from
|
|
19
|
+
* `end` and those are what callers should read.
|
|
20
|
+
*/
|
|
12
21
|
export const TASK_FIELDS = [
|
|
13
22
|
'task_id', 'task', 'status', 'percentage', 'priority',
|
|
14
23
|
'company_id', 'company_name', 'group_id', 'group_name',
|
|
15
24
|
'milestone_id', 'milestone', 'estimated_time', 'total_duration_str',
|
|
16
|
-
'
|
|
25
|
+
'start', 'due_date', 'due_in_days', 'overdue', 'pastdue',
|
|
26
|
+
'staff_assignees', 'total_comments', 'unread_for_me',
|
|
17
27
|
];
|
|
18
28
|
/** Project (group) fields worth returning by default, of 87 available. */
|
|
19
29
|
export const PROJECT_FIELDS = [
|
|
@@ -22,6 +32,22 @@ export const PROJECT_FIELDS = [
|
|
|
22
32
|
'total_tasks', 'total_tasks_open', 'total_tasks_completed',
|
|
23
33
|
'target_date', 'complete_date', 'total_estimated_str', 'total_duration_str',
|
|
24
34
|
];
|
|
35
|
+
/**
|
|
36
|
+
* Note fields worth returning by default. Excludes `note` (the full body) and
|
|
37
|
+
* `firefly_transcript_id`: the account list runs to hundreds of rows and the
|
|
38
|
+
* bodies dominate the payload. Ask for fields:["note"] to read one.
|
|
39
|
+
*/
|
|
40
|
+
export const NOTE_FIELDS = [
|
|
41
|
+
'note_id', 'title', 'book_id', 'creator', 'creator_id',
|
|
42
|
+
'summary', 'tags', 'assigned_users', 'knowledge', 'is_public',
|
|
43
|
+
'last_update', 'create_date',
|
|
44
|
+
];
|
|
45
|
+
/** Project/task file fields worth returning by default. */
|
|
46
|
+
export const FILE_FIELDS = [
|
|
47
|
+
'file_id', 'group_id', 'task_id', 'title', 'file_name', 'file_type',
|
|
48
|
+
'file_size_friendly', 'author', 'user_id', 'post_date', 'url_path',
|
|
49
|
+
'is_public', 'is_private',
|
|
50
|
+
];
|
|
25
51
|
export const DEFAULT_LIMIT = 50;
|
|
26
52
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
27
53
|
/** Parse the staff_assignees blob, which arrives as JSON text or as an array. */
|
|
@@ -76,6 +102,32 @@ export function filterTasks(rows, f) {
|
|
|
76
102
|
return true;
|
|
77
103
|
});
|
|
78
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Add an unambiguous due-date signal to task rows.
|
|
107
|
+
*
|
|
108
|
+
* The API ships three overlapping fields: `end` (the real due date), `pastdue`
|
|
109
|
+
* (a boolean that matches it), and `deadline` (a human string that does not).
|
|
110
|
+
* Rather than make every caller know which to trust, derive the answer from
|
|
111
|
+
* `end` and pass `deadline` through only when explicitly requested.
|
|
112
|
+
*
|
|
113
|
+
* due_in_days is negative for overdue work, 0 for due today.
|
|
114
|
+
*/
|
|
115
|
+
export function annotateTasks(rows, now = Date.now()) {
|
|
116
|
+
return rows.map((row) => {
|
|
117
|
+
if (!isRecord(row))
|
|
118
|
+
return row;
|
|
119
|
+
const due = Date.parse(String(row.end ?? ''));
|
|
120
|
+
if (!Number.isFinite(due)) {
|
|
121
|
+
return { ...row, due_date: null, due_in_days: null, overdue: null };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...row,
|
|
125
|
+
due_date: new Date(due).toISOString(),
|
|
126
|
+
due_in_days: Math.round((due - now) / 86_400_000),
|
|
127
|
+
overdue: due < now,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
79
131
|
function pick(row, fields) {
|
|
80
132
|
if (!isRecord(row))
|
|
81
133
|
return row;
|
package/package.json
CHANGED