@atlastodo/sdk 0.0.0-stage → 0.1.11-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/client.d.ts +121 -0
- package/dist/dates.d.ts +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +372 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +86 -0
- package/package.json +56 -4
- package/test-vectors/parse_due_vectors.json +725 -0
- package/README.md +0 -3
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type CreateTaskInput, type FetchLike, type Label, type Project, type Task } from "@atlastodo/client-core";
|
|
2
|
+
/**
|
|
3
|
+
* A headless Atlas client for integrations, signed in with a personal API token.
|
|
4
|
+
*
|
|
5
|
+
* `connect` opens a session with the token, unwraps the account keys the token carries, and pulls
|
|
6
|
+
* the account into memory, decrypting it on this machine: the server only ever sees ciphertext.
|
|
7
|
+
* Reads are then local; every write is pushed before its promise resolves. Task rules (recurring
|
|
8
|
+
* completion, soft deletes, locked tasks) are the app's own, from `@atlastodo/shared`.
|
|
9
|
+
*/
|
|
10
|
+
export interface ConnectOptions {
|
|
11
|
+
/** The Atlas server, e.g. `https://app.atlastodo.dev` (an `/api` suffix is fine too). */
|
|
12
|
+
serverUrl: string;
|
|
13
|
+
/** An `atlas_pat_…` token from Settings → API tokens. */
|
|
14
|
+
token: string;
|
|
15
|
+
fetch?: FetchLike;
|
|
16
|
+
/** IANA zone for due dates and recurrence; the runtime's by default. */
|
|
17
|
+
timeZone?: string;
|
|
18
|
+
}
|
|
19
|
+
export type TaskStatus = "open" | "completed" | "all";
|
|
20
|
+
export interface TaskQuery {
|
|
21
|
+
/** A project id or name (case-insensitive). */
|
|
22
|
+
project?: string;
|
|
23
|
+
/** Default `open`. */
|
|
24
|
+
status?: TaskStatus;
|
|
25
|
+
/** Unix ms bounds on the due date; tasks without one are left out when either is set. */
|
|
26
|
+
dueBefore?: number;
|
|
27
|
+
dueAfter?: number;
|
|
28
|
+
/** A label id or name (case-insensitive). */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Case-insensitive substring of the title or notes. */
|
|
31
|
+
search?: string;
|
|
32
|
+
/** The app's filter syntax (`today & p1`, `#Work | @errand`, `overdue`). */
|
|
33
|
+
filter?: string;
|
|
34
|
+
}
|
|
35
|
+
/** A task as integrations see it: the app's task plus resolved project and label names. */
|
|
36
|
+
export interface TaskView extends Task {
|
|
37
|
+
project_name: string | null;
|
|
38
|
+
labels: string[];
|
|
39
|
+
}
|
|
40
|
+
export interface CreateTaskOptions extends Partial<Omit<CreateTaskInput, "title">> {
|
|
41
|
+
title: string;
|
|
42
|
+
/** A project name, resolved like `project_id` (ignored when `project_id` is given). */
|
|
43
|
+
project?: string;
|
|
44
|
+
/** Label names; unknown ones are an error. */
|
|
45
|
+
labels?: string[];
|
|
46
|
+
}
|
|
47
|
+
export type TaskPatch = Partial<Pick<Task, "title" | "notes" | "priority" | "due_at" | "start_at" | "recurrence" | "project_id" | "section_id" | "label_ids" | "estimate_min" | "assignee_id">>;
|
|
48
|
+
export interface Changes {
|
|
49
|
+
/** Pass back as `since` next time. */
|
|
50
|
+
cursor: number;
|
|
51
|
+
created: TaskView[];
|
|
52
|
+
updated: TaskView[];
|
|
53
|
+
completed: TaskView[];
|
|
54
|
+
/** Soft-deleted (in the trash) or purged; purged ones carry only their id. */
|
|
55
|
+
deleted: (TaskView | {
|
|
56
|
+
id: string;
|
|
57
|
+
})[];
|
|
58
|
+
}
|
|
59
|
+
export declare class AtlasError extends Error {
|
|
60
|
+
readonly code: string;
|
|
61
|
+
constructor(code: string, message: string);
|
|
62
|
+
}
|
|
63
|
+
export declare class AtlasClient {
|
|
64
|
+
private readonly api;
|
|
65
|
+
private readonly store;
|
|
66
|
+
private readonly sync;
|
|
67
|
+
readonly userId: string;
|
|
68
|
+
private readonly timeZone;
|
|
69
|
+
private constructor();
|
|
70
|
+
static connect(opts: ConnectOptions): Promise<AtlasClient>;
|
|
71
|
+
/** Pull everything, then load the shared projects' keys under the synced trust state. */
|
|
72
|
+
private initialSync;
|
|
73
|
+
private syncOrThrow;
|
|
74
|
+
/** Push pending writes; throws when any did not reach the server. */
|
|
75
|
+
private push;
|
|
76
|
+
/** The server cursor this client has pulled up to. */
|
|
77
|
+
cursor(): number;
|
|
78
|
+
/** Projects and folders that are neither archived nor in the trash. */
|
|
79
|
+
projects(): Project[];
|
|
80
|
+
labels(): Label[];
|
|
81
|
+
private projectId;
|
|
82
|
+
private labelId;
|
|
83
|
+
private view;
|
|
84
|
+
private rawTask;
|
|
85
|
+
/** One task by id, trashed or not; null when there is none. */
|
|
86
|
+
task(id: string): TaskView | null;
|
|
87
|
+
/** Tasks not in the trash or archived, filtered by `query`. */
|
|
88
|
+
tasks(query?: TaskQuery): TaskView[];
|
|
89
|
+
/** Create a task and push it. */
|
|
90
|
+
createTask(input: CreateTaskOptions): Promise<TaskView>;
|
|
91
|
+
/**
|
|
92
|
+
* Create a task from one line in the app's quick-add syntax:
|
|
93
|
+
* `Pay rent every month on the 1st #Home p1 @bills`.
|
|
94
|
+
*/
|
|
95
|
+
quickAdd(text: string): Promise<TaskView>;
|
|
96
|
+
/** Change a task's fields and push. Unchanged values are skipped. */
|
|
97
|
+
updateTask(id: string, patch: TaskPatch): Promise<TaskView>;
|
|
98
|
+
/**
|
|
99
|
+
* Complete a task (no-op when it already is). A recurring task with a due date spawns its next
|
|
100
|
+
* occurrence, returned as `next`.
|
|
101
|
+
*/
|
|
102
|
+
completeTask(id: string): Promise<{
|
|
103
|
+
task: TaskView;
|
|
104
|
+
next: TaskView | null;
|
|
105
|
+
}>;
|
|
106
|
+
reopenTask(id: string): Promise<{
|
|
107
|
+
task: TaskView;
|
|
108
|
+
next: TaskView | null;
|
|
109
|
+
}>;
|
|
110
|
+
private setCompleted;
|
|
111
|
+
/** Move a task to the trash, as the app's delete does. */
|
|
112
|
+
deleteTask(id: string): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Tasks changed on the server after `since` (a cursor from an earlier call or {@link cursor}).
|
|
115
|
+
* Each task is listed once, under the first of deleted, created, completed, updated that its
|
|
116
|
+
* changes match, with its current state.
|
|
117
|
+
*/
|
|
118
|
+
changes(since: number): Promise<Changes>;
|
|
119
|
+
/** Stop background timers. Every write has already been pushed. */
|
|
120
|
+
close(): void;
|
|
121
|
+
}
|
package/dist/dates.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a due date the way an integration is likely to send one, into the app's convention.
|
|
3
|
+
* - `null`, `""` or `"none"`: no date.
|
|
4
|
+
* - A number, or a string of digits: unix milliseconds.
|
|
5
|
+
* - `YYYY-MM-DD`, `today`, `tomorrow`: that day, at the app's default due time in `timeZone`
|
|
6
|
+
* (what picking a day in the app gives).
|
|
7
|
+
* - `YYYY-MM-DDTHH:MM[:SS]`: that wall time in `timeZone`; with `Z` or an offset, that instant.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseDue(value: string | number | null | undefined, timeZone?: string, now?: number): number | null;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import {
|
|
3
|
+
ApiClient,
|
|
4
|
+
Keyring,
|
|
5
|
+
LocalStore,
|
|
6
|
+
SyncClient,
|
|
7
|
+
deriveApiTokenKeys,
|
|
8
|
+
hydrateProjectKeys,
|
|
9
|
+
isProjectCreator,
|
|
10
|
+
parseApiToken,
|
|
11
|
+
readKeyTrust,
|
|
12
|
+
reviveLockedValues,
|
|
13
|
+
unwrapApiTokenKeys,
|
|
14
|
+
unwrapSigningKey
|
|
15
|
+
} from "@atlastodo/client-core";
|
|
16
|
+
import {
|
|
17
|
+
allTasks,
|
|
18
|
+
createTask,
|
|
19
|
+
evaluate,
|
|
20
|
+
parse as parseFilter,
|
|
21
|
+
parseQuickAdd,
|
|
22
|
+
softDeleteTask,
|
|
23
|
+
toProject,
|
|
24
|
+
toTask,
|
|
25
|
+
toggleTask,
|
|
26
|
+
updateTask
|
|
27
|
+
} from "@atlastodo/shared";
|
|
28
|
+
|
|
29
|
+
class AtlasError extends Error {
|
|
30
|
+
code;
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.name = "AtlasError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class AtlasClient {
|
|
39
|
+
api;
|
|
40
|
+
store;
|
|
41
|
+
sync;
|
|
42
|
+
userId;
|
|
43
|
+
timeZone;
|
|
44
|
+
constructor(api, store, sync, userId, timeZone) {
|
|
45
|
+
this.api = api;
|
|
46
|
+
this.store = store;
|
|
47
|
+
this.sync = sync;
|
|
48
|
+
this.userId = userId;
|
|
49
|
+
this.timeZone = timeZone;
|
|
50
|
+
}
|
|
51
|
+
static async connect(opts) {
|
|
52
|
+
const parts = parseApiToken(opts.token);
|
|
53
|
+
const { auth } = deriveApiTokenKeys(parts);
|
|
54
|
+
const baseUrl = opts.serverUrl.replace(/\/+$/, "");
|
|
55
|
+
const api = new ApiClient({
|
|
56
|
+
baseUrl,
|
|
57
|
+
fetch: opts.fetch,
|
|
58
|
+
reauthenticate: async () => (await api.openApiTokenSession(parts.id, auth)).access_token
|
|
59
|
+
});
|
|
60
|
+
const session = await api.openApiTokenSession(parts.id, auth);
|
|
61
|
+
api.setTokens(session.access_token);
|
|
62
|
+
const { dek, privateKey } = unwrapApiTokenKeys(parts, session.user_id, session);
|
|
63
|
+
const keyring = new Keyring({ dek, privateKey, publicKey: session.public_key });
|
|
64
|
+
if (session.encrypted_signing_key && session.signing_public_key) {
|
|
65
|
+
try {
|
|
66
|
+
keyring.setSigningKey(unwrapSigningKey(session.encrypted_signing_key, dek, session.signing_public_key));
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
api.setKeyring(keyring);
|
|
70
|
+
const store = new LocalStore(parts.id, { newId: () => globalThis.crypto.randomUUID() });
|
|
71
|
+
api.setScopeContext(store, session.user_id);
|
|
72
|
+
const sync = new SyncClient(store, api, { cursor: 0 });
|
|
73
|
+
const client = new AtlasClient(api, store, sync, session.user_id, opts.timeZone);
|
|
74
|
+
await client.initialSync(keyring);
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
77
|
+
async initialSync(keyring) {
|
|
78
|
+
await this.syncOrThrow();
|
|
79
|
+
const result = await hydrateProjectKeys(this.api, keyring, {
|
|
80
|
+
isCreator: (projectId) => isProjectCreator(this.store, projectId, this.userId),
|
|
81
|
+
trust: readKeyTrust(this.store),
|
|
82
|
+
userId: this.userId,
|
|
83
|
+
trustWriter: this.store
|
|
84
|
+
});
|
|
85
|
+
if (result.changed) {
|
|
86
|
+
reviveLockedValues(this.store, keyring, this.userId, !this.api.isLegacyMigrated());
|
|
87
|
+
await this.syncOrThrow();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async syncOrThrow() {
|
|
91
|
+
const result = await this.sync.sync();
|
|
92
|
+
if (result.skipped) {
|
|
93
|
+
throw new AtlasError("sync_skipped", `sync did not run (${result.skipped}); try again later`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async push() {
|
|
97
|
+
await this.syncOrThrow();
|
|
98
|
+
const pending = this.store.unsyncedOps().length;
|
|
99
|
+
if (pending > 0) {
|
|
100
|
+
throw new AtlasError("not_pushed", `${pending} change(s) did not reach the server (a shared project whose key is missing?)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cursor() {
|
|
104
|
+
return this.sync.currentCursor();
|
|
105
|
+
}
|
|
106
|
+
projects() {
|
|
107
|
+
return this.store.list("project").map((e) => toProject(e.id, e.fields)).filter((p) => p.archived_at == null && p.deleted_at == null).sort((a, b) => a.sort_order - b.sort_order);
|
|
108
|
+
}
|
|
109
|
+
labels() {
|
|
110
|
+
return this.store.list("label").filter((e) => e.fields.deleted_at == null).map((e) => ({
|
|
111
|
+
id: e.id,
|
|
112
|
+
owner_id: typeof e.fields.owner_id === "string" ? e.fields.owner_id : "",
|
|
113
|
+
name: typeof e.fields.name === "string" ? e.fields.name : "",
|
|
114
|
+
color: typeof e.fields.color === "string" ? e.fields.color : ""
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
projectId(ref) {
|
|
118
|
+
const lower = ref.toLowerCase();
|
|
119
|
+
const project = this.projects().find((p) => p.id === ref) ?? this.projects().find((p) => p.name.toLowerCase() === lower);
|
|
120
|
+
if (!project)
|
|
121
|
+
throw new AtlasError("unknown_project", `no project "${ref}"`);
|
|
122
|
+
return project.id;
|
|
123
|
+
}
|
|
124
|
+
labelId(ref) {
|
|
125
|
+
const lower = ref.replace(/^@/, "").toLowerCase();
|
|
126
|
+
const label = this.labels().find((l) => l.id === ref) ?? this.labels().find((l) => l.name.toLowerCase() === lower);
|
|
127
|
+
if (!label)
|
|
128
|
+
throw new AtlasError("unknown_label", `no label "${ref}"`);
|
|
129
|
+
return label.id;
|
|
130
|
+
}
|
|
131
|
+
view(task) {
|
|
132
|
+
const names = new Map(this.labels().map((l) => [l.id, l.name]));
|
|
133
|
+
const project = task.project_id ? this.store.get("project", task.project_id) : null;
|
|
134
|
+
return {
|
|
135
|
+
...task,
|
|
136
|
+
project_name: project && typeof project.name === "string" ? project.name : null,
|
|
137
|
+
labels: task.label_ids.map((id) => names.get(id)).filter((n) => n != null)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
rawTask(id) {
|
|
141
|
+
const fields = this.store.get("task", id);
|
|
142
|
+
if (!fields)
|
|
143
|
+
throw new AtlasError("unknown_task", `no task "${id}"`);
|
|
144
|
+
return toTask(id, fields);
|
|
145
|
+
}
|
|
146
|
+
task(id) {
|
|
147
|
+
const fields = this.store.get("task", id);
|
|
148
|
+
return fields ? this.view(toTask(id, fields)) : null;
|
|
149
|
+
}
|
|
150
|
+
tasks(query = {}) {
|
|
151
|
+
const status = query.status ?? "open";
|
|
152
|
+
const projectId = query.project ? this.projectId(query.project) : null;
|
|
153
|
+
const labelId = query.label ? this.labelId(query.label) : null;
|
|
154
|
+
const search = query.search?.toLowerCase();
|
|
155
|
+
let filter = null;
|
|
156
|
+
if (query.filter) {
|
|
157
|
+
filter = parseFilter(query.filter);
|
|
158
|
+
if (!filter.ok) {
|
|
159
|
+
throw new AtlasError("bad_filter", `${filter.error} (at ${filter.at})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const labelNames = new Map(this.labels().map((l) => [l.id, l.name]));
|
|
164
|
+
const ctx = {
|
|
165
|
+
now,
|
|
166
|
+
timeZone: this.timeZone,
|
|
167
|
+
labelsOf: (t) => t.label_ids.map((id) => labelNames.get(id) ?? ""),
|
|
168
|
+
projectNameOf: (t) => {
|
|
169
|
+
const p = t.project_id ? this.store.get("project", t.project_id) : null;
|
|
170
|
+
return p && typeof p.name === "string" ? p.name : null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
return allTasks(this.store).filter((t) => t.deleted_at == null && t.archived_at == null).filter((t) => status === "all" || t.is_completed === (status === "completed")).filter((t) => projectId == null || t.project_id === projectId).filter((t) => labelId == null || t.label_ids.includes(labelId)).filter((t) => {
|
|
174
|
+
if (query.dueBefore == null && query.dueAfter == null)
|
|
175
|
+
return true;
|
|
176
|
+
if (t.due_at == null)
|
|
177
|
+
return false;
|
|
178
|
+
return (query.dueBefore == null || t.due_at < query.dueBefore) && (query.dueAfter == null || t.due_at >= query.dueAfter);
|
|
179
|
+
}).filter((t) => !search || t.title.toLowerCase().includes(search) || t.notes.toLowerCase().includes(search)).filter((t) => !filter?.ok || evaluate(filter.ast, t, ctx)).map((t) => this.view(t));
|
|
180
|
+
}
|
|
181
|
+
async createTask(input) {
|
|
182
|
+
const { project, labels, ...rest } = input;
|
|
183
|
+
if (!rest.title?.trim())
|
|
184
|
+
throw new AtlasError("bad_input", "a task needs a title");
|
|
185
|
+
const create = { ...rest, title: rest.title.trim() };
|
|
186
|
+
if (create.project_id)
|
|
187
|
+
create.project_id = this.projectId(create.project_id);
|
|
188
|
+
else if (project)
|
|
189
|
+
create.project_id = this.projectId(project);
|
|
190
|
+
if (labels?.length) {
|
|
191
|
+
create.label_ids = [...create.label_ids ?? [], ...labels.map((l) => this.labelId(l))];
|
|
192
|
+
}
|
|
193
|
+
if (create.priority != null)
|
|
194
|
+
create.priority = checkPriority(create.priority);
|
|
195
|
+
const id = createTask(this.store, create, this.timeZone);
|
|
196
|
+
await this.push();
|
|
197
|
+
return this.view(this.rawTask(id));
|
|
198
|
+
}
|
|
199
|
+
async quickAdd(text) {
|
|
200
|
+
const projects = this.projects();
|
|
201
|
+
const labels = this.labels();
|
|
202
|
+
const parsed = parseQuickAdd(text, Date.now(), {
|
|
203
|
+
timeZone: this.timeZone,
|
|
204
|
+
projectIdByName: (name) => projects.find((p) => p.name.toLowerCase() === name.toLowerCase())?.id ?? null,
|
|
205
|
+
isKnownLabel: (name) => labels.some((l) => l.name.toLowerCase() === name.toLowerCase())
|
|
206
|
+
});
|
|
207
|
+
return this.createTask({ ...parsed.input, labels: parsed.labels });
|
|
208
|
+
}
|
|
209
|
+
async updateTask(id, patch) {
|
|
210
|
+
const task = this.rawTask(id);
|
|
211
|
+
const clean = {};
|
|
212
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
213
|
+
if (value !== undefined)
|
|
214
|
+
clean[key] = value;
|
|
215
|
+
}
|
|
216
|
+
if (clean.project_id)
|
|
217
|
+
clean.project_id = this.projectId(clean.project_id);
|
|
218
|
+
if (clean.priority != null)
|
|
219
|
+
clean.priority = checkPriority(clean.priority);
|
|
220
|
+
if (clean.label_ids)
|
|
221
|
+
clean.label_ids = clean.label_ids.map((l) => this.labelId(l));
|
|
222
|
+
if (!updateTask(this.store, task, clean, this.userId, Date.now(), this.timeZone)) {
|
|
223
|
+
if (task.locked || this.rawTask(id).locked)
|
|
224
|
+
throw lockedError(id);
|
|
225
|
+
return this.view(task);
|
|
226
|
+
}
|
|
227
|
+
await this.push();
|
|
228
|
+
return this.view(this.rawTask(id));
|
|
229
|
+
}
|
|
230
|
+
async completeTask(id) {
|
|
231
|
+
return this.setCompleted(id, true);
|
|
232
|
+
}
|
|
233
|
+
async reopenTask(id) {
|
|
234
|
+
return this.setCompleted(id, false);
|
|
235
|
+
}
|
|
236
|
+
async setCompleted(id, completed) {
|
|
237
|
+
const task = this.rawTask(id);
|
|
238
|
+
if (task.locked)
|
|
239
|
+
throw lockedError(id);
|
|
240
|
+
if (task.is_completed === completed)
|
|
241
|
+
return { task: this.view(task), next: null };
|
|
242
|
+
const spawned = toggleTask(this.store, task, this.userId, Date.now(), this.timeZone);
|
|
243
|
+
await this.push();
|
|
244
|
+
return {
|
|
245
|
+
task: this.view(this.rawTask(id)),
|
|
246
|
+
next: spawned ? this.view(this.rawTask(spawned)) : null
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async deleteTask(id) {
|
|
250
|
+
const task = this.rawTask(id);
|
|
251
|
+
if (task.deleted_at != null)
|
|
252
|
+
return;
|
|
253
|
+
if (!softDeleteTask(this.store, task))
|
|
254
|
+
throw lockedError(id);
|
|
255
|
+
await this.push();
|
|
256
|
+
}
|
|
257
|
+
async changes(since) {
|
|
258
|
+
await this.syncOrThrow();
|
|
259
|
+
const until = this.cursor();
|
|
260
|
+
const touched = new Map;
|
|
261
|
+
let cursor = since;
|
|
262
|
+
while (cursor < until) {
|
|
263
|
+
const page = await this.api.syncPull(cursor);
|
|
264
|
+
for (const op of page.operations) {
|
|
265
|
+
if (op.entity !== "task")
|
|
266
|
+
continue;
|
|
267
|
+
const ops = touched.get(op.entityId) ?? [];
|
|
268
|
+
ops.push(op);
|
|
269
|
+
touched.set(op.entityId, ops);
|
|
270
|
+
}
|
|
271
|
+
if (page.operations.length === 0 || page.cursor <= cursor)
|
|
272
|
+
break;
|
|
273
|
+
cursor = page.cursor;
|
|
274
|
+
}
|
|
275
|
+
const out = {
|
|
276
|
+
cursor: Math.max(until, cursor),
|
|
277
|
+
created: [],
|
|
278
|
+
updated: [],
|
|
279
|
+
completed: [],
|
|
280
|
+
deleted: []
|
|
281
|
+
};
|
|
282
|
+
for (const [id, ops] of touched) {
|
|
283
|
+
const fields = this.store.get("task", id);
|
|
284
|
+
const has = (field, pred = () => true) => ops.some((o) => o.op === "set" && o.field === field && pred(o.value));
|
|
285
|
+
if (!fields) {
|
|
286
|
+
out.deleted.push({ id });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const view = this.view(toTask(id, fields));
|
|
290
|
+
if (ops.some((o) => o.op === "delete") || has("deleted_at", (v) => v != null)) {
|
|
291
|
+
out.deleted.push(view);
|
|
292
|
+
} else if (has("created_at"))
|
|
293
|
+
out.created.push(view);
|
|
294
|
+
else if (has("is_completed", (v) => v === true))
|
|
295
|
+
out.completed.push(view);
|
|
296
|
+
else
|
|
297
|
+
out.updated.push(view);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
close() {
|
|
302
|
+
this.sync.dispose();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function checkPriority(p) {
|
|
306
|
+
if (p === 1 || p === 2 || p === 3 || p === 4)
|
|
307
|
+
return p;
|
|
308
|
+
throw new AtlasError("bad_input", "priority must be 1 (highest) to 4 (none)");
|
|
309
|
+
}
|
|
310
|
+
function lockedError(id) {
|
|
311
|
+
return new AtlasError("task_locked", `task "${id}" is in a shared project whose key this token cannot open`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/index.ts
|
|
315
|
+
import { InvalidApiTokenError } from "@atlastodo/client-core";
|
|
316
|
+
|
|
317
|
+
// src/dates.ts
|
|
318
|
+
import { addDays, endOfDay, makeInstant, resolveTimeZone } from "@atlastodo/shared";
|
|
319
|
+
function parseDue(value, timeZone, now = Date.now()) {
|
|
320
|
+
if (value == null)
|
|
321
|
+
return null;
|
|
322
|
+
if (typeof value === "number")
|
|
323
|
+
return checkMs(value);
|
|
324
|
+
const text = value.trim();
|
|
325
|
+
const lower = text.toLowerCase();
|
|
326
|
+
if (lower === "" || lower === "none")
|
|
327
|
+
return null;
|
|
328
|
+
if (/^\d+$/.test(text))
|
|
329
|
+
return checkMs(Number(text));
|
|
330
|
+
const tz = resolveTimeZone(timeZone);
|
|
331
|
+
if (lower === "today")
|
|
332
|
+
return endOfDay(now, tz);
|
|
333
|
+
if (lower === "tomorrow")
|
|
334
|
+
return endOfDay(addDays(now, 1, tz), tz);
|
|
335
|
+
const date = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
336
|
+
if (date) {
|
|
337
|
+
const [, y, m, d] = date.map(Number);
|
|
338
|
+
checkParts(value, y, m, d);
|
|
339
|
+
return endOfDay(makeInstant(y, m - 1, d, 12, 0, 0, tz), tz);
|
|
340
|
+
}
|
|
341
|
+
const wall = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(text);
|
|
342
|
+
if (wall) {
|
|
343
|
+
const [, y, m, d, h, min, s] = wall.map((v) => Number(v ?? 0));
|
|
344
|
+
checkParts(value, y, m, d, h, min, s);
|
|
345
|
+
return makeInstant(y, m - 1, d, h, min, s, tz);
|
|
346
|
+
}
|
|
347
|
+
const instant = Date.parse(text);
|
|
348
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(text) && !Number.isNaN(instant))
|
|
349
|
+
return instant;
|
|
350
|
+
throw unreadable(value);
|
|
351
|
+
}
|
|
352
|
+
function unreadable(value) {
|
|
353
|
+
return new AtlasError("bad_input", `cannot read the date "${value}" (use YYYY-MM-DD, YYYY-MM-DDTHH:MM, ISO 8601 or unix ms)`);
|
|
354
|
+
}
|
|
355
|
+
function checkParts(value, y, m, d, h = 0, min = 0, s = 0) {
|
|
356
|
+
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
357
|
+
if (m < 1 || m > 12 || d < 1 || d > daysInMonth || h > 23 || min > 59 || s > 59) {
|
|
358
|
+
throw unreadable(value);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function checkMs(ms) {
|
|
362
|
+
if (!Number.isFinite(ms) || Math.abs(ms) > 8640000000000000) {
|
|
363
|
+
throw new AtlasError("bad_input", `not a timestamp: ${ms}`);
|
|
364
|
+
}
|
|
365
|
+
return ms;
|
|
366
|
+
}
|
|
367
|
+
export {
|
|
368
|
+
AtlasClient,
|
|
369
|
+
AtlasError,
|
|
370
|
+
InvalidApiTokenError,
|
|
371
|
+
parseDue
|
|
372
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LocalStore, SyncClient, type FetchLike, type WireOp } from "@atlastodo/client-core";
|
|
2
|
+
export declare const USER_ID = "01920000-0000-7000-8000-0000000000aa";
|
|
3
|
+
/**
|
|
4
|
+
* A stand-in for the server's sync surface: one account's op log, kept as the opaque wire ops the
|
|
5
|
+
* clients push (ciphertext), like the real server. Enough of the API for the SDK and an app-like
|
|
6
|
+
* second client.
|
|
7
|
+
*/
|
|
8
|
+
export declare class FakeServer {
|
|
9
|
+
readonly ops: (WireOp & {
|
|
10
|
+
seq: number;
|
|
11
|
+
})[];
|
|
12
|
+
private seq;
|
|
13
|
+
readonly token: import("@atlastodo/client-core").ApiTokenParts;
|
|
14
|
+
readonly keys: {
|
|
15
|
+
dek: Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
|
|
16
|
+
privateKey: Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
|
|
17
|
+
};
|
|
18
|
+
sessions: number;
|
|
19
|
+
revoked: boolean;
|
|
20
|
+
tokenString(): string;
|
|
21
|
+
fetch: FetchLike;
|
|
22
|
+
/** A signed-in app device of the same account, for writes the SDK should see. */
|
|
23
|
+
appDevice(): Promise<{
|
|
24
|
+
store: LocalStore;
|
|
25
|
+
sync: SyncClient;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/testing.ts
|
|
2
|
+
import {
|
|
3
|
+
ApiClient,
|
|
4
|
+
LocalStore,
|
|
5
|
+
SyncClient,
|
|
6
|
+
Keyring,
|
|
7
|
+
deriveApiTokenKeys,
|
|
8
|
+
formatApiToken,
|
|
9
|
+
generateApiToken,
|
|
10
|
+
randomBytes,
|
|
11
|
+
wrapKeysForApiToken
|
|
12
|
+
} from "@atlastodo/client-core";
|
|
13
|
+
var USER_ID = "01920000-0000-7000-8000-0000000000aa";
|
|
14
|
+
|
|
15
|
+
class FakeServer {
|
|
16
|
+
ops = [];
|
|
17
|
+
seq = 0;
|
|
18
|
+
token = generateApiToken();
|
|
19
|
+
keys = { dek: randomBytes(32), privateKey: randomBytes(32) };
|
|
20
|
+
sessions = 0;
|
|
21
|
+
revoked = false;
|
|
22
|
+
tokenString() {
|
|
23
|
+
return formatApiToken(this.token);
|
|
24
|
+
}
|
|
25
|
+
fetch = async (url, init) => {
|
|
26
|
+
const u = new URL(String(url));
|
|
27
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
28
|
+
const json = (status, data) => new Response(data === undefined ? null : JSON.stringify(data), { status });
|
|
29
|
+
const authed = init?.headers?.authorization;
|
|
30
|
+
switch (`${init?.method} ${u.pathname}`) {
|
|
31
|
+
case "POST /auth/api-token/session": {
|
|
32
|
+
if (this.revoked || body.token_id !== this.token.id || body.auth !== deriveApiTokenKeys(this.token).auth) {
|
|
33
|
+
return json(401, { error: "unauthorized" });
|
|
34
|
+
}
|
|
35
|
+
this.sessions++;
|
|
36
|
+
return json(200, {
|
|
37
|
+
access_token: `access-${this.sessions}`,
|
|
38
|
+
expires_in: 900,
|
|
39
|
+
token_id: this.token.id,
|
|
40
|
+
user_id: USER_ID,
|
|
41
|
+
...wrapKeysForApiToken(this.token, USER_ID, this.keys)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (this.revoked || !authed?.startsWith("Bearer "))
|
|
46
|
+
return json(401, { error: "unauthorized" });
|
|
47
|
+
switch (`${init?.method} ${u.pathname}`) {
|
|
48
|
+
case "GET /sync/snapshot":
|
|
49
|
+
return json(200, { operations: this.ops, cursor: this.seq });
|
|
50
|
+
case "GET /sync/pull": {
|
|
51
|
+
const since = Number(u.searchParams.get("since"));
|
|
52
|
+
const page = this.ops.filter((o) => o.seq > since).slice(0, 500);
|
|
53
|
+
return json(200, {
|
|
54
|
+
operations: page,
|
|
55
|
+
from: since,
|
|
56
|
+
cursor: page.length ? page[page.length - 1].seq : Math.max(since, this.seq)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
case "POST /sync/push": {
|
|
60
|
+
const from = this.seq;
|
|
61
|
+
for (const op of body.operations) {
|
|
62
|
+
if (this.ops.some((o) => o.id === op.id))
|
|
63
|
+
continue;
|
|
64
|
+
this.ops.push({ ...op, seq: ++this.seq });
|
|
65
|
+
}
|
|
66
|
+
return json(200, { applied: body.operations.length, from, cursor: this.seq });
|
|
67
|
+
}
|
|
68
|
+
case "GET /project-keys":
|
|
69
|
+
return json(200, { keys: [], canonical: {} });
|
|
70
|
+
}
|
|
71
|
+
return json(404, { error: `not found: ${init?.method} ${u.pathname}` });
|
|
72
|
+
};
|
|
73
|
+
async appDevice() {
|
|
74
|
+
const api = new ApiClient({ baseUrl: "http://atlas.test", token: "app", fetch: this.fetch });
|
|
75
|
+
api.setKeyring(new Keyring({ dek: this.keys.dek, privateKey: this.keys.privateKey }));
|
|
76
|
+
const store = new LocalStore("app-device", { newId: () => crypto.randomUUID() });
|
|
77
|
+
api.setScopeContext(store, USER_ID);
|
|
78
|
+
const sync = new SyncClient(store, api, { cursor: 0 });
|
|
79
|
+
await sync.sync();
|
|
80
|
+
return { store, sync };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
FakeServer,
|
|
85
|
+
USER_ID
|
|
86
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlastodo/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.11-rc.5",
|
|
4
|
+
"description": "Headless Atlas client for integrations: signs in with a personal API token and syncs an account's tasks into memory",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"homepage": "https://github.com/atlastodo/atlas-todo/tree/main/packages/sdk",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/atlastodo/atlas-todo.git",
|
|
10
|
+
"directory": "packages/sdk"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/atlastodo/atlas-todo/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"@atlastodo/source": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./testing": {
|
|
25
|
+
"@atlastodo/source": "./src/testing.ts",
|
|
26
|
+
"types": "./dist/testing.d.ts",
|
|
27
|
+
"default": "./dist/testing.js"
|
|
28
|
+
},
|
|
29
|
+
"./test-vectors/*": "./test-vectors/*",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"test-vectors"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"atlas": {
|
|
40
|
+
"testVectors": [
|
|
41
|
+
"parse_due_vectors.json"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "bun ../../scripts/build-core-package.mjs",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@atlastodo/client-core": "0.1.11-rc.5",
|
|
51
|
+
"@atlastodo/shared": "0.1.11-rc.5"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^26.6.5",
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vitest": "^5.0.3"
|
|
57
|
+
}
|
|
58
|
+
}
|