@openparachute/vault 0.3.1 → 0.4.0
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/.parachute/module.json +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
|
@@ -1,499 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* scripts/migrate-audio-to-opus.ts
|
|
4
|
-
*
|
|
5
|
-
* NOTE: This script requires `@openparachute/narrate` which is no longer a
|
|
6
|
-
* vault dependency (removed in the webhook trigger refactor). If you need to
|
|
7
|
-
* re-run this migration, install it manually: `bun add @openparachute/narrate`
|
|
8
|
-
*
|
|
9
|
-
* One-shot migration: convert existing WAV / MP3 audio attachments into
|
|
10
|
-
* OGG Opus in-place. Resolves the second half of issue #43 — the first
|
|
11
|
-
* half (new TTS output encoded as Opus) ships in the tts-provider.ts hook
|
|
12
|
-
* change; this script rewrites everything already on disk.
|
|
13
|
-
*
|
|
14
|
-
* What it does
|
|
15
|
-
* ------------
|
|
16
|
-
* For each vault (all vaults by default, or `--vault <name>` for one):
|
|
17
|
-
* 1. Opens the vault SQLite DB.
|
|
18
|
-
* 2. Finds every `attachments` row whose mime_type starts with `audio/`
|
|
19
|
-
* and whose path ends in .wav or .mp3.
|
|
20
|
-
* 3. Runs ffmpeg on the file on disk to produce a sibling .ogg.
|
|
21
|
-
* 4. Updates the attachment row's `path` and `mime_type` using raw SQL
|
|
22
|
-
* (NOT via store.updateNote — we don't want to touch updated_at on
|
|
23
|
-
* the parent note, since this is a pure storage-format migration).
|
|
24
|
-
* 5. Unlinks the original WAV/MP3.
|
|
25
|
-
*
|
|
26
|
-
* Idempotency
|
|
27
|
-
* -----------
|
|
28
|
-
* If a sibling .ogg already exists AND the DB row already points at it,
|
|
29
|
-
* we skip the row entirely. Re-running the script is safe.
|
|
30
|
-
*
|
|
31
|
-
* Dry run
|
|
32
|
-
* -------
|
|
33
|
-
* `--dry-run` reports what would change without touching anything (no
|
|
34
|
-
* ffmpeg, no DB writes, no unlinks).
|
|
35
|
-
*
|
|
36
|
-
* Error handling
|
|
37
|
-
* --------------
|
|
38
|
-
* Per-attachment errors are logged and the script continues with the next
|
|
39
|
-
* attachment. The per-vault summary at the end reports converted / skipped
|
|
40
|
-
* / errors + total bytes saved.
|
|
41
|
-
*
|
|
42
|
-
* Not run automatically
|
|
43
|
-
* ---------------------
|
|
44
|
-
* Aaron invokes this by hand when he's ready:
|
|
45
|
-
* bun scripts/migrate-audio-to-opus.ts # all vaults
|
|
46
|
-
* bun scripts/migrate-audio-to-opus.ts --dry-run
|
|
47
|
-
* bun scripts/migrate-audio-to-opus.ts --vault default
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
import { Database } from "bun:sqlite";
|
|
51
|
-
import { existsSync, statSync, unlinkSync, readdirSync } from "fs";
|
|
52
|
-
import { join, dirname } from "path";
|
|
53
|
-
import { homedir } from "os";
|
|
54
|
-
import { encodeOggOpus } from "@openparachute/narrate";
|
|
55
|
-
import { readFileSync, writeFileSync } from "fs";
|
|
56
|
-
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// Config resolution — keep this script self-contained so it can run against
|
|
59
|
-
// any machine with a `~/.parachute/` (or $PARACHUTE_HOME) without pulling in
|
|
60
|
-
// server startup side effects.
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
// Read PARACHUTE_HOME lazily so tests can override it
|
|
64
|
-
// via process.env after the module has loaded.
|
|
65
|
-
function configDir(): string {
|
|
66
|
-
return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function vaultsDir(): string {
|
|
70
|
-
return join(configDir(), "vaults");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function vaultDir(name: string): string {
|
|
74
|
-
return join(vaultsDir(), name);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function vaultDbPath(name: string): string {
|
|
78
|
-
return join(vaultDir(name), "vault.db");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function vaultAssetsDir(name: string): string {
|
|
82
|
-
return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function listVaultNames(): string[] {
|
|
86
|
-
const root = vaultsDir();
|
|
87
|
-
if (!existsSync(root)) return [];
|
|
88
|
-
return readdirSync(root, { withFileTypes: true })
|
|
89
|
-
.filter((d) => d.isDirectory())
|
|
90
|
-
.map((d) => d.name);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// CLI args
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
interface Args {
|
|
98
|
-
vault?: string;
|
|
99
|
-
dryRun: boolean;
|
|
100
|
-
help: boolean;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function parseArgs(argv: string[]): Args {
|
|
104
|
-
const out: Args = { dryRun: false, help: false };
|
|
105
|
-
for (let i = 0; i < argv.length; i++) {
|
|
106
|
-
const a = argv[i];
|
|
107
|
-
if (a === "--dry-run") out.dryRun = true;
|
|
108
|
-
else if (a === "--help" || a === "-h") out.help = true;
|
|
109
|
-
else if (a === "--vault") {
|
|
110
|
-
out.vault = argv[i + 1];
|
|
111
|
-
i++;
|
|
112
|
-
} else if (a.startsWith("--vault=")) {
|
|
113
|
-
out.vault = a.slice("--vault=".length);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function printHelp(): void {
|
|
120
|
-
console.log(
|
|
121
|
-
`Usage: bun scripts/migrate-audio-to-opus.ts [options]
|
|
122
|
-
|
|
123
|
-
Options:
|
|
124
|
-
--vault <name> Migrate only the named vault (default: all vaults)
|
|
125
|
-
--dry-run Report what would change without touching anything
|
|
126
|
-
--help, -h Show this help
|
|
127
|
-
|
|
128
|
-
Converts existing audio attachments (.wav / .mp3) into OGG Opus in-place.
|
|
129
|
-
Idempotent: safe to re-run. Does not bump updated_at on parent notes.
|
|
130
|
-
`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
// Attachment row shape
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
interface AttachmentRow {
|
|
139
|
-
id: string;
|
|
140
|
-
note_id: string;
|
|
141
|
-
path: string;
|
|
142
|
-
mime_type: string;
|
|
143
|
-
metadata: string | null;
|
|
144
|
-
created_at: string;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
// Per-vault migration
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
|
|
151
|
-
interface VaultSummary {
|
|
152
|
-
vault: string;
|
|
153
|
-
converted: number;
|
|
154
|
-
skipped: number;
|
|
155
|
-
errors: number;
|
|
156
|
-
bytesBefore: number;
|
|
157
|
-
bytesAfter: number;
|
|
158
|
-
dryRunCandidates: number;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function shouldMigrate(mime: string, path: string): boolean {
|
|
162
|
-
if (!mime.toLowerCase().startsWith("audio/")) return false;
|
|
163
|
-
const p = path.toLowerCase();
|
|
164
|
-
return p.endsWith(".wav") || p.endsWith(".mp3");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function inputMimeFromPath(path: string, stored: string): string {
|
|
168
|
-
const lower = path.toLowerCase();
|
|
169
|
-
if (lower.endsWith(".wav")) return "audio/wav";
|
|
170
|
-
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
|
171
|
-
return stored;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function migrateVault(
|
|
175
|
-
vault: string,
|
|
176
|
-
dryRun: boolean,
|
|
177
|
-
): Promise<VaultSummary> {
|
|
178
|
-
const summary: VaultSummary = {
|
|
179
|
-
vault,
|
|
180
|
-
converted: 0,
|
|
181
|
-
skipped: 0,
|
|
182
|
-
errors: 0,
|
|
183
|
-
bytesBefore: 0,
|
|
184
|
-
bytesAfter: 0,
|
|
185
|
-
dryRunCandidates: 0,
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const dbPath = vaultDbPath(vault);
|
|
189
|
-
if (!existsSync(dbPath)) {
|
|
190
|
-
console.error(`[${vault}] vault db not found at ${dbPath}, skipping`);
|
|
191
|
-
return summary;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const assetsBase = vaultAssetsDir(vault);
|
|
195
|
-
const db = new Database(dbPath);
|
|
196
|
-
|
|
197
|
-
// Count the rows up-front so per-row logs can show [N/total] progress.
|
|
198
|
-
// Matches the SELECT below so the denominator is meaningful even when
|
|
199
|
-
// most rows turn out to be already-migrated and get fast-skipped.
|
|
200
|
-
let total = 0;
|
|
201
|
-
try {
|
|
202
|
-
const row = db
|
|
203
|
-
.prepare(
|
|
204
|
-
"SELECT COUNT(*) AS c FROM attachments WHERE mime_type LIKE 'audio/%'",
|
|
205
|
-
)
|
|
206
|
-
.get() as { c: number } | undefined;
|
|
207
|
-
total = row?.c ?? 0;
|
|
208
|
-
} catch {
|
|
209
|
-
total = 0;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Running count of rows we've touched in any terminal way (converted,
|
|
213
|
-
// errored, or skipped). Used to prefix per-row logs with [N/total].
|
|
214
|
-
// Only emitted when total > 0 — for empty vaults the prefix is noise.
|
|
215
|
-
let processed = 0;
|
|
216
|
-
const progress = (): string =>
|
|
217
|
-
total > 0 ? `[${processed + 1}/${total}] ` : "";
|
|
218
|
-
|
|
219
|
-
// One-shot notice about missing original_size_bytes metadata on fixup
|
|
220
|
-
// rows (see the fixup branch below). We log this at most once per vault
|
|
221
|
-
// so partially-migrated vaults don't spam the summary.
|
|
222
|
-
let warnedUnknownOriginalSize = false;
|
|
223
|
-
|
|
224
|
-
// Caveat: the fixup branch (DB stale, .ogg already exists on disk) can
|
|
225
|
-
// only credit bytesBefore from the attachment's metadata
|
|
226
|
-
// (`original_size_bytes`, written by tts-provider.ts). Rows encoded by an
|
|
227
|
-
// earlier pass of this migration script never had that metadata written,
|
|
228
|
-
// so their pre-migration size is unknowable and the "saved X%" summary
|
|
229
|
-
// will undercount on those rows. The summary is cosmetic; the data is
|
|
230
|
-
// correct.
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
const rows = db
|
|
234
|
-
.prepare(
|
|
235
|
-
"SELECT id, note_id, path, mime_type, metadata, created_at FROM attachments WHERE mime_type LIKE 'audio/%'",
|
|
236
|
-
)
|
|
237
|
-
.all() as AttachmentRow[];
|
|
238
|
-
|
|
239
|
-
const updateStmt = db.prepare(
|
|
240
|
-
"UPDATE attachments SET path = ?, mime_type = ? WHERE id = ?",
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
for (const row of rows) {
|
|
244
|
-
// Already-migrated rows (audio/ogg + .ogg path) count as skipped so
|
|
245
|
-
// re-runs produce a meaningful "nothing to do" summary.
|
|
246
|
-
if (
|
|
247
|
-
row.mime_type.toLowerCase() === "audio/ogg" &&
|
|
248
|
-
row.path.toLowerCase().endsWith(".ogg")
|
|
249
|
-
) {
|
|
250
|
-
summary.skipped++;
|
|
251
|
-
processed++;
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (!shouldMigrate(row.mime_type, row.path)) {
|
|
256
|
-
processed++;
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const absIn = join(assetsBase, row.path);
|
|
261
|
-
// Build the target path: same directory, same stem, .ogg extension.
|
|
262
|
-
const lastDot = row.path.lastIndexOf(".");
|
|
263
|
-
const stem = lastDot === -1 ? row.path : row.path.slice(0, lastDot);
|
|
264
|
-
const relOut = `${stem}.ogg`;
|
|
265
|
-
const absOut = join(assetsBase, relOut);
|
|
266
|
-
|
|
267
|
-
// Idempotency: DB row already points to .ogg and file exists.
|
|
268
|
-
if (row.path === relOut && existsSync(absOut)) {
|
|
269
|
-
summary.skipped++;
|
|
270
|
-
processed++;
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// If a sibling .ogg already exists AND the DB is stale (still points
|
|
275
|
-
// to the WAV/MP3), fix up the DB row and drop the original source
|
|
276
|
-
// file — don't re-run ffmpeg.
|
|
277
|
-
if (existsSync(absOut) && row.path !== relOut) {
|
|
278
|
-
if (dryRun) {
|
|
279
|
-
console.log(
|
|
280
|
-
`${progress()}[${vault}] DRY-RUN fixup (ogg exists, db stale): ${row.path} -> ${relOut}`,
|
|
281
|
-
);
|
|
282
|
-
summary.dryRunCandidates++;
|
|
283
|
-
processed++;
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
try {
|
|
287
|
-
updateStmt.run(relOut, "audio/ogg", row.id);
|
|
288
|
-
if (existsSync(absIn) && absIn !== absOut) {
|
|
289
|
-
unlinkSync(absIn);
|
|
290
|
-
}
|
|
291
|
-
summary.converted++;
|
|
292
|
-
let outSize = 0;
|
|
293
|
-
try {
|
|
294
|
-
outSize = statSync(absOut).size;
|
|
295
|
-
summary.bytesAfter += outSize;
|
|
296
|
-
} catch {
|
|
297
|
-
// ignore
|
|
298
|
-
}
|
|
299
|
-
// Credit bytesBefore from the attachment's metadata if the
|
|
300
|
-
// encoding path recorded it. Historically the old in-process
|
|
301
|
-
// encoder wrote `original_size_bytes` alongside each OGG
|
|
302
|
-
// attachment; the narrate-era hook (tts-hook.ts) does NOT —
|
|
303
|
-
// narrate doesn't surface the pre-encode size. So going forward,
|
|
304
|
-
// this field will be absent on all new rows; it only exists on
|
|
305
|
-
// legacy rows from before the narrate swap. For rows without it
|
|
306
|
-
// we log a one-time notice per vault and skip the credit. The
|
|
307
|
-
// summary's "saved X%" will undercount; treat it as a lower bound.
|
|
308
|
-
let originalBytes: number | undefined;
|
|
309
|
-
if (row.metadata) {
|
|
310
|
-
try {
|
|
311
|
-
const meta = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
312
|
-
const v = meta.original_size_bytes;
|
|
313
|
-
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
314
|
-
originalBytes = v;
|
|
315
|
-
}
|
|
316
|
-
} catch {
|
|
317
|
-
// malformed metadata JSON — treat as unknown
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (originalBytes !== undefined) {
|
|
321
|
-
summary.bytesBefore += originalBytes;
|
|
322
|
-
} else if (!warnedUnknownOriginalSize) {
|
|
323
|
-
warnedUnknownOriginalSize = true;
|
|
324
|
-
console.log(
|
|
325
|
-
`[${vault}] note: one or more fixup rows have unknown original size (no original_size_bytes in metadata); summary savings will undercount`,
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
console.log(
|
|
329
|
-
`${progress()}[${vault}] fixup ${row.path} -> ${relOut}${
|
|
330
|
-
originalBytes !== undefined
|
|
331
|
-
? ` (${fmtBytes(originalBytes)} -> ${fmtBytes(outSize)})`
|
|
332
|
-
: ` (${fmtBytes(outSize)}, original size unknown)`
|
|
333
|
-
}`,
|
|
334
|
-
);
|
|
335
|
-
} catch (err) {
|
|
336
|
-
console.error(`[${vault}] fixup failed for ${row.id}:`, err);
|
|
337
|
-
summary.errors++;
|
|
338
|
-
}
|
|
339
|
-
processed++;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (!existsSync(absIn)) {
|
|
344
|
-
console.error(
|
|
345
|
-
`${progress()}[${vault}] source file missing for attachment ${row.id}: ${absIn} — skipping`,
|
|
346
|
-
);
|
|
347
|
-
summary.errors++;
|
|
348
|
-
processed++;
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (dryRun) {
|
|
353
|
-
let inSize = 0;
|
|
354
|
-
try {
|
|
355
|
-
inSize = statSync(absIn).size;
|
|
356
|
-
} catch {
|
|
357
|
-
// ignore
|
|
358
|
-
}
|
|
359
|
-
summary.bytesBefore += inSize;
|
|
360
|
-
summary.dryRunCandidates++;
|
|
361
|
-
console.log(
|
|
362
|
-
`${progress()}[${vault}] DRY-RUN convert: ${row.path} (${fmtBytes(inSize)}, ${row.mime_type}) -> ${relOut}`,
|
|
363
|
-
);
|
|
364
|
-
processed++;
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
const inBytes = readFileSync(absIn);
|
|
370
|
-
const beforeSize = inBytes.byteLength;
|
|
371
|
-
const ogg = await encodeOggOpus(
|
|
372
|
-
Buffer.from(inBytes),
|
|
373
|
-
inputMimeFromPath(row.path, row.mime_type),
|
|
374
|
-
);
|
|
375
|
-
writeFileSync(absOut, ogg);
|
|
376
|
-
|
|
377
|
-
// Raw SQL update — deliberately bypasses store.updateNote so we do
|
|
378
|
-
// NOT bump updated_at on the parent note. This is a pure storage
|
|
379
|
-
// format change; the note content hasn't changed. See parachute-
|
|
380
|
-
// vault#44 for the related "hooks shouldn't bump updated_at" work.
|
|
381
|
-
updateStmt.run(relOut, "audio/ogg", row.id);
|
|
382
|
-
|
|
383
|
-
if (absIn !== absOut) {
|
|
384
|
-
try {
|
|
385
|
-
unlinkSync(absIn);
|
|
386
|
-
} catch (err) {
|
|
387
|
-
console.error(
|
|
388
|
-
`[${vault}] converted but failed to unlink original ${absIn}:`,
|
|
389
|
-
err,
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
summary.converted++;
|
|
395
|
-
summary.bytesBefore += beforeSize;
|
|
396
|
-
summary.bytesAfter += ogg.byteLength;
|
|
397
|
-
|
|
398
|
-
console.log(
|
|
399
|
-
`${progress()}[${vault}] converted ${row.path} -> ${relOut} (${fmtBytes(beforeSize)} -> ${fmtBytes(ogg.byteLength)})`,
|
|
400
|
-
);
|
|
401
|
-
} catch (err) {
|
|
402
|
-
console.error(
|
|
403
|
-
`${progress()}[${vault}] error converting attachment ${row.id} (${row.path}):`,
|
|
404
|
-
err,
|
|
405
|
-
);
|
|
406
|
-
summary.errors++;
|
|
407
|
-
}
|
|
408
|
-
processed++;
|
|
409
|
-
}
|
|
410
|
-
} finally {
|
|
411
|
-
db.close();
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return summary;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ---------------------------------------------------------------------------
|
|
418
|
-
// Formatting helpers
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
|
|
421
|
-
function fmtBytes(n: number): string {
|
|
422
|
-
if (n === 0) return "0B";
|
|
423
|
-
const units = ["B", "KB", "MB", "GB"];
|
|
424
|
-
let i = 0;
|
|
425
|
-
let val = n;
|
|
426
|
-
while (val >= 1024 && i < units.length - 1) {
|
|
427
|
-
val /= 1024;
|
|
428
|
-
i++;
|
|
429
|
-
}
|
|
430
|
-
return `${val.toFixed(val < 10 && i > 0 ? 2 : 0)}${units[i]}`;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function printSummary(s: VaultSummary, dryRun: boolean): void {
|
|
434
|
-
if (dryRun) {
|
|
435
|
-
console.log(
|
|
436
|
-
`[${s.vault}] DRY-RUN summary: ${s.dryRunCandidates} candidate(s), ${fmtBytes(
|
|
437
|
-
s.bytesBefore,
|
|
438
|
-
)} current total, ${s.errors} errors`,
|
|
439
|
-
);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
const savedBytes = s.bytesBefore - s.bytesAfter;
|
|
443
|
-
const savedLabel =
|
|
444
|
-
s.bytesBefore > 0
|
|
445
|
-
? ` (saved ${fmtBytes(savedBytes)}, ${((savedBytes / s.bytesBefore) * 100).toFixed(1)}%)`
|
|
446
|
-
: "";
|
|
447
|
-
console.log(
|
|
448
|
-
`[${s.vault}] done: ${s.converted} converted, ${s.skipped} skipped, ${s.errors} errors, ${fmtBytes(
|
|
449
|
-
s.bytesBefore,
|
|
450
|
-
)} -> ${fmtBytes(s.bytesAfter)}${savedLabel}`,
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
// Entrypoint
|
|
456
|
-
// ---------------------------------------------------------------------------
|
|
457
|
-
|
|
458
|
-
export async function runMigration(argv: string[]): Promise<VaultSummary[]> {
|
|
459
|
-
const args = parseArgs(argv);
|
|
460
|
-
if (args.help) {
|
|
461
|
-
printHelp();
|
|
462
|
-
return [];
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const vaults = args.vault ? [args.vault] : listVaultNames();
|
|
466
|
-
if (vaults.length === 0) {
|
|
467
|
-
console.error(`No vaults found under ${vaultsDir()}`);
|
|
468
|
-
return [];
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
console.log(
|
|
472
|
-
`Migrating audio attachments to OGG Opus${args.dryRun ? " (DRY RUN)" : ""}`,
|
|
473
|
-
);
|
|
474
|
-
console.log(`Vaults: ${vaults.join(", ")}`);
|
|
475
|
-
|
|
476
|
-
const summaries: VaultSummary[] = [];
|
|
477
|
-
for (const v of vaults) {
|
|
478
|
-
try {
|
|
479
|
-
const s = await migrateVault(v, args.dryRun);
|
|
480
|
-
summaries.push(s);
|
|
481
|
-
printSummary(s, args.dryRun);
|
|
482
|
-
} catch (err) {
|
|
483
|
-
console.error(`[${v}] fatal error:`, err);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
return summaries;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Only auto-run when invoked directly (`bun scripts/migrate-audio-to-opus.ts`),
|
|
491
|
-
// not when imported from a test.
|
|
492
|
-
if (import.meta.main) {
|
|
493
|
-
runMigration(process.argv.slice(2))
|
|
494
|
-
.then(() => process.exit(0))
|
|
495
|
-
.catch((err) => {
|
|
496
|
-
console.error("fatal:", err);
|
|
497
|
-
process.exit(1);
|
|
498
|
-
});
|
|
499
|
-
}
|