data-ark 0.1.0 → 0.1.2

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 CHANGED
@@ -1,50 +1,89 @@
1
1
  # data-ark
2
2
 
3
- Cắt file lớn thành từng chunk 1.8GB lưu trữ trên Telegram, khôi phục lại được nguyên vẹn.
3
+ Split large files into 1.8GB chunks, store them on Telegram, and restore them intact.
4
4
 
5
- ## Dùng ngay
5
+ ## Quick start
6
6
 
7
7
  ```bash
8
- npx data-ark login # chỉ cần một lần
9
- npx data-ark data.tar --to @kho_backup # đích được ghi nhớ cho lần sau
10
- npx data-ark data.tar # từ lần thứ hai trở đi
8
+ npx data-ark login # once only
9
+ npx data-ark data.tar --to @my_backups # the destination is remembered
10
+ npx data-ark --to @my_backups # only change the destination, upload nothing
11
+ npx data-ark status # account, destination, unfinished backups
12
+ npx data-ark list # what is already stored in the destination
13
+ npx data-ark data.tar # from the second run on
11
14
  npx data-ark restore ark-20260905-7f3a91
12
15
  ```
13
16
 
14
- ## Cần chuẩn bị
17
+ ## What you need
15
18
 
16
- - Node.js 18 trở lên.
17
- - `api_id` `api_hash` lấy tại <https://my.telegram.org> → API development tools. Lệnh `login` sẽ hỏi hai giá trị này, kèm số điện thoại xác nhận.
19
+ - Node.js 18 or newer.
20
+ - An `api_id` and `api_hash` from <https://my.telegram.org> → API development tools. The `login` command asks for both, plus your phone number and the verification code.
18
21
 
19
- data-ark đăng nhập bằng chính tài khoản Telegram của bạn (MTProto), không phải bot. Đây điều kiện bắt buộc: Bot API chỉ cho upload 50MB mỗi file, còn tài khoản người dùng được 2GB.
22
+ data-ark signs in with your own Telegram account (MTProto), not a bot. That is a hard requirement: the Bot API caps uploads at 50MB per file, while a user account gets 2GB.
20
23
 
21
- ## Tuỳ chọn
24
+ ## Options
22
25
 
23
- | Cờ | Mặc định | Ý nghĩa |
26
+ | Flag | Default | Meaning |
24
27
  |---|---|---|
25
- | `--to <chat>` | đích đã ghi nhớ | `@username`, `-100123…`, hoặc `me` |
26
- | `--chunk-size <n>` | `1800MB` | dụ `1.8GB`, `500MB`. Trần cứng 1950MB. |
27
- | `--concurrency <n>` | `8` | Số phần 512KB gửi song song. Phải số nguyên từ 1 đến 64. |
28
- | `--out <đường-dẫn>` | basename của tên trong manifest | Nơi ghi file khi restore, tính từ thư mục hiện tại nếu đường dẫn tương đối |
28
+ | `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me`. `upload` and `status` remember it; `list` and `restore` only look there. A negative channel id works either way: `--to -100123…` or `--to=-100123…` |
29
+ | `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
30
+ | `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
31
+ | `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
32
+ | `--limit <n>` | `20` | How many backups `list` shows, newest first |
33
+ | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
29
34
 
30
- ## Hoạt động thế nào
35
+ ## What the chat looks like
31
36
 
32
- Mỗi lần chạy sinh ra một `backupId`. File được đọc thẳng theo offset, không tạo file tạm, và đẩy lên thành các document tên `<backupId>.partNNNN`. Xong hết, data-ark gửi thêm một manifest JSON liệt kê message id và sha256 của từng chunk. Restore chỉ cần `backupId`: tìm manifest trong chat, tải từng chunk về đúng vị trí trong một file `.partial`, đối chiếu sha256 và kích thước từng chunk, và chỉ đổi tên thành file thật sau khi *toàn bộ* chunk đã khớp.
37
+ Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, and the manifest that
38
+ follows carries a summary card:
33
39
 
34
- Đứt mạng giữa chừng lúc **upload** thì cứ chạy lại đúng lệnh cũ — tiến độ nằm ở `~/.data-ark/state/`, những chunk đã xong sẽ được bỏ qua (`backupId` giữ nguyên). Vài lưu ý về việc chạy lại:
40
+ ```
41
+ 🗄 data.tar
42
+ ━━━━━━━━━━━━━━━
43
+ 💾 21.4 GB · 12 chunks
44
+ 🆔 ark-20260905-7f3a91
45
+ 📅 2026-09-05 16:40 UTC
46
+
47
+ ↩ npx data-ark restore ark-20260905-7f3a91
48
+ #dataark
49
+ ```
50
+
51
+ `npx data-ark list` reads those cards straight out of the chat — one search, no downloads —
52
+ and lays them out as a table:
53
+
54
+ ```
55
+ Destination https://web.telegram.org/k/#@my_backups
56
+
57
+ BACKUP ID FILE SIZE CHUNKS CREATED
58
+ ark-20260905-7f3a91 data.tar 21.4 GB 12 2026-09-05
59
+ ark-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
60
+
61
+ 2 backups. Restore with: npx data-ark restore <backup-id>
62
+ ```
63
+
64
+ A backup uploaded before the card existed still gets a row, with dashes where the caption
65
+ says nothing — `list` reports what the chat holds and never fills gaps with guesses.
66
+ `--to` here only chooses which chat to look at; it does not move the destination the way
67
+ `status --to` does.
68
+
69
+ ## How it works
70
+
71
+ Every run mints a `backupId`. The file is read directly by offset — no temporary copies — and uploaded as documents named `<backupId>.partNNNN`. Once every chunk is up, data-ark sends a JSON manifest listing the message id and sha256 of each one. Restore needs only the `backupId`: it finds the manifest in the chat, downloads each chunk to its exact position in a `.partial` file, checks every chunk's sha256 and size, and renames it to the real file only after *all* chunks match.
72
+
73
+ If the connection drops during an **upload**, just run the same command again — progress lives in `~/.data-ark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
35
74
 
