@nightowne/tas-cli 1.0.0 β†’ 1.1.1

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,158 +1,136 @@
1
- # πŸ“¦ TAS - Use Telegram as Your Cloud Storage
1
+ # TAS β€” Telegram as Storage
2
2
 
3
- > **Free. Encrypted. Unlimited.** Stop paying for cloud storage.
3
+ [![CI](https://github.com/ixchio/tas/actions/workflows/ci.yml/badge.svg)](https://github.com/ixchio/tas/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@nightowne/tas-cli)](https://www.npmjs.com/package/@nightowne/tas-cli)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
6
 
5
- ```
6
- β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
7
- β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•
8
- β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
9
- β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β•šβ•β•β•β•β–ˆβ–ˆβ•‘
10
- β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘
11
- β•šβ•β• β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β•
12
- ```
13
-
14
- I got tired of paying $10/month for cloud storage. So I built this.
15
-
16
- **TAS turns your Telegram bot into unlimited cloud storage.** Files are encrypted with AES-256 before upload β€” not even Telegram can read them.
17
-
18
- The killer feature? **Mount it as a folder.** Drag and drop files like it's Google Drive, but it's actually your private Telegram chat.
19
-
20
- ---
21
-
22
- ## ⚑ TL;DR
23
-
24
- ```bash
25
- npm install -g @nightowne/tas-cli
26
- tas init
27
- tas mount ~/cloud
28
- # Now use ~/cloud like any folder. Files go to Telegram.
29
- ```
30
-
31
- ---
32
-
33
- ## πŸ€” Why?
34
-
35
- | | TAS | Google Drive | Dropbox |
36
- |---|:---:|:---:|:---:|
37
- | **Price** | Free forever | $10/mo after 15GB | $12/mo after 2GB |
38
- | **Storage** | Unlimited | Limited | Limited |
39
- | **E2E Encrypted** | βœ… | ❌ | ❌ |
40
- | **Mounts as folder** | βœ… | ❌ | ❌ |
41
- | **Your data, your control** | βœ… | ❌ | ❌ |
42
-
43
- ---
44
-
45
- ## πŸš€ Quick Start
7
+ <p align="center">
8
+ <img src="assets/demo.gif" alt="TAS Demo" width="600">
9
+ </p>
46
10
 
47
- ### 1. Get a Telegram Bot (30 seconds)
48
- - Message [@BotFather](https://t.me/BotFather) on Telegram
49
- - Send `/newbot`, pick a name
50
- - Copy the token
11
+ A CLI tool that uses your Telegram bot as encrypted file storage. Files are compressed, encrypted locally, then uploaded to your private bot chat.
51
12
 
52
- ### 2. Install & Setup
53
- ```bash
54
- npm install -g tas-cli
55
- tas init
56
- # Paste token, set password, message your bot
57
13
  ```
58
-
59
- ### 3. Use It
60
- ```bash
61
- # Upload files
62
- tas push secret.pdf
63
-
64
- # Mount as a folder (the magic ✨)
65
- tas mount ~/cloud
66
- cp anything.zip ~/cloud/ # uploads to Telegram
67
- open ~/cloud/secret.pdf # downloads from Telegram
14
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
15
+ β”‚ CLI │────▢│ Compress & │────▢│ Telegram β”‚
16
+ β”‚ FUSE β”‚ β”‚ Encrypt β”‚ β”‚ Bot API β”‚
17
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
18
+ β”‚ β”‚ β”‚
19
+ β–Ό β–Ό β–Ό
20
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
21
+ β”‚ SQLite Indexβ”‚ β”‚ 49MB Chunks β”‚ β”‚ Private Chat β”‚
22
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
68
23
  ```
69
24
 
70
- ---
25
+ ## Why TAS?
71
26
 
72
- ## οΏ½ The Folder Thing
27
+ | Feature | TAS | Session-based tools (e.g. teldrive) |
28
+ |---------|:---:|:-----------------------------------:|
29
+ | Account ban risk | **None** (Bot API) | High (session hijack detection) |
30
+ | Encryption | AES-256-GCM | Usually none |
31
+ | Dependencies | SQLite only | Rclone, external DB |
32
+ | Setup complexity | 2 minutes | Docker + multiple services |
73
33
 
74
- This is the part that makes TAS different. Run `tas mount ~/cloud` and you get a folder that:
34
+ **Key differences:**
35
+ - Uses **Bot API**, not session-based auth β€” Telegram can't ban your account
36
+ - **Encryption by default** β€” files encrypted before leaving your machine
37
+ - **Local-first** β€” SQLite index, no cloud dependencies
38
+ - **FUSE mount** β€” use Telegram like a folder
75
39
 
76
- - **Looks normal** in your file manager
77
- - **Drag & drop** = upload to Telegram
78
- - **Open files** = download from Telegram
79
- - **Delete files** = removes from Telegram
40
+ ## Security Model
80
41
 
81
- It's like Dropbox, except free and you own your data.
42
+ | Component | Implementation |
43
+ |-----------|----------------|
44
+ | Cipher | AES-256-GCM |
45
+ | Key derivation | PBKDF2-SHA512, 100,000 iterations |
46
+ | Salt | 32 bytes, random per file |
47
+ | IV | 12 bytes, random per file |
48
+ | Auth tag | 16 bytes (integrity) |
82
49
 
83
- ```bash
84
- $ ls ~/cloud
85
- secret.pdf photos.zip notes.txt
50
+ Your password never leaves your machine. Telegram stores encrypted blobs.
86
51
 
87
- $ cp newfile.doc ~/cloud/
88
- # Compresses β†’ Encrypts β†’ Uploads to Telegram
89
- ```
52
+ ## Limitations
90
53
 
91
- ---
54
+ - **Not a backup** β€” Telegram can delete content without notice
55
+ - **No versioning** β€” overwriting a file deletes the old version
56
+ - **49MB chunks** β€” files split due to Bot API limits
57
+ - **FUSE required** β€” mount feature needs `libfuse` on Linux/macOS
58
+ - **Single user** β€” designed for personal use, not multi-tenant
92
59
 
93
- ## 🏷️ Organize with Tags
60
+ ## Quick Start
94
61
 
95
62
  ```bash
96
- tas tag add report.pdf work finance
97
- tas tag list work # shows all "work" files
98
- tas tag remove report.pdf finance
99
- ```
100
-
101
- ---
63
+ # Install
64
+ npm install -g @nightowne/tas-cli
102
65
 
103
- ## πŸ”„ Auto-Sync Folders
66
+ # Setup (creates bot connection + encryption password)
67
+ tas init
104
68
 
105
- Dropbox-style sync. Any changes in the folder β†’ auto-upload to Telegram.
69
+ # Upload a file
70
+ tas push secret.pdf
106
71
 
107
- ```bash
108
- tas sync add ~/Documents/work
109
- tas sync start
110
- # Now any file changes auto-sync to Telegram
111
- ```
72
+ # Download a file
73
+ tas pull secret.pdf
112
74
 
113
- Two-way sync:
114
- ```bash
115
- tas sync pull # Download everything from Telegram β†’ local
75
+ # Mount as folder (requires libfuse)
76
+ tas mount ~/cloud
116
77
  ```
117
78
 
118
- ---
119
-
120
- ## �️ Security
121
-
122
- - **AES-256-GCM** encryption
123
- - **PBKDF2** key derivation (100k iterations)
124
- - **Random IV** per file
125
- - Password never stored (only hash for verification)
126
-
127
- Your files are encrypted **before** they leave your computer. Telegram sees gibberish.
128
-
129
- ---
130
-
131
- ## πŸ“– All Commands
79
+ ### Prerequisites
80
+ - Node.js β‰₯18
81
+ - Telegram account + bot token from [@BotFather](https://t.me/BotFather)
82
+ - `libfuse` for mount feature:
83
+ ```bash
84
+ # Debian/Ubuntu
85
+ sudo apt install fuse libfuse-dev
86
+
87
+ # Fedora
88
+ sudo dnf install fuse fuse-devel
89
+
90
+ # macOS
91
+ brew install macfuse
92
+ ```
93
+
94
+ ## CLI Reference
132
95
 
133
96
  ```bash
134
- tas init # Setup
135
- tas push <file> # Upload
136
- tas pull <file> # Download
137
- tas list # List files
138
- tas delete <file> # Remove
139
- tas mount <folder> # πŸ”₯ Mount as folder
140
- tas unmount <folder> # Unmount
141
- tas tag add/remove/list # Tags
142
- tas sync add/start/pull # Folder sync
143
- tas verify # Check file integrity
144
- tas status # Stats
97
+ # Core
98
+ tas init # Setup wizard
99
+ tas push <file> # Upload file
100
+ tas pull <file|hash> # Download file
101
+ tas list [-l] # List files (long format)
102
+ tas delete <file|hash> # Remove file
103
+ tas status # Show stats
104
+
105
+ # Search & Resume (v1.1.0)
106
+ tas search <query> # Search by filename
107
+ tas search -t <query> # Search by tag
108
+ tas resume # Resume interrupted uploads
109
+
110
+ # FUSE Mount
111
+ tas mount <path> # Mount as folder
112
+ tas unmount <path> # Unmount
113
+
114
+ # Tags
115
+ tas tag add <file> <tags...> # Add tags
116
+ tas tag remove <file> <tags...> # Remove tags
117
+ tas tag list [tag] # List tags or files by tag
118
+
119
+ # Sync (Dropbox-style)
120
+ tas sync add <folder> # Register folder for sync
121
+ tas sync start # Start watching
122
+ tas sync pull # Download all to sync folders
123
+ tas sync status # Show sync status
124
+
125
+ # Verification
126
+ tas verify # Check file integrity
145
127
  ```
146
128
 
147
- ---
148
-
149
- ## βš™οΈ Auto-Start on Boot
129
+ ## Auto-Start (systemd)
150
130
 
151
- Want sync running 24/7? Check out [systemd/README.md](systemd/README.md) for the setup.
131
+ See [systemd/README.md](systemd/README.md) for running sync as a service.
152
132
 
153
- ---
154
-
155
- ## πŸ§ͺ Development
133
+ ## Development
156
134
 
157
135
  ```bash
158
136
  git clone https://github.com/ixchio/tas
@@ -161,14 +139,19 @@ npm install
161
139
  npm test # 28 tests
162
140
  ```
163
141
 
164
- ---
165
-
166
- ## πŸ“ License
167
-
168
- MIT β€” do whatever you want.
169
-
170
- ---
142
+ ### Project Structure
143
+ ```
144
+ src/
145
+ β”œβ”€β”€ cli.js # Command definitions
146
+ β”œβ”€β”€ index.js # Upload/download pipeline
147
+ β”œβ”€β”€ crypto/ # AES-256-GCM encryption
148
+ β”œβ”€β”€ db/ # SQLite file index
149
+ β”œβ”€β”€ fuse/ # FUSE filesystem mount
150
+ β”œβ”€β”€ sync/ # Folder sync engine
151
+ β”œβ”€β”€ telegram/ # Bot API client
152
+ └── utils/ # Compression, chunking
153
+ ```
171
154
 
172
- **Made because cloud storage shouldn't cost money.** ☁️
155
+ ## License
173
156
 
174
- If this saved you some subscription fees, star the repo ⭐
157
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "πŸ“¦ Telegram as Storage - Free encrypted cloud storage via Telegram. Mount Telegram as a folder!",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node src/cli.js",
12
12
  "init": "node src/cli.js init",
13
- "test": "node --test tests/",
13
+ "test": "node --test tests/*.test.js",
14
14
  "prepublishOnly": "npm test"
15
15
  },
16
16
  "keywords": [
@@ -43,15 +43,15 @@
43
43
  "LICENSE"
44
44
  ],
45
45
  "dependencies": {
46
- "node-telegram-bot-api": "^0.66.0",
46
+ "better-sqlite3": "^12.6.2",
47
+ "chalk": "^5.3.0",
47
48
  "commander": "^12.1.0",
48
- "better-sqlite3": "^11.6.0",
49
49
  "fuse-native": "^2.2.6",
50
- "ora": "^8.1.1",
51
- "chalk": "^5.3.0",
52
- "inquirer": "^12.2.0"
50
+ "inquirer": "^12.2.0",
51
+ "node-telegram-bot-api": "^0.66.0",
52
+ "ora": "^8.1.1"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">=18.0.0"
56
56
  }
57
- }
57
+ }
package/src/cli.js CHANGED
@@ -179,16 +179,33 @@ program
179
179
 
180
180
  spinner.start('Processing file...');
181
181
 
182
+ // Import progress bar
183
+ const { ProgressBar } = await import('./utils/progress.js');
184
+ let progressBar = null;
185
+
182
186
  // Process and upload
183
187
  const result = await processFile(file, {
184
188
  password,
185
189
  dataDir: DATA_DIR,
186
190
  customName: options.name,
187
191
  config,
188
- onProgress: (msg) => { spinner.text = msg; }
192
+ onProgress: (msg) => {
193
+ if (!progressBar) spinner.text = msg;
194
+ },
195
+ onByteProgress: ({ uploaded, total }) => {
196
+ if (!progressBar) {
197
+ spinner.stop();
198
+ progressBar = new ProgressBar({ label: 'Uploading', total });
199
+ }
200
+ progressBar.update(uploaded);
201
+ }
189
202
  });
190
203
 
191
- spinner.succeed(`Uploaded: ${chalk.green(result.filename)}`);
204
+ if (progressBar) {
205
+ progressBar.complete(`Uploaded: ${result.filename}`);
206
+ } else {
207
+ spinner.succeed(`Uploaded: ${chalk.green(result.filename)}`);
208
+ }
192
209
  console.log(chalk.dim(` Hash: ${result.hash}`));
193
210
  console.log(chalk.dim(` Size: ${formatBytes(result.originalSize)} β†’ ${formatBytes(result.storedSize)}`));
194
211
  console.log(chalk.dim(` Chunks: ${result.chunks}`));
@@ -201,10 +218,10 @@ program
201
218
 
202
219
  // ============== PULL COMMAND ==============
203
220
  program
204
- .command('pull <identifier>')
221
+ .command('pull <identifier> [output]')
205
222
  .description('Download a file from Telegram storage (by filename or hash)')
206
223
  .option('-o, --output <path>', 'Output path for the file')
207
- .action(async (identifier, options) => {
224
+ .action(async (identifier, output, options) => {
208
225
  const spinner = ora('Looking up file...').start();
209
226
 
210
227
  try {
@@ -247,16 +264,33 @@ program
247
264
 
248
265
  spinner.start('Downloading...');
249
266
 
250
- const outputPath = options.output || fileRecord.filename;
267
+ // Import progress bar
268
+ const { ProgressBar } = await import('./utils/progress.js');
269
+ let progressBar = null;
270
+
271
+ const outputPath = output || options.output || fileRecord.filename;
251
272
  await retrieveFile(fileRecord, {
252
273
  password,
253
274
  dataDir: DATA_DIR,
254
275
  outputPath,
255
276
  config,
256
- onProgress: (msg) => { spinner.text = msg; }
277
+ onProgress: (msg) => {
278
+ if (!progressBar) spinner.text = msg;
279
+ },
280
+ onByteProgress: ({ downloaded, total }) => {
281
+ if (!progressBar && total > 0) {
282
+ spinner.stop();
283
+ progressBar = new ProgressBar({ label: 'Downloading', total });
284
+ }
285
+ if (progressBar) progressBar.update(downloaded);
286
+ }
257
287
  });
258
288
 
259
- spinner.succeed(`Downloaded: ${chalk.green(outputPath)}`);
289
+ if (progressBar) {
290
+ progressBar.complete(`Downloaded: ${outputPath}`);
291
+ } else {
292
+ spinner.succeed(`Downloaded: ${chalk.green(outputPath)}`);
293
+ }
260
294
 
261
295
  } catch (err) {
262
296
  spinner.fail(`Download failed: ${err.message}`);
@@ -1005,6 +1039,201 @@ function formatBytes(bytes) {
1005
1039
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1006
1040
  }
1007
1041
 
1042
+ // ============== SEARCH COMMAND ==============
1043
+ program
1044
+ .command('search <query>')
1045
+ .description('Search files by name or tag')
1046
+ .option('-t, --tag', 'Search by tag instead of filename')
1047
+ .action(async (query, options) => {
1048
+ try {
1049
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1050
+ db.init();
1051
+
1052
+ const results = options.tag
1053
+ ? db.searchByTag(query)
1054
+ : db.search(query);
1055
+
1056
+ if (results.length === 0) {
1057
+ console.log(chalk.yellow(`\nπŸ“­ No files found matching "${query}"\n`));
1058
+ db.close();
1059
+ return;
1060
+ }
1061
+
1062
+ console.log(chalk.cyan(`\nπŸ” Search Results for "${query}" (${results.length})\n`));
1063
+
1064
+ for (const file of results) {
1065
+ const tags = file.tags ? chalk.dim(` [${file.tags}]`) : '';
1066
+ console.log(` ${chalk.blue('●')} ${file.filename} ${chalk.dim(`(${formatBytes(file.original_size)})`)}${tags}`);
1067
+ }
1068
+
1069
+ console.log();
1070
+ db.close();
1071
+ } catch (err) {
1072
+ console.error(chalk.red('Search failed:'), err.message);
1073
+ process.exit(1);
1074
+ }
1075
+ });
1076
+
1077
+ // ============== RESUME COMMAND ==============
1078
+ program
1079
+ .command('resume')
1080
+ .description('Resume interrupted uploads')
1081
+ .action(async () => {
1082
+ try {
1083
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1084
+ db.init();
1085
+
1086
+ const pending = db.getPendingUploads();
1087
+
1088
+ if (pending.length === 0) {
1089
+ console.log(chalk.yellow('\nπŸ“­ No interrupted uploads found.\n'));
1090
+ db.close();
1091
+ return;
1092
+ }
1093
+
1094
+ console.log(chalk.cyan(`\nπŸ”„ Pending Uploads (${pending.length})\n`));
1095
+
1096
+ for (const upload of pending) {
1097
+ const progress = Math.round((upload.uploaded_chunks / upload.total_chunks) * 100);
1098
+ console.log(` ${chalk.blue('●')} ${upload.filename}`);
1099
+ console.log(chalk.dim(` Progress: ${upload.uploaded_chunks}/${upload.total_chunks} chunks (${progress}%)`));
1100
+ console.log(chalk.dim(` Started: ${new Date(upload.created_at).toLocaleString()}`));
1101
+ }
1102
+
1103
+ console.log();
1104
+
1105
+ // Ask if user wants to resume
1106
+ const { action } = await inquirer.prompt([
1107
+ {
1108
+ type: 'list',
1109
+ name: 'action',
1110
+ message: 'What would you like to do?',
1111
+ choices: [
1112
+ { name: 'Resume all pending uploads', value: 'resume' },
1113
+ { name: 'Clear all pending uploads', value: 'clear' },
1114
+ { name: 'Cancel', value: 'cancel' }
1115
+ ]
1116
+ }
1117
+ ]);
1118
+
1119
+ if (action === 'cancel') {
1120
+ db.close();
1121
+ return;
1122
+ }
1123
+
1124
+ if (action === 'clear') {
1125
+ for (const upload of pending) {
1126
+ // Clean up temp files
1127
+ const chunks = db.getPendingChunks(upload.id);
1128
+ for (const chunk of chunks) {
1129
+ try { fs.unlinkSync(chunk.chunk_path); } catch (e) { }
1130
+ }
1131
+ if (upload.temp_dir) {
1132
+ try { fs.rmdirSync(upload.temp_dir); } catch (e) { }
1133
+ }
1134
+ db.deletePendingUpload(upload.id);
1135
+ }
1136
+ console.log(chalk.green('βœ“ Cleared all pending uploads'));
1137
+ db.close();
1138
+ return;
1139
+ }
1140
+
1141
+ // Resume uploads
1142
+ const configPath = path.join(DATA_DIR, 'config.json');
1143
+ if (!fs.existsSync(configPath)) {
1144
+ console.log(chalk.red('βœ— TAS not initialized.'));
1145
+ db.close();
1146
+ return;
1147
+ }
1148
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1149
+
1150
+ // Get password
1151
+ const { password } = await inquirer.prompt([
1152
+ {
1153
+ type: 'password',
1154
+ name: 'password',
1155
+ message: 'Enter your encryption password:',
1156
+ mask: '*'
1157
+ }
1158
+ ]);
1159
+
1160
+ const encryptor = new Encryptor(password);
1161
+ if (encryptor.getPasswordHash() !== config.passwordHash) {
1162
+ console.log(chalk.red('βœ— Incorrect password'));
1163
+ db.close();
1164
+ return;
1165
+ }
1166
+
1167
+ // Connect to Telegram
1168
+ const { TelegramClient } = await import('./telegram/client.js');
1169
+ const client = new TelegramClient(DATA_DIR);
1170
+ await client.initialize(config.botToken);
1171
+ client.setChatId(config.chatId);
1172
+
1173
+ for (const upload of pending) {
1174
+ console.log(chalk.cyan(`\nπŸ“€ Resuming: ${upload.filename}`));
1175
+
1176
+ const chunks = db.getPendingChunks(upload.id);
1177
+ const pendingChunks = chunks.filter(c => !c.uploaded);
1178
+
1179
+ for (const chunk of pendingChunks) {
1180
+ if (!fs.existsSync(chunk.chunk_path)) {
1181
+ console.log(chalk.red(` βœ— Chunk file missing: ${chunk.chunk_path}`));
1182
+ continue;
1183
+ }
1184
+
1185
+ console.log(chalk.dim(` ↑ Uploading chunk ${chunk.chunk_index + 1}/${upload.total_chunks}...`));
1186
+
1187
+ const caption = upload.total_chunks > 1
1188
+ ? `πŸ“¦ ${upload.filename} (${chunk.chunk_index + 1}/${upload.total_chunks})`
1189
+ : `πŸ“¦ ${upload.filename}`;
1190
+
1191
+ const result = await client.sendFile(chunk.chunk_path, caption);
1192
+ db.markChunkUploaded(upload.id, chunk.chunk_index, result.messageId.toString(), result.fileId);
1193
+
1194
+ // Clean up temp file
1195
+ fs.unlinkSync(chunk.chunk_path);
1196
+ }
1197
+
1198
+ // All chunks uploaded - finalize
1199
+ const allChunks = db.getPendingChunks(upload.id);
1200
+ if (allChunks.every(c => c.uploaded)) {
1201
+ // Add to main files table
1202
+ const fileId = db.addFile({
1203
+ filename: upload.filename,
1204
+ hash: upload.hash,
1205
+ originalSize: upload.original_size,
1206
+ storedSize: upload.original_size, // Approximate
1207
+ chunks: upload.total_chunks,
1208
+ compressed: true
1209
+ });
1210
+
1211
+ // Add chunk records
1212
+ for (const chunk of allChunks) {
1213
+ db.addChunk(fileId, chunk.chunk_index, chunk.message_id, 0);
1214
+ db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
1215
+ .run(chunk.file_telegram_id, fileId, chunk.chunk_index);
1216
+ }
1217
+
1218
+ // Clean up pending record
1219
+ db.deletePendingUpload(upload.id);
1220
+ if (upload.temp_dir) {
1221
+ try { fs.rmdirSync(upload.temp_dir); } catch (e) { }
1222
+ }
1223
+
1224
+ console.log(chalk.green(` βœ“ Completed: ${upload.filename}`));
1225
+ }
1226
+ }
1227
+
1228
+ console.log(chalk.green('\n✨ All uploads resumed!\n'));
1229
+ db.close();
1230
+
1231
+ } catch (err) {
1232
+ console.error(chalk.red('Resume failed:'), err.message);
1233
+ process.exit(1);
1234
+ }
1235
+ });
1236
+
1008
1237
  program.parse();
1009
1238
 
1010
1239
 
package/src/db/index.js CHANGED
@@ -91,6 +91,34 @@ export class FileIndex {
91
91
  UNIQUE(folder_id, relative_path)
92
92
  );
93
93
  `);
94
+
95
+ // Create pending_uploads table for resume functionality
96
+ this.db.exec(`
97
+ CREATE TABLE IF NOT EXISTS pending_uploads (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ filename TEXT NOT NULL,
100
+ file_path TEXT NOT NULL,
101
+ hash TEXT NOT NULL,
102
+ original_size INTEGER NOT NULL,
103
+ total_chunks INTEGER NOT NULL,
104
+ uploaded_chunks INTEGER NOT NULL DEFAULT 0,
105
+ temp_dir TEXT,
106
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
107
+ UNIQUE(hash)
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS pending_chunks (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ pending_id INTEGER NOT NULL,
113
+ chunk_index INTEGER NOT NULL,
114
+ chunk_path TEXT NOT NULL,
115
+ uploaded INTEGER NOT NULL DEFAULT 0,
116
+ message_id TEXT,
117
+ file_telegram_id TEXT,
118
+ FOREIGN KEY (pending_id) REFERENCES pending_uploads(id) ON DELETE CASCADE,
119
+ UNIQUE(pending_id, chunk_index)
120
+ );
121
+ `);
94
122
  }
95
123
 
96
124
  /**
@@ -345,6 +373,125 @@ export class FileIndex {
345
373
  stmt.run(folderId, relativePath);
346
374
  }
347
375
 
376
+ // ============== SEARCH METHODS ==============
377
+
378
+ /**
379
+ * Search files by filename (fuzzy match)
380
+ */
381
+ search(query) {
382
+ const stmt = this.db.prepare(`
383
+ SELECT f.*, GROUP_CONCAT(t.tag) as tags
384
+ FROM files f
385
+ LEFT JOIN tags t ON f.id = t.file_id
386
+ WHERE f.filename LIKE ?
387
+ GROUP BY f.id
388
+ ORDER BY f.created_at DESC
389
+ `);
390
+ return stmt.all(`%${query}%`);
391
+ }
392
+
393
+ /**
394
+ * Search files by tag
395
+ */
396
+ searchByTag(query) {
397
+ const stmt = this.db.prepare(`
398
+ SELECT f.*, GROUP_CONCAT(t.tag) as tags
399
+ FROM files f
400
+ INNER JOIN tags t ON f.id = t.file_id
401
+ WHERE t.tag LIKE ?
402
+ GROUP BY f.id
403
+ ORDER BY f.created_at DESC
404
+ `);
405
+ return stmt.all(`%${query}%`);
406
+ }
407
+
408
+ // ============== RESUME UPLOAD METHODS ==============
409
+
410
+ /**
411
+ * Add a pending upload
412
+ */
413
+ addPendingUpload(data) {
414
+ const stmt = this.db.prepare(`
415
+ INSERT OR REPLACE INTO pending_uploads
416
+ (filename, file_path, hash, original_size, total_chunks, uploaded_chunks, temp_dir)
417
+ VALUES (?, ?, ?, ?, ?, ?, ?)
418
+ `);
419
+ const result = stmt.run(
420
+ data.filename,
421
+ data.filePath,
422
+ data.hash,
423
+ data.originalSize,
424
+ data.totalChunks,
425
+ data.uploadedChunks || 0,
426
+ data.tempDir
427
+ );
428
+ return result.lastInsertRowid;
429
+ }
430
+
431
+ /**
432
+ * Add a pending chunk
433
+ */
434
+ addPendingChunk(pendingId, chunkIndex, chunkPath) {
435
+ const stmt = this.db.prepare(`
436
+ INSERT OR REPLACE INTO pending_chunks (pending_id, chunk_index, chunk_path, uploaded)
437
+ VALUES (?, ?, ?, 0)
438
+ `);
439
+ stmt.run(pendingId, chunkIndex, chunkPath);
440
+ }
441
+
442
+ /**
443
+ * Mark chunk as uploaded
444
+ */
445
+ markChunkUploaded(pendingId, chunkIndex, messageId, fileTelegramId) {
446
+ const stmt = this.db.prepare(`
447
+ UPDATE pending_chunks
448
+ SET uploaded = 1, message_id = ?, file_telegram_id = ?
449
+ WHERE pending_id = ? AND chunk_index = ?
450
+ `);
451
+ stmt.run(messageId, fileTelegramId, pendingId, chunkIndex);
452
+
453
+ // Update uploaded count
454
+ this.db.prepare(`
455
+ UPDATE pending_uploads SET uploaded_chunks = uploaded_chunks + 1 WHERE id = ?
456
+ `).run(pendingId);
457
+ }
458
+
459
+ /**
460
+ * Get all pending uploads
461
+ */
462
+ getPendingUploads() {
463
+ const stmt = this.db.prepare(`
464
+ SELECT * FROM pending_uploads ORDER BY created_at DESC
465
+ `);
466
+ return stmt.all();
467
+ }
468
+
469
+ /**
470
+ * Get pending chunks for an upload
471
+ */
472
+ getPendingChunks(pendingId) {
473
+ const stmt = this.db.prepare(`
474
+ SELECT * FROM pending_chunks WHERE pending_id = ? ORDER BY chunk_index
475
+ `);
476
+ return stmt.all(pendingId);
477
+ }
478
+
479
+ /**
480
+ * Delete a pending upload (and its chunks via CASCADE)
481
+ */
482
+ deletePendingUpload(pendingId) {
483
+ const stmt = this.db.prepare('DELETE FROM pending_uploads WHERE id = ?');
484
+ stmt.run(pendingId);
485
+ }
486
+
487
+ /**
488
+ * Get pending upload by hash
489
+ */
490
+ getPendingByHash(hash) {
491
+ const stmt = this.db.prepare('SELECT * FROM pending_uploads WHERE hash = ?');
492
+ return stmt.get(hash);
493
+ }
494
+
348
495
  /**
349
496
  * Close database connection
350
497
  */
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ const TELEGRAM_CHUNK_SIZE = 49 * 1024 * 1024;
19
19
  * Process and upload a file to Telegram
20
20
  */
21
21
  export async function processFile(filePath, options) {
22
- const { password, dataDir, customName, config, onProgress } = options;
22
+ const { password, dataDir, customName, config, onProgress, onByteProgress } = options;
23
23
 
24
24
  onProgress?.('Reading file...');
25
25
 
@@ -109,6 +109,9 @@ export async function processFile(filePath, options) {
109
109
  compressed
110
110
  });
111
111
 
112
+ let uploadedBytes = 0;
113
+ const totalBytes = chunkFiles.reduce((acc, c) => acc + c.size, 0);
114
+
112
115
  for (const chunk of chunkFiles) {
113
116
  onProgress?.(`Uploading chunk ${chunk.index + 1}/${chunkFiles.length}...`);
114
117
 
@@ -118,6 +121,9 @@ export async function processFile(filePath, options) {
118
121
 
119
122
  const result = await client.sendFile(chunk.path, caption);
120
123
 
124
+ uploadedBytes += chunk.size;
125
+ onByteProgress?.({ uploaded: uploadedBytes, total: totalBytes, chunk: chunk.index + 1, totalChunks: chunkFiles.length });
126
+
121
127
  // Store file_id instead of message_id for downloads
122
128
  db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
123
129
 
@@ -152,7 +158,7 @@ export async function processFile(filePath, options) {
152
158
  * Retrieve a file from Telegram
153
159
  */
154
160
  export async function retrieveFile(fileRecord, options) {
155
- const { password, dataDir, outputPath, config, onProgress } = options;
161
+ const { password, dataDir, outputPath, config, onProgress, onByteProgress } = options;
156
162
 
157
163
  onProgress?.('Connecting to Telegram...');
158
164
 
@@ -174,11 +180,15 @@ export async function retrieveFile(fileRecord, options) {
174
180
 
175
181
  // Download all chunks
176
182
  const downloadedChunks = [];
183
+ let downloadedBytes = 0;
184
+ const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
177
185
 
178
186
  for (const chunk of chunks) {
179
187
  onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
180
188
 
181
189
  const data = await client.downloadFile(chunk.file_telegram_id);
190
+ downloadedBytes += data.length;
191
+ onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
182
192
 
183
193
  // Parse header
184
194
  const header = parseHeader(data);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Progress bar utility with speed calculation
3
+ * Shows actual MB/s instead of boring spinners
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+
8
+ export class ProgressBar {
9
+ constructor(options = {}) {
10
+ this.total = options.total || 100;
11
+ this.width = options.width || 30;
12
+ this.label = options.label || 'Progress';
13
+ this.current = 0;
14
+ this.startTime = Date.now();
15
+ this.lastUpdate = 0;
16
+ this.lastBytes = 0;
17
+ this.speed = 0;
18
+ }
19
+
20
+ /**
21
+ * Update progress
22
+ * @param {number} current - Current bytes processed
23
+ */
24
+ update(current) {
25
+ this.current = current;
26
+
27
+ const now = Date.now();
28
+ const elapsed = now - this.lastUpdate;
29
+
30
+ // Calculate speed every 200ms
31
+ if (elapsed >= 200) {
32
+ const bytesDelta = current - this.lastBytes;
33
+ this.speed = (bytesDelta / elapsed) * 1000; // bytes per second
34
+ this.lastUpdate = now;
35
+ this.lastBytes = current;
36
+ }
37
+
38
+ this.render();
39
+ }
40
+
41
+ /**
42
+ * Render the progress bar
43
+ */
44
+ render() {
45
+ const percent = Math.min(100, Math.round((this.current / this.total) * 100));
46
+ const filled = Math.round((percent / 100) * this.width);
47
+ const empty = this.width - filled;
48
+
49
+ const bar = chalk.cyan('β–ˆ'.repeat(filled)) + chalk.dim('β–‘'.repeat(empty));
50
+ const speedStr = this.formatSpeed(this.speed);
51
+ const sizeStr = `${this.formatBytes(this.current)}/${this.formatBytes(this.total)}`;
52
+
53
+ // Calculate ETA
54
+ const eta = this.speed > 0
55
+ ? Math.round((this.total - this.current) / this.speed)
56
+ : 0;
57
+ const etaStr = eta > 0 ? this.formatTime(eta) : '--:--';
58
+
59
+ // Clear line and write
60
+ process.stdout.write(`\r${this.label} ${bar} ${percent}% | ${sizeStr} | ${speedStr} | ETA: ${etaStr} `);
61
+ }
62
+
63
+ /**
64
+ * Complete the progress bar
65
+ */
66
+ complete(message) {
67
+ const totalTime = (Date.now() - this.startTime) / 1000;
68
+ const avgSpeed = this.total / totalTime;
69
+
70
+ process.stdout.write('\r' + ' '.repeat(100) + '\r'); // Clear line
71
+ console.log(chalk.green(`βœ“ ${message || this.label}`) +
72
+ chalk.dim(` (${this.formatBytes(this.total)} in ${totalTime.toFixed(1)}s, avg ${this.formatSpeed(avgSpeed)})`));
73
+ }
74
+
75
+ /**
76
+ * Format bytes to human readable
77
+ */
78
+ formatBytes(bytes) {
79
+ if (bytes === 0) return '0 B';
80
+ const k = 1024;
81
+ const sizes = ['B', 'KB', 'MB', 'GB'];
82
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
83
+ return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
84
+ }
85
+
86
+ /**
87
+ * Format speed to human readable
88
+ */
89
+ formatSpeed(bytesPerSec) {
90
+ if (bytesPerSec === 0) return '-- MB/s';
91
+ const mbps = bytesPerSec / (1024 * 1024);
92
+ if (mbps >= 1) {
93
+ return mbps.toFixed(1) + ' MB/s';
94
+ }
95
+ const kbps = bytesPerSec / 1024;
96
+ return kbps.toFixed(0) + ' KB/s';
97
+ }
98
+
99
+ /**
100
+ * Format seconds to mm:ss
101
+ */
102
+ formatTime(seconds) {
103
+ const mins = Math.floor(seconds / 60);
104
+ const secs = seconds % 60;
105
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Create a simple progress callback for ora-style usage
111
+ */
112
+ export function createProgressCallback(label, total) {
113
+ const bar = new ProgressBar({ label, total });
114
+ return {
115
+ update: (current) => bar.update(current),
116
+ complete: (msg) => bar.complete(msg),
117
+ bar
118
+ };
119
+ }