import enquirer from "enquirer" import {watch} from "fs" import {access, readFile, rename, stat, writeFile} from "fs/promises" import glob from "fast-glob" import ini from "ini" import envPaths from "env-paths" import chalk from "chalk" import {isAbsolute, join, normalize, basename} from "path" import makeDir from "make-dir" import homeDir from "home-dir" const {Toggle, Select, Input} = enquirer function log(message) { process.stderr.write(message + "\n") } async function getDirectoryFromUser(message, initial) { while (true) { const newDir = normalize(await new Input({ message, initial }).run()) if (!isAbsolute(newDir)) { log(chalk.red("Can't use that folder: This path must be absolute. Try again.")) continue } try { const statResult = await stat(newDir) if (!statResult.isDirectory()) { log(chalk.red("Can't use that folder: This path must be a directory. Try again.")) } return newDir } catch (ex) { if (ex.code === "ENOENT") { const create = await new Toggle({ message: "That folder doesn't exist. Want me to create it?", enabled: "Create it", disabled: "No, I'll enter the path again", }).run() if (!create) { continue } try { await makeDir(newDir) return newDir } catch (ex) { log(chalk.red(`Couldn't create that folder: ${chalk.redBright.bold(ex)}. Try again.`)) } } else { log(chalk.red(`Couldn't check on that folder: ${chalk.redBright.bold(ex)}. Try again.`)) } } } } async function loadPaths(config, home) { let sourceDir = process.env.SMVA_SOURCE_DIR || null let destDir = process.env.SMVA_DEST_DIR || null const configPath = join(config, "smva-indexer.ini") if (!(sourceDir && destDir)) { try { const configText = await readFile(configPath, {encoding: "utf-8"}) log(chalk.cyan(`Read configuration from ${chalk.cyanBright.bold(configPath)}`)) const configIni = ini.parse(configText) if (!sourceDir && configIni.paths && configIni.paths.source) { sourceDir = configIni.paths.source } if (!destDir && configIni.paths && configIni.paths.dest) { destDir = configIni.paths.dest } } catch (ex) { if (ex.code !== "ENOENT") { log(chalk.red(`Failed to read configuration from ${chalk.redBright.bold(configPath)}: ${chalk.redBright.bold(ex)}`)) } } } if (!(sourceDir && destDir)) { log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing or incomplete. Configure now.`)) sourceDir = await getDirectoryFromUser( "Where are SMVA saves downloaded to? (e.g., your Downloads folder)", sourceDir || join(home, "Downloads")) destDir = await getDirectoryFromUser( "Where do you want to store your organized SMVA saves? (e.g., your Documents folder)", destDir || join(home, "Documents", "SMVA Saves") ) await makeDir(config) await writeFile(configPath, ini.encode({paths: {source: sourceDir, dest: destDir}}), {encoding: "utf-8"}) log(chalk.cyan(`Wrote configuration to ${chalk.cyanBright.bold(configPath)}`)) } return {sourceDir, destDir} } async function* watchNewSaves(sourceDir) { const sourceRe = /^saint-miluinas-vore-academy-.*\.save$/ let resolveNext let nextPromise function makeNewPromise() { nextPromise = new Promise((resolve) => { resolveNext = (...args) => { makeNewPromise() resolve(...args) } }) } makeNewPromise() watch(sourceDir, {}, (eventType, filename) => { if (eventType === "rename" && sourceRe.test(filename)) { resolveNext(join(sourceDir, filename)) } }) log(chalk.cyan(`Waiting for new saves to appear in ${chalk.cyanBright.bold(sourceDir)}`)) while (true) { yield await nextPromise } } async function findExistingSaves(sourceDir) { const sourceGlob = `${glob.escapePath(sourceDir)}/saint-miluinas-vore-academy-*.save` return glob(sourceGlob, {"onlyFiles": true}) } async function promptForFilename() { const saveType = await new Select({ message: "What type of save is this?", choices: ["Useful Save", "Vore Scene", "Skip This Save"], }).run() const dayPrompt = new Select({ message: "When is this save from?", choices: ["Day 1", "Day 2", "Day 3", "Day 4", "Day 5", "Day 6"] }) if (saveType === "Vore Scene") { const voreType = await new Select({ message: "What type of vore is involved in this save?", choices: ["Oral", "Anal", "Unbirth"] }).run() const voreRole = await new Select({ message: "What role does the player play in this scene?", choices: ["Predator", "Prey", "Observer"] }).run() const voreResult = await new Select({ message: "What is the result of the vore?", choices: ["Fatal", "Always Fatal", "Non-fatal"], }).run() const day = await dayPrompt.run() const vorePartner = await new Input({ message: "Who is/are the partner(s) the player is with in this scene?", }).run() const voreVariant = await new Input({ message: "What is the variant name/comment for this scene? (leave blank to skip)", }).run() return `${voreType} ${voreRole} - ${voreResult} - ${day} ${vorePartner}${voreVariant ? ` (${voreVariant})` : ""}.save` } else if (saveType === "Useful Save") { const day = await dayPrompt.run() const title = await new Input({ message: "What is the title of this useful save?" }).run() return `Useful Saves - ${day} - ${title}.save` } else { return null } } async function shouldDoRename(destPath) { try { await access(destPath) console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`)) } catch (ex) { if (ex.code === "ENOENT") { return true } else { console.log(chalk.red(`The file named ${chalk.redBright.bold(destPath)} could not be accessed: ${chalk.redBright.bold(ex)}`)) } } const result = await new Select({message: "What should we do?", choices: ["Skip it", "Rename it", "Overwrite it"]}).run(); switch (result) { case "Skip it": return false; case "Overwrite it": return true; case "Rename it": return (await new Input({message: "What would you like to name the file?", initial: basename(destPath, ".save")}).run()) + ".save" } } function queueMovingTo(destDir) { let resolvePromise, rejectPromise const completionPromise = new Promise((resolve, reject) => { resolvePromise = resolve rejectPromise = reject }) const filequeue = [] let processingFiles = false async function processFiles() { if (processingFiles) { return } processingFiles = true while (filequeue.length > 0) { const nextFile = filequeue[0] try { access(nextFile) } catch (ex) { if (ex.code !== "ENOENT") { log(chalk.red(`failed accessing ${chalk.redBright.bold(nextFile)}: ${chalk.redBright.bold(ex)}`)) } filequeue.shift() continue } log(chalk.green(`For save ${chalk.greenBright.bold(nextFile)}:`)) const destFilename = await promptForFilename() if (!destFilename) { log(chalk.yellow("Skipped.")) filequeue.shift() continue } let destPath = join(destDir, destFilename) let confirmed = false; while (!confirmed) { const promptResult = await shouldDoRename(destPath) if (typeof promptResult === "string") { destPath = join(destDir, promptResult) } else if (!promptResult) { destPath = null confirmed = true } else { confirmed = true } } if (!destPath) { log(chalk.yellow("Skipped.")) filequeue.shift() continue } await rename(nextFile, destPath) log(chalk.cyan(`Moved to ${chalk.cyanBright.bold(destPath)}`)) filequeue.shift() } processingFiles = false } return { push(...files) { if (!rejectPromise) { throw Error("Already stopped the queue") } for (const file of files) { if (!filequeue.includes(file)) { filequeue.push(file) } } processFiles().catch(() => { if (!rejectPromise) { return } rejectPromise() resolvePromise = null rejectPromise = null }) }, complete() { if (!resolvePromise) { throw Error("Already stopped the queue") } resolvePromise() resolvePromise = null rejectPromise = null }, completionPromise } } async function main() { const {config} = envPaths("SMVA-Indexer") const home = homeDir() const {sourceDir, destDir} = await loadPaths(config, home) const queue = queueMovingTo(destDir) const existingSavesPromise = (async () => { try { const existingSaves = await findExistingSaves(sourceDir) log(chalk.cyan(`found ${chalk.cyanBright.bold(existingSaves.length)} existing saves`)) queue.push(...existingSaves) } catch (ex) { log(chalk.red(`failed searching for existing saves: ${chalk.redBright.bold(ex)}`)) } })() const newSavesPromise = (async () => { for await (const newSave of watchNewSaves(sourceDir)) { queue.push(newSave) } })() await Promise.all([newSavesPromise, existingSavesPromise, queue.completionPromise]) } main()