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.
309 lines
11 KiB
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() |