36
- - Nếu chạy lại với `--to` khác với đích đã lưu trong tiến độ dở dang, data-ark **từ chối chạy** thay tự chuyển hướng một backup không thể tách làm hai đích. Thông báo lỗi chỉ đường: bỏ `--to` để tiếp tục gửi vào đích cũ, hoặc xoá file trạng thái để bắt đầu backup mới.
37
- - Nếu chạy lại với `--chunk-size` khác, data-ark coi đó một backup mới hoàn toàn (backup id mới), không resume.
75
+ - Running again with a `--to` that differs from the destination stored in the unfinished progress makes data-ark **refuse to run** rather than silently redirectone backup cannot be split across two destinations. The error points the way out: drop `--to` to keep sending to the original destination, or delete the state file to start a new backup.
76
+ - Running again with a different `--chunk-size` is treated as an entirely new backup (new backup id), not a resume.
38
77
 
39
- **Restore không trạng thái để resume.** Nhấn `Ctrl-C` giữa lúc restore thì không có gì được lưu chạy lại sẽ tải lại từ đầu.
78
+ **Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothingrunning again starts over.
40
79
 
41
- ## Giới hạn cần biết
80
+ ## Limits worth knowing
42
81
 
43
- - Chunk không thể vượt 1950MB: Telegram nhận tối đa 4000 phần 512KB mỗi file, tức trần số học khoảng 1953MB, data-ark chốt 1950MB để chừa biên an toàn.
44
- - Dữ liệu **không** được hoá. Đừng đẩy thứ bạn không muốn nằm trên hạ tầng của người khác.
45
- - Xoá message chunk trên Telegram mất backup, không cách cứu.
46
- - Giữ lấy `backupId`. Không thì phải tự tìm manifest trong chat bằng tay.
82
+ - A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and data-ark stops at 1950MB to leave a safety margin.
83
+ - The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
84
+ - Deleting a chunk message on Telegram destroys the backup, with no way to recover it.
85
+ - Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
47
86
 
48
- ## Cấu hình lưu ở đâu
87
+ ## Where the config lives
49
88
 
50
- `~/.data-ark/config.json` (quyền 600) chứa `apiId`, `apiHash`, session đích lưu mặc định. `npx data-ark logout` **chỉ xoá bản session lưu dưới máy** giữ lại phần còn lại phiên đăng nhập vẫn còn sống phía Telegram. Muốn cắt hẳn quyền truy cập thì vào Telegram → Settings → Devices (Active sessions) thu hồi phiên đó.
89
+ `~/.data-ark/config.json` (mode 600) holds `apiId`, `apiHash`, the session and the default destination. `npx data-ark logout` **only deletes the locally stored session** and keeps the restthe session is still alive on Telegram's side. To revoke access for good, open Telegram → Settings → Devices (Active sessions) and terminate that session.
package/bin/data-ark.js CHANGED
@@ -1,26 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { route, HELP } from '../src/cli.js'
3
3
  import { runLogin } from '../src/commands/login.js'
