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.
- package/README.md +77 -8
- package/bin/telstore.js +34 -7
- package/package.json +1 -1
- package/src/caption.js +40 -2
- package/src/cli.js +105 -22
- package/src/commands/delete.js +230 -2
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +297 -50
- package/src/commands/status.js +80 -13
- package/src/commands/upload.js +255 -13
- package/src/downloader.js +4 -1
- package/src/manifest.js +21 -1
- package/src/progress.js +1 -0
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
- package/src/state.js +161 -45
package/src/commands/delete.js
CHANGED
|
@@ -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 --
|
|
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
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) => (
|
|
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(
|
|
72
|
-
...rows.map((row) => line(
|
|
95
|
+
line(columns.map((column) => column.header)),
|
|
96
|
+
...rows.map((row) => line(columns.map((column) => row[column.key]))),
|
|
73
97
|
]
|
|
74
98
|
}
|
|
75
99
|
|