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.
580 lines
22 KiB
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 |