4
+ import { runList } from '../src/commands/list.js'
4
5
  import { runLogout } from '../src/commands/logout.js'
5
6
  import { runRestore } from '../src/commands/restore.js'
7
+ import { runSetDestination } from '../src/commands/set-destination.js'
8
+ import { runStatus } from '../src/commands/status.js'
6
9
  import { runUpload } from '../src/commands/upload.js'
7
10
 
8
11
  const SIGINT_EXIT_CODE = 130
9
12
 
10
- // Lệnh đang chạy khi Ctrl-C xảy ra mỗi lệnh một sự thật khác nhau về việc
11
- // tiến độ được lưu hay không, nên phải biết đang lệnh nào mới chọn đúng câu.
13
+ // Which command is running when Ctrl-C arriveseach one tells a different truth
14
+ // about whether progress was saved, so we need to know which to pick the right line.
12
15
  let currentCommand = null
13
16
 
14
17
  function sigintMessage(command) {
15
18
  if (command === 'upload') {
16
- return '\nĐã dừng. Tiến độ đã lưu, chạy lại lệnh để đi tiếp.\n'
19
+ return '\nStopped. Progress is saved, run the same command again to continue.\n'
17
20
  }
18
21
 
19
22
  if (command === 'restore') {
20
- return '\nĐã dừng. Chưa lưu tiến độ tải, chạy lại sẽ tải lại từ đầu.\n'
23
+ return '\nStopped. Download progress is not saved, running again starts over.\n'
21
24
  }
22
25
 
23
- return '\nĐã dừng.\n'
26
+ return '\nStopped.\n'
24
27
  }
25
28
 
