telstore 0.1.8 → 0.1.10
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 +110 -5
- package/bin/telstore.js +272 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +279 -12
- package/src/client.js +168 -9
- package/src/commands/delete.js +335 -43
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +170 -16
- package/src/commands/restore-stream.js +407 -0
- package/src/commands/restore.js +27 -20
- package/src/commands/status.js +190 -19
- package/src/commands/upload-stream.js +459 -0
- package/src/commands/upload.js +37 -25
- package/src/commands/verify.js +294 -0
- package/src/manifest.js +59 -2
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +78 -0
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { describeChat } from '../chat.js'
|
|
6
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
7
|
+
import { askConfirm } from '../confirm.js'
|
|
8
|
+
import { deleteCommand, shellArg } from '../shell.js'
|
|
9
|
+
import { listRestores, listStates } from '../state.js'
|
|
10
|
+
|
|
11
|
+
// Nothing here may reach src/client.js, directly or through a command that does: `down` is
|
|
12
|
+
// run on a machine somebody is finished with, often one with no network and sometimes one
|
|
13
|
+
// they no longer trust. It opens no socket, and test/bin.test.js measures that it stays
|
|
14
|
+
// that way — a single import of status.js would put teleproto back in the path.
|
|
15
|
+
|
|
16
|
+
const LABEL_WIDTH = 'Destination'.length + 2
|
|
17
|
+
|
|
18
|
+
function row(label, value) {
|
|
19
|
+
return ` ${label.padEnd(LABEL_WIDTH)}${value}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// status prints this same count about this same directory. It cannot be imported from there
|
|
23
|
+
// without dragging teleproto in, so it is copied — and copied exactly, because two commands
|
|
24
|
+
// disagreeing about what is unfinished on one machine is worse than either wording alone.
|
|
25
|
+
function unfinishedCount(uploads, restores) {
|
|
26
|
+
const parts = []
|
|
27
|
+
|
|
28
|
+
if (uploads > 0) parts.push(`${uploads} upload${uploads === 1 ? '' : 's'}`)
|
|
29
|
+
if (restores > 0) parts.push(`${restores} restore${restores === 1 ? '' : 's'}`)
|
|
30
|
+
|
|
31
|
+
return parts.length === 0 ? 'none' : parts.join(', ')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A recursive remove is the one mistake in this command nobody could apologise for, and the
|
|
35
|
+
// path it walks is derived rather than typed — os.homedir() reading an empty HOME is all it
|
|
36
|
+
// takes. These two are not reachable by any correct call, which is exactly why they are
|
|
37
|
+
// worth refusing by name rather than trusting the caller.
|
|
38
|
+
function refuseDangerousTarget(dir) {
|
|
39
|
+
if (dir === os.homedir() || path.dirname(dir) === dir) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`telstore refuses to remove ${dir}: that is a home directory or a filesystem root, ` +
|
|
42
|
+
'not a telstore config directory. Nothing was removed.',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The config is read to describe it, never to trust it. loadConfig throws on a file that is
|
|
48
|
+
// corrupt, that holds a `settings` which is not a group, or that holds both a sealed session
|
|
49
|
+
// and a plain one — and its own advice for all three is to delete the file and log in again.
|
|
50
|
+
// This is the command that does that, so it is the one command a broken config must not stop.
|
|
51
|
+
async function describeConfig(configDir) {
|
|
52
|
+
try {
|
|
53
|
+
return { config: await loadConfig(configDir), error: null }
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { config: {}, error: err.message }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The error itself is deliberately not quoted here. Every message loadConfig throws names the
|
|
60
|
+
// config file, every path to it contains a dot, and a first-sentence split therefore cuts
|
|
61
|
+
// "/home/sho/.telstore/config.json is not valid JSON" down to "/home/sho/" — a mangled path
|
|
62
|
+
// presented as a diagnosis. The whole message is no better: its advice is "delete the file and
|
|
63
|
+
// log in again", which is what this command is in the middle of doing.
|
|
64
|
+
function sessionLine(config, error) {
|
|
65
|
+
if (error) return 'cannot be read — whatever is in that file, it goes with the rest'
|
|
66
|
+
if (config.sealed) return 'sealed — the api_id and api_hash are inside it, so they go too'
|
|
67
|
+
if (config.session) return 'stored here in plain text — api_id and api_hash go with it'
|
|
68
|
+
|
|
69
|
+
return 'not logged in'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// What an unfinished upload was of — either kind of record, which is why this is not the
|
|
73
|
+
// `describeStreamSource` `status` has: that one answers only for a stream record, and one
|
|
74
|
+
// name over two answers is how the two came to disagree. A stream record has no path — its
|
|
75
|
+
// bytes came from a command's stdout — and carries the name the backup was given instead.
|
|
76
|
+
// This listing exists so that nothing goes unnamed before a recursive remove, so a row
|
|
77
|
+
// reading "undefined" is the exact failure it is here to prevent.
|
|
78
|
+
//
|
|
79
|
+
// Blank is the same failure wearing a string's clothes: a record holding `name: " "` would
|
|
80
|
+
// print ` (a command's output)` — a row that names nothing, in the one command whose job
|
|
81
|
+
// is that nothing goes unnamed. Trimmed for that, exactly as `status` trims, and both fields
|
|
82
|
+
// get the rule because a whitespace path is no more a name than a whitespace name is.
|
|
83
|
+
function describeRecordSource(state) {
|
|
84
|
+
if (typeof state.path === 'string' && state.path.trim() !== '') return state.path
|
|
85
|
+
if (typeof state.name === 'string' && state.name.trim() !== '') {
|
|
86
|
+
return `${state.name} (a command's output)`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return 'a record that does not say what it was backing up'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The last thing anything anywhere will say about those chunks. down.md prints a resume
|
|
93
|
+
// command for a .partial "because this is the last time anything will mention that file", and
|
|
94
|
+
// a stream record is the sharper case: it cannot be re-run onto — those bytes have gone past —
|
|
95
|
+
// so once this record is gone nothing on this machine lists those message ids and no manifest
|
|
96
|
+
// in the chat names them. Printing the command removes nothing and opens no socket; it is the
|
|
97
|
+
// naming this whole listing exists for, done for something that lives on Telegram.
|
|
98
|
+
//
|
|
99
|
+
// The chat is named for the reason status names it, and the line itself is built by
|
|
100
|
+
// `deleteCommand` in shell.js so that all four places that print it spell `--chat` the same
|
|
101
|
+
// way. The check in front of it is this command's own decision and stays here: a record that
|
|
102
|
+
// cannot say where its chunks went gets no command at all rather than the chatless one that
|
|
103
|
+
// function would otherwise hand back, because here that would be a guess — `--chat` missing is
|
|
104
|
+
// not `--chat` empty, and runDelete would take it as no destination and resolve one from
|
|
105
|
+
// config. The id needs no guard — the listing above measures state.id.length for its own
|
|
106
|
+
// column, so a record without one never reaches this line.
|
|
107
|
+
function removeCommand(state) {
|
|
108
|
+
const chat = state.chat === null || state.chat === undefined ? '' : String(state.chat).trim()
|
|
109
|
+
|
|
110
|
+
if (chat === '') return null
|
|
111
|
+
|
|
112
|
+
return deleteCommand(state.id, chat)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Only the ones actually on disk. A restore record survives a .partial that was deleted by
|
|
116
|
+
// hand, and pointing at a file that is not there sends somebody looking for nothing.
|
|
117
|
+
async function strandedPartials(restores) {
|
|
118
|
+
const found = []
|
|
119
|
+
|
|
120
|
+
for (const { record } of restores) {
|
|
121
|
+
const partial = `${record.target}.partial`
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await fs.stat(partial)
|
|
125
|
+
found.push({ partial, record })
|
|
126
|
+
} catch {
|
|
127
|
+
// Nothing there to tell them about.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return found
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Everything in the directory that telstore did not put there. The whole directory goes
|
|
135
|
+
// either way — it is what was asked for — but delete's rule holds here too: nothing is
|
|
136
|
+
// removed unnamed.
|
|
137
|
+
//
|
|
138
|
+
// `tmp` is telstore's too: a stream upload borrows one chunk of disk at a time under
|
|
139
|
+
// ~/.telstore rather than /tmp, which is tmpfs on many distributions and would turn a chunk
|
|
140
|
+
// size into a memory limit. Naming it here would be down reporting its own working directory
|
|
141
|
+
// as a stranger's file — and if a run died mid-chunk it may hold up to one chunk, which the
|
|
142
|
+
// recursive remove below takes with everything else.
|
|
143
|
+
async function foreignEntries(configDir) {
|
|
144
|
+
const ours = new Set(['config.json', 'config.json.tmp', 'state', 'tmp'])
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
return (await fs.readdir(configDir)).filter((name) => !ours.has(name))
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function runDown(args = [], options = {}, deps = {}) {
|
|
154
|
+
const {
|
|
155
|
+
configDir = defaultConfigDir(),
|
|
156
|
+
confirm = askConfirm,
|
|
157
|
+
interactive = () => Boolean(process.stdin.isTTY),
|
|
158
|
+
log = (line) => console.log(line),
|
|
159
|
+
} = deps
|
|
160
|
+
|
|
161
|
+
// `telstore down telstore-20260905-7f3a91` is the plausible typo — somebody reaching for
|
|
162
|
+
// the command that removes one backup. Obeying it would wipe the machine instead.
|
|
163
|
+
if (args.length > 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`down takes no arguments, but got "${args.join(' ')}". It removes everything on this ` +
|
|
166
|
+
'machine or nothing. To remove one backup from Telegram, use ' +
|
|
167
|
+
'"npx telstore delete <backup-id>".',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
refuseDangerousTarget(configDir)
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await fs.stat(configDir)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err.code !== 'ENOENT') throw err
|
|
177
|
+
|
|
178
|
+
log(`Nothing to remove: ${configDir} is not there.`)
|
|
179
|
+
return { removed: false, dir: configDir }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { config, error } = await describeConfig(configDir)
|
|
183
|
+
const uploads = await listStates(configDir)
|
|
184
|
+
const restores = await listRestores(configDir)
|
|
185
|
+
const stranded = await strandedPartials(restores)
|
|
186
|
+
const foreign = await foreignEntries(configDir)
|
|
187
|
+
const chat = config.settings?.chat ?? null
|
|
188
|
+
|
|
189
|
+
log('This removes everything telstore keeps on this machine.')
|
|
190
|
+
log('')
|
|
191
|
+
log(row('Directory', configDir))
|
|
192
|
+
log(row('Session', sessionLine(config, error)))
|
|
193
|
+
if (chat !== null) log(row('Destination', describeChat(chat)))
|
|
194
|
+
log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
|
|
195
|
+
if (foreign.length > 0) log(row('Also there', foreign.join(', ')))
|
|
196
|
+
|
|
197
|
+
// The expensive half of what is about to go. An upload record is what lets a second run
|
|
198
|
+
// keep the same backupId and skip the chunks already sent; without it the same file goes
|
|
199
|
+
// up again as a new backup, and the chunks already in the chat stay there under an id
|
|
200
|
+
// nothing on this machine remembers. So the ids are said out loud while someone can read
|
|
201
|
+
// them. A restore record costs nothing to lose — the .partial resumes without it — and is
|
|
202
|
+
// dealt with on the way out instead.
|
|
203
|
+
if (uploads.length > 0) {
|
|
204
|
+
const width = Math.max(...uploads.map(({ state }) => state.id.length))
|
|
205
|
+
|
|
206
|
+
log('')
|
|
207
|
+
log('These uploads have not finished. Their records are the only thing that lets a second')
|
|
208
|
+
log('run carry on: without them the same file goes up again as a new backup, and the chunks')
|
|
209
|
+
log('already sent stay in the chat under these ids and nothing else:')
|
|
210
|
+
log('')
|
|
211
|
+
for (const { state } of uploads) {
|
|
212
|
+
log(` ${state.id.padEnd(width)} ${describeRecordSource(state)}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const streams = uploads.filter(({ state }) => state.kind === 'stream')
|
|
216
|
+
|
|
217
|
+
if (streams.length > 0) {
|
|
218
|
+
log('')
|
|
219
|
+
log('The ones marked as a command\'s output cannot be carried on at all: those bytes have')
|
|
220
|
+
log('gone past, and a second run cuts them differently. Their records are the only list of')
|
|
221
|
+
log('the chunks those runs left in the chat, and nothing replaces them — so this is the')
|
|
222
|
+
log('last chance to copy the commands that remove those chunks:')
|
|
223
|
+
log('')
|
|
224
|
+
|
|
225
|
+
for (const { state } of streams) {
|
|
226
|
+
const command = removeCommand(state)
|
|
227
|
+
|
|
228
|
+
if (command === null) {
|
|
229
|
+
log(` ${state.id}`)
|
|
230
|
+
log(' this record does not say which chat its chunks went to, so there is no')
|
|
231
|
+
log(' command that could reach them')
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
log(` ${command}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
log('')
|
|
241
|
+
log('Nothing on Telegram is touched: every backup stays where it is.')
|
|
242
|
+
|
|
243
|
+
if (!options.yes) {
|
|
244
|
+
if (!interactive()) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Nothing was removed: there is no terminal to confirm in. Run again with --yes to ` +
|
|
247
|
+
`remove ${configDir} without being asked.`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
log('')
|
|
252
|
+
|
|
253
|
+
if (!(await confirm(`This cannot be undone. Remove ${configDir}? [y/N] `))) {
|
|
254
|
+
throw new Error('Cancelled on request.')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await fs.rm(configDir, { recursive: true, force: true })
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// fs.rm walks the tree, so a permission error halfway leaves some of it gone. Reporting
|
|
262
|
+
// that as a success is the one thing this project never does.
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Could not remove ${configDir}: ${err.message}. Some of it may already be gone — ` +
|
|
265
|
+
'run "npx telstore down" again once the permissions allow it.',
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
log('')
|
|
270
|
+
log(`Done. Removed ${configDir}.`)
|
|
271
|
+
|
|
272
|
+
if (config.session || config.sealed || error) {
|
|
273
|
+
log(
|
|
274
|
+
'Note: this only deletes the local copy — the session is still alive on Telegram\'s ' +
|
|
275
|
+
'side. To revoke access for good, open Telegram → Settings → Devices (Active ' +
|
|
276
|
+
'sessions) and terminate that session.',
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (config.apiId || config.apiHash || config.sealed) {
|
|
281
|
+
log(
|
|
282
|
+
'api_id and api_hash are gone too, which logout would have kept: the next ' +
|
|
283
|
+
'"npx telstore login" asks for them again (my.telegram.org).',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
log(
|
|
288
|
+
chat === null
|
|
289
|
+
? 'Your backups are still on Telegram. Log in again and "npx telstore list" finds them.'
|
|
290
|
+
: `Your backups are still in ${describeChat(chat)} — log in again and ` +
|
|
291
|
+
'"npx telstore list" finds them.',
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
// The .partial is the user's data, and down removes what was asked for and nothing else.
|
|
295
|
+
// Unlike after a delete it can still be finished: the chunks and the manifest a resume
|
|
296
|
+
// checks against were never touched. What is gone is the record that listed it, so this
|
|
297
|
+
// is the last time anything will mention the file at all.
|
|
298
|
+
for (const { partial, record } of stranded) {
|
|
299
|
+
const chatFlag = record.chat ? ` --chat ${shellArg(record.chat)}` : ''
|
|
300
|
+
|
|
301
|
+
log('')
|
|
302
|
+
log(`${partial} is a half-finished restore of ${record.id}, and is left where it is.`)
|
|
303
|
+
log('It still resumes — log in again and run it from anywhere:')
|
|
304
|
+
log(
|
|
305
|
+
` npx telstore restore ${shellArg(record.id)} --out ${shellArg(record.target)}${chatFlag}`,
|
|
306
|
+
)
|
|
307
|
+
log('Nothing else will remind you: the record that listed it went with the rest.')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { removed: true, dir: configDir, uploads: uploads.length, restores: restores.length }
|
|
311
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseManifestCaption } from '../caption.js'
|
|
2
2
|
import { chatName, describeChat } from '../chat.js'
|
|
3
3
|
import {
|
|
4
4
|
closeQuietly,
|
|
5
5
|
connect as realConnect,
|
|
6
|
-
|
|
6
|
+
iterDocuments,
|
|
7
|
+
iterManifestSearch,
|
|
7
8
|
} from '../client.js'
|
|
8
9
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
10
|
+
import { MANIFEST_SUFFIX } from '../manifest.js'
|
|
11
|
+
import { createWalkNotice } from '../progress.js'
|
|
9
12
|
import { assertLoggedIn } from '../session.js'
|
|
10
13
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
11
14
|
|
|
@@ -30,15 +33,48 @@ function shorten(note) {
|
|
|
30
33
|
return note.length > NOTE_WIDTH ? `${note.slice(0, NOTE_WIDTH - 1)}…` : note
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// What list has to read through depends on how big the backups are, not how many there are:
|
|
37
|
+
// it walks from the newest message down and stops the moment it has --limit manifests, so the
|
|
38
|
+
// three thousandth backup in a chat costs nothing because it is never reached. What costs is
|
|
39
|
+
// the chunks in between — one message each — which is why the ceiling is a budget per backup
|
|
40
|
+
// asked for rather than one number for every chat.
|
|
41
|
+
//
|
|
42
|
+
// 60 documents per backup covers a backup of about 105GB at the default chunk size. A fixed
|
|
43
|
+
// 1000 was both too tight and too loose at once: twenty backups of 100GB need 1140 documents
|
|
44
|
+
// and got 1000 of them, while `--limit 5` never needed more than 300.
|
|
45
|
+
export const DOCUMENTS_PER_BACKUP = 60
|
|
46
|
+
|
|
47
|
+
// A search result is already a manifest, so it buys far more backups per document read. Its
|
|
48
|
+
// budget only has to cover what matchesTerm throws away — measured 2026-09-08, a term like
|
|
49
|
+
// "2026-09" comes back matching everything and is then cut down to the month asked for.
|
|
50
|
+
export const RESULTS_PER_BACKUP = 20
|
|
51
|
+
|
|
52
|
+
// --limit takes any whole number, so the budget needs an end of its own: without one,
|
|
53
|
+
// `--limit 100000` would ask for six million documents and sixty thousand requests.
|
|
54
|
+
export const MAX_LIST_DOCUMENTS = 10000
|
|
55
|
+
|
|
56
|
+
export function documentBudget(limit, perBackup) {
|
|
57
|
+
return Math.min(limit * perBackup, MAX_LIST_DOCUMENTS)
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
// Stopping at the ceiling used to leave the reader standing there: "there may be older backups
|
|
61
|
+
// further back" is true and offers nothing to do about it. --search reaches them without
|
|
62
|
+
// reading the chunks in between, which is the whole reason it exists.
|
|
63
|
+
const DEEPER_HINT =
|
|
64
|
+
'"npx telstore list --search <text>" reaches older ones without reading every chunk ' +
|
|
65
|
+
'in between.'
|
|
66
|
+
|
|
67
|
+
// Telegram matches whole words and nothing shorter: measured 2026-09-08, "projex" found
|
|
68
|
+
// projex.zip while "proj", "pro" and "pr" each found nothing at all. That is the one way a
|
|
69
|
+
// search can come back empty over a backup that is plainly there, so the empty answer says
|
|
70
|
+
// it, and points at the listing that never asks the index.
|
|
71
|
+
const SEARCH_MISS_HELP =
|
|
72
|
+
'Telegram matches whole words: "projex" finds projex.zip, "proj" does not. ' +
|
|
73
|
+
'Run "npx telstore list" without --search to see every backup without going through ' +
|
|
74
|
+
'the search index.'
|
|
75
|
+
|
|
40
76
|
function backupIdFromFileName(fileName) {
|
|
41
|
-
return fileName.
|
|
77
|
+
return fileName.slice(0, -MANIFEST_SUFFIX.length)
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
function utcDay(unixSeconds) {
|
|
@@ -74,6 +110,45 @@ function toRow(message) {
|
|
|
74
110
|
}
|
|
75
111
|
}
|
|
76
112
|
|
|
113
|
+
// A search term is a question about one run, and an empty one is not a question: answering
|
|
114
|
+
// it with every backup would look exactly like a search that matched everything.
|
|
115
|
+
function parseSearchTerm(raw) {
|
|
116
|
+
if (raw === undefined || raw === null) return null
|
|
117
|
+
|
|
118
|
+
const term = String(raw).trim()
|
|
119
|
+
|
|
120
|
+
if (term === '') {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'--search is empty. Write the word to look for, or leave the flag off — "list" ' +
|
|
123
|
+
'without it shows every backup.',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return term
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The four fields a person remembers about a backup, and the whole of what --search compares
|
|
131
|
+
// against. The note is matched entire rather than the 40 characters the table has room for:
|
|
132
|
+
// a word that fell off the end of the column is still a word they typed. A card that cannot
|
|
133
|
+
// be read back leaves only what the message itself knows.
|
|
134
|
+
function searchableFields(message) {
|
|
135
|
+
const id = backupIdFromFileName(message.fileName)
|
|
136
|
+
const card = parseManifestCaption(message.caption)
|
|
137
|
+
|
|
138
|
+
if (!card) return [id, utcDay(message.date)]
|
|
139
|
+
|
|
140
|
+
return [id, card.name, card.note ?? '', card.createdAt.slice(0, 10)]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Telegram decides what comes back; this decides what is true. The index answers a term the
|
|
144
|
+
// way it wants to — measured 2026-09-08, "2026-09" returned every document in the chat — so
|
|
145
|
+
// a hit is shown only if the term really is in one of the fields above. Without this pass a
|
|
146
|
+
// search for a month would list backups from every other month, which is the plausible wrong
|
|
147
|
+
// answer this project exists to refuse.
|
|
148
|
+
function matchesTerm(message, term) {
|
|
149
|
+
return searchableFields(message).some((field) => field.toLowerCase().includes(term))
|
|
150
|
+
}
|
|
151
|
+
|
|
77
152
|
function renderTable(rows) {
|
|
78
153
|
// Most people never write a note, and a column of dashes tells them nothing they did not
|
|
79
154
|
// already know while costing every other column the width it takes.
|
|
@@ -102,12 +177,21 @@ export async function runList(options = {}, deps = {}) {
|
|
|
102
177
|
configDir = defaultConfigDir(),
|
|
103
178
|
connect = realConnect,
|
|
104
179
|
disconnect = (client) => client.destroy(),
|
|
105
|
-
|
|
180
|
+
readDocuments = iterDocuments,
|
|
181
|
+
searchManifests = iterManifestSearch,
|
|
106
182
|
log = (line) => console.log(line),
|
|
183
|
+
// The notice is drawn on stderr, and only onto a terminal: unlike an upload's progress
|
|
184
|
+
// bar, `list` is a command people pipe into grep, and a carriage return in a log file is
|
|
185
|
+
// rubbish. Null means draw nothing at all.
|
|
186
|
+
writeProgress = process.stderr.isTTY ? (text) => process.stderr.write(text) : null,
|
|
187
|
+
now = () => Date.now(),
|
|
107
188
|
} = deps
|
|
108
189
|
|
|
109
190
|
const config = await loadConfig(configDir)
|
|
110
191
|
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
192
|
+
// Before the login gate: a bad term is the user's own typing, and telling them to log in
|
|
193
|
+
// first would send them off after the wrong thing.
|
|
194
|
+
const term = parseSearchTerm(options.search)
|
|
111
195
|
// Ask about the login before the destination: telling someone who has never logged in
|
|
112
196
|
// to pick a chat sends them off after the wrong thing.
|
|
113
197
|
assertLoggedIn(config)
|
|
@@ -115,29 +199,99 @@ export async function runList(options = {}, deps = {}) {
|
|
|
115
199
|
|
|
116
200
|
const client = await connect(config, { verbose: settings.verbose })
|
|
117
201
|
|
|
118
|
-
|
|
202
|
+
// Walked rather than searched, unless a term was given. Telegram's text index can answer
|
|
203
|
+
// nothing at all about a chat that is full of backups — it did for a whole day in a channel
|
|
204
|
+
// that had just been created — and "No backups found" is a sentence someone acts on. The
|
|
205
|
+
// documents themselves were right every time they were asked for.
|
|
206
|
+
//
|
|
207
|
+
// --search is the one place worth paying the index for: a term matches a backup that may be
|
|
208
|
+
// ten thousand messages back, and walking to it would cost a request per hundred documents
|
|
209
|
+
// in between, every time, for as long as the chat keeps growing. So the search narrows and
|
|
210
|
+
// matchesTerm decides — the index is asked where to look, never what is true.
|
|
211
|
+
const found = []
|
|
212
|
+
let read = 0
|
|
213
|
+
|
|
214
|
+
const unit = term ? 'search results' : 'documents'
|
|
215
|
+
const budget = documentBudget(settings.limit, term ? RESULTS_PER_BACKUP : DOCUMENTS_PER_BACKUP)
|
|
216
|
+
|
|
217
|
+
const results = term
|
|
218
|
+
? searchManifests(client, chat, term, { max: budget })
|
|
219
|
+
: readDocuments(client, chat, { max: budget })
|
|
220
|
+
|
|
221
|
+
const wanted = term === null ? null : term.toLowerCase()
|
|
222
|
+
const notice = writeProgress ? createWalkNotice({ write: writeProgress, now }) : null
|
|
223
|
+
|
|
119
224
|
try {
|
|
120
|
-
|
|
225
|
+
for await (const document of results) {
|
|
226
|
+
read += 1
|
|
227
|
+
|
|
228
|
+
notice?.tick(
|
|
229
|
+
`Reading ${chatName(chat)}… ${read} ${unit}, ${found.length} backup` +
|
|
230
|
+
`${found.length === 1 ? '' : 's'}`,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if (!document.fileName?.endsWith(MANIFEST_SUFFIX)) continue
|
|
234
|
+
if (wanted !== null && !matchesTerm(document, wanted)) continue
|
|
235
|
+
|
|
236
|
+
found.push(document)
|
|
237
|
+
|
|
238
|
+
// Everything past here is older than the twentieth newest backup, and nobody asked
|
|
239
|
+
// for it. In a chat of ten thousand chunks this is the difference between one
|
|
240
|
+
// request and ten.
|
|
241
|
+
if (found.length >= settings.limit) break
|
|
242
|
+
}
|
|
121
243
|
} finally {
|
|
244
|
+
notice?.clear()
|
|
122
245
|
await closeQuietly(client, disconnect)
|
|
123
246
|
}
|
|
124
247
|
|
|
248
|
+
// The one thing either reader cannot see is what lies past its own ceiling, so anything it
|
|
249
|
+
// says about the whole chat has to stop at the edge of what it read.
|
|
250
|
+
const capped = found.length < settings.limit && read >= budget
|
|
251
|
+
|
|
125
252
|
log(`Destination ${describeChat(chat)}`)
|
|
253
|
+
if (term) log(`Search ${JSON.stringify(term)}`)
|
|
126
254
|
log('')
|
|
127
255
|
|
|
128
|
-
const rows = found
|
|
129
|
-
.filter((message) => message.fileName?.endsWith('.manifest.json'))
|
|
130
|
-
.map(toRow)
|
|
256
|
+
const rows = found.map(toRow)
|
|
131
257
|
|
|
132
258
|
if (rows.length === 0) {
|
|
133
|
-
|
|
259
|
+
if (term) {
|
|
260
|
+
log(
|
|
261
|
+
capped
|
|
262
|
+
? `No backups matching ${JSON.stringify(term)} in the newest ${budget} ` +
|
|
263
|
+
`${unit} from ${chatName(chat)}. There may be older ones further back.`
|
|
264
|
+
: `No backups matching ${JSON.stringify(term)} in ${chatName(chat)}.`,
|
|
265
|
+
)
|
|
266
|
+
log(SEARCH_MISS_HELP)
|
|
267
|
+
return rows
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
log(
|
|
271
|
+
capped
|
|
272
|
+
? `No backups in the newest ${budget} ${unit} of ${chatName(chat)}. ` +
|
|
273
|
+
`There may be older ones further back. ${DEEPER_HINT}`
|
|
274
|
+
: `No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`,
|
|
275
|
+
)
|
|
134
276
|
return rows
|
|
135
277
|
}
|
|
136
278
|
|
|
137
279
|
for (const line of renderTable(rows)) log(line)
|
|
138
280
|
|
|
139
281
|
log('')
|
|
140
|
-
log(
|
|
282
|
+
log(
|
|
283
|
+
`${rows.length} backup${rows.length === 1 ? '' : 's'}` +
|
|
284
|
+
`${term ? ` matching ${JSON.stringify(term)}` : ''}. ` +
|
|
285
|
+
'Restore with: npx telstore restore <backup-id>',
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if (capped) {
|
|
289
|
+
log(
|
|
290
|
+
`Read the newest ${budget} ${unit} in ${chatName(chat)} to find them — ` +
|
|
291
|
+
`there may be older ${term ? 'matches' : 'backups'} further back.` +
|
|
292
|
+
`${term ? '' : ` ${DEEPER_HINT}`}`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
141
295
|
|
|
142
296
|
return rows
|
|
143
297
|
}
|