Something went wrong. Try again.
Something went wrong. Try again.
13 kB · 386 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387#!/usr/bin/env node// sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive.//// `epub-optimize` remains the standalone converter; this entry point adds the// OAuth session, BookHive client, ledger and watcher.
import { existsSync, readdirSync, statSync } from 'node:fs';import { createInterface } from 'node:readline/promises';import { resolve } from 'node:path';import { parseArgs } from 'node:util';
import { DEVICE_PROFILES } from './constants.js';import { formatBytes } from './util.js';import { AuthError, login, logout, readSessionPointer, restoreSession,} from './sinker/auth.js';import { createBookHiveClient } from './sinker/bookhive.js';import { ConfigError, describeConfig, loadConfig,} from './sinker/config.js';import { createLogger } from './sinker/log.js';import { createContext, syncOnce, summarize } from './sinker/pipeline.js';import { Ledger, stateFileFor } from './sinker/state.js';import { watch } from './sinker/watcher.js';
const USAGE = `sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive
Usage: sinker <command> [options]
Commands: login Authenticate with your atproto account (opens a browser) logout Revoke the saved session and forget it status Show config, session and sync ledger sync [file…] Compress and upload new books once, then exit watch Watch the source folder and keep BookHive in sync
Options: --env-file FILE load configuration from FILE (default: ./.env) --verbose log per-image detail and retry decisions --quiet only warnings and errors -h, --help show this message
Run \`sinker <command> --help\` for command options.`.trim();
const COMMAND_USAGE = { login: `Usage: sinker login [options]
Options: --handle HANDLE atproto handle (default: ATPROTO_HANDLE) --port PORT loopback callback port (default: 8765) --login-host HOST interface to bind (default: 127.0.0.1; use 0.0.0.0 in Docker) --no-open print the URL instead of opening a browser`.trim(),
logout: `Usage: sinker logout [options]`.trim(),
status: `Usage: sinker status [options]
Options: --json machine-readable output --check verify the saved session against BookHive`.trim(),
sync: `Usage: sinker sync [file…] [options]
Compresses and uploads new books once. With no files, scans the source folder.
Options: --source DIR source folder (default: SINKER_SOURCE_DIR) --out DIR output folder (default: SINKER_OUTPUT_DIR) --device X4|X3 target screen (default: X4) --quality N JPEG quality 1-100 (default: 85) --auto-crop trim uniform margins before resizing --no-rename keep source filenames instead of "Title - Author.epub" --dry-run do everything except write files, upload, or update state --force upload even if the book is already in BookHive --retry-failed retry files that previously failed permanently`.trim(),
watch: `Usage: sinker watch [options]
Watches the source folder until Ctrl+C.
Options: (same as sync, plus) --no-backfill only process files added after startup --poll poll the filesystem (network volumes) --rescan-ms N periodic rescan interval in ms (default: 300000) --retry-max N upload retry attempts (default: 5) --timeout-ms N per-request timeout in ms (default: 120000) --verbose log per-image detail and retry decisions`.trim(),};
class UsageError extends Error {}
function buildOverrides(values) { return { handle: values.handle, sourceDir: values.source, outputDir: values.out, device: values.device, quality: values.quality, autoCrop: values['auto-crop'] ? true : undefined, rename: values['no-rename'] ? false : undefined, loginPort: values.port, loginHost: values['login-host'], openBrowser: values['no-open'] ? false : undefined, poll: values.poll ? true : undefined, backfill: values['no-backfill'] ? false : undefined, rescanMs: values['rescan-ms'], retryMax: values['retry-max'], timeoutMs: values['timeout-ms'], dryRun: values['dry-run'] ? true : undefined, force: values.force ? true : undefined, retryFailed: values['retry-failed'] ? true : undefined, verbose: values.verbose ? true : undefined, quiet: values.quiet ? true : undefined, };}
function parseCli(argv) { let parsed; try { parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: { 'env-file': { type: 'string' }, help: { type: 'boolean', short: 'h' },
verbose: { type: 'boolean' }, quiet: { type: 'boolean' },
handle: { type: 'string' }, port: { type: 'string' }, 'login-host': { type: 'string' }, 'no-open': { type: 'boolean' },
source: { type: 'string' }, out: { type: 'string' }, device: { type: 'string' }, quality: { type: 'string' }, 'auto-crop': { type: 'boolean' }, 'no-rename': { type: 'boolean' }, 'dry-run': { type: 'boolean' }, force: { type: 'boolean' }, 'retry-failed': { type: 'boolean' },
poll: { type: 'boolean' }, 'no-backfill': { type: 'boolean' }, 'rescan-ms': { type: 'string' }, 'retry-max': { type: 'string' }, 'timeout-ms': { type: 'string' },
json: { type: 'boolean' }, check: { type: 'boolean' }, }, }); } catch (error) { throw new UsageError(error.message); }
return parsed;}
function helpFor(command) { if (command && COMMAND_USAGE[command]) return `${COMMAND_USAGE[command]}\n\n${USAGE}`; return USAGE;}
async function promptHandle() { if (!process.stdin.isTTY) return null; const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question('Bluesky handle (e.g. you.bsky.social): '); return answer.trim() || null; } finally { rl.close(); }}
function ledgerFor(config, did) { return new Ledger(stateFileFor(config.stateDir, did ?? 'anonymous')).load();}
async function runLogin(config, log) { let handle = config.handle; if (!handle) handle = await promptHandle(); if (!handle) throw new UsageError('a handle is required: pass --handle or set ATPROTO_HANDLE');
const { did } = await login(config, { handle, log }); log.ok(`Signed in as ${did}.`); return 0;}
async function runLogout(config, log) { const did = await logout(config); if (did) log.ok(`Signed out ${did}.`); else log.info('No saved session.'); return 0;}
function sourceSummary(config) { if (!existsSync(config.sourceDir)) { return { exists: false, epubs: 0, other: 0 }; } let epubs = 0; let other = 0; for (const entry of readdirSync(config.sourceDir, { withFileTypes: true })) { if (entry.name.startsWith('.') || entry.isDirectory()) continue; let isFile = entry.isFile(); if (!isFile) { try { isFile = statSync(resolve(config.sourceDir, entry.name)).isFile(); } catch { continue; } } if (!isFile) continue; if (/\.epub$/i.test(entry.name)) epubs++; else other++; } return { exists: true, epubs, other };}
async function runStatus(config, log, values) { const pointer = readSessionPointer(config); const did = pointer?.did ?? null; const ledger = ledgerFor(config, did); const source = sourceSummary(config);
const data = { signedIn: Boolean(did), did, handle: pointer?.handle ?? config.handle ?? null, signedInAt: pointer?.signedInAt ?? null, bookhive: { url: config.bookhiveUrl, did: config.bookhiveDid }, source: { ...source, dir: config.sourceDir }, output: { dir: config.outputDir, exists: existsSync(config.outputDir) }, device: config.device, quality: config.quality, autoCrop: config.autoCrop, rename: config.rename, ledger: { file: stateFileFor(config.stateDir, did ?? 'anonymous'), counts: ledger.counts() }, };
if (values.check) { if (!did) throw new AuthError('not signed in — run `sinker login` first'); const { session } = await restoreSession(config); const client = createBookHiveClient({ config, session, logger: log }); const library = await client.refreshLibrary(); data.library = { books: library.size, storage: client.storage, }; }
if (values.json) { console.log(JSON.stringify(data, null, 2)); return 0; }
console.log(describeConfig(config)); console.log(''); if (did) { console.log(`session ${pointer.handle ? `@${pointer.handle} ` : ''}${did}`); console.log(`signed in ${pointer.signedInAt ?? 'unknown'}`); } else { console.log('session not signed in (run `sinker login`)'); }
const counts = data.ledger.counts; const parts = Object.entries(counts).map(([status, count]) => `${count} ${status}`); console.log(`ledger ${parts.length ? parts.join(', ') : 'empty'}`); console.log(`files ${source.epubs} epub(s)${source.other ? `, ${source.other} other file(s)` : ''}`); if (data.library) { const storage = data.library.storage ? ` (${formatBytes(data.library.storage.usedBytes)} / ${formatBytes(data.library.storage.quotaBytes)})` : ''; console.log(`library ${data.library.books} book(s)${storage}`); } return 0;}
async function makeClient(config, log) { const { did, session } = await restoreSession(config); const client = createBookHiveClient({ config, session, logger: log }); return { did, client };}
async function runSync(config, log, paths) { const { did, client } = await makeClient(config, log); const ctx = createContext({ config, bookhive: client, logger: log, did });
const explicit = paths.length ? paths.map(path => { const resolved = resolve(config.cwd, path); if (!existsSync(resolved) || !statSync(resolved).isFile()) { throw new UsageError(`not a file: ${resolved}`); } return resolved; }) : undefined;
log.info(`Signed in as ${did}`); log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`);
const stats = await syncOnce(ctx, explicit ? { paths: explicit } : {}); log.info(`Done: ${summarize(stats)}`); return stats.failed > 0 ? 1 : 0;}
async function runWatch(config, log) { const { did, client } = await makeClient(config, log); const ctx = createContext({ config, bookhive: client, logger: log, did });
log.info(`Signed in as ${did}`); log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`);
return watch(ctx);}
async function main(argv) { const { values, positionals } = parseCli(argv); const command = positionals[0];
if (values.help || command === 'help') { console.log(helpFor(command === 'help' ? null : command)); return 0; }
if (!command) { console.log(USAGE); return 2; }
if (!['login', 'logout', 'status', 'sync', 'watch'].includes(command)) { throw new UsageError(`unknown command "${command}"`); }
const log = createLogger({ verbose: values.verbose, quiet: values.quiet });
const config = loadConfig({ envFile: values['env-file'] ? resolve(values['env-file']) : undefined, cwd: process.cwd(), overrides: buildOverrides(values), });
const extra = positionals.slice(1); if (extra.length && command !== 'sync') { throw new UsageError(`unexpected argument "${extra[0]}"`); }
switch (command) { case 'login': return runLogin(config, log); case 'logout': return runLogout(config, log); case 'status': return runStatus(config, log, values); case 'sync': return runSync(config, log, extra); case 'watch': return runWatch(config, log); default: throw new UsageError(`unknown command "${command}"`); }}
main(process.argv.slice(2)) .then(code => { process.exitCode = code; }) .catch(error => { if (error instanceof UsageError) { console.error(`error: ${error.message}`); console.error(''); console.error(USAGE); } else if (error instanceof ConfigError || error instanceof AuthError) { console.error(`error: ${error.message}`); } else { console.error(`error: ${error?.message ?? error}`); if (process.env.DEBUG) console.error(error.stack); } process.exitCode = 1; });