telstore 0.1.6 → 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.
@@ -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
 
@@ -5,10 +5,13 @@ import { Api } from 'teleproto'
5
5
  import { CustomFile } from 'teleproto/client/uploads.js'
6
6
 
7
7
  import { PART_SIZE, planChunks } from '../chunking.js'
8
- import { chunkCaption, manifestCaption } from '../caption.js'
8
+ import { chunkCaption, manifestCaption, parseNote } from '../caption.js'
9
9
  import { describeChat } from '../chat.js'
10
10
  import { closeQuietly, connect as realConnect } from '../client.js'
11
+ import { askConfirm } from '../confirm.js'
11
12
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
13
+ import { expandSources } from '../sources.js'
14
+ import { assertLoggedIn } from '../session.js'
12
15
  import { requireChat, resolveSettings } from '../settings.js'
13
16
  import {
14
17
  buildManifest,
@@ -61,6 +64,49 @@ async function realSendManifest(client, peer, { bytes, fileName, caption }) {
61
64
  })
62
65
  }
63
66
 
67
+ // `--note quarterly accounts` is two arguments by the time node sees it: the note is
68
+ // "quarterly", and "accounts" is a file telstore has been asked to upload. Nothing here can
69
+ // put them back together — the shell dropped the quotes before the process started — so the
70
+ // one useful thing left is to name the mistake this probably was.
71
+ //
72
+ // Two things have to be true before it is said, and neither is enough alone. The note must
73
+ // still be one word — one that kept its spaces is one the shell was told to keep whole, so
74
+ // this cannot have happened to it — and a file must have been named *after* the flag, which
75
+ // is where an unquoted note's remaining words land. Without both, the file name is simply
76
+ // wrong, and sending someone off after a quoting bug that is not there costs them the typo
77
+ // waiting at the front of the same sentence.
78
+ function splitNoteHint(note, filesAfterNote) {
79
+ if (!note || !filesAfterNote || /\s/.test(note)) return ''
80
+
81
+ return (
82
+ ` This run also carries --note ${JSON.stringify(note)}: a note with spaces in it has to be ` +
83
+ 'quoted, or the shell hands every word after the first to telstore as another file. ' +
84
+ 'Write it as --note "the whole note".'
85
+ )
86
+ }
87
+
88
+ // What telstore will read the bytes from. A batch checks every path through this before it
89
+ // sends anything, so "File does not exist" reads the same whether it came from the one file
90
+ // asked for or from the fourth of six — one definition, one wording.
91
+ async function statSource(absPath, { note = null, filesAfterNote = false } = {}) {
92
+ let stat
93
+
94
+ try {
95
+ stat = await fs.stat(absPath)
96
+ } catch (err) {
97
+ if (err.code === 'ENOENT') {
98
+ throw new Error(`File does not exist: ${absPath}.${splitNoteHint(note, filesAfterNote)}`)
99
+ }
100
+ throw err
101
+ }
102
+
103
+ if (!stat.isFile()) {
104
+ throw new Error(`${absPath} is not a file.`)
105
+ }
106
+
107
+ return stat
108
+ }
109
+
64
110
  export async function runUpload(filePath, options = {}, deps = {}) {
65
111
  const {
66
112
  connect = realConnect,
@@ -74,21 +120,20 @@ export async function runUpload(filePath, options = {}, deps = {}) {
74
120
  log: writeLog = (line) => console.log(line),
75
121
  silent = false,
76
122
  onBackupId = () => {},
123
+ // Where on the command line the note sat, as `route` saw it. Nothing else can know, and
124
+ // a run that never says leaves the advice unsaid rather than guessed at.
125
+ filesAfterNote = false,
77
126
  } = deps
78
127
 
79
128
  const absPath = path.resolve(filePath)
80
129
 
81
- let stat
82
- try {
83
- stat = await fs.stat(absPath)
84
- } catch (err) {
85
- if (err.code === 'ENOENT') throw new Error(`File does not exist: ${absPath}`)
86
- throw err
87
- }
130
+ // Before the file is even looked at: the note is the only thing this command sends that a
131
+ // person typed by hand, and it is written into the manifest, which goes out last. A note
132
+ // Telegram would refuse has to stop the run here, not after an hour of chunks whose only
133
+ // list can no longer be sent.
134
+ const note = parseNote(options.note)
88
135
 
89
- if (!stat.isFile()) {
90
- throw new Error(`${absPath} is not a file.`)
91
- }
136
+ const stat = await statSource(absPath, { note, filesAfterNote })
92
137
 
93
138
  const config = await loadConfig(configDir)
94
139
  const { values: settings, source } = resolveSettings(options, config, {
@@ -138,14 +183,14 @@ export async function runUpload(filePath, options = {}, deps = {}) {
138
183
  const resuming = Boolean(state)
139
184
 
140
185
  // Naming the way back rather than a flag to drop: the destination may have come from the
141
- // command line or from the stored setting, and "run again without --to" is no help to
186
+ // command line or from the stored setting, and "run again without --chat" is no help to
142
187
  // someone who never typed one. Pointing at the chat itself is right either way.
143
188
  if (resuming && state.chat !== String(chat)) {
144
189
  const file = stateFile(key, configDir)
145
190
  throw new Error(
146
191
  `This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
147
192
  `a single backup cannot be split across two destinations. Run again with ` +
148
- `--to ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
193
+ `--chat ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
149
194
  `start a new backup in ${chat}.`,
150
195
  )
151
196
  }
@@ -299,6 +344,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
299
344
  name: path.basename(absPath),
300
345
  size: stat.size,
301
346
  chunkSize,
347
+ note,
302
348
  chunks: chunks.map((chunk) => ({ i: chunk.i, ...state.done[String(chunk.i)] })),
303
349
  })
304
350
 
@@ -311,6 +357,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
311
357
  size: manifest.size,
312
358
  chunks: manifest.chunks.length,
313
359
  createdAt: manifest.createdAt,
360
+ note: manifest.note ?? null,
314
361
  }),
315
362
  })
316
363
 
@@ -325,3 +372,198 @@ export async function runUpload(filePath, options = {}, deps = {}) {
325
372
  )
326
373
  }
327
374
  }
375
+
376
+ // `telstore a b c` is three backups, not one: each file keeps its own id, its own manifest and
377
+ // its own resumable record, so a batch is exactly what running the command three times would
378
+ // have produced — minus two logins. The connection is the one thing worth sharing, and it is
379
+ // shared through the same deps seam the tests drive, so runUpload stays the only caller of
380
+ // connect and nothing here has to know what a client is.
381
+ export async function runUploads(filePaths, options = {}, deps = {}) {
382
+ const {
383
+ connect = realConnect,
384
+ disconnect = (client) => client.destroy(),
385
+ configDir = defaultConfigDir(),
386
+ writeErr = (line) => process.stderr.write(line),
387
+ log: writeLog = (line) => console.log(line),
388
+ silent = false,
389
+ onFileDone = () => {},
390
+ confirm = askConfirm,
391
+ interactive = () => Boolean(process.stdin.isTTY),
392
+ filesAfterNote = false,
393
+ } = deps
394
+
395
+ const log = silent ? () => {} : writeLog
396
+ const warn = silent ? () => {} : writeErr
397
+
398
+ // One note covers the whole run, so a note telstore cannot send is one mistake, not one per
399
+ // file. Left to runUpload it would be caught per file and swallowed into the summary as a
400
+ // failed row, after the files ahead of it had already gone out labelled with nothing.
401
+ const note = parseNote(options.note)
402
+
403
+ // What was typed and what will be sent are two different lists once a folder or a pattern is
404
+ // allowed: resolve them here, before anything else has an opinion about them.
405
+ const { paths: found, skipped } = await expandSources(filePaths)
406
+ const paths = found.map((filePath) => path.resolve(filePath))
407
+
408
+ // Something inside a named folder that is not going to be uploaded is still something the
409
+ // user pointed at, so it is said out loud rather than quietly missing from the list.
410
+ const folders = skipped.filter((entry) => entry.reason === 'directory')
411
+
412
+ if (folders.length > 0) {
413
+ warn(
414
+ `\ntelstore reads one level down, so these folders were left alone: ` +
415
+ `${folders.map((entry) => path.basename(entry.path)).join(', ')}. ` +
416
+ 'Name one of them to upload what is inside it.\n',
417
+ )
418
+ }
419
+
420
+ for (const entry of skipped) {
421
+ if (entry.reason !== 'directory') warn(`\n${entry.path} was skipped: ${entry.reason}.\n`)
422
+ }
423
+
424
+ // A single file is the common case and must read exactly as it did before this existed:
425
+ // no batch heading, no question, no summary, and an error that reaches the caller rather
426
+ // than a report.
427
+ if (paths.length === 1) {
428
+ const { id, chunks } = await runUpload(paths[0], options, deps)
429
+ return { results: [{ path: paths[0], id, chunks }], failed: 0 }
430
+ }
431
+
432
+ // Everything that can be known before the first byte goes out is settled here. A typo in the
433
+ // fourth name must not surface an hour into the third file, and a destination nobody set is
434
+ // one problem, not one per file — the report at the end is for what only the transfer can
435
+ // discover.
436
+ const duplicate = paths.find((absPath, index) => paths.indexOf(absPath) !== index)
437
+
438
+ if (duplicate) {
439
+ throw new Error(
440
+ `${duplicate} is named twice — a folder or a pattern can pick up a file that was ` +
441
+ 'named on its own as well. Uploading one file twice would make two backups of the ' +
442
+ 'same bytes, each with its own id: name it once, or run telstore again afterwards if ' +
443
+ 'a second copy is really what you want.',
444
+ )
445
+ }
446
+
447
+ const config = await loadConfig(configDir)
448
+
449
+ // The login is checked here rather than left to the first connect: reported from inside the
450
+ // loop it would arrive once per file, each one having already written a state record for a
451
+ // backup that never sent a byte.
452
+ assertLoggedIn(config)
453
+
454
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
455
+ const chat = requireChat(settings)
456
+
457
+ const sizes = []
458
+
459
+ for (const absPath of paths) sizes.push((await statSource(absPath, { note, filesAfterNote })).size)
460
+
461
+ // The last thing before the first byte. A folder or a pattern hands telstore a list nobody
462
+ // has read yet, and even a hand-typed one is worth seeing added up: this is the moment where
463
+ // "23 files, 180 GB, to @family_photos" is still a question rather than an afternoon.
464
+ if (!options.yes) {
465
+ if (!interactive()) {
466
+ throw new Error(
467
+ `${paths.length} files to upload, and no terminal to confirm that in. Run again with ` +
468
+ '--yes to upload them without being asked.',
469
+ )
470
+ }
471
+
472
+ for (const line of listingLines(paths, sizes, chat)) log(line)
473
+
474
+ if (!(await confirm(`Upload these ${paths.length} files? [y/N] `))) {
475
+ throw new Error('Cancelled on request.')
476
+ }
477
+ }
478
+
479
+ let shared = null
480
+ const perFile = {
481
+ ...deps,
482
+ connect: async (theirConfig, connectOptions) =>
483
+ (shared ??= await connect(theirConfig, connectOptions)),
484
+ disconnect: async () => {},
485
+ }
486
+
487
+ const results = []
488
+
489
+ try {
490
+ for (const [index, absPath] of paths.entries()) {
491
+ if (index > 0) log('')
492
+ log(`[${index + 1}/${paths.length}] ${path.basename(absPath)}`)
493
+
494
+ let result
495
+ try {
496
+ const { id, chunks } = await runUpload(absPath, options, perFile)
497
+ result = { path: absPath, id, chunks }
498
+ } catch (err) {
499
+ // One file's trouble is that file's trouble. Stopping here would leave the files
500
+ // named after it untouched and unmentioned, which is the batch equivalent of the
501
+ // silent drop this command exists to end — so it is recorded and named in the
502
+ // summary, and the exit code carries it out to the shell.
503
+ //
504
+ // It is also said out loud here and now. Waiting for the summary would leave the bar
505
+ // of the next file scrolling for an hour over a failure nobody had been told about.
506
+ result = { path: absPath, error: err.message }
507
+ warn(`\n${path.basename(absPath)} failed: ${err.message}\n`)
508
+ }
509
+
510
+ results.push(result)
511
+ onFileDone(result)
512
+ }
513
+ } finally {
514
+ if (shared) {
515
+ await closeQuietly(shared, disconnect, (err) =>
516
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
517
+ )
518
+ }
519
+ }
520
+
521
+ const failed = results.filter((result) => result.error).length
522
+
523
+ log('')
524
+ for (const line of summaryLines(results, failed)) log(line)
525
+
526
+ return { results, failed }
527
+ }
528
+
529
+ // What the batch is about to do, in the shape the summary will report it afterwards: the same
530
+ // names, the same order, so the two lists can be read against each other.
531
+ function listingLines(paths, sizes, chat) {
532
+ const names = paths.map((absPath) => path.basename(absPath))
533
+ const amounts = sizes.map(formatBytes)
534
+ const width = Math.max(...names.map((name) => name.length))
535
+ const amountWidth = Math.max(...amounts.map((amount) => amount.length))
536
+ const total = sizes.reduce((sum, size) => sum + size, 0)
537
+
538
+ return [
539
+ `${paths.length} files, ${formatBytes(total)}, to ${describeChat(chat)}`,
540
+ '',
541
+ ...names.map((name, i) => ` ${name.padEnd(width)} ${amounts[i].padStart(amountWidth)}`),
542
+ '',
543
+ ]
544
+ }
545
+
546
+ // The one place a batch says how it went. Every file gets a line whether it worked or not:
547
+ // a name missing from this list would be a file nobody could tell the fate of.
548
+ function summaryLines(results, failed) {
549
+ const width = Math.max(...results.map((result) => path.basename(result.path).length))
550
+ const uploaded = results.length - failed
551
+ const lines = [`${results.length} files: ${uploaded} uploaded, ${failed} failed.`, '']
552
+
553
+ for (const result of results) {
554
+ const name = path.basename(result.path).padEnd(width)
555
+
556
+ if (result.error) {
557
+ lines.push(` ${name} failed: ${result.error}`)
558
+ continue
559
+ }
560
+
561
+ lines.push(` ${name} ${result.id} (${result.chunks} chunk${result.chunks === 1 ? '' : 's'})`)
562
+ }
563
+
564
+ if (uploaded > 0) {
565
+ lines.push('', 'Restore with: npx telstore restore <backup-id>')
566
+ }
567
+
568
+ return lines
569
+ }
package/src/manifest.js CHANGED
@@ -19,7 +19,15 @@ export function manifestFileName(id) {
19
19
  return `${id}.manifest.json`
20
20
  }
21
21
 
22
- export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = new Date().toISOString() }) {
22
+ export function buildManifest({
23
+ id,
24
+ name,
25
+ size,
26
+ chunkSize,
27
+ chunks,
28
+ createdAt = new Date().toISOString(),
29
+ note = null,
30
+ }) {
23
31
  return {
24
32
  v: MANIFEST_VERSION,
25
33
  id,
@@ -27,6 +35,9 @@ export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = n
27
35
  size,
28
36
  chunkSize,
29
37
  createdAt,
38
+ // Absent rather than null when there is none: a manifest without a note has to be the
39
+ // same file telstore wrote before the flag existed, down to the bytes.
40
+ ...(note ? { note } : {}),
30
41
  chunks: [...chunks]
31
42
  .sort((a, b) => a.i - b.i)
32
43
  .map(({ i, msgId, size: chunkBytes, sha256 }) => ({ i, msgId, size: chunkBytes, sha256 })),
@@ -105,6 +116,15 @@ export function parseManifest(input) {
105
116
  )
106
117
  }
107
118
 
119
+ // The note is decoration — nothing restores differently because of it — but a manifest is
120
+ // a file a person can edit and send back, and a field holding something other than what it
121
+ // claims to be is the point where telstore stops reading rather than guesses.
122
+ if (manifest.note !== undefined && typeof manifest.note !== 'string') {
123
+ throw new Error(
124
+ `Manifest records a note of ${JSON.stringify(manifest.note)}, which is not text.`,
125
+ )
126
+ }
127
+
108
128
  if (!Number.isSafeInteger(manifest.chunkSize) || manifest.chunkSize < 1) {
109
129
  throw new Error(
110
130
  `Manifest records a chunk size of ${JSON.stringify(manifest.chunkSize)}, ` +
package/src/settings.js CHANGED
@@ -35,7 +35,7 @@ function wholeNumber(raw, where, minimum, maximum, explanation) {
35
35
  // which is why chunkSize prints bytes and leaves "1.8 GB" to `describe`.
36
36
  export const SETTINGS = {
37
37
  chat: {
38
- flag: 'to',
38
+ flag: 'chat',
39
39
  default: null,
40
40
  // A number here is a chat id, and a chat id is a whole number. 42.5 would otherwise
41
41
  // slip through as the string "42.5" and only fail much later, at Telegram, as a chat
@@ -129,9 +129,9 @@ export const SETTINGS = {
129
129
 
130
130
  export const SETTING_KEYS = Object.keys(SETTINGS)
131
131
 
132
- // Someone who has been typing `--to` and `--chunk-size` for a week will type them at the
133
- // config command too. Accept the flag spelling as a way in, and canonicalise on the way to
134
- // disk so the file only ever holds one name per setting.
132
+ // Someone who has been typing `--chunk-size` and `--upload-concurrency` for a week will type
133
+ // them at the config command too. Accept the flag spelling as a way in, and canonicalise on
134
+ // the way to disk so the file only ever holds one name per setting.
135
135
  const ALIASES = new Map()
136
136
 
137
137
  for (const [key, spec] of Object.entries(SETTINGS)) {
@@ -150,7 +150,7 @@ export function isManagedByLogin(input) {
150
150
  // Where a value came from, spelled the way the user would recognise it. A stored value that
151
151
  // fails to parse must not be reported as a bad flag: nobody typed a flag, and telling them
152
152
  // to fix one sends them off after the wrong thing — the same mistake the old
153
- // "run again without --to" advice made.
153
+ // "run again without --chat" advice made.
154
154
  function origin(key, from, file) {
155
155
  return from === 'flag' ? `--${SETTINGS[key].flag}` : `${key} in ${file}`
156
156
  }
@@ -197,7 +197,7 @@ export function requireChat(values) {
197
197
  if (values.chat === null || values.chat === undefined) {
198
198
  throw new Error(
199
199
  'No destination set — run "npx telstore config chat @my_backups" to set one ' +
200
- '("config chat me" for Saved Messages), or pass --to to choose one for this run.',
200
+ '("config chat me" for Saved Messages), or pass --chat to choose one for this run.',
201
201
  )
202
202
  }
203
203