Indexer for SMVA save files
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
smva-indexer/indexer.mjs

309 lines
11 KiB

import enquirer from "enquirer"
import {watch} from "fs"
import {access, readFile, rename, stat, writeFile} from "fs/promises"
import glob from "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 = `${sourceDir}/saint-miluinas-vore-academy-*.save`
return new Promise((resolve, reject) => {
glob(sourceGlob, {"nodir": true}, (err, results) => {
if (err !== null) {
reject(err)
} else if (results.length > 0) {
resolve(results)
}
})
})
}
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()