driggsby 0.8.0 → 0.9.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 +20 -0
- package/dist/args-import.js +211 -0
- package/dist/args-query.js +20 -2
- package/dist/args-shared.js +1 -0
- package/dist/args.js +19 -2
- package/dist/help.js +66 -0
- package/dist/import/import-client.js +215 -0
- package/dist/import/import-command.js +307 -0
- package/dist/import/import-render.js +334 -0
- package/dist/import/import-target.js +49 -0
- package/dist/import/import-view.js +124 -0
- package/dist/main.js +3 -0
- package/dist/rules/rules-rpc.js +4 -4
- package/package.json +3 -3
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// driggsby import: send a bank or card statement CSV to Driggsby into the
|
|
2
|
+
// account the person names, follow it to the end,
|
|
3
|
+
// answer its rare question, and undo it; and driggsby accounts list. Built for agents with
|
|
4
|
+
// no Driggsby context: the file (or the ID Driggsby gives it) is the only
|
|
5
|
+
// handle, repeating any command is safe, `--wait` never outlasts a coding
|
|
6
|
+
// agent's command limit, and every output ends with the exact next command
|
|
7
|
+
// (import-render.ts holds that contract).
|
|
8
|
+
import { apiBaseUrl } from "../api/base-url.js";
|
|
9
|
+
import { CliError } from "../cli-error.js";
|
|
10
|
+
import { defaultCredentialEnvironment } from "../credentials/store.js";
|
|
11
|
+
import { deployFailure, requireDeploySession, SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
|
|
12
|
+
import { wrapProse } from "../terminal-text.js";
|
|
13
|
+
import { GENERIC_TOOL_TROUBLE } from "../dev/dev-servers.js";
|
|
14
|
+
import { accounts, answer, DriggsbyBusy, findByFile, findByRef, IMPORTS_NOT_AVAILABLE_MESSAGE, list, TROUBLE, undo, upload, } from "./import-client.js";
|
|
15
|
+
import { accountFlags, CLI, kindWord, renderAccounts, renderList, renderNotUndoable, renderQuestion, renderStarted, renderStatus, renderUndoPreview, undoneLine, } from "./import-render.js";
|
|
16
|
+
import { resolveTarget, shellWord, tooLargeToImport } from "./import-target.js";
|
|
17
|
+
import {} from "./import-view.js";
|
|
18
|
+
// Under Claude Code's 2-minute limit for one command, counted from the
|
|
19
|
+
// command's start (an upload's time included), so a wait is never cut off
|
|
20
|
+
// mid-poll; the output says to run the same command again. Every 3 seconds
|
|
21
|
+
// keeps an agent waiting on a few imports at once well inside Driggsby's
|
|
22
|
+
// per-minute limit on tool calls.
|
|
23
|
+
export const WAIT_MS = 90_000;
|
|
24
|
+
const POLL_MS = 3_000;
|
|
25
|
+
// What running the same command again wouldn't fix; anything else mid-wait
|
|
26
|
+
// ends the wait where it stands.
|
|
27
|
+
const SIGNED_OUT = new Set([SIGN_IN_AGAIN_MESSAGE, IMPORTS_NOT_AVAILABLE_MESSAGE]);
|
|
28
|
+
// Refusals about naming the account, whose fix starts from the account list.
|
|
29
|
+
const ACCOUNT_REFUSALS = new Set(["account_required", "account_invalid", "account_not_found"]);
|
|
30
|
+
const NOTHING_TO_UNDO = { text: "Nothing to undo: no import of this file is in Driggsby right now.\n", exitCode: 0 };
|
|
31
|
+
function defaultImportIo() {
|
|
32
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
33
|
+
return {
|
|
34
|
+
out: (text) => {
|
|
35
|
+
process.stdout.write(text);
|
|
36
|
+
},
|
|
37
|
+
now: () => new Date(),
|
|
38
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
39
|
+
ask: interactive
|
|
40
|
+
? async (question) => {
|
|
41
|
+
const { createInterface } = await import("node:readline/promises");
|
|
42
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
try {
|
|
44
|
+
return await readline.question(question);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
readline.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function runImport(options, environment = defaultCredentialEnvironment(), io = defaultImportIo()) {
|
|
54
|
+
const baseUrl = apiBaseUrl(environment.env);
|
|
55
|
+
try {
|
|
56
|
+
const session = await requireDeploySession(environment);
|
|
57
|
+
const rendered = options.action === "list"
|
|
58
|
+
? renderList(await list(session), context(io))
|
|
59
|
+
: options.action === "accounts"
|
|
60
|
+
? renderAccounts(await accounts(session))
|
|
61
|
+
: options.action === "undo"
|
|
62
|
+
? await runUndo(session, options, io)
|
|
63
|
+
: await runStart(session, options, io);
|
|
64
|
+
io.out(rendered.text);
|
|
65
|
+
return rendered.exitCode;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof DriggsbyBusy) {
|
|
69
|
+
throw new CliError(`Driggsby is busy right now. Run the same command again in a minute:\n ${retryCommand(options)}`, 1);
|
|
70
|
+
}
|
|
71
|
+
// An answer the CLI couldn't read ends, like every output, on the command
|
|
72
|
+
// to run.
|
|
73
|
+
if (error instanceof CliError && (error.message === TROUBLE || error.message === GENERIC_TOOL_TROUBLE)) {
|
|
74
|
+
throw new CliError(`${error.message} Then run the same command again:\n ${retryCommand(options)}`, 1);
|
|
75
|
+
}
|
|
76
|
+
throw deployFailure(error, baseUrl, retryCommand(options));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function runStart(session, options, io) {
|
|
80
|
+
const startedAt = io.now().getTime();
|
|
81
|
+
const target = await resolveTarget(options.target);
|
|
82
|
+
const given = answerGiven(options);
|
|
83
|
+
const typedFile = target.kind === "file" ? shellWord(target.typed) : null;
|
|
84
|
+
let outcome;
|
|
85
|
+
let created = false;
|
|
86
|
+
let uploaded = false;
|
|
87
|
+
if (target.kind === "ref") {
|
|
88
|
+
if (options.account !== null || options.create !== null) {
|
|
89
|
+
throw new CliError(`${wrapProse("--account and --create-account name the account a file goes in; an import's ID already has one. To see where it stands:")}\n` +
|
|
90
|
+
` ${CLI} import ${target.ref}`, 2);
|
|
91
|
+
}
|
|
92
|
+
outcome = await findByRef(session, target.ref);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
outcome = await findByFile(session, target.sha256, named(options));
|
|
96
|
+
if (outcome.kind === "refused" && outcome.code === "not_found") {
|
|
97
|
+
const file = typedFile ?? "<FILE>";
|
|
98
|
+
if (given !== null) {
|
|
99
|
+
throw new CliError(`${wrapProse("--dates and --amounts answer a question an import asked, and this file hasn't been imported yet. Start it first:")}\n` +
|
|
100
|
+
` ${CLI} import ${file} --account <ID>`, 2);
|
|
101
|
+
}
|
|
102
|
+
outcome = await upload(session, target.file, destination(options, file));
|
|
103
|
+
uploaded = true;
|
|
104
|
+
created = outcome.kind === "import" && outcome.created;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (outcome.kind === "import" && !created && given !== null) {
|
|
108
|
+
outcome = await answer(session, outcome.view.ref, given);
|
|
109
|
+
}
|
|
110
|
+
if (outcome.kind === "refused") {
|
|
111
|
+
return renderRefusal(outcome, { typedFile, retry: retryCommand(options), uploaded, create: options.create });
|
|
112
|
+
}
|
|
113
|
+
const startedIn = outcome.view.state;
|
|
114
|
+
const view = options.wait ? await waitFor(session, outcome.view, io, startedAt) : outcome.view;
|
|
115
|
+
if (created && !options.wait) {
|
|
116
|
+
return renderStarted(view);
|
|
117
|
+
}
|
|
118
|
+
return renderStatus(view, {
|
|
119
|
+
compact: options.wait && startedIn === "categorizing",
|
|
120
|
+
repeated: target.kind === "file" && !created && given === null,
|
|
121
|
+
unchanged: outcome.note !== null,
|
|
122
|
+
rerun: { file: typedFile, account: options.account, create: options.create },
|
|
123
|
+
}, context(io));
|
|
124
|
+
}
|
|
125
|
+
async function runUndo(session, options, io) {
|
|
126
|
+
let startedAt = io.now().getTime();
|
|
127
|
+
// A file over the import limit was never imported.
|
|
128
|
+
if (await tooLargeToImport(options.target)) {
|
|
129
|
+
return NOTHING_TO_UNDO;
|
|
130
|
+
}
|
|
131
|
+
const target = await resolveTarget(options.target);
|
|
132
|
+
const found = target.kind === "ref" ? await findByRef(session, target.ref) : await findByFile(session, target.sha256, null);
|
|
133
|
+
if (found.kind === "refused") {
|
|
134
|
+
if (found.code === "not_found" && target.kind === "file") {
|
|
135
|
+
// Never imported, or already undone: either way nothing of it is left.
|
|
136
|
+
return NOTHING_TO_UNDO;
|
|
137
|
+
}
|
|
138
|
+
return renderRefusal(found, { typedFile: null, retry: retryCommand(options), uploaded: false });
|
|
139
|
+
}
|
|
140
|
+
const previewed = await undo(session, found.view.ref, false);
|
|
141
|
+
if (previewed.kind === "refused") {
|
|
142
|
+
return renderRefusal(previewed, { typedFile: null, retry: retryCommand(options), uploaded: false });
|
|
143
|
+
}
|
|
144
|
+
const preview = previewed.undo;
|
|
145
|
+
if (preview === null) {
|
|
146
|
+
throw new CliError("Driggsby couldn't say what undo would remove just now. Please try again in a minute.", 1);
|
|
147
|
+
}
|
|
148
|
+
if (!preview.undoable) {
|
|
149
|
+
return renderNotUndoable(previewed.view, preview);
|
|
150
|
+
}
|
|
151
|
+
if (!options.yes) {
|
|
152
|
+
const shown = renderUndoPreview(previewed.view, preview);
|
|
153
|
+
if (io.ask === null) {
|
|
154
|
+
return shown;
|
|
155
|
+
}
|
|
156
|
+
// A person at a terminal answers here instead of re-running with --yes.
|
|
157
|
+
io.out(`${shown.text}\n`);
|
|
158
|
+
const reply = await io.ask(`${preview.cancels ? "Cancel" : "Undo"} it now? [y/N] `);
|
|
159
|
+
if (!/^y(es)?$/i.test(reply.trim())) {
|
|
160
|
+
return { text: "Not undone: nothing changed.\n", exitCode: 0 };
|
|
161
|
+
}
|
|
162
|
+
// The person's time to answer isn't the wait's.
|
|
163
|
+
startedAt = io.now().getTime();
|
|
164
|
+
}
|
|
165
|
+
const result = await undo(session, previewed.view.ref, true);
|
|
166
|
+
if (result.kind === "refused") {
|
|
167
|
+
return renderRefusal(result, { typedFile: null, retry: retryCommand(options), uploaded: false });
|
|
168
|
+
}
|
|
169
|
+
const view = result.view.state === "undoing" ? await waitFor(session, result.view, io, startedAt) : result.view;
|
|
170
|
+
if (view.state === "undone") {
|
|
171
|
+
return { text: `${undoneLine(view, preview.cancels ? null : preview.transactions)}\n`, exitCode: 0 };
|
|
172
|
+
}
|
|
173
|
+
// Back where it started: Driggsby couldn't finish the undo, and removed
|
|
174
|
+
// nothing.
|
|
175
|
+
if (view.state === "done" || view.state === "waiting") {
|
|
176
|
+
return refused(`Not undone: Driggsby couldn't finish undoing it just now, and nothing was removed.\n\nNext:\n ${retryCommand(options)}\n`);
|
|
177
|
+
}
|
|
178
|
+
return renderStatus(view, { compact: false, repeated: false, unchanged: false, rerun: { file: null, account: null, create: null } }, context(io));
|
|
179
|
+
}
|
|
180
|
+
// Polls until something an agent acts on: the transactions are in, a
|
|
181
|
+
// question, a stop, the end of an undo, or (for a wait that began while
|
|
182
|
+
// categories filled in) the end of categorizing. Never past WAIT_MS from
|
|
183
|
+
// startedAt.
|
|
184
|
+
export async function waitFor(session, view, io, startedAt) {
|
|
185
|
+
const startedIn = view.state;
|
|
186
|
+
const deadline = startedAt + WAIT_MS;
|
|
187
|
+
let current = view;
|
|
188
|
+
while (!settled(current, startedIn) && io.now().getTime() + POLL_MS <= deadline) {
|
|
189
|
+
await io.sleep(POLL_MS);
|
|
190
|
+
// A busy moment, a server error or a lost connection mid-wait ends the
|
|
191
|
+
// wait where it stands; its output names the same command to run again.
|
|
192
|
+
const outcome = await findByRef(session, current.ref).catch((error) => {
|
|
193
|
+
if (error instanceof CliError && SIGNED_OUT.has(error.message))
|
|
194
|
+
throw error;
|
|
195
|
+
return null;
|
|
196
|
+
});
|
|
197
|
+
if (outcome?.kind !== "import") {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
current = outcome.view;
|
|
201
|
+
}
|
|
202
|
+
return current;
|
|
203
|
+
}
|
|
204
|
+
function settled(view, startedIn) {
|
|
205
|
+
switch (view.state) {
|
|
206
|
+
case "waiting":
|
|
207
|
+
case "done":
|
|
208
|
+
case "stopped":
|
|
209
|
+
case "undone":
|
|
210
|
+
return true;
|
|
211
|
+
case "categorizing":
|
|
212
|
+
return startedIn !== "categorizing";
|
|
213
|
+
default:
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function renderRefusal(outcome, where) {
|
|
218
|
+
const ref = outcome.view?.ref ?? null;
|
|
219
|
+
const message = outcome.message;
|
|
220
|
+
if (outcome.code === "not_found") {
|
|
221
|
+
return refused(`Refused: there's no import with that ID on your account.\n\nNext, to see your imports:\n ${CLI} import list\n`);
|
|
222
|
+
}
|
|
223
|
+
// A new account's name another account has: import into that one by its
|
|
224
|
+
// ID (its kind said, since it may not be the kind asked for), or name the
|
|
225
|
+
// new one differently.
|
|
226
|
+
if (outcome.code === "account_name_taken") {
|
|
227
|
+
if (outcome.account === null) {
|
|
228
|
+
return refused(`Refused: ${message} Nothing was imported.\n\nNext, to see where that import stands:\n ${CLI} import list\n`);
|
|
229
|
+
}
|
|
230
|
+
const file = where.typedFile ?? "<FILE>";
|
|
231
|
+
const kind = kindWord(outcome.account.type);
|
|
232
|
+
return refused(`Refused: ${message} Nothing was imported.\n\n` +
|
|
233
|
+
`Next, to import into ${kind === "" ? "it" : `that ${kind} account`}:\n ${CLI} import ${file} --account ${outcome.account.ref}\n` +
|
|
234
|
+
`Or into a new account by another name:\n ${CLI} import ${file} --create-account --account-type ${where.create?.type ?? "card"} --account-name "<NAME>"\n`);
|
|
235
|
+
}
|
|
236
|
+
if (ACCOUNT_REFUSALS.has(outcome.code)) {
|
|
237
|
+
return refused(`Refused: ${message} Nothing was imported.\n\nNext, to see your accounts and their IDs:\n ${CLI} accounts list\n`);
|
|
238
|
+
}
|
|
239
|
+
if (outcome.code === "already_imported" && ref !== null) {
|
|
240
|
+
return refused(`Refused: ${message}\n\nNext, to undo it first:\n ${CLI} import undo ${ref}\n`);
|
|
241
|
+
}
|
|
242
|
+
// Another send of this file was in flight; the same command again finds
|
|
243
|
+
// where it stands, or starts it afresh.
|
|
244
|
+
if (outcome.code === "already_imported") {
|
|
245
|
+
return refused(`Refused: ${message} Nothing was imported.\n\nNext, run the same command again:\n ${where.retry}\n`);
|
|
246
|
+
}
|
|
247
|
+
if (outcome.code === "wrong_answer" && outcome.view !== null) {
|
|
248
|
+
return refused(`Refused: ${message}\n\n${renderQuestion(outcome.view)}`);
|
|
249
|
+
}
|
|
250
|
+
if (outcome.code === "busy" || outcome.code === "account_still_importing") {
|
|
251
|
+
return refused(`Refused: ${message}\n\nNext, once that's done:\n ${where.retry}\n`);
|
|
252
|
+
}
|
|
253
|
+
// An undo another change beat: where the import stands now says what's next.
|
|
254
|
+
if (outcome.code === "not_undoable" && ref !== null) {
|
|
255
|
+
return refused(`Refused: ${message}\n\nNext, to see where it stands:\n ${CLI} import ${ref}\n`);
|
|
256
|
+
}
|
|
257
|
+
return refused(`Refused: ${message}${where.uploaded ? " Nothing was imported." : ""}\n`);
|
|
258
|
+
}
|
|
259
|
+
function refused(text) {
|
|
260
|
+
return { text, exitCode: 1 };
|
|
261
|
+
}
|
|
262
|
+
function answerGiven(options) {
|
|
263
|
+
if (options.dates !== null)
|
|
264
|
+
return { dates: options.dates };
|
|
265
|
+
if (options.amounts !== null)
|
|
266
|
+
return { amounts: options.amounts };
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
// The account the command names, if it names one.
|
|
270
|
+
function named(options) {
|
|
271
|
+
if (options.account !== null)
|
|
272
|
+
return { account: options.account };
|
|
273
|
+
if (options.create !== null)
|
|
274
|
+
return { create: options.create };
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
// The account a new file goes in, or the commands that name one: an import
|
|
278
|
+
// always names its account, and Driggsby never picks one.
|
|
279
|
+
function destination(options, file) {
|
|
280
|
+
const chosen = named(options);
|
|
281
|
+
if (chosen !== null)
|
|
282
|
+
return chosen;
|
|
283
|
+
throw new CliError(`error: name the account this statement goes in, one you have or a new one:\n` +
|
|
284
|
+
` ${CLI} import ${file} --account <ID>\n` +
|
|
285
|
+
` ${CLI} import ${file} --create-account --account-type card --account-name "<NAME>"\n\n` +
|
|
286
|
+
`Your accounts and their IDs:\n ${CLI} accounts list`, 2);
|
|
287
|
+
}
|
|
288
|
+
function context(io) {
|
|
289
|
+
return io.timeZone === undefined ? { now: io.now() } : { now: io.now(), timeZone: io.timeZone };
|
|
290
|
+
}
|
|
291
|
+
// The command as given, so a retry lands exactly where the first try aimed:
|
|
292
|
+
// an account named on the first try is named again.
|
|
293
|
+
function retryCommand(options) {
|
|
294
|
+
if (options.action === "list")
|
|
295
|
+
return `${CLI} import list`;
|
|
296
|
+
if (options.action === "accounts")
|
|
297
|
+
return `${CLI} accounts list`;
|
|
298
|
+
if (options.action === "undo")
|
|
299
|
+
return `${CLI} import undo ${shellWord(options.target)}${options.yes ? " --yes" : ""}`;
|
|
300
|
+
const flags = [
|
|
301
|
+
accountFlags(options.account, options.create),
|
|
302
|
+
options.dates === null ? "" : ` --dates ${options.dates}`,
|
|
303
|
+
options.amounts === null ? "" : ` --amounts ${options.amounts}`,
|
|
304
|
+
options.wait ? " --wait" : "",
|
|
305
|
+
].join("");
|
|
306
|
+
return `${CLI} import ${shellWord(options.target)}${flags}`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// What the import command prints. Every output follows one contract, so an
|
|
2
|
+
// agent with no Driggsby context never has to guess: the first line says
|
|
3
|
+
// where things stand ("Started:", "Waiting for an answer:", "Done:", ...)
|
|
4
|
+
// and is never wrapped, and the last lines give the exact command to run
|
|
5
|
+
// next, complete, with one command per answer when the person must choose.
|
|
6
|
+
// Names Driggsby sends (a file, an account) print quoted and capped, so they
|
|
7
|
+
// read as names and never as the CLI's own sentence.
|
|
8
|
+
import { formatBytes } from "@driggsby/deploy";
|
|
9
|
+
import { capForTerminal, quotedForTerminal, wrapProse } from "../terminal-text.js";
|
|
10
|
+
import {} from "./import-client.js";
|
|
11
|
+
import {} from "./import-view.js";
|
|
12
|
+
export const CLI = "npx driggsby@latest";
|
|
13
|
+
const NAME_CHARS = 120;
|
|
14
|
+
const MESSAGE_CHARS = 1_000;
|
|
15
|
+
export const ASK = "If you're working for someone, ask them; don't guess. Nothing lands until it's answered.";
|
|
16
|
+
// A stop whose fix is another account, not the same command again.
|
|
17
|
+
const ACCOUNT_STOPS = new Set(["account_not_found", "account_mismatch"]);
|
|
18
|
+
const TYPE_WORDS = { credit: "card", depository: "bank" };
|
|
19
|
+
const EXPLAIN = {
|
|
20
|
+
date_order: "Every date in it reads either way, like 03/04/2026.",
|
|
21
|
+
amount_direction: "It doesn't say which way its amounts go.",
|
|
22
|
+
};
|
|
23
|
+
const NOTHING_NEW = {
|
|
24
|
+
all_duplicates: "all of its transactions were already in Driggsby",
|
|
25
|
+
all_covered_by_link: "the bank link already brought in all of its transactions",
|
|
26
|
+
all_pending: "all of its transactions are still pending; import it again once they post",
|
|
27
|
+
};
|
|
28
|
+
const name = (value) => quotedForTerminal(value, NAME_CHARS);
|
|
29
|
+
const said = (value) => capForTerminal(value, MESSAGE_CHARS).trim();
|
|
30
|
+
const next = (...commands) => `Next:\n${commands.map((command) => ` ${command}`).join("\n")}\n`;
|
|
31
|
+
const waitCommand = (ref) => `${CLI} import ${ref} --wait`;
|
|
32
|
+
const indented = (text) => wrapProse(text, 74)
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line) => ` ${line}`)
|
|
35
|
+
.join("\n");
|
|
36
|
+
export function renderStarted(view) {
|
|
37
|
+
const size = view.lines === null ? "" : `${plural(view.lines, "line")}${view.bytes === null ? "" : `, ${formatBytes(view.bytes)}`}`;
|
|
38
|
+
return ok(`Started: importing ${name(view.file)} as ${view.ref}.\n\n` +
|
|
39
|
+
(size === "" ? "" : `✓ Uploaded ${size}\n\n`) +
|
|
40
|
+
`${wrapProse("Driggsby is reading it now, which usually takes under a minute. Nothing lands in your accounts until it finishes.")}\n\n` +
|
|
41
|
+
next(waitCommand(view.ref)));
|
|
42
|
+
}
|
|
43
|
+
export function renderStatus(view, options, context) {
|
|
44
|
+
const body = statusBody(view, options, context);
|
|
45
|
+
if (!options.unchanged) {
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
// The first line still says where things stand; the note comes after it.
|
|
49
|
+
const [first = "", ...rest] = body.text.split("\n\n");
|
|
50
|
+
return { text: [first, "Nothing changed: this import isn't waiting for an answer.", ...rest].join("\n\n"), exitCode: body.exitCode };
|
|
51
|
+
}
|
|
52
|
+
function statusBody(view, options, context) {
|
|
53
|
+
switch (view.state) {
|
|
54
|
+
case "reading":
|
|
55
|
+
case "importing": {
|
|
56
|
+
const doing = view.state === "reading" ? "Still reading" : "Still importing";
|
|
57
|
+
return ok(`${doing}: ${name(view.file)}, started ${ago(view.startedAt, context.now)}.\n\n` +
|
|
58
|
+
`${wrapProse("Some files take a few minutes. Nothing lands until it finishes.")}\n\n${next(waitCommand(view.ref))}`);
|
|
59
|
+
}
|
|
60
|
+
case "waiting":
|
|
61
|
+
return ok(renderQuestion(view));
|
|
62
|
+
case "categorizing":
|
|
63
|
+
return ok(options.compact ? categorizingLine(view) : landed(view, "Imported"));
|
|
64
|
+
case "done":
|
|
65
|
+
return ok(done(view, options, context));
|
|
66
|
+
case "stopped":
|
|
67
|
+
return { text: `Stopped: ${name(view.file)} wasn't imported. Nothing landed.\n\n${wrapProse(said(view.stopped?.message ?? ""))}\n\n${rerun(view, options)}`, exitCode: 1 };
|
|
68
|
+
case "undoing":
|
|
69
|
+
return ok(`${undoingLine(view)}\n\n${next(waitCommand(view.ref))}`);
|
|
70
|
+
case "undone":
|
|
71
|
+
return ok(`${undoneLine(view, null)}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A cancel landed nothing, so it removes nothing either.
|
|
75
|
+
function undoingLine(view) {
|
|
76
|
+
return view.counts === null
|
|
77
|
+
? `Undoing: cancelling ${name(view.file)}; nothing of it had landed.`
|
|
78
|
+
: `Undoing: removing the transactions ${name(view.file)} added.`;
|
|
79
|
+
}
|
|
80
|
+
// Importing the file again, aimed where the first try was; a stop about the
|
|
81
|
+
// account starts from the account list instead.
|
|
82
|
+
function rerun(view, options) {
|
|
83
|
+
if (ACCOUNT_STOPS.has(view.stopped?.code ?? "")) {
|
|
84
|
+
return `Next, to see your accounts and their IDs:\n ${CLI} accounts list\n`;
|
|
85
|
+
}
|
|
86
|
+
const flags = accountFlags(options.rerun.account, options.rerun.create);
|
|
87
|
+
if (options.rerun.file !== null && flags !== "") {
|
|
88
|
+
return next(`${CLI} import ${options.rerun.file}${flags}`);
|
|
89
|
+
}
|
|
90
|
+
// Looked up by ID: the account it was for may be one it was making.
|
|
91
|
+
const file = options.rerun.file ?? "<FILE>";
|
|
92
|
+
return (`Next, with the same file, into one of your accounts or a new one:\n ${CLI} import ${file} --account <ID>\n` +
|
|
93
|
+
` ${CLI} import ${file} --create-account --account-type card --account-name "<NAME>"\n`);
|
|
94
|
+
}
|
|
95
|
+
// The account flags as typed: an ID, or the new account's type and name
|
|
96
|
+
// (always quoted for a POSIX shell).
|
|
97
|
+
export function accountFlags(account, create) {
|
|
98
|
+
if (account !== null)
|
|
99
|
+
return ` --account ${account}`;
|
|
100
|
+
if (create !== null)
|
|
101
|
+
return ` --create-account --account-type ${create.type} --account-name ${quotedName(create.name)}`;
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
export function quotedName(value) {
|
|
105
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
106
|
+
}
|
|
107
|
+
// An account's kind in the words the flags use (card, bank), or as Driggsby
|
|
108
|
+
// names it.
|
|
109
|
+
export function kindWord(type) {
|
|
110
|
+
return TYPE_WORDS[type] ?? capForTerminal(type, 20);
|
|
111
|
+
}
|
|
112
|
+
// Each account with its kind and the ID an import names, then how to import.
|
|
113
|
+
export function renderAccounts(rows) {
|
|
114
|
+
const create = `${CLI} import <FILE> --create-account --account-type card --account-name "<NAME>"`;
|
|
115
|
+
if (rows.length === 0) {
|
|
116
|
+
return ok(`No accounts yet.\n\nNext, to import a statement into a new account:\n ${create}\n`);
|
|
117
|
+
}
|
|
118
|
+
const lines = rows.map((row) => {
|
|
119
|
+
const label = name(row.last4 === null ? row.name : `${row.name} ····${row.last4}`);
|
|
120
|
+
return ` ${label}\n ${kindWord(row.type)} · ${row.ref}`;
|
|
121
|
+
});
|
|
122
|
+
return ok(`Your accounts (${String(rows.length)}):\n${lines.join("\n")}\n\n` +
|
|
123
|
+
`Next, to import a statement into one:\n ${CLI} import <FILE> --account <ID>\nOr into a new account:\n ${create}\n`);
|
|
124
|
+
}
|
|
125
|
+
export function renderQuestion(view) {
|
|
126
|
+
const question = view.question;
|
|
127
|
+
const prompt = question === null ? "What does Driggsby need to know?" : said(question.prompt);
|
|
128
|
+
const explain = question === null ? "" : (EXPLAIN[question.kind] ?? "");
|
|
129
|
+
const asked = indented(`${explain === "" ? "" : `${explain} `}${ASK}`).replace(/^ {2}/, "? ");
|
|
130
|
+
const choices = question?.choices ?? [];
|
|
131
|
+
const answers = choices.length === 0
|
|
132
|
+
? next(`${CLI} import ${view.ref}`)
|
|
133
|
+
: `Next, run the command for the answer:\n${choiceLines(choices, (choice) => `${CLI} import ${view.ref} ${choice.flag} ${choice.value} --wait`)}`;
|
|
134
|
+
return `Waiting for an answer about ${name(view.file)}: ${prompt}\n\n${asked}\n\n${answers}`;
|
|
135
|
+
}
|
|
136
|
+
export function choiceLines(choices, command) {
|
|
137
|
+
return `${choices.map((choice) => ` ${name(choice.label)}\n ${command(choice)}`).join("\n")}\n`;
|
|
138
|
+
}
|
|
139
|
+
// The transactions are in: what landed, then what's left to do.
|
|
140
|
+
function landed(view, headline) {
|
|
141
|
+
const where = view.account === null ? " landed" : ` are in ${name(view.account)}`;
|
|
142
|
+
const top = `${headline}: ${plural(view.counts?.new ?? 0, "transaction")} from ${name(view.file)}${where}.`;
|
|
143
|
+
const undoCommand = ` ${CLI} import undo ${view.ref}\n`;
|
|
144
|
+
// A new account is the person's to name by its ID from here on.
|
|
145
|
+
const again = view.newAccount && view.accountRef !== null ? `Next time, for this account:\n ${CLI} import <FILE> --account ${view.accountRef}\n\n` : "";
|
|
146
|
+
const tail = again +
|
|
147
|
+
(headline === "Imported"
|
|
148
|
+
? `${wrapProse("The transactions are usable now, and categories fill in on their own. Nothing else to do unless you need the categories (for a spending summary, say). To wait for them:")}\n` +
|
|
149
|
+
` ${waitCommand(view.ref)}\n\nIf it went into the wrong account, undo it once categories finish:\n${undoCommand}`
|
|
150
|
+
: `${wrapProse("Nothing else to do: the import is finished. If it went into the wrong account, undo it with:")}\n${undoCommand}`);
|
|
151
|
+
return `${top}\n\n${details(view, headline)}\n${tail}`;
|
|
152
|
+
}
|
|
153
|
+
function details(view, headline) {
|
|
154
|
+
const rows = [];
|
|
155
|
+
if (view.lines !== null) {
|
|
156
|
+
rows.push(`✓ Read ${plural(view.lines, "line")}${view.dates === null ? "" : `, ${dateRange(view.dates.from, view.dates.to)}`}`);
|
|
157
|
+
}
|
|
158
|
+
if (view.account !== null) {
|
|
159
|
+
rows.push(`✓ Account ${name(view.account)}${view.newAccount ? " (a new account)" : ""}`);
|
|
160
|
+
}
|
|
161
|
+
if (view.counts !== null) {
|
|
162
|
+
const already = view.counts.alreadyInDriggsby;
|
|
163
|
+
rows.push(`✓ Imported ${view.counts.new.toLocaleString("en-US")} new${already > 0 ? ` · ${already.toLocaleString("en-US")} already in Driggsby, skipped` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
rows.push(headline === "Imported" ? `● Categories ${progressText(view)}` : categoriesDone(view));
|
|
166
|
+
const leftOut = leftOutText(view);
|
|
167
|
+
if (leftOut !== "") {
|
|
168
|
+
rows.push(`· Left out ${leftOut}`);
|
|
169
|
+
}
|
|
170
|
+
return `${rows.join("\n")}\n`;
|
|
171
|
+
}
|
|
172
|
+
function categoriesDone(view) {
|
|
173
|
+
return view.categorized === false
|
|
174
|
+
? "· Categories some couldn't be filled in; those show as uncategorized"
|
|
175
|
+
: `✓ Categories all ${(view.counts?.new ?? 0).toLocaleString("en-US")} categorized`;
|
|
176
|
+
}
|
|
177
|
+
function progressText(view) {
|
|
178
|
+
const progress = view.progress;
|
|
179
|
+
if (progress === null || progress.categorized === 0) {
|
|
180
|
+
return "starting now";
|
|
181
|
+
}
|
|
182
|
+
const left = progress.secondsLeft === null ? "" : `, ${timeLeft(progress.secondsLeft)}`;
|
|
183
|
+
return `${progress.categorized.toLocaleString("en-US")} of ${progress.total.toLocaleString("en-US")} so far${left}`;
|
|
184
|
+
}
|
|
185
|
+
function categorizingLine(view) {
|
|
186
|
+
const progress = view.progress;
|
|
187
|
+
const where = progress === null || progress.categorized === 0
|
|
188
|
+
? "categorizing has started"
|
|
189
|
+
: `${progress.categorized.toLocaleString("en-US")} of ${plural(progress.total, "transaction")} so far${progress.secondsLeft === null ? "" : `, ${timeLeft(progress.secondsLeft)}`}`;
|
|
190
|
+
return `Categorizing: ${where}.\n\nThe transactions are usable while this runs.\n\n${next(waitCommand(view.ref))}`;
|
|
191
|
+
}
|
|
192
|
+
function done(view, options, context) {
|
|
193
|
+
if (view.nothingNew !== null) {
|
|
194
|
+
const why = NOTHING_NEW[view.nothingNew] ?? "none of its transactions were new";
|
|
195
|
+
return `Done: nothing new in ${name(view.file)}: ${why}.\n\nNothing else to do.\n`;
|
|
196
|
+
}
|
|
197
|
+
// A wait that followed categorizing ends on how it went, even for a file
|
|
198
|
+
// sent again.
|
|
199
|
+
if (options.compact && view.categorized === false) {
|
|
200
|
+
return (`Done: ${plural(view.counts?.new ?? 0, "transaction")} from ${name(view.file)} are in, but some couldn't be categorized.\n\n` +
|
|
201
|
+
`${wrapProse("Those show as uncategorized. Nothing else to do: the import is finished.")}\n`);
|
|
202
|
+
}
|
|
203
|
+
if (options.compact) {
|
|
204
|
+
return `Done: all ${plural(view.counts?.new ?? 0, "transaction")} from ${name(view.file)} are categorized.\n\nNothing else to do: the import is finished.\n`;
|
|
205
|
+
}
|
|
206
|
+
if (options.repeated) {
|
|
207
|
+
const on = view.finishedAt === null ? "" : ` on ${shortDate(view.finishedAt, context)}`;
|
|
208
|
+
const into = view.account === null ? " landed" : ` went into ${name(view.account)}`;
|
|
209
|
+
return `Already done: this file's ${plural(view.counts?.new ?? 0, "transaction")}${into}${on} (${view.ref}). Nothing to do.\n`;
|
|
210
|
+
}
|
|
211
|
+
return landed(view, "Done");
|
|
212
|
+
}
|
|
213
|
+
export function undoneLine(view, removed) {
|
|
214
|
+
if (view.counts === null) {
|
|
215
|
+
return `Undone: ${name(view.file)} was cancelled before anything landed, and its file is deleted.`;
|
|
216
|
+
}
|
|
217
|
+
const what = removed === null ? "the transactions it added are gone" : `removed ${plural(removed, "transaction")}`;
|
|
218
|
+
return `Undone: ${name(view.file)}: ${what}${view.account === null ? "" : ` from ${name(view.account)}`}.`;
|
|
219
|
+
}
|
|
220
|
+
// Undo asked for while it can't run yet, or after it already has: where
|
|
221
|
+
// things stand and, when there's a wait, the commands in order. Not a
|
|
222
|
+
// failure: repeating an undo is safe.
|
|
223
|
+
export function renderNotUndoable(view, preview) {
|
|
224
|
+
switch (view.state) {
|
|
225
|
+
case "undone":
|
|
226
|
+
return ok(`Already undone: nothing ${name(view.file)} added is left.\n`);
|
|
227
|
+
case "undoing":
|
|
228
|
+
return ok(`${undoingLine(view)}\n\n${next(waitCommand(view.ref))}`);
|
|
229
|
+
case "stopped":
|
|
230
|
+
return ok(`Nothing to undo: ${name(view.file)} stopped before anything landed.\n`);
|
|
231
|
+
case "reading":
|
|
232
|
+
case "importing":
|
|
233
|
+
case "categorizing":
|
|
234
|
+
return ok(`Not undone yet: ${name(view.file)} is still ${view.state}, and undo works once that finishes.\n\n` +
|
|
235
|
+
`Next, run the first until it says Done or asks a question, then the second:\n ${waitCommand(view.ref)}\n ${CLI} import undo ${view.ref}\n`);
|
|
236
|
+
default:
|
|
237
|
+
return { text: `Refused: ${said(preview.message ?? "This import can't be undone right now.")}\n`, exitCode: 1 };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export function renderUndoPreview(view, preview) {
|
|
241
|
+
const yes = `${CLI} import undo ${view.ref} --yes`;
|
|
242
|
+
if (preview.cancels) {
|
|
243
|
+
return ok(`Not undone yet: ${name(view.file)} is still waiting for an answer, so nothing has landed.\n\n` +
|
|
244
|
+
`${indented("Undoing cancels it and deletes its uploaded file.")}\n\nNext, to cancel it:\n ${yes}\n`);
|
|
245
|
+
}
|
|
246
|
+
const edited = preview.edited > 0 ? `, and ${preview.edited} ${preview.edited === 1 ? "was" : "were"} edited since` : "";
|
|
247
|
+
const parts = [
|
|
248
|
+
`They're the transactions it added${view.account === null ? "" : ` to ${name(view.account)}`}${edited}.`,
|
|
249
|
+
preview.edited > 0 ? "Importing the file again brings the transactions back, but not those edits." : "Importing the file again brings them back.",
|
|
250
|
+
preview.accountRemoved ? "The account it made goes too, since nothing else is in it." : "",
|
|
251
|
+
preview.keptForLaterImports > 0 ? `${plural(preview.keptForLaterImports, "transaction")} stay, since a later import found them too.` : "",
|
|
252
|
+
"If you're working for someone, check with them first.",
|
|
253
|
+
].filter((part) => part !== "");
|
|
254
|
+
return ok(`Not undone yet: undoing ${name(view.file)} would remove ${plural(preview.transactions, "transaction")}.\n\n${indented(parts.join(" "))}\n\n` +
|
|
255
|
+
`Next, to undo it:\n ${yes}\n`);
|
|
256
|
+
}
|
|
257
|
+
export function renderList(views, context) {
|
|
258
|
+
if (views.length === 0) {
|
|
259
|
+
return ok(`You haven't imported any statements yet.\n\n${next(`${CLI} import <FILE>`)}`);
|
|
260
|
+
}
|
|
261
|
+
const rows = views.map((view) => ({ ref: view.ref, state: listState(view), file: quotedForTerminal(view.file, 40), date: shortDate(view.startedAt, context) }));
|
|
262
|
+
const stateWidth = Math.max(...rows.map((row) => row.state.length));
|
|
263
|
+
const fileWidth = Math.max(...rows.map((row) => row.file.length));
|
|
264
|
+
const table = rows.map((row) => ` ${row.ref} ${row.state.padEnd(stateWidth)} ${row.file.padEnd(fileWidth)} ${row.date}`.trimEnd()).join("\n");
|
|
265
|
+
const waiting = views.filter((view) => view.state === "waiting");
|
|
266
|
+
const attention = waiting.length === 0 ? "" : `\nNeeds attention:\n${waiting.map((view) => ` ${CLI} import ${view.ref}`).join("\n")}\n`;
|
|
267
|
+
return ok(`Your recent imports, newest first:\n\n${table}\n${attention}`);
|
|
268
|
+
}
|
|
269
|
+
function listState(view) {
|
|
270
|
+
switch (view.state) {
|
|
271
|
+
case "waiting":
|
|
272
|
+
return "waiting for an answer";
|
|
273
|
+
case "categorizing":
|
|
274
|
+
return view.progress === null ? "categorizing" : `categorizing, ${view.progress.categorized}/${view.progress.total}`;
|
|
275
|
+
case "done":
|
|
276
|
+
return view.nothingNew !== null ? "done, nothing new" : `done, ${view.counts?.new ?? 0} new`;
|
|
277
|
+
default:
|
|
278
|
+
return view.state;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function leftOutText(view) {
|
|
282
|
+
const pending = (view.counts?.pending ?? 0) + view.leftOut.notPosted;
|
|
283
|
+
return [
|
|
284
|
+
pending > 0 ? plural(pending, "pending charge") : "",
|
|
285
|
+
view.leftOut.balance > 0 ? plural(view.leftOut.balance, "balance line") : "",
|
|
286
|
+
view.leftOut.unreadable > 0 ? plural(view.leftOut.unreadable, "unreadable line") : "",
|
|
287
|
+
]
|
|
288
|
+
.filter((part) => part !== "")
|
|
289
|
+
.join(" · ");
|
|
290
|
+
}
|
|
291
|
+
export function plural(count, noun) {
|
|
292
|
+
return `${count.toLocaleString("en-US")} ${noun}${count === 1 ? "" : "s"}`;
|
|
293
|
+
}
|
|
294
|
+
function ok(text) {
|
|
295
|
+
return { text, exitCode: 0 };
|
|
296
|
+
}
|
|
297
|
+
function timeLeft(seconds) {
|
|
298
|
+
if (seconds < 60)
|
|
299
|
+
return "under a minute left";
|
|
300
|
+
const minutes = Math.round(seconds / 60);
|
|
301
|
+
if (minutes < 90)
|
|
302
|
+
return `about ${minutes} ${minutes === 1 ? "minute" : "minutes"} left`;
|
|
303
|
+
return `about ${Math.round(minutes / 60)} hours left`;
|
|
304
|
+
}
|
|
305
|
+
function ago(iso, now) {
|
|
306
|
+
const started = iso === null ? Number.NaN : Date.parse(iso);
|
|
307
|
+
if (Number.isNaN(started))
|
|
308
|
+
return "a moment ago";
|
|
309
|
+
const seconds = Math.max(0, Math.round((now.getTime() - started) / 1000));
|
|
310
|
+
if (seconds < 60)
|
|
311
|
+
return `${seconds}s ago`;
|
|
312
|
+
const minutes = Math.floor(seconds / 60);
|
|
313
|
+
if (minutes < 60)
|
|
314
|
+
return `${minutes}m ${seconds % 60}s ago`;
|
|
315
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`;
|
|
316
|
+
}
|
|
317
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
318
|
+
// Statement dates are calendar dates (YYYY-MM-DD), read without a zone.
|
|
319
|
+
function dateRange(from, to) {
|
|
320
|
+
const [fromYear, fromMonth, fromDay] = from.split("-").map(Number);
|
|
321
|
+
const [toYear, toMonth, toDay] = to.split("-").map(Number);
|
|
322
|
+
if ([fromYear, fromMonth, fromDay, toYear, toMonth, toDay].some((part) => part === undefined || Number.isNaN(part)))
|
|
323
|
+
return "";
|
|
324
|
+
const day = (month, dayOfMonth) => `${MONTHS[(month ?? 1) - 1] ?? ""} ${String(dayOfMonth)}`;
|
|
325
|
+
return fromYear === toYear
|
|
326
|
+
? `${day(fromMonth, fromDay)} – ${day(toMonth, toDay)}, ${String(toYear)}`
|
|
327
|
+
: `${day(fromMonth, fromDay)}, ${String(fromYear)} – ${day(toMonth, toDay)}, ${String(toYear)}`;
|
|
328
|
+
}
|
|
329
|
+
function shortDate(iso, context) {
|
|
330
|
+
const at = iso === null ? Number.NaN : Date.parse(iso);
|
|
331
|
+
if (Number.isNaN(at))
|
|
332
|
+
return "";
|
|
333
|
+
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: context.timeZone }).format(new Date(at));
|
|
334
|
+
}
|