telstore 0.1.6 → 0.1.8

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.
@@ -1,3 +1,5 @@
1
+ import { promises as fs } from 'node:fs'
2
+
1
3
  import { chatName, describeChat } from '../chat.js'
2
4
  import {
3
5
  DELETE_BATCH_SIZE,
@@ -13,7 +15,7 @@ import { manifestFileName, manifestMessageIds, parseManifestJson } from '../mani
13
15
  import { formatBytes, formatDuration } from '../progress.js'
14
16
  import { assertLoggedIn } from '../session.js'
15
17
  import { requireChat, resolveSettings } from '../settings.js'
16
- import { clearState, findStates } from '../state.js'
18
+ import { clearRestore, clearState, findRestores, findStates } from '../state.js'
17
19
 
18
20
  // What list prints when a card cannot be read back. A manifest is text off a chat, and a
19
21
  // summary is not worth inventing: the numbers below only decorate a decision the backup id
@@ -120,7 +122,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
120
122
  if (!manifestMessage && !record) {
121
123
  throw new Error(
122
124
  `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 ' +
125
+ 'this machine. Check the id with "npx telstore list", or use --chat to point at the ' +
124
126
  'right chat.',
125
127
  )
126
128
  }
@@ -223,6 +225,30 @@ export async function runDelete(backupId, options = {}, deps = {}) {
223
225
 
224
226
  if (record) await clearState(record.key, configDir)
225
227
 
228
+ // The chunks are gone from the chat, so a restore record pointing at this backup now
229
+ // names messages nobody can fetch: `status` would keep offering a resume command that
230
+ // can only fail. Dropped here rather than earlier for the same reason the upload record
231
+ // is — anything that throws above leaves the way back intact.
232
+ //
233
+ // The .partial itself stays. It is the user's data, sometimes gigabytes of it, and this
234
+ // command removes what was asked for and nothing else. But it can never be completed
235
+ // now, so it is named on the way out: that is the difference between a file they can
236
+ // reclaim and one they will never think to look for.
237
+ const stranded = []
238
+
239
+ for (const found of await findRestores(backupId, configDir)) {
240
+ await clearRestore(found.key, configDir)
241
+
242
+ const partial = `${found.record.target}.partial`
243
+
244
+ try {
245
+ await fs.stat(partial)
246
+ stranded.push(partial)
247
+ } catch {
248
+ // Nothing there to tell them about.
249
+ }
250
+ }
251
+
226
252
  if (manifestMessage) {
227
253
  log(
228
254
  `\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
@@ -236,6 +262,13 @@ export async function runDelete(backupId, options = {}, deps = {}) {
236
262
  )
237
263
  }
238
264
 
265
+ for (const partial of stranded) {
266
+ log(
267
+ `${partial} is a half-finished restore of this backup. Nothing can finish it now — ` +
268
+ 'delete it when you want the space back.',
269
+ )
270
+ }
271
+
239
272
  return {
240
273
  id: backupId,
241
274
  chunks: chunkIds.length,
@@ -248,3 +281,198 @@ export async function runDelete(backupId, options = {}, deps = {}) {
248
281
  )
249
282
  }
250
283
  }
284
+
285
+
286
+ // `telstore delete a b c` is three deletes, and the question that guards them is asked once —
287
+ // which means it has to be able to say what all three are. So the batch looks every id up
288
+ // before it asks: the manifest for the finished ones, the local record for the unfinished, and
289
+ // an id that neither knows about refuses the whole run rather than half of it. runDelete then
290
+ // does exactly what it does alone, having already been told the answer.
291
+ export async function runDeletes(backupIds, options = {}, deps = {}) {
292
+ const {
293
+ connect = realConnect,
294
+ disconnect = (client) => client.destroy(),
295
+ configDir = defaultConfigDir(),
296
+ searchManifest = findManifestMessage,
297
+ readMessageBytes = realReadMessageBytes,
298
+ confirm = askConfirm,
299
+ interactive = () => Boolean(process.stdin.isTTY),
300
+ writeErr = (line) => process.stderr.write(line),
301
+ log: writeLog = (line) => console.log(line),
302
+ silent = false,
303
+ } = deps
304
+
305
+ // One id keeps its own wording, its own question and its own thrown error.
306
+ if (backupIds.length === 1) {
307
+ const result = await runDelete(backupIds[0], options, deps)
308
+ return { results: [result], failed: 0 }
309
+ }
310
+
311
+ const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
312
+
313
+ if (duplicate) {
314
+ throw new Error(
315
+ `${duplicate} is named twice. Deleting one backup twice does nothing the first pass ` +
316
+ 'did not already do — name it once.',
317
+ )
318
+ }
319
+
320
+ const config = await loadConfig(configDir)
321
+
322
+ assertLoggedIn(config)
323
+
324
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
325
+ const chat = requireChat(settings)
326
+
327
+ const log = silent ? () => {} : writeLog
328
+ const warn = silent ? () => {} : writeErr
329
+
330
+ let shared = null
331
+ const perId = {
332
+ ...deps,
333
+ connect: async (theirConfig, connectOptions) =>
334
+ (shared ??= await connect(theirConfig, connectOptions)),
335
+ disconnect: async () => {},
336
+ }
337
+
338
+ const results = []
339
+
340
+ try {
341
+ const client = await perId.connect(config, { verbose: settings.verbose })
342
+ const rows = []
343
+ const unknown = []
344
+
345
+ for (const backupId of backupIds) {
346
+ const message = await searchManifest(client, chat, backupId)
347
+ const records = await findStates(backupId, configDir)
348
+
349
+ if (!message && records.length === 0) {
350
+ unknown.push(backupId)
351
+ continue
352
+ }
353
+
354
+ // A manifest too damaged to read is exactly the backup somebody is here to remove, so
355
+ // it costs the row its name and size, not the run. runDelete refuses the ones that
356
+ // cannot name their message ids, which is the check that actually protects anything.
357
+ let manifest = null
358
+
359
+ if (message) {
360
+ try {
361
+ manifest = parseManifestJson(await readMessageBytes(client, message))
362
+ } catch {
363
+ manifest = null
364
+ }
365
+ }
366
+
367
+ rows.push({ id: backupId, manifest, record: records[0] ?? null })
368
+ }
369
+
370
+ if (unknown.length > 0) {
371
+ throw new Error(
372
+ `Nothing was deleted: ${unknown.join(', ')} — not found in ${chatName(chat)}, and no ` +
373
+ 'local record of it on this machine either. Check the ids with "npx telstore list".',
374
+ )
375
+ }
376
+
377
+ if (!options.yes) {
378
+ if (!interactive()) {
379
+ throw new Error(
380
+ `${backupIds.length} backups to delete, and no terminal to confirm that in. Run ` +
381
+ 'again with --yes to delete them without being asked.',
382
+ )
383
+ }
384
+
385
+ for (const line of listingLines(rows, chat)) log(line)
386
+
387
+ if (
388
+ !(await confirm(
389
+ `The chunks cannot be recovered. Delete all ${backupIds.length}? [y/N] `,
390
+ ))
391
+ ) {
392
+ throw new Error('Cancelled on request.')
393
+ }
394
+ }
395
+
396
+ for (const [index, backupId] of backupIds.entries()) {
397
+ if (index > 0) log('')
398
+ log(`[${index + 1}/${backupIds.length}] ${backupId}`)
399
+
400
+ try {
401
+ // The question was asked about the whole list a moment ago; asking again per backup
402
+ // would be asking the same thing three times.
403
+ results.push(await runDelete(backupId, { ...options, yes: true }, perId))
404
+ } catch (err) {
405
+ results.push({ id: backupId, error: err.message })
406
+ warn(`\n${backupId} failed: ${err.message}\n`)
407
+ }
408
+ }
409
+ } finally {
410
+ if (shared) {
411
+ await closeQuietly(shared, disconnect, (err) =>
412
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
413
+ )
414
+ }
415
+ }
416
+
417
+ const failed = results.filter((result) => result.error).length
418
+
419
+ log('')
420
+ for (const line of summaryLines(results, failed)) log(line)
421
+
422
+ return { results, failed }
423
+ }
424
+
425
+ // What is about to be destroyed, spelled out before the one question that authorises it. An
426
+ // unfinished backup has no manifest to describe it, so its own record speaks for it.
427
+ function listingLines(rows, chat) {
428
+ const described = rows.map(({ id, manifest, record }) => ({
429
+ id,
430
+ name: manifest
431
+ ? describeName(manifest.name)
432
+ : `${describeName(record?.state?.path)} (unfinished)`,
433
+ size: manifest ? describeSize(manifest.size) : UNKNOWN,
434
+ chunks: plural(countChunks(manifest, record), 'chunk'),
435
+ }))
436
+
437
+ const width = (key) => Math.max(...described.map((row) => row[key].length))
438
+ const [idWidth, nameWidth, sizeWidth] = [width('id'), width('name'), width('size')]
439
+
440
+ return [
441
+ `Deleting ${rows.length} backups from ${describeChat(chat)}`,
442
+ '',
443
+ ...described.map(
444
+ (row) =>
445
+ ` ${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ` +
446
+ `${row.size.padStart(sizeWidth)} ${row.chunks}`,
447
+ ),
448
+ '',
449
+ ]
450
+ }
451
+
452
+ function countChunks(manifest, record) {
453
+ if (Array.isArray(manifest?.chunks)) return manifest.chunks.length
454
+
455
+ const done = record?.state?.done
456
+
457
+ return typeof done === 'object' && done !== null ? Object.keys(done).length : 0
458
+ }
459
+
460
+ // Every id gets a line whether it worked or not: one missing from this list would be a backup
461
+ // nobody could tell the fate of.
462
+ function summaryLines(results, failed) {
463
+ const width = Math.max(...results.map((result) => result.id.length))
464
+ const deleted = results.length - failed
465
+
466
+ return [
467
+ `${results.length} backups: ${deleted} deleted, ${failed} failed.`,
468
+ '',
469
+ ...results.map((result) => {
470
+ const id = result.id.padEnd(width)
471
+
472
+ return result.error
473
+ ? ` ${id} failed: ${result.error}`
474
+ : ` ${id} ${plural(result.chunks, 'chunk message')} removed` +
475
+ (result.manifestDeleted ? ' with its manifest' : '')
476
+ }),
477
+ ]
478
+ }
@@ -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