import enquirer from "enquirer" import {watch, constants as fsConstants} from "fs" import {access, readFile, opendir, stat, writeFile} from "fs/promises" import ini from "ini" import envPaths from "env-paths" import chalk from "chalk" import {isAbsolute, join, normalize, basename, sep} from "path" import {moveFile} from "move-file" import isValidFilename from "valid-filename" import makeDir from "make-dir" import homeDir from "home-dir" import "source-map-support/register" import defaultData from "./default-data.json" import packageData from "./package.json" import buildTimestamp from "consts:buildTimestamp" import configName from "consts:configName" const {Toggle, Select, Input, AutoComplete} = enquirer const {R_OK, W_OK} = fsConstants function log(message) { process.stderr.write(message + "\n") } function validateFilenamePart(part, {allowEmpty = false} = {}) { if (typeof part !== "string") { return `must be a string, but was ${part === null ? "null" : typeof part}` } if (part.length === 0) { if (allowEmpty) { return true } else { return "must not be empty, but was empty" } } if (!isValidFilename(part + ".save")) { return `cannot contain any of the following characters: <>:"/\\|?*` } return true } const DIRECTORY_OK = "OK" const DIRECTORY_EMPTY = "Path is empty" const DIRECTORY_NOT_ABSOLUTE = "Path must be absolute" const DIRECTORY_NOT_VALID = "Path must contain segments which are valid filenames" const DIRECTORY_EXISTS_NOT_DIRECTORY = "Path exists, but is not a directory" const DIRECTORY_NOT_EXISTS = "Path does not exist" const DIRECTORY_NOT_ACCESSIBLE = "Path is a directory, but is not both readable and writable" function validatePath(path) { if (typeof path !== "string" || path.length === 0) { return DIRECTORY_EMPTY } else if (!isAbsolute(path)) { return DIRECTORY_NOT_ABSOLUTE } else if (!path.split(sep).slice(1).every(isValidFilename)) { return DIRECTORY_NOT_VALID } return DIRECTORY_OK } async function validateDirectory(path) { const pathValidity = validatePath(path) if (pathValidity !== DIRECTORY_OK) { return pathValidity } try { const statResult = await stat(path) if (!statResult.isDirectory()) { return DIRECTORY_EXISTS_NOT_DIRECTORY } await access(path, R_OK | W_OK) } catch (ex) { if (ex.code === "ENOENT") { return DIRECTORY_NOT_EXISTS } else if (ex.code === "EACCES") { return DIRECTORY_NOT_ACCESSIBLE } else { throw ex } } return DIRECTORY_OK } function validateRegExp(text) { if (typeof text !== "string") { return `must be a string, but was ${text === null ? "null" : typeof text}` } try { new RegExp(text) return true } catch (ex) { return `must be a valid regular expression, but couldn't construct a RegExp with it: ${ex}` } } function validateFilenamePartList(list, {minLength = 0, allowMissing = false} = {}) { if (typeof list === "undefined") { if (allowMissing) { return true } else { return `must be present, but was absent` } } if (!Array.isArray(list)) { return `must be an array, but was ${list === null ? typeof list : "null"}` } if (list.length < minLength) { return `must have at least ${minLength} ${minLength === 1 ? "element" : "elements"}, but had ${list.length}` } const firstNonmatching = list.findIndex((item) => !(typeof item === "string" && item.length > 0 && validateFilenamePart(item) === true)) if (firstNonmatching !== -1) { return `element at index ${firstNonmatching} ${validateFilenamePart(list[firstNonmatching])}` } return true } function validateGameData(data) { if (typeof data !== "object" || data === null) { return `must be an object, but was ${typeof data}` } const fileRegExpError = validateRegExp(data.fileRegExp) if (validateRegExp(data.fileRegExp) !== true) { return `fileRegExp ${fileRegExpError}` } const charactersError = validateFilenamePartList(data.characters, {allowMissing: true}) if (charactersError !== true) { return `characters ${charactersError}` } const timesError = validateFilenamePartList(data.times, {minLength: 1}) if (timesError !== true) { return `times ${timesError}` } const voreRolesError = validateFilenamePartList(data.voreRoles, {minLength: 1}) if (voreRolesError !== true) { return `voreRoles ${voreRolesError}` } const voreTypesError = validateFilenamePartList(data.voreTypes, {minLength: 1}) if (voreTypesError !== true) { return `voreTypes ${voreTypesError}` } const voreResultsError = validateFilenamePartList(data.voreResults, {minLength: 1}) if (voreResultsError !== true) { return `voreResults ${voreResultsError}` } const characterSceneTypesError = validateFilenamePartList(data.characterSceneTypes, {allowMissing: true}) if (characterSceneTypesError !== true) { return `characterSceneTypes ${characterSceneTypesError}` } const otherSceneTypesError = validateFilenamePartList(data.otherSceneTypes, {allowMissing: true}) if (otherSceneTypesError !== true) { return `otherSceneTypes ${otherSceneTypesError}` } return true } async function getDirectoryFromUser(message, initial) { let dirState = null let newDir = initial while (dirState !== DIRECTORY_OK) { newDir = normalize(await new Input({ message, initial: newDir, validate: (text) => { const pathErr = validatePath(text) if (pathErr === DIRECTORY_OK) { return true } else { return pathErr } } }).run()) try { dirState = await validateDirectory(newDir) } catch (ex) { log(chalk.red(`Couldn't check on that folder: ${chalk.redBright.bold(ex)}. Try again.`)) dirState = null } if (dirState === DIRECTORY_NOT_EXISTS) { const create = await new Toggle({ message: "That folder doesn't exist. Want me to create it?", enabled: "Yes, create it", disabled: "No, I'll enter the path again", }).run() if (create) { try { await makeDir(newDir) try { dirState = await validateDirectory(newDir) if (dirState !== DIRECTORY_OK) { log(chalk.red(`Creating the new folder led to an unusable directory: ${chalk.redBright.bold(dirState)}. Try again.`)) } } catch (ex) { log(chalk.red(`Couldn't check on the new folder: ${chalk.redBright.bold(ex)}. Try again.`)) } } catch (ex) { log(chalk.red(`Couldn't create that folder: ${chalk.redBright.bold(ex)}. Try again.`)) } } } else if (dirState !== null && dirState !== DIRECTORY_OK) { log(chalk.red(`Can't use that folder: ${dirState}. Try again.`)) } } return newDir } 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)}`)) } } } const sourceDirOK = (await validateDirectory(sourceDir)) === DIRECTORY_OK const destDirOK = (await validateDirectory(destDir)) === DIRECTORY_OK if (!(sourceDirOK && destDirOK)) { log(chalk.yellow(`Configuration at ${chalk.yellowBright.bold(configPath)} is missing, incomplete, or damaged. Configuring now.`)) if (!sourceDirOK) { sourceDir = await getDirectoryFromUser( "Where are SMVA saves downloaded to? (e.g., your Downloads folder)", sourceDir || join(home, "Downloads")) } if (!destDirOK) { destDir = await getDirectoryFromUser( "Where do you want to store your organized SMVA saves? (e.g., your Documents folder)", destDir || join(home, "Documents", "SMVA Saves") ) } try { 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)}`)) } catch (ex) { log(chalk.red(`Could not write configuration to ${chalk.redBright.bold(configPath)}`)) } } const dataPath = join(config, "game-data.json") let data = defaultData try { data = JSON.parse(await readFile(dataPath, {encoding: "utf-8"})) const err = validateGameData(data) if (err === true) { log(chalk.cyan(`Read game data from ${chalk.cyanBright.bold(dataPath)}`)) } else { log(chalk.red(`${chalk.redBright.bold(dataPath)} had a problem: ${chalk.redBright.bold(err)}`)) log(chalk.yellow(`Using default game data.`)) data = defaultData } } catch (ex) { switch (ex.code) { case "ENOENT": log(chalk.yellow(`${chalk.yellowBright.bold(dataPath)} not found. Using default game data.`)) try { await writeFile(dataPath, JSON.stringify(defaultData, null, 4), {encoding: "utf-8"}) log(chalk.cyan(`Wrote default game data to ${chalk.cyanBright.bold(dataPath)}`)) } catch (ex) { log(chalk.red(`Could not write default game data to ${chalk.redBright.bold(dataPath)}: ${chalk.redBright.bold(ex)}`)) } break default: log(chalk.red(`Could not read game data from ${chalk.redBright.bold(dataPath)}: ${chalk.redBright.bold(ex)}`)) log(chalk.yellow(`Using default game data.`)) } } return {sourceDir, destDir, data} } async function* watchNewSaves(sourceDir, sourceRegexpString) { const sourceRe = new RegExp(`^${sourceRegexpString}\$`) let resolveNext let nextPromise let active = true function makeNewPromise() { nextPromise = new Promise((resolve) => { resolveNext = (...args) => { makeNewPromise() resolve(...args) } }) } makeNewPromise() watch(sourceDir, {}, (eventType, filename) => { if (eventType === "rename" && sourceRe.test(filename)) { resolveNext(filename) } }) log(chalk.green(`Waiting for new saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} to appear in ${chalk.greenBright.bold(sourceDir)}`)) while (true) { const next = await nextPromise if (next && active) { yield next } } } async function* findExistingSaves(sourceDir, sourceRegexpString) { const sourceRe = new RegExp(`^${sourceRegexpString}\$`) log(chalk.green(`Scanning for existing saves with filenames like ${chalk.greenBright.bold(`/${sourceRegexpString}/`)} in ${chalk.greenBright.bold(sourceDir)}`)) const dir = await opendir(sourceDir) for await (const dirent of dir) { if (dirent.isFile() && sourceRe.test(dirent.name)) { yield dirent.name } } log(chalk.cyan(`Finished scanning for existing saves`)) } async function promptForFilename({ times, voreTypes, voreRoles, voreResults, characterSceneTypes, otherSceneTypes, characters }) { const saveType = await new AutoComplete({ message: "What type of save is this?", limit: 10, choices: ["Useful Save", "Vore Scene", ...(Array.isArray(characterSceneTypes) && characterSceneTypes.length > 0 ? characterSceneTypes.map((item) => `${item} (Character Scene)`) : []), ...(Array.isArray(otherSceneTypes) && otherSceneTypes.length > 0 ? otherSceneTypes.map((item) => `${item} (Other Scene)`) : []), "Skip This Save"], }).run() const timePrompt = times.length > 1 ? new AutoComplete({ message: "When is this save from?", limit: 10, choices: times }) : { async run() { return times[0] } } if (saveType === "Vore Scene") { const voreType = voreTypes.length > 1 ? await new AutoComplete({ message: "What type of vore is involved in this save?", limit: 10, choices: voreTypes }).run() : voreTypes[0] const voreRole = voreRoles.length > 1 ? await new AutoComplete({ message: "What role does the player play in this scene?", limit: 10, choices: voreRoles }).run() : voreRoles[0] const voreResult = voreResults.length > 1 ? await new AutoComplete({ message: "What is the result of the vore?", limit: 10, choices: voreResults, }).run() : voreResults[0] const time = await timePrompt.run() const vorePartners = Array.isArray(characters) && characters.length > 0 ? await new AutoComplete({ message: "Who is/are the partner(s) the player is with in this scene?", limit: 10, choices: characters, multiple: true, }).run() : [] const voreVariant = await new Input({ message: `What is the variant name/comment for this scene?${vorePartners.length > 0 ? " (leave blank to skip)" : ""}`, validate: (filename) => validateFilenamePart(filename, {allowEmpty: vorePartners.length > 0}) }).run() return `${voreType} ${voreRole} - ${voreResult} - ${time} ${vorePartners.join(" + ")}${voreVariant ? vorePartners.length > 0 ? ` (${voreVariant})` : voreVariant : ""}.save` } else if (saveType === "Useful Save") { const time = await timePrompt.run() const title = await new Input({ message: "What is the title of this useful save?", validate: validateFilenamePart }).run() return `Useful Saves - ${time} - ${title}.save` } else if (saveType.endsWith(" (Character Scene)")) { const sceneType = saveType.substring(0, saveType.length - " (Character Scene)".length) const time = await timePrompt.run() const scenePartners = Array.isArray(characters) && characters.length > 0 ? await new AutoComplete({ message: "Who is/are the partner(s) the player is with in this scene?", limit: 10, choices: characters, multiple: true, validate: (list) => list.length > 0, }).run() : [] const title = await new Input({ message: `What is the comment for this ${sceneType} scene?`, validate: validateFilenamePart }).run() return `${sceneType} with ${scenePartners.join("+")} - ${time} - ${title}.save` } else if (saveType.endsWith(" (Other Scene)")) { const sceneType = saveType.substring(0, saveType.length - " (Other Scene)".length) const time = await timePrompt.run() const title = await new Input({ message: `What is the comment for this ${sceneType} scene?`, validate: validateFilenamePart }).run() return `${sceneType} - ${time} - ${title}.save` } else { return null } } async function askIfOverwrite(destPath) { console.log(chalk.yellow(`A file named ${chalk.yellowBright.bold(destPath)} already exists.`)) 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"), validate: (name) => name.length > 0 && isValidFilename(name) ? true : "must be a nonempty string not ending in trailing periods or containing any of the characters <>:\"/\\|?*" }).run()) + ".save" } } function queueMovingTo(destDir, filenameData) { let resolvePromise, rejectPromise const completionPromise = new Promise((resolve, reject) => { resolvePromise = resolve rejectPromise = reject }) const filequeue = [] let processingFiles = false let endAfterCompleting = false async function processFiles() { if (processingFiles) { return } processingFiles = true while (filequeue.length > 0) { const nextFile = filequeue[0] try { await 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(filenameData) if (!destFilename) { log(chalk.yellow("Skipped.")) filequeue.shift() continue } let destPath = join(destDir, destFilename) let overwrite = false while (true) { try { await moveFile(nextFile, destPath, {overwrite}) log(chalk.cyan(`Moved to ${chalk.cyanBright.bold(destPath)}`)) break } catch (ex) { if (ex.message.startsWith("The destination file exists:")) { const promptResult = await askIfOverwrite(destPath) if (typeof promptResult === "string") { destPath = join(destDir, promptResult) } else if (promptResult === true) { overwrite = true } else { break } } else { log(chalk.red(`Something went wrong: ${chalk.redBright.bold(ex)}`)) const giveUp = await new Toggle({ message: "Want to keep trying?", disabled: "Yes, try again", enabled: "No, skip this save" }).run() if (giveUp) { break } } } } filequeue.shift() } processingFiles = false if (endAfterCompleting) { resolvePromise() } } log(chalk.green(`Ready to move renamed saves to ${chalk.greenBright.bold(destDir)}`)) 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((err) => { if (!rejectPromise) { return } rejectPromise(err) resolvePromise = null rejectPromise = null }) }, shutDown() { if (!resolvePromise) { return } if (processingFiles) { endAfterCompleting = true } else { resolvePromise() } }, completionPromise } } export async function main() { log(chalk.green(`${packageData.name} ${chalk.greenBright.bold(`v${packageData.version}`)} - built at ${chalk.greenBright.bold(new Date(buildTimestamp).toISOString())}`)) const {config} = envPaths(configName) const home = homeDir() const { sourceDir, destDir, data: {fileRegExp, ...filenameData} } = await loadPaths(config, home) const queue = queueMovingTo(destDir, filenameData) const existingSavesPromise = (async () => { try { for await (const oldSave of findExistingSaves(sourceDir, fileRegExp)) { queue.push(join(sourceDir, oldSave)) } } catch (ex) { log(chalk.red(`failed searching for existing saves: ${chalk.redBright.bold(ex)}`)) } })() const newSavesPromise = (async () => { try { for await (const newSave of watchNewSaves(sourceDir, fileRegExp)) { queue.push(join(sourceDir, newSave)) } } catch (ex) { log(chalk.red(`failed waiting for new saves: ${chalk.redBright.bold(ex)}`)) } })() Promise.allSettled([newSavesPromise, existingSavesPromise]).finally(() => queue.shutDown()) try { await queue.completionPromise } catch (err) { console.log(chalk.redBright.bold(err)) process.exit(1) } process.exit(0) } main.packageName = packageData.name main.version = packageData.version main.buildTimestamp = packageData.buildTimestamp