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.
- package/README.md +68 -6
- package/bin/telstore.js +27 -7
- package/package.json +1 -1
- package/src/caption.js +40 -2
- 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/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
|
|
package/src/commands/upload.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 --
|
|
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
|
-
`--
|
|
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({
|
|
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: '
|
|
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 `--
|
|
133
|
-
// config command too. Accept the flag spelling as a way in, and canonicalise on
|
|
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 --
|
|
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 --
|
|
200
|
+
'("config chat me" for Saved Messages), or pass --chat to choose one for this run.',
|
|
201
201
|
)
|
|
202
202
|
}
|
|
203
203
|
|