26
29
  process.on('SIGINT', () => {
@@ -34,7 +37,7 @@ async function main() {
34
37
  try {
35
38
  parsed = route(process.argv.slice(2))
36
39
  } catch (err) {
37
- process.stderr.write(`Lỗi: ${err.message}\n\n${HELP}`)
40
+ process.stderr.write(`Error: ${err.message}\n\n${HELP}`)
38
41
  process.exitCode = 2
39
42
  return
40
43
  }
@@ -47,37 +50,49 @@ async function main() {
47
50
  return
48
51
 
49
52
  case 'login':
50
- await runLogin()
53
+ await runLogin({ verbose: parsed.options.verbose })
51
54
  return
52
55
 
53
56
  case 'logout':
54
57
  await runLogout()
55
58
  return
56
59
 
60
+ case 'list':
61
+ await runList(parsed.options)
62
+ return
63
+
64
+ case 'status':
65
+ await runStatus(parsed.options)
66
+ return
67
+
68
+ case 'set-destination':
69
+ await runSetDestination(parsed.options)
70
+ return
71
+
57
72
  case 'upload':
58
73
  await runUpload(parsed.args[0], parsed.options)
59
74
  return
60
75
 
61
76
  case 'restore':
62
77
  if (!parsed.args[0]) {
63
- throw new Error('Thiếu backup id. dụ: npx data-ark restore ark-20260905-7f3a91')
78
+ throw new Error('Missing backup id. Example: npx data-ark restore ark-20260905-7f3a91')
64
79
  }
65
80
  await runRestore(parsed.args[0], parsed.options)
66
81
  return
67
82
 
68
83
  default:
69
- throw new Error(`Lệnh không nhận ra: ${parsed.command}`)
84
+ throw new Error(`Unknown command: ${parsed.command}`)
70
85
  }
71
86
  }
72
87
 
73
- // GramJS giữ lại các "exported sender" cùng một timer 30 giây để giải phóng
74
- // chúng, client.disconnect()/destroy() không dọn nổi: cả hai map đó là Map
75
- // nhưng code duyệt bằng Object.values nên bỏ sót sạch. Hệ quả: lệnh in "Xong"
76
- // rồi vẫn treo thêm ~30 giây, Ctrl-C trong khoảng đó lại báo sai rằng chưa
77
- // lưu được gì. Chạy xong việc thoát hẳn, không nấn ná.
88
+ // GramJS keeps "exported senders" around together with a 30-second timer to release
89
+ // them, and neither client.disconnect() nor destroy() cleans them up: both of those
90
+ // maps are Maps, but the code walks them with Object.values and so misses everything.
91
+ // The result is a command that prints "Done" and then hangs for another ~30 seconds,
92
+ // during which Ctrl-C falsely reports that nothing was saved. Finish the work, exit.
78
93
  function exitWhenFlushed(code) {
79
- // Ghi rỗng chỉ để mượn callback: chạy sau khi mọi thứ xếp hàng trước đó đã
80
- // xả xuống pipe, nên không mất chữ nào khi stdout/stderr không phải TTY.
94
+ // The empty writes exist only to borrow their callbacks: they fire after everything
95
+ // queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
81
96
  let pending = 2
82
97
 
83
98
  const done = () => {
@@ -85,7 +100,7 @@ function exitWhenFlushed(code) {
85
100
  if (pending === 0) process.exit(code)
86
101
  }
87
102
 
88
- // Lưới an toàn: nếu callback không bao giờ tới (pipe đã đóng) thì vẫn thoát.
103
+ // Safety net: exit anyway if a callback never arrives (the pipe is already closed).
89
104
  setTimeout(() => process.exit(code), 2000).unref()
90
105
 
91
106
  process.stdout.write('', done)
@@ -97,7 +112,7 @@ main().then(
97
112
  exitWhenFlushed(process.exitCode ?? 0)
98
113
  },
99
114
  (err) => {
100
- process.stderr.write(`Lỗi: ${err.message}\n`)
115
+ process.stderr.write(`Error: ${err.message}\n`)
101
116
  process.exitCode = 1
102
117
  exitWhenFlushed(1)
103
118
  },
package/package.json CHANGED
@@ -1,9 +1,27 @@
1
1
  {
2
2
  "name": "data-ark",
3
- "version": "0.1.0",
4
- "description": "Cắt file lớn thành chunk lưu trữ trên Telegram",
3
+ "version": "0.1.2",
4
+ "description": "Split large files into chunks and store them on Telegram",
5
+ "keywords": [
6
+ "telegram",
7
+ "backup",
8
+ "chunk",
9
+ "split",
10
+ "restore",
11
+ "cli",
12
+ "mtproto"
13
+ ],
14
+ "homepage": "https://github.com/shovity/data-ark#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/shovity/data-ark/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/shovity/data-ark.git"
21
+ },
5
22
  "type": "module",
6
23
  "license": "MIT",
24
+ "author": "shovity",
7
25
  "bin": {
8
26
  "data-ark": "bin/data-ark.js"
9
27
  },
package/src/caption.js ADDED
@@ -0,0 +1,63 @@
1
+ import { formatBytes } from './progress.js'
2
+
3
+ // Captions are plain text on purpose. Telegram would render bold through a parse mode,
4
+ // but that turns every file name into something that has to be escaped correctly, and
5
+ // the fake client the tests talk to would never notice a mistake there.
6
+
7
+ const DIVIDER = '━'.repeat(15)
8
+
9
+ // The hashtag is what `list` searches for, and it lives on the manifest alone: chunk
10
+ // captions stay out of that search so a twelve-chunk backup is one hit, not thirteen.
11
+ export const MANIFEST_TAG = '#dataark'
12
+
13
+ // A file name may legally contain a newline or a tab, and either one would push the
14
+ // rest of the card down a row and take its shape apart.
15
+ function oneLine(name) {
16
+ return String(name).replace(/\s+/g, ' ').trim()
17
+ }
18
+
19
+ function utcMinutes(createdAt) {
20
+ return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
21
+ }
22
+
23
+ export function chunkCaption({ id, number, total }) {
24
+ return `📦 ${id} · ${number}/${total}`
25
+ }
26
+
27
+ export function manifestCaption({ id, name, size, chunks, createdAt }) {
28
+ return [
29
+ `🗄 ${oneLine(name)}`,
30
+ DIVIDER,
31
+ `💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
32
+ `🆔 ${id}`,
33
+ `📅 ${utcMinutes(createdAt)}`,
34
+ '',
35
+ `↩ npx data-ark restore ${id}`,
36
+ MANIFEST_TAG,
37
+ ].join('\n')
38
+ }
39
+
40
+ function marker(lines, emoji) {
41
+ const found = lines.find((line) => line.startsWith(`${emoji} `))
42
+ return found ? found.slice(emoji.length + 1).trim() : null
43
+ }
44
+
45
+ // list reads what the chat shows, and a caption is text a person can edit. Anything that
46
+ // does not carry the whole card is reported as unknown rather than half-guessed: a backup
47
+ // listed with invented numbers is worse than one listed with dashes.
48
+ export function parseManifestCaption(text) {
49
+ const lines = String(text ?? '').split('\n')
50
+
51
+ const name = marker(lines, '🗄')
52
+ const totals = marker(lines, '💾')
53
+ const id = marker(lines, '🆔')
54
+ const createdAt = marker(lines, '📅')
55
+
56
+ if (!name || !totals || !id || !createdAt) return null
57
+
58
+ const match = /^(.+) · (\d+) chunks?$/.exec(totals)
59
+
60
+ if (!match) return null
61
+
62
+ return { id, name, size: match[1], chunks: Number(match[2]), createdAt }
63
+ }
package/src/chunking.js CHANGED
@@ -17,28 +17,34 @@ export function parseSize(input) {
17
17
  const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/)
18
18
 
19
19
  if (!match) {
20
- throw new Error(`Kích thước không hợp lệ: "${input}". dụ hợp lệ: 1800MB, 1.8GB, 524288.`)
20
+ throw new Error(`Invalid size: "${input}". Valid examples: 1800MB, 1.8GB, 524288.`)
21
21
  }
22
22
 
23
23
  const bytes = Math.floor(Number(match[1]) * UNITS[match[2] ?? 'b'])
24
24
 
25
25
  if (bytes <= 0) {
26
- throw new Error('Kích thước phải lớn hơn 0.')
26
+ throw new Error('Size must be greater than 0.')
27
27
  }
28
28
 
29
29
  if (bytes > MAX_CHUNK_SIZE) {
30
30
  throw new Error(
31
- 'Chunk tối đa 1950MB. Telegram chỉ nhận 4000 phần 512KB cho mỗi file, ' +
32
- 'tức trần số học khoảng 1953MB, nên cần chừa biên an toàn.',
31
+ 'Maximum chunk size is 1950MB. Telegram accepts only 4000 parts of 512KB per file, ' +
32
+ 'an arithmetic ceiling of about 1953MB, so a safety margin is needed.',
33
33
  )
34
34
  }
35
35
 
36
36
  return bytes
37
37
  }
38
38
 
39
+ // How many chunks a file of this size splits into. planChunks builds them and parseManifest
40
+ // checks them against this same rule, so the layout has one definition, not three.
41
+ export function countChunks(fileSize, chunkSize) {
42
+ return Math.ceil(fileSize / chunkSize)
43
+ }
44
+
39
45
  export function planChunks(fileSize, chunkSize) {
40
46
  if (fileSize <= 0) {
41
- throw new Error('File rỗng, không gì để upload.')
47
+ throw new Error('File is empty, nothing to upload.')
42
48
  }
43
49
 
44
50
  const chunks = []
package/src/cli.js CHANGED
@@ -1,40 +1,80 @@
1
1
  import { parseArgs } from 'node:util'
2
2
 
3
- const SUBCOMMANDS = new Set(['login', 'logout', 'restore', 'help'])
3
+ const SUBCOMMANDS = new Set(['login', 'logout', 'list', 'restore', 'status', 'help'])
4
4
 
5
5
  const OPTIONS = {
6
6
  to: { type: 'string' },
7
7
  'chunk-size': { type: 'string' },
8
8
  concurrency: { type: 'string' },
9
9
  out: { type: 'string' },
10
+ limit: { type: 'string' },
11
+ verbose: { type: 'boolean' },
10
12
  help: { type: 'boolean', short: 'h' },
11
13
  }
12
14
 
13
- export const HELP = `data-ark — cắt file lớn thành chunk lưu trữ trên Telegram
15
+ export const HELP = `data-ark — split large files into chunks and store them on Telegram
14
16
 
15
- Cách dùng:
16
- npx data-ark login Đăng nhập Telegram, chỉ cần chạy một lần
17
- npx data-ark <file> Cắt file đẩy lên Telegram
18
- npx data-ark restore <backup-id> Tải về ghép lại file gốc
19
- npx data-ark logout Xoá phiên đăng nhập đã lưu
17
+ Usage:
18
+ npx data-ark login Log in to Telegram, only needed once
19
+ npx data-ark <file> Split a file and upload it to Telegram
20
+ npx data-ark list List the backups stored in the destination
21
+ npx data-ark restore <backup-id> Download the chunks and reassemble the file
22
+ npx data-ark status Show the account, the destination and unfinished backups
23
+ npx data-ark --to <chat> Set the destination without uploading anything
24
+ npx data-ark logout Remove the saved session
20
25
 
21
- Tuỳ chọn:
22
- --to <chat> Đích lưu: @username, -100123..., hoặc me. Được ghi nhớ cho lần sau.
23
- --chunk-size <n> Kích thước mỗi chunk, mặc định 1800MB. Ví dụ: 1.8GB, 500MB.
24
- --concurrency <n> Số phần 512KB gửi song song, mặc định 8, tối đa 64.
25
- --out <đường-dẫn> Nơi ghi file khi restore. Mặc định lấy basename của tên trong manifest.
26
- -h, --help Hiện trợ giúp này.
26
+ Options:
27
+ --to <chat> Destination: @username, -100123..., or me. upload and status
28
+ remember it; list and restore only look there.
29
+ --chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
30
+ --concurrency <n> 512KB parts sent in parallel, default 8, max 64.
31
+ --out <path> Where to write the restored file. Defaults to the basename in the manifest.
32
+ --limit <n> How many backups list shows, default 20.
33
+ --verbose Show Telegram connection logs, hidden by default.
34
+ -h, --help Show this help.
27
35
  `
28
36
 
37
+ // A channel id is negative, and typing it separated by a space is the natural reflex —
38
+ // but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
39
+ // so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
40
+ // `--to --verbose` still reports the missing value instead of eating the next flag, and
41
+ // everything after `--` is left alone because it is no longer an option there.
42
+ function joinNegativeChatId(argv) {
43
+ const joined = []
44
+
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ if (argv[i] === '--') {
47
+ joined.push(...argv.slice(i))
48
+ return joined
49
+ }
50
+
51
+ if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
52
+ joined.push(`--to=${argv[i + 1]}`)
53
+ i += 1
54
+ continue
55
+ }
56
+
57
+ joined.push(argv[i])
58
+ }
59
+
60
+ return joined
61
+ }
62
+
29
63
  export function route(argv) {
30
64
  const { values, positionals } = parseArgs({
31
- args: argv,
65
+ args: joinNegativeChatId(argv),
32
66
  options: OPTIONS,
33
67
  allowPositionals: true,
34
68
  })
35
69
 
36
70
  const [first, ...rest] = positionals
37
71
 
72
+ // `data-ark --to @chan` with no file is not a malformed upload, it is someone changing
73
+ // where the next upload goes. Help would be an unhelpful answer to a clear request.
74
+ if (first === undefined && values.to && !values.help) {
75
+ return { command: 'set-destination', args: [], options: values }
76
+ }
77
+
38
78
  if (values.help || first === undefined || first === 'help') {
39
79
  return { command: 'help', args: [], options: values }
40
80
  }
package/src/client.js CHANGED
@@ -1,11 +1,21 @@
1
- import { TelegramClient } from 'telegram'
1
+ import { Api, TelegramClient } from 'telegram'
2
+ import { Logger } from 'telegram/extensions/index.js'
3
+ import { LogLevel } from 'telegram/extensions/Logger.js'
2
4
  import { StringSession } from 'telegram/sessions/index.js'
3
5
 
6
+ // GramJS narrates its version, every connection and every disconnect at info level, and
7
+ // those timestamped lines land in the middle of the progress bar. The client reads this
8
+ // logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
9
+ // for the running commentary back when a connection needs diagnosing.
10
+ export function createLogger(verbose) {
11
+ return new Logger(verbose ? LogLevel.INFO : LogLevel.NONE)
12
+ }
13
+
4
14
  export function normalizeChatTarget(input) {
5
15
  const text = String(input).trim()
6
16
 
7
17
  if (text === '') {
8
- throw new Error('Đích lưu không được để rỗng.')
18
+ throw new Error('Destination must not be empty.')
9
19
  }
10
20
 
11
21
  if (/^-?\d+$/.test(text)) {
@@ -15,13 +25,78 @@ export function normalizeChatTarget(input) {
15
25
  return text
16
26
  }
17
27
 
28
+ // Telegram's web client addresses a chat by putting the raw target in the fragment, which
29
+ // covers both a negative channel id and an @username. Saved Messages is the exception: it
30
+ // is reached by the account's own id, which data-ark does not know, so it gets no link
31
+ // rather than a guessed one that lands somewhere else.
32
+ export function chatUrl(chat) {
33
+ const text = String(chat)
34
+
35
+ if (text === 'me') return null
36
+
37
+ return `https://web.telegram.org/k/#${text}`
38
+ }
39
+
40
+ // How a destination is spoken about. "me" is a target, not a name someone would recognise
41
+ // in a sentence, so every command that mentions a chat in prose goes through here.
42
+ export function chatName(chat) {
43
+ return String(chat) === 'me' ? 'Saved Messages' : String(chat)
44
+ }
45
+
46
+ // A destination is worth more as something clickable than as a raw id, but Saved Messages
47
+ // has no link to give, so it is named instead of being dressed up as one.
48
+ export function describeChat(chat) {
49
+ const url = chatUrl(chat)
50
+
51
+ return url ?? `${chat} (${chatName(chat)})`
52
+ }
53
+
54
+ // The name Telegram shows under a document lives in an attribute, not on the message.
55
+ export function documentFileName(message) {
56
+ const attributes = message?.media?.document?.attributes ?? []
57
+ const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
58
+ return named?.fileName ?? null
59
+ }
60
+
61
+ // The one place data-ark searches a chat. Both callers want documents and nothing else,
62
+ // and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
63
+ // hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
64
+ // message is kept alongside the flat fields because downloading needs it whole.
65
+ export async function searchDocuments(client, peer, { search, limit }) {
66
+ const messages = await client.getMessages(peer, {
67
+ search,
68
+ filter: new Api.InputMessagesFilterDocument(),
69
+ limit,
70
+ })
71
+
72
+ return messages.map((message) => ({
73
+ id: message.id,
74
+ fileName: documentFileName(message),
75
+ caption: message.message ?? '',
76
+ date: message.date,
77
+ message,
78
+ }))
79
+ }
80
+
81
+ // Every command ends by putting the connection down, and a failure there must never
82
+ // swallow the real error already on its way up. Commands that print progress hand in an
83
+ // onWarn to say so; the quieter ones let it pass, because a connection that will not
84
+ // close cleanly says nothing about the work that already succeeded.
85
+ export async function closeQuietly(client, disconnect, onWarn) {
86
+ try {
87
+ await disconnect(client)
88
+ } catch (err) {
89
+ if (onWarn) onWarn(err)
90
+ }
91
+ }
92
+
18
93
  export function requireChat(options, config) {
19
94
  const raw = options.to ?? config.defaultChat
20
95
 
21
96
  if (!raw) {
22
97
  throw new Error(
23
- 'Chưa đích lưu chạy lại với --to @kho_backup (hoặc --to me để dùng Saved Messages). ' +
24
- 'Lần sau data-ark sẽ tự nhớ.',
98
+ 'No destination setrun again with --to @my_backups (or --to me for Saved Messages). ' +
99
+ 'data-ark will remember it next time.',
25
100
  )
26
101
  }
27
102
 
@@ -30,22 +105,23 @@ export function requireChat(options, config) {
30
105
 
31
106
  export function assertLoggedIn(config) {
32
107
  if (!config.session || !config.apiId || !config.apiHash) {
33
- throw new Error('Chưa đăng nhậpchạy "npx data-ark login" trước.')
108
+ throw new Error('Not logged inrun "npx data-ark login" first.')
34
109
  }
35
110
  }
36
111
 
37
- export async function connect(config) {
112
+ export async function connect(config, { verbose = false } = {}) {
38
113
  assertLoggedIn(config)
39
114
 
40
115
  const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
41
116
  connectionRetries: 5,
42
117
  floodSleepThreshold: 60,
118
+ baseLogger: createLogger(verbose),
43
119
  })
44
120
 
45
121
  await client.connect()
46
122
 
47
123
  if (!(await client.isUserAuthorized())) {
48
- throw new Error('Phiên đăng nhập đã hết hạn chạy "npx data-ark login".')
124
+ throw new Error('Session expiredrun "npx data-ark login".')
49
125
  }
50
126
 
51
127
  return client