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