Created
July 13, 2026 17:34
-
-
Save ag4ve/12e2f6531b5c163c44b36648b2b56ff5 to your computer and use it in GitHub Desktop.
iOS scriptable script to batch download humble bundle assets you purchased
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Humble ebooks Scriptable helper | |
| // ============================================================ | |
| // GOAL | |
| // ----- | |
| // * Log into humblebundle.com in a WebView. | |
| // * Fetch /api/v1/user/order + each order's /api/v1/order/<gamekey>?all_tpkds=true. | |
| // * Extract obvious ebook downloads (.epub/.mobi/.pdf etc.). | |
| // * Let the user choose a download root folder via the iOS folder picker. | |
| // * Download only NEW ebooks (don’t re-download existing files). | |
| // * Keep state (what’s downloaded & where) in an iCloud JSON file. | |
| // * Prepare “Books batch” folders that can be imported into Apple Books on macOS/iOS. | |
| // * Always return to a main menu loop until the user chooses Exit. | |
| // | |
| // STATE FILE | |
| // ---------- | |
| // Location: iCloud Scriptable documents: | |
| // FileManager.iCloud().documentsDirectory() + "/humble_ebooks_state.json" | |
| // | |
| // Shape (evolves over time; compatible upgrades): | |
| // { | |
| // version: string, // script version that last wrote the file | |
| // schemaVersion: number, // to help migrations | |
| // cacheTtlDays: number, | |
| // library: [...], // raw Humble order JSON objects | |
| // libraryUpdatedAt: ISO string, // when `library` was fetched | |
| // entries: [ // derived ebook entries | |
| // { | |
| // id: string, // stable key: gamekey/subproduct/fileName | |
| // gamekey: string, | |
| // bundleName: string, // eg "Humble Book Bundle: X" | |
| // subTitle: string, // ebook “product” title | |
| // format: string, // epub/mobi/pdf/… | |
| // humbleUrl: string, // direct download URL (web) | |
| // fileName: string, // suggested filename | |
| // relativePath: string, // path under download root | |
| // localPath: string|null, // full path where it lives (if downloaded) | |
| // localExists: boolean, // fs check result | |
| // downloadedAt: string|null, | |
| // importedToBooks: boolean // if user has already batched/imported | |
| // }, | |
| // ... | |
| // ], | |
| // downloadRootBookmark: string|null, // bookmark for chosen folder | |
| // downloadRootDisplayName: string|null,// human friendly path/name | |
| // lastRun: ISO string|null | |
| // } | |
| // | |
| // IMPORTANT BEHAVIOUR | |
| // ------------------- | |
| // * Main menu appears FIRST and after each completed action. | |
| // * “Refresh Humble order JSON” will: | |
| // | |
| // - Open Humble library in WebView, let you log in, | |
| // - Then use the same WebView (with your cookies) to fetch JSON | |
| // via /api/v1/user/order and each /api/v1/order/<gamekey>?all_tpkds=true. | |
| // | |
| // * “Download new ebooks”: | |
| // | |
| // - Requires a download root (it will prompt with the folder picker). | |
| // - Builds ebook entries from the Humble JSON each run. | |
| // - Uses entry.relativePath to decide where to place a file. | |
| // - If a file already exists at that location, it is NOT re-downloaded. | |
| // | |
| // * “Prepare Books batch”: | |
| // | |
| // - Creates one batch folder under <downloadRoot>/BooksBatches/. | |
| // - Copies (not symlinks; Scriptable has no symlink API) up to BATCH_SIZE | |
| // ebooks that are downloaded but not yet importedToBooks. | |
| // - Marks those entries as pending/importedToBooks=true. | |
| // | |
| // * Logging: | |
| // | |
| // - logTS() prints timestamped log lines (for Scriptable’s log pane). | |
| // - notifyStatus() sends a small notification after long-running operations. | |
| // | |
| // BUG/TODO NOTES | |
| // -------------- | |
| // * If Humble changes their HTML “<pre>JSON</pre>” wrapper, the JSON scraping | |
| // in humbleFetchJSON() may need adjustment. | |
| // * No direct, fully-automatic Apple Books import is possible from Scriptable. | |
| // The “Books batch” folders are meant for macOS Finder / Files.app drag-drop. | |
| // ============================================================ | |
| const SCRIPT_VERSION = '2025-12-11-01'; | |
| const STATE_SCHEMA_VERSION = 2; | |
| const DEFAULT_CACHE_TTL_DAYS = 2; // refresh Humble JSON every ~2 days | |
| const BATCH_SIZE = 40; // ebooks per “Books batch” folder | |
| const EBOOK_EXTS = ['epub', 'mobi', 'pdf', 'azw3', 'cbz', 'cbr']; | |
| const fm = FileManager.iCloud(); | |
| const STATE_FILE = fm.joinPath(fm.documentsDirectory(), 'humble_ebooks_state.json'); | |
| // ---------- small utilities ---------- | |
| function nowIso() { return new Date().toISOString(); } | |
| function logTS(msg) { | |
| const ts = nowIso(); | |
| console.log(`${ts}: ${msg}`); | |
| } | |
| async function notifyStatus(title, body) { | |
| try { | |
| const n = new Notification(); | |
| n.title = title; | |
| n.body = body; | |
| n.sound = null; | |
| await n.schedule(); | |
| } catch (err) { | |
| logTS(`notifyStatus failed: ${String(err)}`); | |
| } | |
| } | |
| function minutesBetween(olderIso, newerIso) { | |
| const older = new Date(olderIso).getTime(); | |
| const newer = new Date(newerIso).getTime(); | |
| return (newer - older) / 60000; | |
| } | |
| function sanitizeName(name) { | |
| if (!name) return 'unnamed'; | |
| return String(name) | |
| .replace(/[\\/:*?"<>|]/g, '_') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function sanitizeFileName(name) { | |
| const cleaned = sanitizeName(name); | |
| return cleaned === '' ? 'file' : cleaned; | |
| } | |
| // ---------- state load/save & migrations ---------- | |
| function defaultState() { | |
| return { | |
| version: SCRIPT_VERSION, | |
| schemaVersion: STATE_SCHEMA_VERSION, | |
| cacheTtlDays: DEFAULT_CACHE_TTL_DAYS, | |
| library: null, | |
| libraryUpdatedAt: null, | |
| entries: [], | |
| downloadRootBookmark: null, | |
| downloadRootDisplayName: null, | |
| lastRun: null | |
| }; | |
| } | |
| async function loadState() { | |
| if (!fm.fileExists(STATE_FILE)) { | |
| logTS(`State file does not exist at ${STATE_FILE}`); | |
| return defaultState(); | |
| } | |
| try { | |
| const raw = fm.readString(STATE_FILE); | |
| const obj = JSON.parse(raw); | |
| logTS(`Loaded state file from ${STATE_FILE}, keys=${Object.keys(obj).join(',')}`); | |
| if (typeof obj.schemaVersion !== 'number') { | |
| obj.schemaVersion = 1; | |
| } | |
| if (obj.cacheTtlDays == null) obj.cacheTtlDays = DEFAULT_CACHE_TTL_DAYS; | |
| obj.version = SCRIPT_VERSION; | |
| // migrate schema v1 -> v2 (add entries array if missing) | |
| if (obj.schemaVersion < STATE_SCHEMA_VERSION) { | |
| if (!Array.isArray(obj.entries)) obj.entries = []; | |
| obj.schemaVersion = STATE_SCHEMA_VERSION; | |
| logTS(`Migrated state schema to v${STATE_SCHEMA_VERSION}`); | |
| saveState(obj); | |
| } | |
| return obj; | |
| } catch (err) { | |
| logTS(`Failed to parse state; starting fresh. Error=${String(err)}`); | |
| return defaultState(); | |
| } | |
| } | |
| function saveState(state) { | |
| try { | |
| state.version = SCRIPT_VERSION; | |
| const text = JSON.stringify(state, null, 2); | |
| fm.writeString(STATE_FILE, text); | |
| logTS(`Saved state to ${STATE_FILE}, length=${text.length}`); | |
| } catch (err) { | |
| logTS(`ERROR: Failed to save state: ${String(err)}`); | |
| } | |
| } | |
| // ---------- Humble JSON via WebView ---------- | |
| async function humbleLoginAndCollectLibrary(state) { | |
| logTS('collectLibraryViaWebView: Creating WebView and loading Humble library…'); | |
| const wv = new WebView(); | |
| await wv.loadURL('https://www.humblebundle.com/home/library?sort=recent'); | |
| await wv.present(true); // user logs in, closes when ready | |
| logTS('collectLibraryViaWebView: Login WebView closed; starting JSON collection via WebView.getHTML().'); | |
| // orders list | |
| const ordersList = await humbleFetchJSON( | |
| wv, | |
| 'https://www.humblebundle.com/api/v1/user/order' | |
| ); | |
| if (!Array.isArray(ordersList)) { | |
| throw new Error('Unexpected /api/v1/user/order response; expected array.'); | |
| } | |
| logTS(`collectLibraryViaWebView: got ${ordersList.length} order stubs`); | |
| const orders = []; | |
| for (let i = 0; i < ordersList.length; i++) { | |
| const stub = ordersList[i]; | |
| const gamekey = stub.gamekey || stub.game_key || stub.gameKey; | |
| if (!gamekey) { | |
| logTS(`collectLibraryViaWebView: order stub ${i} missing gamekey; skipping`); | |
| continue; | |
| } | |
| const url = `https://www.humblebundle.com/api/v1/order/${encodeURIComponent( | |
| gamekey | |
| )}?all_tpkds=true`; | |
| logTS(`collectLibraryViaWebView: fetching order ${i + 1}/${ | |
| ordersList.length | |
| } -> ${url}`); | |
| const order = await humbleFetchJSON(wv, url); | |
| orders.push(order); | |
| } | |
| state.library = orders; | |
| state.libraryUpdatedAt = nowIso(); | |
| saveState(state); | |
| logTS( | |
| `collectLibraryViaWebView: stored ${orders.length} orders; libraryUpdatedAt=${state.libraryUpdatedAt}` | |
| ); | |
| await notifyStatus( | |
| 'Humble ebooks', | |
| `Fetched ${orders.length} Humble orders.` | |
| ); | |
| } | |
| // Extract JSON from the HTML <pre> wrapper Humble uses for API responses. | |
| async function humbleFetchJSON(webView, url) { | |
| logTS(`[WV_FETCH] loadURL -> ${url}`); | |
| await webView.loadURL(url); | |
| const text = await webView.getHTML(); | |
| logTS( | |
| `[WV_FETCH] got text length=${text.length}, preview=${text.slice( | |
| 0, | |
| 120 | |
| ).replace(/\s+/g, ' ')}` | |
| ); | |
| // Try extracting either an array or an object from within the HTML. | |
| function sliceJsonCandidate(t, openChar, closeChar) { | |
| const start = t.indexOf(openChar); | |
| const end = t.lastIndexOf(closeChar); | |
| if (start === -1 || end === -1 || end <= start) return null; | |
| return t.slice(start, end + 1); | |
| } | |
| const candidates = []; | |
| const objCandidate = sliceJsonCandidate(text, '{', '}'); | |
| const arrCandidate = sliceJsonCandidate(text, '[', ']'); | |
| if (objCandidate) candidates.push(objCandidate); | |
| if (arrCandidate) candidates.push(arrCandidate); | |
| let lastError = null; | |
| for (const c of candidates) { | |
| try { | |
| const json = JSON.parse(c); | |
| logTS(`[WV_FETCH] parsed JSON successfully for ${url}`); | |
| return json; | |
| } catch (err) { | |
| lastError = err; | |
| // continue | |
| } | |
| } | |
| throw new Error( | |
| `Failed to parse JSON from ${url}: ${String(lastError || 'no JSON candidate')}` | |
| ); | |
| } | |
| function isLibraryFresh(state) { | |
| if (!state.library || !state.libraryUpdatedAt) return false; | |
| const ageMin = minutesBetween(state.libraryUpdatedAt, nowIso()); | |
| const ttlMin = (state.cacheTtlDays || DEFAULT_CACHE_TTL_DAYS) * 1440; | |
| logTS( | |
| `isLibraryFresh: ageMinutes=${ageMin.toFixed( | |
| 1 | |
| )}, ttlMinutes=${ttlMin.toFixed(1)}` | |
| ); | |
| return ageMin <= ttlMin && state.library.length > 0; | |
| } | |
| async function ensureHumbleLibrary(state) { | |
| logTS('ensureHumbleLibrary: starting.'); | |
| if (isLibraryFresh(state)) { | |
| logTS( | |
| `ensureHumbleLibrary: cache is fresh; library entries=${state.library.length}` | |
| ); | |
| return; | |
| } | |
| logTS( | |
| 'ensureHumbleLibrary: cache is stale or library empty; refreshing via WebView…' | |
| ); | |
| await humbleLoginAndCollectLibrary(state); | |
| } | |
| // ---------- ebook entry extraction & filesystem sync ---------- | |
| function entryIdFromPieces(gamekey, subTitle, fileName) { | |
| return [ | |
| String(gamekey || '').trim(), | |
| String(subTitle || '').trim(), | |
| String(fileName || '').trim() | |
| ].join('::'); | |
| } | |
| // Rebuild entries from the raw Humble library JSON, preserving per-entry | |
| // flags (localPath/importedToBooks) where possible by ID. | |
| function rebuildEntriesFromLibrary(state) { | |
| if (!Array.isArray(state.library)) { | |
| state.entries = []; | |
| return; | |
| } | |
| const prevById = {}; | |
| if (Array.isArray(state.entries)) { | |
| for (const e of state.entries) { | |
| if (e && e.id) prevById[e.id] = e; | |
| } | |
| } | |
| const entries = []; | |
| for (const order of state.library) { | |
| if (!order) continue; | |
| const gamekey = order.gamekey || order.game_key || order.gameKey; | |
| const product = order.product || {}; | |
| const bundleName = | |
| product.human_name || | |
| product.humanName || | |
| product.machine_name || | |
| product.machineName || | |
| 'Humble Bundle'; | |
| const subproducts = order.subproducts || []; | |
| for (const sub of subproducts) { | |
| const subTitle = | |
| sub.human_name || sub.humanName || sub.machine_name || sub.machineName; | |
| const downloads = sub.downloads || []; | |
| for (const dl of downloads) { | |
| const dStructs = dl.download_struct || dl.downloads || []; | |
| for (const ds of dStructs) { | |
| const urlObj = ds.url || {}; | |
| const url = urlObj.web || urlObj.bittorrent || urlObj.us || urlObj.uk; | |
| if (!url) continue; | |
| const fileNameSource = | |
| ds.file_name || ds.filename || ds.name || subTitle || 'ebook'; | |
| const fileName = sanitizeFileName(fileNameSource); | |
| const lowerName = fileName.toLowerCase(); | |
| const lowerLabel = String(ds.name || '').toLowerCase(); | |
| let ext = ''; | |
| const dotIdx = lowerName.lastIndexOf('.'); | |
| if (dotIdx !== -1) ext = lowerName.slice(dotIdx + 1); | |
| const looksLikeEbook = | |
| EBOOK_EXTS.includes(ext) || | |
| /epub|mobi|pdf|ebook|book/i.test(lowerLabel); | |
| if (!looksLikeEbook) continue; | |
| const id = entryIdFromPieces(gamekey, subTitle, fileName); | |
| const prev = prevById[id] || {}; | |
| const bundleDir = sanitizeName(bundleName); | |
| const relativePath = `${bundleDir}/${sanitizeFileName( | |
| subTitle | |
| )} - ${fileName}`; | |
| const entry = { | |
| id, | |
| gamekey, | |
| bundleName, | |
| subTitle: subTitle || fileName, | |
| format: ext || 'ebook', | |
| humbleUrl: url, | |
| fileName, | |
| relativePath, | |
| localPath: prev.localPath || null, | |
| localExists: false, | |
| downloadedAt: prev.downloadedAt || null, | |
| importedToBooks: !!prev.importedToBooks | |
| }; | |
| entries.push(entry); | |
| } | |
| } | |
| } | |
| } | |
| logTS( | |
| `rebuildEntriesFromLibrary: extracted ${entries.length} ebook candidates from ${state.library.length} orders` | |
| ); | |
| state.entries = entries; | |
| } | |
| // Use downloadRootBookmark to check which entries already exist on disk. | |
| function syncEntriesWithFilesystem(state) { | |
| if (!Array.isArray(state.entries)) state.entries = []; | |
| if (!state.downloadRootBookmark) { | |
| for (const e of state.entries) { | |
| e.localExists = false; | |
| e.localPath = null; | |
| } | |
| logTS( | |
| 'syncEntriesWithFilesystem: no download root bookmark; all entries treated as not downloaded.' | |
| ); | |
| return; | |
| } | |
| let rootPath; | |
| try { | |
| rootPath = fm.bookmarkedPath(state.downloadRootBookmark); | |
| } catch (err) { | |
| logTS( | |
| `syncEntriesWithFilesystem: failed to resolve bookmark; clearing. Error=${String( | |
| err | |
| )}` | |
| ); | |
| state.downloadRootBookmark = null; | |
| state.downloadRootDisplayName = null; | |
| for (const e of state.entries) { | |
| e.localExists = false; | |
| e.localPath = null; | |
| } | |
| saveState(state); | |
| return; | |
| } | |
| if (!fm.isDirectory(rootPath)) { | |
| logTS( | |
| `syncEntriesWithFilesystem: resolved rootPath is not a directory: ${rootPath}` | |
| ); | |
| } | |
| for (const e of state.entries) { | |
| const fullPath = fm.joinPath(rootPath, e.relativePath); | |
| const exists = fm.fileExists(fullPath); | |
| e.localExists = exists; | |
| e.localPath = exists ? fullPath : null; | |
| } | |
| const counts = summarizeEntryStats(state.entries); | |
| logTS( | |
| `syncEntriesWithFilesystem: total=${counts.total}, localExists=${counts.localExists}, pendingDownload=${counts.pendingDownload}, pendingBooks=${counts.pendingBooks}` | |
| ); | |
| } | |
| function summarizeEntryStats(entries) { | |
| let total = 0; | |
| let localExists = 0; | |
| let pendingDownload = 0; | |
| let pendingBooks = 0; | |
| for (const e of entries) { | |
| if (!e) continue; | |
| total++; | |
| if (e.localExists) localExists++; | |
| else pendingDownload++; | |
| if (e.localExists && !e.importedToBooks) pendingBooks++; | |
| } | |
| return { total, localExists, pendingDownload, pendingBooks }; | |
| } | |
| // ---------- download root selection ---------- | |
| async function ensureDownloadRoot(state) { | |
| if (state.downloadRootBookmark) { | |
| return; | |
| } | |
| await promptForDownloadRoot(state); | |
| } | |
| async function promptForDownloadRoot(state) { | |
| const folderPath = await DocumentPicker.openFolder(); | |
| if (!folderPath) { | |
| throw new Error('No folder selected.'); | |
| } | |
| const bookmark = fm.bookmarkForFile(folderPath); | |
| state.downloadRootBookmark = bookmark; | |
| state.downloadRootDisplayName = folderPath; | |
| saveState(state); | |
| logTS(`Selected download root folder: ${folderPath}`); | |
| } | |
| // ---------- downloading ebooks ---------- | |
| async function downloadNewEbooks(state) { | |
| await ensureHumbleLibrary(state); | |
| rebuildEntriesFromLibrary(state); | |
| await ensureDownloadRoot(state); | |
| syncEntriesWithFilesystem(state); | |
| const entries = state.entries || []; | |
| const countsBefore = summarizeEntryStats(entries); | |
| const candidates = entries.filter((e) => !e.localExists); | |
| logTS( | |
| `downloadNewEbooks: candidates=${candidates.length}, beforeStats=${JSON.stringify( | |
| countsBefore | |
| )}` | |
| ); | |
| if (candidates.length === 0) { | |
| const a = new Alert(); | |
| a.title = 'Download Ebooks'; | |
| a.message = | |
| 'No new ebooks need downloading.\n\n' + | |
| `Downloaded files: ${countsBefore.localExists}\n` + | |
| `Pending for Books: ${countsBefore.pendingBooks}`; | |
| a.addAction('OK'); | |
| await a.present(); | |
| return; | |
| } | |
| let rootPath = fm.bookmarkedPath(state.downloadRootBookmark); | |
| const total = candidates.length; | |
| let doneCount = 0; | |
| let skippedCount = 0; | |
| let errorCount = 0; | |
| for (const e of candidates) { | |
| doneCount++; | |
| const fullPath = fm.joinPath(rootPath, e.relativePath); | |
| const dir = fullPath.slice(0, fullPath.lastIndexOf('/')); | |
| if (!fm.isDirectory(dir)) { | |
| fm.createDirectory(dir, true); | |
| } | |
| if (fm.fileExists(fullPath)) { | |
| logTS( | |
| `downloadNewEbooks: already exists on disk; marking downloaded. ${fullPath}` | |
| ); | |
| e.localExists = true; | |
| e.localPath = fullPath; | |
| skippedCount++; | |
| continue; | |
| } | |
| try { | |
| logTS(`Downloading ${doneCount}/${total}: ${e.humbleUrl}`); | |
| const req = new Request(e.humbleUrl); | |
| const data = await req.load(); | |
| fm.write(fullPath, data); | |
| e.localExists = true; | |
| e.localPath = fullPath; | |
| e.downloadedAt = nowIso(); | |
| logTS( | |
| `Saved ebook to ${fullPath}, size=${data.byteLength || data.length}` | |
| ); | |
| } catch (err) { | |
| logTS( | |
| `ERROR downloading ${e.humbleUrl} -> ${fullPath}: ${String(err)}` | |
| ); | |
| errorCount++; | |
| } | |
| if (doneCount % 5 === 0) { | |
| saveState(state); // checkpoint | |
| } | |
| } | |
| saveState(state); | |
| syncEntriesWithFilesystem(state); | |
| const countsAfter = summarizeEntryStats(entries); | |
| const msgLines = [ | |
| `Downloaded: ${doneCount - skippedCount - errorCount}`, | |
| `Already on disk: ${skippedCount}`, | |
| `Errors: ${errorCount}`, | |
| '', | |
| `Total ebooks: ${countsAfter.total}`, | |
| `Downloaded files: ${countsAfter.localExists}`, | |
| `Pending for Books: ${countsAfter.pendingBooks}` | |
| ]; | |
| const a = new Alert(); | |
| a.title = 'Download Ebooks'; | |
| a.message = msgLines.join('\n'); | |
| a.addAction('OK'); | |
| await a.present(); | |
| await notifyStatus( | |
| 'Humble ebooks', | |
| `Download complete. New=${doneCount - skippedCount - errorCount}, errors=${errorCount}` | |
| ); | |
| } | |
| // ---------- prepare Books batch folders ---------- | |
| async function prepareBooksBatch(state) { | |
| await ensureHumbleLibrary(state); | |
| rebuildEntriesFromLibrary(state); | |
| await ensureDownloadRoot(state); | |
| syncEntriesWithFilesystem(state); | |
| const entries = state.entries || []; | |
| const eligible = entries.filter((e) => e.localExists && !e.importedToBooks); | |
| if (eligible.length === 0) { | |
| const counts = summarizeEntryStats(entries); | |
| const a = new Alert(); | |
| a.title = 'Prepare Books Batch'; | |
| a.message = | |
| 'No ebooks are ready to batch for Books.\n\n' + | |
| `Downloaded files: ${counts.localExists}\n` + | |
| `Already marked for/imported to Books: ${ | |
| counts.localExists - counts.pendingBooks | |
| }`; | |
| a.addAction('OK'); | |
| await a.present(); | |
| return; | |
| } | |
| const batchSize = Math.min(BATCH_SIZE, eligible.length); | |
| const batchItems = eligible.slice(0, batchSize); | |
| const rootPath = fm.bookmarkedPath(state.downloadRootBookmark); | |
| const batchesRoot = fm.joinPath(rootPath, 'BooksBatches'); | |
| if (!fm.isDirectory(batchesRoot)) { | |
| fm.createDirectory(batchesRoot, true); | |
| } | |
| const stamp = new Date() | |
| .toISOString() | |
| .replace(/[:-]/g, '') | |
| .replace(/\..+$/, ''); | |
| const batchFolder = fm.joinPath(batchesRoot, `Batch-${stamp}`); | |
| fm.createDirectory(batchFolder, true); | |
| let copied = 0; | |
| for (const e of batchItems) { | |
| if (!e.localPath || !fm.fileExists(e.localPath)) continue; | |
| const dest = fm.joinPath(batchFolder, e.fileName); | |
| if (!fm.fileExists(dest)) { | |
| fm.copy(e.localPath, dest); | |
| copied++; | |
| } | |
| e.importedToBooks = true; // mark as “batched” | |
| } | |
| saveState(state); | |
| const msgLines = [ | |
| `Batch folder:`, | |
| batchFolder, | |
| '', | |
| `Files in batch: ${copied}`, | |
| `Remaining pending for Books: ${ | |
| summarizeEntryStats(entries).pendingBooks | |
| }` | |
| ]; | |
| const a = new Alert(); | |
| a.title = 'Prepare Books Batch'; | |
| a.message = msgLines.join('\n'); | |
| a.addAction('OK'); | |
| await a.present(); | |
| await notifyStatus( | |
| 'Humble ebooks', | |
| `Prepared Books batch: ${copied} files.` | |
| ); | |
| } | |
| // ---------- menu & summary ---------- | |
| async function showSummaryDialog(state) { | |
| const entries = state.entries || []; | |
| const counts = summarizeEntryStats(entries); | |
| const updatedAt = state.libraryUpdatedAt || 'never'; | |
| const a = new Alert(); | |
| a.title = 'Humble ebooks'; | |
| a.message = | |
| `Script version: ${SCRIPT_VERSION}\n` + | |
| `Library orders: ${Array.isArray(state.library) ? state.library.length : 0}\n` + | |
| `Library updated at:\n${updatedAt}\n\n` + | |
| `Ebook entries: ${counts.total}\n` + | |
| `Downloaded files: ${counts.localExists}\n` + | |
| `Pending download: ${counts.pendingDownload}\n` + | |
| `Pending for Books: ${counts.pendingBooks}\n\n` + | |
| `Download root:\n${ | |
| state.downloadRootDisplayName || '(not chosen yet)' | |
| }`; | |
| a.addAction('OK'); | |
| await a.present(); | |
| } | |
| async function mainMenuLoop(state) { | |
| while (true) { | |
| rebuildEntriesFromLibrary(state); // no-op if library missing | |
| if (state.downloadRootBookmark) { | |
| syncEntriesWithFilesystem(state); | |
| } | |
| const entries = state.entries || []; | |
| const counts = summarizeEntryStats(entries); | |
| const menu = new Alert(); | |
| menu.title = 'Humble ebooks'; | |
| menu.message = | |
| `Choose an action.\n\n` + | |
| `Orders: ${ | |
| Array.isArray(state.library) ? state.library.length : 0 | |
| } | Ebooks: ${counts.total}\n` + | |
| `Downloaded: ${counts.localExists}, Pending DL: ${ | |
| counts.pendingDownload | |
| }, Pending Books: ${counts.pendingBooks}\n` + | |
| `Download root: ${ | |
| state.downloadRootDisplayName || '(not set)' | |
| }`; | |
| menu.addAction('Refresh Humble JSON'); | |
| menu.addAction('Download new ebooks'); | |
| menu.addAction('Prepare Books batch'); | |
| menu.addAction('Change download folder'); | |
| menu.addAction('Show summary'); | |
| menu.addCancelAction('Exit'); | |
| const idx = await menu.presentSheet(); | |
| if (idx === -1) { | |
| // Exit | |
| logTS('User chose Exit from main menu.'); | |
| return; | |
| } | |
| try { | |
| if (idx === 0) { | |
| await ensureHumbleLibrary(state); | |
| } else if (idx === 1) { | |
| await downloadNewEbooks(state); | |
| } else if (idx === 2) { | |
| await prepareBooksBatch(state); | |
| } else if (idx === 3) { | |
| await promptForDownloadRoot(state); | |
| } else if (idx === 4) { | |
| await showSummaryDialog(state); | |
| } | |
| } catch (err) { | |
| const msg = `Top-level error in menu action: ${String(err)}`; | |
| logTS(msg); | |
| const a = new Alert(); | |
| a.title = 'Script crashed'; | |
| a.message = msg; | |
| a.addAction('OK'); | |
| await a.present(); | |
| } | |
| } | |
| } | |
| // ---------- entry point ---------- | |
| async function main() { | |
| try { | |
| logTS(`Starting Humble ebooks script (version=${SCRIPT_VERSION})`); | |
| const state = await loadState(); | |
| state.lastRun = nowIso(); | |
| saveState(state); | |
| await mainMenuLoop(state); | |
| } catch (outerErr) { | |
| const msg = 'Top-level error: ' + String(outerErr); | |
| logTS(msg); | |
| const a = new Alert(); | |
| a.title = 'Script crashed'; | |
| a.message = msg; | |
| a.addAction('OK'); | |
| await a.present(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment