telstore 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { basename } from 'node:path'
1
2
  import { parseArgs } from 'node:util'
2
3
 
3
4
  const SUBCOMMANDS = new Set([
@@ -13,11 +14,12 @@ const SUBCOMMANDS = new Set([
13
14
  ])
14
15
 
15
16
  const OPTIONS = {
16
- to: { type: 'string' },
17
+ chat: { type: 'string' },
17
18
  'chunk-size': { type: 'string' },
18
19
  'upload-concurrency': { type: 'string' },
19
20
  'download-concurrency': { type: 'string' },
20
21
  out: { type: 'string' },
22
+ note: { type: 'string' },
21
23
  limit: { type: 'string' },
22
24
  verbose: { type: 'boolean' },
23
25
  unset: { type: 'boolean' },
@@ -30,10 +32,10 @@ export const HELP = `telstore — split large files into chunks and store them o
30
32
 
31
33
  Usage:
32
34
  npx telstore login Log in to Telegram, only needed once
33
- npx telstore <file> Split a file and upload it to Telegram
35
+ npx telstore <file|folder|pattern>... Split files and upload them to Telegram
34
36
  npx telstore list List the backups stored in the destination
35
- npx telstore restore <backup-id> Download the chunks and reassemble the file
36
- npx telstore delete <backup-id> Remove a backup's chunks and manifest from the chat
37
+ npx telstore restore <backup-id>... Download the chunks and reassemble the files
38
+ npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
37
39
  npx telstore status Show the account, the destination and unfinished backups
38
40
  npx telstore config Show every setting and where its value comes from
39
41
  npx telstore logout Remove the saved session
@@ -42,6 +44,15 @@ Running on a machine you do not trust:
42
44
  npx telstore token Print a session token for another machine
43
45
  npx telstore login --token Log in there by pasting one, session stays sealed
44
46
 
47
+ Several files in one run go one after another over a single connection, each becoming its own
48
+ backup. A folder means the files one level inside it, and a pattern means the names it matches
49
+ — the shell usually expands those itself, so quote one to hand it to telstore intact. More than
50
+ one file is listed and confirmed before the first byte goes out. Run telstore again with only
51
+ the files that are left to carry on after an interruption.
52
+
53
+ restore and delete take several ids the same way: one connection, one line each, and an exit
54
+ code that reports any that failed. delete shows everything it is about to destroy and asks once.
55
+
45
56
  Settings:
46
57
  npx telstore config <name> Print one setting's value
47
58
  npx telstore config <name> <value> Change it for good
@@ -55,19 +66,25 @@ Settings:
55
66
  verbose Show Telegram connection logs, default false.
56
67
 
57
68
  Options apply to one run and are never saved. Use config to change a setting for good.
58
- --to <chat> Destination for this run only.
69
+ --chat <chat> Destination for this run only.
59
70
  --chunk-size <n> Chunk size for this run only. An unfinished backup keeps the
60
71
  size it started with.
61
72
  --upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
62
73
  --download-concurrency <n> 8MB slices in parallel while restoring, this run only.
63
74
  --out <path> Where to write the restored file. Defaults to the basename in
64
- the manifest.
75
+ the manifest, and works with one backup id only.
76
+ --note <text> A note to store with the upload. It goes into the manifest and
77
+ onto the manifest message, where Telegram's own search can find
78
+ it, and every file of a batch gets the same one. A note with
79
+ spaces in it has to be quoted — --note "march archive" — or the
80
+ shell hands the words after the first to telstore as more files
81
+ to upload.
65
82
  --limit <n> How many backups list shows this run.
66
83
  --token Log in by pasting a session token. It takes no value on
67
84
  purpose: a token written on the command line would sit in
68
85
  "ps" for the whole life of the command, and stay in that
69
86
  machine's shell history afterwards.
70
- --yes Delete without asking to confirm first.
87
+ --yes Upload a batch, or delete, without being asked to confirm.
71
88
  --verbose Show Telegram connection logs for this run.
72
89
  -h, --help Show this help.
73
90
  `
@@ -76,10 +93,27 @@ Options apply to one run and are never saved. Use config to change a setting for
76
93
  // finished chunk to a state file, restore has not. Naming the backup matters because the
77
94
  // id is what `status` lists and what a later `restore` needs — the chunks are already in
78
95
  // the chat under that id, whether or not this run ever finishes.
79
- export function interruptMessage(command, { backupId } = {}) {
96
+ export function interruptMessage(command, { backupId, done = [] } = {}) {
80
97
  if (command === 'upload') {
81
98
  const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
82
99
 
100
+ // A batch is where "run the same command again" turns into a lie: the files it already
101
+ // finished have had their records cleared, so repeating the whole line would upload them
102
+ // a second time under new ids. Name them, and ask for the ones that are left instead.
103
+ if (done.length > 0) {
104
+ const width = Math.max(...done.map((file) => basename(file.path).length))
105
+ const finished = done
106
+ .map((file) => ` ${basename(file.path).padEnd(width)} ${file.id}`)
107
+ .join('\n')
108
+
109
+ return (
110
+ `\n${backup}. These are finished and need no second run:\n${finished}\n` +
111
+ 'Run telstore again with only the files that are left — repeating the whole command ' +
112
+ 'would upload the finished ones again as new backups. "npx telstore status" shows ' +
113
+ 'what is unfinished.\n'
114
+ )
115
+ }
116
+
83
117
  return (
84
118
  `\n${backup} — run the same command again to continue, ` +
85
119
  'or "npx telstore status" to see what is left.\n'
@@ -107,8 +141,8 @@ export function interruptMessage(command, { backupId } = {}) {
107
141
  // parseArgs rejects anything starting with a dash as an option, and reports it as one:
108
142
  // `config chat -100123` fails with "Unknown option '-1'", naming a flag nobody typed.
109
143
  //
110
- // Two shapes need rescuing, and they are rescued differently. As a flag value, `--to -100123`
111
- // is joined into `--to=-100123`; only a bare negative integer qualifies, so `--to --verbose`
144
+ // Two shapes need rescuing, and they are rescued differently. As a flag value, `--chat -100123`
145
+ // is joined into `--chat=-100123`; only a bare negative integer qualifies, so `--chat --verbose`
112
146
  // still reports the missing value instead of eating the next flag. As a positional —
113
147
  // `config chat -100123` — there is nothing to join it to, so `--` goes in front and the rest
114
148
  // of the line is handed over verbatim. That is greedy on purpose: a flag written after the
@@ -125,8 +159,8 @@ function protectNegativeChatIds(argv) {
125
159
  return safe
126
160
  }
127
161
 
128
- if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
129
- safe.push(`--to=${argv[i + 1]}`)
162
+ if (argv[i] === '--chat' && /^-\d+$/.test(argv[i + 1] ?? '')) {
163
+ safe.push(`--chat=${argv[i + 1]}`)
130
164
  i += 1
131
165
  continue
132
166
  }
@@ -142,33 +176,52 @@ function protectNegativeChatIds(argv) {
142
176
  return safe
143
177
  }
144
178
 
179
+ // An unquoted note is gone by the time node starts: the shell hands `--note ghi chu` over as
180
+ // three arguments and nothing can put the quotes back. The one trace it leaves is its own
181
+ // tail — every word after the first sits here as a positional, after the flag — and upload
182
+ // needs that to tell the mistake from a plain missing file. The shape of the command line is
183
+ // already this file's business, so the observation is made here rather than guessed at later.
184
+ //
185
+ // The last --note is the one parseArgs kept, so it is the one whose position counts.
186
+ function filesNamedAfterNote(tokens) {
187
+ const note = tokens.findLast((token) => token.kind === 'option' && token.name === 'note')
188
+
189
+ if (!note) return false
190
+
191
+ return tokens.some((token) => token.kind === 'positional' && token.index > note.index)
192
+ }
193
+
145
194
  export function route(argv) {
146
- const { values, positionals } = parseArgs({
195
+ const { values, positionals, tokens } = parseArgs({
147
196
  args: protectNegativeChatIds(argv),
148
197
  options: OPTIONS,
149
198
  allowPositionals: true,
199
+ tokens: true,
150
200
  })
151
201
 
152
202
  const [first, ...rest] = positionals
203
+ const filesAfterNote = filesNamedAfterNote(tokens)
153
204
 
154
- // `telstore --to @chan` with no file used to mean "remember this destination". Flags no
205
+ // `telstore --chat @chan` with no file used to mean "remember this destination". Flags no
155
206
  // longer write anything, so that line now asks for a run that has nothing to upload —
156
207
  // say where the destination actually lives instead of printing help at someone who was
157
208
  // perfectly clear about what they wanted.
158
- if (first === undefined && values.to && !values.help) {
209
+ if (first === undefined && values.chat && !values.help) {
159
210
  throw new Error(
160
- `Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.to}". ` +
161
- 'To use it for one run, pass --to alongside a file or a command.',
211
+ `Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.chat}". ` +
212
+ 'To use it for one run, pass --chat alongside a file or a command.',
162
213
  )
163
214
  }
164
215
 
165
216
  if (values.help || first === undefined || first === 'help') {
166
- return { command: 'help', args: [], options: values }
217
+ return { command: 'help', args: [], options: values, filesAfterNote }
167
218
  }
168
219
 
169
220
  if (SUBCOMMANDS.has(first)) {
170
- return { command: first, args: rest, options: values }
221
+ return { command: first, args: rest, options: values, filesAfterNote }
171
222
  }
172
223
 
173
- return { command: 'upload', args: [first], options: values }
224
+ // Every positional, not just the first: `telstore a b c` used to upload `a` and drop the
225
+ // rest without a word, which is the one thing this project never does.
226
+ return { command: 'upload', args: positionals, options: values, filesAfterNote }
174
227
  }
@@ -120,7 +120,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
120
120
  if (!manifestMessage && !record) {
121
121
  throw new Error(
122
122
  `No backup ${backupId} found in ${chatName(chat)}, and no unfinished record of it on ` +
123
- 'this machine. Check the id with "npx telstore list", or use --to to point at the ' +
123
+ 'this machine. Check the id with "npx telstore list", or use --chat to point at the ' +
124
124
  'right chat.',
125
125
  )
126
126
  }
@@ -248,3 +248,198 @@ export async function runDelete(backupId, options = {}, deps = {}) {
248
248
  )
249
249
  }
250
250
  }
251
+
252
+
253
+ // `telstore delete a b c` is three deletes, and the question that guards them is asked once —
254
+ // which means it has to be able to say what all three are. So the batch looks every id up
255
+ // before it asks: the manifest for the finished ones, the local record for the unfinished, and
256
+ // an id that neither knows about refuses the whole run rather than half of it. runDelete then
257
+ // does exactly what it does alone, having already been told the answer.
258
+ export async function runDeletes(backupIds, options = {}, deps = {}) {
259
+ const {
260
+ connect = realConnect,
261
+ disconnect = (client) => client.destroy(),
262
+ configDir = defaultConfigDir(),
263
+ searchManifest = findManifestMessage,
264
+ readMessageBytes = realReadMessageBytes,
265
+ confirm = askConfirm,
266
+ interactive = () => Boolean(process.stdin.isTTY),
267
+ writeErr = (line) => process.stderr.write(line),
268
+ log: writeLog = (line) => console.log(line),
269
+ silent = false,
270
+ } = deps
271
+
272
+ // One id keeps its own wording, its own question and its own thrown error.
273
+ if (backupIds.length === 1) {
274
+ const result = await runDelete(backupIds[0], options, deps)
275
+ return { results: [result], failed: 0 }
276
+ }
277
+
278
+ const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
279
+
280
+ if (duplicate) {
281
+ throw new Error(
282
+ `${duplicate} is named twice. Deleting one backup twice does nothing the first pass ` +
283
+ 'did not already do — name it once.',
284
+ )
285
+ }
286
+
287
+ const config = await loadConfig(configDir)
288
+
289
+ assertLoggedIn(config)
290
+
291
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
292
+ const chat = requireChat(settings)
293
+
294
+ const log = silent ? () => {} : writeLog
295
+ const warn = silent ? () => {} : writeErr
296
+
297
+ let shared = null
298
+ const perId = {
299
+ ...deps,
300
+ connect: async (theirConfig, connectOptions) =>
301
+ (shared ??= await connect(theirConfig, connectOptions)),
302
+ disconnect: async () => {},
303
+ }
304
+
305
+ const results = []
306
+
307
+ try {
308
+ const client = await perId.connect(config, { verbose: settings.verbose })
309
+ const rows = []
310
+ const unknown = []
311
+
312
+ for (const backupId of backupIds) {
313
+ const message = await searchManifest(client, chat, backupId)
314
+ const records = await findStates(backupId, configDir)
315
+
316
+ if (!message && records.length === 0) {
317
+ unknown.push(backupId)
318
+ continue
319
+ }
320
+
321
+ // A manifest too damaged to read is exactly the backup somebody is here to remove, so
322
+ // it costs the row its name and size, not the run. runDelete refuses the ones that
323
+ // cannot name their message ids, which is the check that actually protects anything.
324
+ let manifest = null
325
+
326
+ if (message) {
327
+ try {
328
+ manifest = parseManifestJson(await readMessageBytes(client, message))
329
+ } catch {
330
+ manifest = null
331
+ }
332
+ }
333
+
334
+ rows.push({ id: backupId, manifest, record: records[0] ?? null })
335
+ }
336
+
337
+ if (unknown.length > 0) {
338
+ throw new Error(
339
+ `Nothing was deleted: ${unknown.join(', ')} — not found in ${chatName(chat)}, and no ` +
340
+ 'local record of it on this machine either. Check the ids with "npx telstore list".',
341
+ )
342
+ }
343
+
344
+ if (!options.yes) {
345
+ if (!interactive()) {
346
+ throw new Error(
347
+ `${backupIds.length} backups to delete, and no terminal to confirm that in. Run ` +
348
+ 'again with --yes to delete them without being asked.',
349
+ )
350
+ }
351
+
352
+ for (const line of listingLines(rows, chat)) log(line)
353
+
354
+ if (
355
+ !(await confirm(
356
+ `The chunks cannot be recovered. Delete all ${backupIds.length}? [y/N] `,
357
+ ))
358
+ ) {
359
+ throw new Error('Cancelled on request.')
360
+ }
361
+ }
362
+
363
+ for (const [index, backupId] of backupIds.entries()) {
364
+ if (index > 0) log('')
365
+ log(`[${index + 1}/${backupIds.length}] ${backupId}`)
366
+
367
+ try {
368
+ // The question was asked about the whole list a moment ago; asking again per backup
369
+ // would be asking the same thing three times.
370
+ results.push(await runDelete(backupId, { ...options, yes: true }, perId))
371
+ } catch (err) {
372
+ results.push({ id: backupId, error: err.message })
373
+ warn(`\n${backupId} failed: ${err.message}\n`)
374
+ }
375
+ }
376
+ } finally {
377
+ if (shared) {
378
+ await closeQuietly(shared, disconnect, (err) =>
379
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
380
+ )
381
+ }
382
+ }
383
+
384
+ const failed = results.filter((result) => result.error).length
385
+
386
+ log('')
387
+ for (const line of summaryLines(results, failed)) log(line)
388
+
389
+ return { results, failed }
390
+ }
391
+
392
+ // What is about to be destroyed, spelled out before the one question that authorises it. An
393
+ // unfinished backup has no manifest to describe it, so its own record speaks for it.
394
+ function listingLines(rows, chat) {
395
+ const described = rows.map(({ id, manifest, record }) => ({
396
+ id,
397
+ name: manifest
398
+ ? describeName(manifest.name)
399
+ : `${describeName(record?.state?.path)} (unfinished)`,
400
+ size: manifest ? describeSize(manifest.size) : UNKNOWN,
401
+ chunks: plural(countChunks(manifest, record), 'chunk'),
402
+ }))
403
+
404
+ const width = (key) => Math.max(...described.map((row) => row[key].length))
405
+ const [idWidth, nameWidth, sizeWidth] = [width('id'), width('name'), width('size')]
406
+
407
+ return [
408
+ `Deleting ${rows.length} backups from ${describeChat(chat)}`,
409
+ '',
410
+ ...described.map(
411
+ (row) =>
412
+ ` ${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ` +
413
+ `${row.size.padStart(sizeWidth)} ${row.chunks}`,
414
+ ),
415
+ '',
416
+ ]
417
+ }
418
+
419
+ function countChunks(manifest, record) {
420
+ if (Array.isArray(manifest?.chunks)) return manifest.chunks.length
421
+
422
+ const done = record?.state?.done
423
+
424
+ return typeof done === 'object' && done !== null ? Object.keys(done).length : 0
425
+ }
426
+
427
+ // Every id gets a line whether it worked or not: one missing from this list would be a backup
428
+ // nobody could tell the fate of.
429
+ function summaryLines(results, failed) {
430
+ const width = Math.max(...results.map((result) => result.id.length))
431
+ const deleted = results.length - failed
432
+
433
+ return [
434
+ `${results.length} backups: ${deleted} deleted, ${failed} failed.`,
435
+ '',
436
+ ...results.map((result) => {
437
+ const id = result.id.padEnd(width)
438
+
439
+ return result.error
440
+ ? ` ${id} failed: ${result.error}`
441
+ : ` ${id} ${plural(result.chunks, 'chunk message')} removed` +
442
+ (result.manifestDeleted ? ' with its manifest' : '')
443
+ }),
444
+ ]
445
+ }
@@ -18,8 +18,18 @@ const COLUMNS = [
18
18
  { header: 'SIZE', key: 'size', right: true },
19
19
  { header: 'CHUNKS', key: 'chunks', right: true },
20
20
  { header: 'CREATED', key: 'created' },
21
+ { header: 'NOTE', key: 'note' },
21
22
  ]
22
23
 
24
+ // The table is read at a glance, and the note is the one field with no shape at all — 500
25
+ // characters of it would push every column off the side. The whole note is still in the
26
+ // manifest and on the card in the chat, which is where anyone reading it properly will look.
27
+ const NOTE_WIDTH = 40
28
+
29
+ function shorten(note) {
30
+ return note.length > NOTE_WIDTH ? `${note.slice(0, NOTE_WIDTH - 1)}…` : note
31
+ }
32
+
23
33
  // Telegram indexes the tag the manifest caption carries, so one search returns one hit
24
34
  // per backup instead of one per chunk. What comes back is still whatever the server
25
35
  // decided to match, which is why the caller filters on the file name afterwards.
@@ -44,7 +54,14 @@ function toRow(message) {
44
54
  const card = parseManifestCaption(message.caption)
45
55
 
46
56
  if (!card) {
47
- return { id, name: UNKNOWN, size: UNKNOWN, chunks: UNKNOWN, created: utcDay(message.date) }
57
+ return {
58
+ id,
59
+ name: UNKNOWN,
60
+ size: UNKNOWN,
61
+ chunks: UNKNOWN,
62
+ created: utcDay(message.date),
63
+ note: UNKNOWN,
64
+ }
48
65
  }
49
66
 
50
67
  return {
@@ -53,23 +70,30 @@ function toRow(message) {
53
70
  size: card.size,
54
71
  chunks: String(card.chunks),
55
72
  created: card.createdAt.slice(0, 10),
73
+ note: card.note ? shorten(card.note) : UNKNOWN,
56
74
  }
57
75
  }
58
76
 
59
77
  function renderTable(rows) {
60
- const widths = COLUMNS.map((column) =>
78
+ // Most people never write a note, and a column of dashes tells them nothing they did not
79
+ // already know while costing every other column the width it takes.
80
+ const columns = COLUMNS.filter(
81
+ (column) => column.key !== 'note' || rows.some((row) => row.note !== UNKNOWN),
82
+ )
83
+
84
+ const widths = columns.map((column) =>
61
85
  Math.max(column.header.length, ...rows.map((row) => row[column.key].length)),
62
86
  )
63
87
 
64
88
  const line = (cells) =>
65
89
  cells
66
- .map((cell, i) => (COLUMNS[i].right ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
90
+ .map((cell, i) => (columns[i].right ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
67
91
  .join(GAP)
68
92
  .trimEnd()
69
93
 
70
94
  return [
71
- line(COLUMNS.map((column) => column.header)),
72
- ...rows.map((row) => line(COLUMNS.map((column) => row[column.key]))),
95
+ line(columns.map((column) => column.header)),
96
+ ...rows.map((row) => line(columns.map((column) => row[column.key]))),
73
97
  ]
74
98
  }
75
99
 
@@ -9,6 +9,7 @@ import {
9
9
  } from '../client.js'
10
10
  import { askConfirm } from '../confirm.js'
11
11
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
12
+ import { assertLoggedIn } from '../session.js'
12
13
  import { requireChat, resolveSettings } from '../settings.js'
13
14
  import { downloadToFile } from '../downloader.js'
14
15
  import { parseManifest } from '../manifest.js'
@@ -102,7 +103,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
102
103
  if (!manifestMessage) {
103
104
  throw new Error(
104
105
  `No manifest found for ${backupId} in ${chat}. ` +
105
- 'Check the backup id, or use --to to point at the right chat.',
106
+ 'Check the backup id, or use --chat to point at the right chat.',
106
107
  )
107
108
  }
108
109
 
@@ -216,3 +217,119 @@ export async function runRestore(backupId, options = {}, deps = {}) {
216
217
  )
217
218
  }
218
219
  }
220
+
221
+
222
+ // `telstore restore a b c` is three restores, not one download: each backup keeps its own
223
+ // manifest, its own name and its own verification, so a batch is what running the command
224
+ // three times would have produced minus two logins. As with uploads, the connection is shared
225
+ // through the deps seam, which leaves runRestore the only caller of connect.
226
+ export async function runRestores(backupIds, options = {}, deps = {}) {
227
+ const {
228
+ connect = realConnect,
229
+ disconnect = (client) => client.destroy(),
230
+ configDir = defaultConfigDir(),
231
+ writeErr = (line) => process.stderr.write(line),
232
+ log: writeLog = (line) => console.log(line),
233
+ silent = false,
234
+ } = deps
235
+
236
+ // One id must read exactly as it did before this existed: --out still works, the error still
237
+ // reaches the caller, and nothing prints a summary of a list with one thing in it.
238
+ if (backupIds.length === 1) {
239
+ const { path: target, size } = await runRestore(backupIds[0], options, deps)
240
+ return { results: [{ id: backupIds[0], path: target, size }], failed: 0 }
241
+ }
242
+
243
+ // Everything knowable before the first byte arrives is settled here, so a batch never stops
244
+ // halfway over something that was already visible on the command line.
245
+ if (options.out !== undefined) {
246
+ throw new Error(
247
+ `--out names one file, and this run restores ${backupIds.length} backups. Leave it off ` +
248
+ 'to write each one under the name in its own manifest, or restore them one command at ' +
249
+ 'a time to choose the names yourself.',
250
+ )
251
+ }
252
+
253
+ const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
254
+
255
+ if (duplicate) {
256
+ throw new Error(
257
+ `${duplicate} is named twice. Restoring one backup twice would write the same file ` +
258
+ 'over itself — name it once.',
259
+ )
260
+ }
261
+
262
+ const config = await loadConfig(configDir)
263
+
264
+ // Once for the batch. Reported from inside the loop, "Not logged in" would arrive once per
265
+ // id, each time as though that particular backup were the problem.
266
+ assertLoggedIn(config)
267
+
268
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
269
+ requireChat(settings)
270
+
271
+ const log = silent ? () => {} : writeLog
272
+ const warn = silent ? () => {} : writeErr
273
+
274
+ let shared = null
275
+ const perId = {
276
+ ...deps,
277
+ connect: async (theirConfig, connectOptions) =>
278
+ (shared ??= await connect(theirConfig, connectOptions)),
279
+ disconnect: async () => {},
280
+ }
281
+
282
+ const results = []
283
+
284
+ try {
285
+ for (const [index, backupId] of backupIds.entries()) {
286
+ if (index > 0) log('')
287
+ log(`[${index + 1}/${backupIds.length}] ${backupId}`)
288
+
289
+ try {
290
+ const { path: target, size } = await runRestore(backupId, options, perId)
291
+ results.push({ id: backupId, path: target, size })
292
+ } catch (err) {
293
+ // A backup whose chunks are gone says nothing about the next one, and the summary at
294
+ // the end would arrive an hour after the bar of the following id started scrolling
295
+ // over it — so it is named here, and again down there, and carried out as exit code 1.
296
+ results.push({ id: backupId, error: err.message })
297
+ warn(`\n${backupId} failed: ${err.message}\n`)
298
+ }
299
+ }
300
+ } finally {
301
+ if (shared) {
302
+ await closeQuietly(shared, disconnect, (err) =>
303
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
304
+ )
305
+ }
306
+ }
307
+
308
+ const failed = results.filter((result) => result.error).length
309
+
310
+ log('')
311
+ for (const line of summaryLines(results, failed)) log(line)
312
+
313
+ return { results, failed }
314
+ }
315
+
316
+ // Every id gets a line whether it worked or not: one missing from this list would be a backup
317
+ // nobody could tell the fate of.
318
+ function summaryLines(results, failed) {
319
+ const width = Math.max(...results.map((result) => result.id.length))
320
+ const restored = results.length - failed
321
+
322
+ const lines = [
323
+ `${results.length} backups: ${restored} restored, ${failed} failed.`,
324
+ '',
325
+ ...results.map((result) => {
326
+ const id = result.id.padEnd(width)
327
+
328
+ return result.error
329
+ ? ` ${id} failed: ${result.error}`
330
+ : ` ${id} ${result.path} (${formatBytes(result.size)})`
331
+ }),
332
+ ]
333
+
334
+ return lines
335
+ }
@@ -34,12 +34,12 @@ function shellArg(text) {
34
34
 
35
35
  // runUpload refuses to send the rest of a backup to a different chat, so the command has to
36
36
  // name the one the chunks are already in — unless the destination in force is that chat
37
- // anyway, where --to would just be noise. Not knowing the destination counts as not matching:
38
- // leaving --to out would be a guess about where a backup already in progress went.
37
+ // anyway, where --chat would just be noise. Not knowing the destination counts as not matching:
38
+ // leaving --chat out would be a guess about where a backup already in progress went.
39
39
  function resumeCommand(state, destination) {
40
40
  const matches = destination !== null && state.chat === String(destination)
41
41
 
42
- return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --to ${shellArg(state.chat)}`}`
42
+ return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --chat ${shellArg(state.chat)}`}`
43
43
  }
44
44
 
45
45
  // Why a resume is off the table, in the words of the thing the user would have to fix. The
@@ -162,7 +162,7 @@ export async function runStatus(options = {}, deps = {}) {
162
162
 
163
163
  log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
164
164
 
165
- // The destination is what decides whether the resume command needs a --to. A row that
165
+ // The destination is what decides whether the resume command needs a --chat. A row that
166
166
  // failed to parse leaves nothing to compare against, which is not the same as a match.
167
167
  const destination = settings?.chat ?? null
168
168