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

580 lines
22 KiB

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