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/README.md +122 -161
- package/bin/telstore.js +27 -7
- package/package.json +1 -1
- package/src/caption.js +42 -4
- package/src/cli.js +73 -20
- package/src/commands/delete.js +196 -1
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +118 -1
- package/src/commands/status.js +4 -4
- package/src/commands/upload.js +255 -13
- package/src/manifest.js +21 -1
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
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
|
-
|
|
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
|
|
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
|
|
36
|
-
npx telstore delete <backup-id
|
|
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
|
-
--
|
|
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
|
|
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, `--
|
|
111
|
-
// is joined into `--
|
|
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] === '--
|
|
129
|
-
safe.push(`--
|
|
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 --
|
|
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.
|
|
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.
|
|
161
|
-
'To use it for one run, pass --
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/delete.js
CHANGED
|
@@ -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 --
|
|
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
|
+
}
|
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
|
|
package/src/commands/restore.js
CHANGED
|
@@ -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 --
|
|
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
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -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 --
|
|
38
|
-
// leaving --
|
|
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 ? '' : ` --
|
|
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 --
|
|
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
|
|