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/restore.js
CHANGED
|
@@ -9,10 +9,12 @@ 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
|
-
import { downloadToFile } from '../downloader.js'
|
|
14
|
+
import { downloadToFile, hashRange } from '../downloader.js'
|
|
14
15
|
import { parseManifest } from '../manifest.js'
|
|
15
16
|
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
17
|
+
import { clearRestore, pruneRestores, restoreKey, saveRestore } from '../state.js'
|
|
16
18
|
|
|
17
19
|
// Anything past a minute of waiting needs saying out loud; below that the pause is shorter
|
|
18
20
|
// than the time a user would spend wondering about it.
|
|
@@ -49,6 +51,29 @@ function safeOutName(name) {
|
|
|
49
51
|
return base
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
// How many chunks at the front of a .partial already hold what the manifest says they
|
|
55
|
+
// should. The evidence is the file, never a record: a record makes claims about a local
|
|
56
|
+
// file anyone can edit between runs, and a claim that is wrong here renames a corrupt file
|
|
57
|
+
// into place. Every chunk in the finished file was hashed against the manifest by the run
|
|
58
|
+
// that renamed it, whether this run downloaded it or found it already there.
|
|
59
|
+
async function scanPartial(handle, manifest, log) {
|
|
60
|
+
let done = 0
|
|
61
|
+
|
|
62
|
+
for (const chunk of manifest.chunks) {
|
|
63
|
+
const digest = await hashRange(handle.fd, chunk.i * manifest.chunkSize, chunk.size)
|
|
64
|
+
|
|
65
|
+
// Downloads run in order, so what is already present is a prefix. The first chunk that
|
|
66
|
+
// does not match is where this run starts, and reading past it would hash gigabytes
|
|
67
|
+
// nobody has written yet.
|
|
68
|
+
if (digest !== chunk.sha256) break
|
|
69
|
+
|
|
70
|
+
done += 1
|
|
71
|
+
log(`Chunk ${chunk.i + 1}/${manifest.chunks.length} already restored, skipping.`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return done
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
export async function runRestore(backupId, options = {}, deps = {}) {
|
|
53
78
|
const {
|
|
54
79
|
connect = realConnect,
|
|
@@ -63,6 +88,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
63
88
|
writeErr = (line) => process.stderr.write(line),
|
|
64
89
|
log: writeLog = (line) => console.log(line),
|
|
65
90
|
silent = false,
|
|
91
|
+
onBackupId = () => {},
|
|
66
92
|
} = deps
|
|
67
93
|
|
|
68
94
|
const config = await loadConfig(configDir)
|
|
@@ -102,7 +128,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
102
128
|
if (!manifestMessage) {
|
|
103
129
|
throw new Error(
|
|
104
130
|
`No manifest found for ${backupId} in ${chat}. ` +
|
|
105
|
-
'Check the backup id, or use --
|
|
131
|
+
'Check the backup id, or use --chat to point at the right chat.',
|
|
106
132
|
)
|
|
107
133
|
}
|
|
108
134
|
|
|
@@ -111,6 +137,41 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
111
137
|
const target = path.resolve(options.out ?? safeOutName(manifest.name))
|
|
112
138
|
const partial = `${target}.partial`
|
|
113
139
|
|
|
140
|
+
const key = restoreKey(backupId, target)
|
|
141
|
+
|
|
142
|
+
// The record is a signpost for `status`, never evidence. The scan above proved every
|
|
143
|
+
// chunk it skipped against the manifest and would do so again if this file vanished, so
|
|
144
|
+
// a signpost that cannot be planted warns and gets out of the way. Deliberately the
|
|
145
|
+
// opposite of markChunkDone, where a failed write must be fatal because losing it
|
|
146
|
+
// strands chunks in a chat with nothing left pointing at them.
|
|
147
|
+
// A record that cannot be written warns once, not once per chunk: warn is the same
|
|
148
|
+
// stderr stream the progress bar owns with \r, and a warning for every one of ~30
|
|
149
|
+
// chunks on a real restore would tear through the bar as badly as the retry
|
|
150
|
+
// announcements above reason about at length.
|
|
151
|
+
let recordWarned = false
|
|
152
|
+
|
|
153
|
+
async function note(done) {
|
|
154
|
+
try {
|
|
155
|
+
await saveRestore(
|
|
156
|
+
key,
|
|
157
|
+
{
|
|
158
|
+
v: 1,
|
|
159
|
+
id: backupId,
|
|
160
|
+
target,
|
|
161
|
+
chat: String(chat),
|
|
162
|
+
size: manifest.size,
|
|
163
|
+
chunks: manifest.chunks.length,
|
|
164
|
+
done,
|
|
165
|
+
},
|
|
166
|
+
configDir,
|
|
167
|
+
)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (recordWarned) return
|
|
170
|
+
recordWarned = true
|
|
171
|
+
warn(`\nWarning: could not record restore progress: ${err.message}\n`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
114
175
|
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
115
176
|
// would have telstore overwrite the user's file without asking.
|
|
116
177
|
let exists = true
|
|
@@ -128,66 +189,120 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
128
189
|
log(`Backup ${manifest.id}`)
|
|
129
190
|
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
|
|
130
191
|
|
|
131
|
-
|
|
192
|
+
let handle
|
|
193
|
+
let resuming = true
|
|
194
|
+
|
|
195
|
+
// r+ keeps whatever an earlier run left behind; w+ truncates it to zero, which is what
|
|
196
|
+
// made a kept .partial useless. Only ENOENT means "no file yet" — a permission error
|
|
197
|
+
// quietly becoming "start over" is how two hours of downloading disappear unexplained.
|
|
198
|
+
try {
|
|
199
|
+
handle = await fs.open(partial, 'r+')
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.code !== 'ENOENT') throw err
|
|
202
|
+
handle = await fs.open(partial, 'w+')
|
|
203
|
+
resuming = false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The message this id feeds says a .partial was kept, so naming the id is only honest
|
|
207
|
+
// once one actually exists — before this line, Ctrl-C would have nothing to carry on from.
|
|
208
|
+
onBackupId(backupId)
|
|
132
209
|
|
|
133
210
|
try {
|
|
211
|
+
// Extends a short .partial with zeros and cuts an over-long one, and touches no byte
|
|
212
|
+
// below manifest.size — so one path serves a fresh file and a resumed one alike.
|
|
134
213
|
await handle.truncate(manifest.size)
|
|
135
214
|
|
|
136
|
-
|
|
137
|
-
// byte counts, the speed and the ETA all describe the file, so the line runs 0% to 100%
|
|
138
|
-
// once instead of restarting at every chunk boundary — with 1800MB chunks, a per-chunk
|
|
139
|
-
// ETA answers a question nobody asked.
|
|
140
|
-
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
141
|
-
const progress = createProgress({
|
|
142
|
-
total: manifest.size,
|
|
143
|
-
label: `Chunk 1/${manifest.chunks.length}`,
|
|
144
|
-
write: warn,
|
|
145
|
-
})
|
|
215
|
+
let done = 0
|
|
146
216
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
217
|
+
if (resuming) {
|
|
218
|
+
// Hashing 1800MB takes about 9 seconds, so a large scan runs for minutes. Silence
|
|
219
|
+
// that long is the hang this project refuses everywhere: the heading lands before
|
|
220
|
+
// the first read and a line per chunk arrives as the scan advances.
|
|
221
|
+
log(`Checking what is already in ${partial}...`)
|
|
222
|
+
done = await scanPartial(handle, manifest, log)
|
|
223
|
+
if (done === 0) log(`Nothing in ${partial} matches this backup, starting over.`)
|
|
224
|
+
log('')
|
|
225
|
+
}
|
|
152
226
|
|
|
153
|
-
|
|
227
|
+
await note(done)
|
|
154
228
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
229
|
+
// Housekeeping, the way runUpload prunes its own records. It removes signposts only:
|
|
230
|
+
// dropping one costs a line of `status` for a .partial that still resumes.
|
|
231
|
+
try {
|
|
232
|
+
await pruneRestores(configDir)
|
|
233
|
+
} catch (err) {
|
|
234
|
+
warn(`\nWarning: could not tidy old restore records: ${err.message}\n`)
|
|
235
|
+
}
|
|
161
236
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
237
|
+
const pending = manifest.chunks.slice(done)
|
|
238
|
+
|
|
239
|
+
// A .partial holding every chunk is what a run that died between its last chunk and
|
|
240
|
+
// the rename leaves: no bar at all, rather than one springing into existence at 100%.
|
|
241
|
+
if (pending.length > 0) {
|
|
242
|
+
const present = manifest.chunks
|
|
243
|
+
.slice(0, done)
|
|
244
|
+
.reduce((sum, chunk) => sum + chunk.size, 0)
|
|
245
|
+
|
|
246
|
+
// One bar for the whole restore. The label names the chunk in flight, but the bar, the
|
|
247
|
+
// byte counts, the speed and the ETA all describe the file, so the line runs 0% to 100%
|
|
248
|
+
// once instead of restarting at every chunk boundary — with 1800MB chunks, a per-chunk
|
|
249
|
+
// ETA answers a question nobody asked. Chunks an earlier run left count towards the bar
|
|
250
|
+
// but not towards the speed, so an hour-old chunk cannot inflate the ETA of the rest.
|
|
251
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
252
|
+
const progress = createProgress({
|
|
253
|
+
total: manifest.size,
|
|
254
|
+
done: present,
|
|
255
|
+
label: `Chunk ${pending[0].i + 1}/${manifest.chunks.length}`,
|
|
256
|
+
write: warn,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
for (const chunk of pending) {
|
|
261
|
+
// Before getMessage, not after: the bar is then on screen from the first moment,
|
|
262
|
+
// and finish() below always has a line to close.
|
|
263
|
+
progress.setLabel(`Chunk ${chunk.i + 1}/${manifest.chunks.length}`)
|
|
264
|
+
|
|
265
|
+
const message = await getMessage(client, chat, chunk.msgId)
|
|
266
|
+
|
|
267
|
+
if (!message) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
|
|
270
|
+
'This backup cannot be restored.',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { sha256, size } = await downloadChunk(
|
|
275
|
+
client,
|
|
276
|
+
message,
|
|
277
|
+
handle,
|
|
278
|
+
chunk.i * manifest.chunkSize,
|
|
279
|
+
progress.advance,
|
|
280
|
+
{
|
|
281
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
282
|
+
concurrency: settings.downloadConcurrency,
|
|
283
|
+
},
|
|
177
284
|
)
|
|
178
|
-
}
|
|
179
285
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
286
|
+
if (size !== chunk.size) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytes — mismatch.`,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sha256 !== chunk.sha256) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await note(chunk.i + 1)
|
|
184
299
|
}
|
|
300
|
+
} finally {
|
|
301
|
+
// The bar owns a line that \r keeps returning to. Ending it here rather than after the
|
|
302
|
+
// loop means a chunk that fails mid-download still leaves the cursor on a fresh line,
|
|
303
|
+
// so "Error: ..." does not land on top of the bar.
|
|
304
|
+
progress.finish()
|
|
185
305
|
}
|
|
186
|
-
} finally {
|
|
187
|
-
// The bar owns a line that \r keeps returning to. Ending it here rather than after the
|
|
188
|
-
// loop means a chunk that fails mid-download still leaves the cursor on a fresh line,
|
|
189
|
-
// so "Error: ..." does not land on top of the bar.
|
|
190
|
-
progress.finish()
|
|
191
306
|
}
|
|
192
307
|
} finally {
|
|
193
308
|
await handle.close()
|
|
@@ -207,6 +322,13 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
207
322
|
|
|
208
323
|
await fs.rename(partial, target)
|
|
209
324
|
|
|
325
|
+
// The restore is finished; the signpost has nothing left to point at.
|
|
326
|
+
try {
|
|
327
|
+
await clearRestore(key, configDir)
|
|
328
|
+
} catch (err) {
|
|
329
|
+
warn(`\nWarning: could not remove the restore record: ${err.message}\n`)
|
|
330
|
+
}
|
|
331
|
+
|
|
210
332
|
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
211
333
|
|
|
212
334
|
return { path: target, size: manifest.size }
|
|
@@ -216,3 +338,128 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
216
338
|
)
|
|
217
339
|
}
|
|
218
340
|
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
// `telstore restore a b c` is three restores, not one download: each backup keeps its own
|
|
344
|
+
// manifest, its own name and its own verification, so a batch is what running the command
|
|
345
|
+
// three times would have produced minus two logins. As with uploads, the connection is shared
|
|
346
|
+
// through the deps seam, which leaves runRestore the only caller of connect.
|
|
347
|
+
export async function runRestores(backupIds, options = {}, deps = {}) {
|
|
348
|
+
const {
|
|
349
|
+
connect = realConnect,
|
|
350
|
+
disconnect = (client) => client.destroy(),
|
|
351
|
+
configDir = defaultConfigDir(),
|
|
352
|
+
writeErr = (line) => process.stderr.write(line),
|
|
353
|
+
log: writeLog = (line) => console.log(line),
|
|
354
|
+
silent = false,
|
|
355
|
+
onRestoreDone = () => {},
|
|
356
|
+
onBackupId = () => {},
|
|
357
|
+
} = deps
|
|
358
|
+
|
|
359
|
+
// One id must read exactly as it did before this existed: --out still works, the error still
|
|
360
|
+
// reaches the caller, and nothing prints a summary of a list with one thing in it.
|
|
361
|
+
if (backupIds.length === 1) {
|
|
362
|
+
const { path: target, size } = await runRestore(backupIds[0], options, deps)
|
|
363
|
+
return { results: [{ id: backupIds[0], path: target, size }], failed: 0 }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Everything knowable before the first byte arrives is settled here, so a batch never stops
|
|
367
|
+
// halfway over something that was already visible on the command line.
|
|
368
|
+
if (options.out !== undefined) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`--out names one file, and this run restores ${backupIds.length} backups. Leave it off ` +
|
|
371
|
+
'to write each one under the name in its own manifest, or restore them one command at ' +
|
|
372
|
+
'a time to choose the names yourself.',
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
|
|
377
|
+
|
|
378
|
+
if (duplicate) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`${duplicate} is named twice. Restoring one backup twice would write the same file ` +
|
|
381
|
+
'over itself — name it once.',
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const config = await loadConfig(configDir)
|
|
386
|
+
|
|
387
|
+
// Once for the batch. Reported from inside the loop, "Not logged in" would arrive once per
|
|
388
|
+
// id, each time as though that particular backup were the problem.
|
|
389
|
+
assertLoggedIn(config)
|
|
390
|
+
|
|
391
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
392
|
+
requireChat(settings)
|
|
393
|
+
|
|
394
|
+
const log = silent ? () => {} : writeLog
|
|
395
|
+
const warn = silent ? () => {} : writeErr
|
|
396
|
+
|
|
397
|
+
let shared = null
|
|
398
|
+
const perId = {
|
|
399
|
+
...deps,
|
|
400
|
+
connect: async (theirConfig, connectOptions) =>
|
|
401
|
+
(shared ??= await connect(theirConfig, connectOptions)),
|
|
402
|
+
disconnect: async () => {},
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const results = []
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
for (const [index, backupId] of backupIds.entries()) {
|
|
409
|
+
if (index > 0) log('')
|
|
410
|
+
log(`[${index + 1}/${backupIds.length}] ${backupId}`)
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const { path: target, size } = await runRestore(backupId, options, perId)
|
|
414
|
+
results.push({ id: backupId, path: target, size })
|
|
415
|
+
onRestoreDone({ id: backupId, path: target })
|
|
416
|
+
} catch (err) {
|
|
417
|
+
// A backup whose chunks are gone says nothing about the next one, and the summary at
|
|
418
|
+
// the end would arrive an hour after the bar of the following id started scrolling
|
|
419
|
+
// over it — so it is named here, and again down there, and carried out as exit code 1.
|
|
420
|
+
results.push({ id: backupId, error: err.message })
|
|
421
|
+
warn(`\n${backupId} failed: ${err.message}\n`)
|
|
422
|
+
} finally {
|
|
423
|
+
// Cleared whether this id finished, failed, or never got as far as opening a
|
|
424
|
+
// .partial: otherwise a Ctrl-C while the next id is still connecting, searching for
|
|
425
|
+
// its manifest, or blocked at the overwrite prompt would go on naming this one, as
|
|
426
|
+
// though there were a file to carry on from when there is none yet.
|
|
427
|
+
onBackupId(null)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
if (shared) {
|
|
432
|
+
await closeQuietly(shared, disconnect, (err) =>
|
|
433
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const failed = results.filter((result) => result.error).length
|
|
439
|
+
|
|
440
|
+
log('')
|
|
441
|
+
for (const line of summaryLines(results, failed)) log(line)
|
|
442
|
+
|
|
443
|
+
return { results, failed }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Every id gets a line whether it worked or not: one missing from this list would be a backup
|
|
447
|
+
// nobody could tell the fate of.
|
|
448
|
+
function summaryLines(results, failed) {
|
|
449
|
+
const width = Math.max(...results.map((result) => result.id.length))
|
|
450
|
+
const restored = results.length - failed
|
|
451
|
+
|
|
452
|
+
const lines = [
|
|
453
|
+
`${results.length} backups: ${restored} restored, ${failed} failed.`,
|
|
454
|
+
'',
|
|
455
|
+
...results.map((result) => {
|
|
456
|
+
const id = result.id.padEnd(width)
|
|
457
|
+
|
|
458
|
+
return result.error
|
|
459
|
+
? ` ${id} failed: ${result.error}`
|
|
460
|
+
: ` ${id} ${result.path} (${formatBytes(result.size)})`
|
|
461
|
+
}),
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
return lines
|
|
465
|
+
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { countChunks } from '../chunking.js'
|
|
2
4
|
import { describeChat } from '../chat.js'
|
|
3
5
|
import { closeQuietly, connect as realConnect } from '../client.js'
|
|
@@ -5,7 +7,7 @@ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
|
5
7
|
import { formatBytes } from '../progress.js'
|
|
6
8
|
import { assertLoggedIn } from '../session.js'
|
|
7
9
|
import { resolveSettings } from '../settings.js'
|
|
8
|
-
import { canResume, listStates } from '../state.js'
|
|
10
|
+
import { canResume, listRestores, listStates } from '../state.js'
|
|
9
11
|
|
|
10
12
|
const LABEL_WIDTH = 'Destination'.length + 2
|
|
11
13
|
|
|
@@ -34,12 +36,12 @@ function shellArg(text) {
|
|
|
34
36
|
|
|
35
37
|
// runUpload refuses to send the rest of a backup to a different chat, so the command has to
|
|
36
38
|
// name the one the chunks are already in — unless the destination in force is that chat
|
|
37
|
-
// anyway, where --
|
|
38
|
-
// leaving --
|
|
39
|
+
// anyway, where --chat would just be noise. Not knowing the destination counts as not matching:
|
|
40
|
+
// leaving --chat out would be a guess about where a backup already in progress went.
|
|
39
41
|
function resumeCommand(state, destination) {
|
|
40
42
|
const matches = destination !== null && state.chat === String(destination)
|
|
41
43
|
|
|
42
|
-
return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --
|
|
44
|
+
return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --chat ${shellArg(state.chat)}`}`
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
// Why a resume is off the table, in the words of the thing the user would have to fix. The
|
|
@@ -52,6 +54,15 @@ const NO_RESUME = {
|
|
|
52
54
|
unreadable: 'the record does not name a file that can be read',
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// Why the .partial cannot be resumed from, in the same spirit as NO_RESUME above: an
|
|
58
|
+
// EACCES or an ELOOP is not the same fact as the file being gone, and telling the user
|
|
59
|
+
// their multi-gigabyte download vanished when it is sitting there, unreadable, sends them
|
|
60
|
+
// looking for the wrong problem.
|
|
61
|
+
const NO_PARTIAL_RESUME = {
|
|
62
|
+
missing: 'the partial download is no longer there',
|
|
63
|
+
unreadable: 'the partial download cannot be read',
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
// A command is printed only when it will really resume. Printing one regardless would be
|
|
56
67
|
// telling the user to run something that quietly starts a second backup and abandons every
|
|
57
68
|
// chunk this one already sent — and those chunks are then findable only by this id, which
|
|
@@ -73,6 +84,44 @@ async function resumeLines(key, state, destination, done) {
|
|
|
73
84
|
return lines
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
// The record holds an absolute target, and printing it is what makes the pasted line
|
|
88
|
+
// correct. Without --out, runRestore resolves the manifest's own name against the current
|
|
89
|
+
// directory — a name status does not have (it is in a manifest on Telegram, and status
|
|
90
|
+
// reaches Telegram only for the account line, where it deliberately tolerates failure) and
|
|
91
|
+
// a directory status cannot assume. A command pasted from elsewhere would resolve to
|
|
92
|
+
// another path, miss the .partial and start over, which is the failure resume exists to end.
|
|
93
|
+
function restoreCommand(record, destination) {
|
|
94
|
+
const matches = destination !== null && record.chat === String(destination)
|
|
95
|
+
const chat = matches ? '' : ` --chat ${shellArg(record.chat)}`
|
|
96
|
+
|
|
97
|
+
return `npx telstore restore ${shellArg(record.id)} --out ${shellArg(record.target)}${chat}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The .partial is the whole reason a resume is possible, so its absence is the one thing
|
|
101
|
+
// worth checking before offering a command that would silently start over.
|
|
102
|
+
async function restoreResumeLine(record, destination) {
|
|
103
|
+
try {
|
|
104
|
+
await fs.stat(`${record.target}.partial`)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const reason = err.code === 'ENOENT' ? 'missing' : 'unreadable'
|
|
107
|
+
|
|
108
|
+
return field('Resume', `not possible: ${NO_PARTIAL_RESUME[reason]}.`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return field('Resume', restoreCommand(record, destination))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only the kinds actually present are named. "N backups" fits an upload and not a restore:
|
|
115
|
+
// there the backup is finished and sitting in the chat, and it is the restore that stopped.
|
|
116
|
+
function unfinishedCount(uploads, restores) {
|
|
117
|
+
const parts = []
|
|
118
|
+
|
|
119
|
+
if (uploads > 0) parts.push(`${uploads} upload${uploads === 1 ? '' : 's'}`)
|
|
120
|
+
if (restores > 0) parts.push(`${restores} restore${restores === 1 ? '' : 's'}`)
|
|
121
|
+
|
|
122
|
+
return parts.length === 0 ? 'none' : parts.join(', ')
|
|
123
|
+
}
|
|
124
|
+
|
|
76
125
|
function describeAccount(me) {
|
|
77
126
|
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
78
127
|
const handle = me.username ? ` (@${me.username})` : ''
|
|
@@ -153,24 +202,42 @@ export async function runStatus(options = {}, deps = {}) {
|
|
|
153
202
|
),
|
|
154
203
|
)
|
|
155
204
|
|
|
156
|
-
const
|
|
205
|
+
const uploads = await listStates(configDir)
|
|
206
|
+
const restores = await listRestores(configDir)
|
|
157
207
|
|
|
158
|
-
|
|
159
|
-
log(row('Unfinished', 'none'))
|
|
160
|
-
return
|
|
161
|
-
}
|
|
208
|
+
log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
if (uploads.length === 0 && restores.length === 0) return
|
|
164
211
|
|
|
165
|
-
// The destination is what decides whether
|
|
212
|
+
// The destination is what decides whether a resume command needs a --chat. A row that
|
|
166
213
|
// failed to parse leaves nothing to compare against, which is not the same as a match.
|
|
167
214
|
const destination = settings?.chat ?? null
|
|
168
215
|
|
|
169
|
-
|
|
216
|
+
// Newest first, by when the record last changed — which is when that transfer last made
|
|
217
|
+
// progress, and the same ordering pruneStates already means by "recent".
|
|
218
|
+
const entries = [
|
|
219
|
+
...uploads.map((entry) => ({ ...entry, kind: 'upload' })),
|
|
220
|
+
...restores.map((entry) => ({ ...entry, kind: 'restore' })),
|
|
221
|
+
].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
222
|
+
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
log('')
|
|
225
|
+
|
|
226
|
+
if (entry.kind === 'restore') {
|
|
227
|
+
const { record } = entry
|
|
228
|
+
|
|
229
|
+
log(` ${record.id}`)
|
|
230
|
+
log(field('File', `${record.target} (${formatBytes(record.size)})`))
|
|
231
|
+
log(field('Chunks', `${record.done ?? 0} of ${record.chunks ?? '?'} restored`))
|
|
232
|
+
log(field('Chat', describeChat(record.chat)))
|
|
233
|
+
log(await restoreResumeLine(record, destination))
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const { key, state } = entry
|
|
170
238
|
const total = countChunks(state.size, state.chunkSize)
|
|
171
239
|
const done = Object.keys(state.done ?? {}).length
|
|
172
240
|
|
|
173
|
-
log('')
|
|
174
241
|
log(` ${state.id}`)
|
|
175
242
|
log(field('File', `${state.path} (${formatBytes(state.size)})`))
|
|
176
243
|
log(field('Chunks', `${done} of ${total} uploaded`))
|