|
import fs from 'node:fs'; |
|
import path from 'node:path'; |
|
|
|
import express from 'express'; |
|
import _ from 'lodash'; |
|
import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
|
|
|
import { SETTINGS_FILE } from '../constants.js'; |
|
import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js'; |
|
import { getAllUserHandles, getUserDirectories } from '../users.js'; |
|
import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
|
|
|
const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean'); |
|
const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean'); |
|
const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean'); |
|
|
|
|
|
const AUTOSAVE_INTERVAL = 10 * 60 * 1000; |
|
|
|
|
|
|
|
|
|
|
|
const AUTOSAVE_FUNCTIONS = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
function triggerAutoSave(handle) { |
|
if (!AUTOSAVE_FUNCTIONS.has(handle)) { |
|
const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL); |
|
AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave); |
|
} |
|
|
|
const functionToCall = AUTOSAVE_FUNCTIONS.get(handle); |
|
if (functionToCall && typeof functionToCall === 'function') { |
|
functionToCall(); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { |
|
const files = fs |
|
.readdirSync(directoryPath) |
|
.filter(x => path.parse(x).ext == fileExtension) |
|
.sort(); |
|
|
|
const parsedFiles = []; |
|
|
|
files.forEach(item => { |
|
try { |
|
const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8'); |
|
parsedFiles.push(fileExtension == '.json' ? JSON.parse(file) : file); |
|
} |
|
catch { |
|
|
|
} |
|
}); |
|
|
|
return parsedFiles; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function sortByName(_) { |
|
return (a, b) => a.localeCompare(b); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getFilePrefix(handle) { |
|
return `settings_${handle}_`; |
|
} |
|
|
|
function readPresetsFromDirectory(directoryPath, options = {}) { |
|
const { |
|
sortFunction, |
|
removeFileExtension = false, |
|
fileExtension = '.json', |
|
} = options; |
|
|
|
const files = fs.readdirSync(directoryPath).sort(sortFunction).filter(x => path.parse(x).ext == fileExtension); |
|
const fileContents = []; |
|
const fileNames = []; |
|
|
|
files.forEach(item => { |
|
try { |
|
const file = fs.readFileSync(path.join(directoryPath, item), 'utf8'); |
|
JSON.parse(file); |
|
fileContents.push(file); |
|
fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item); |
|
} catch { |
|
|
|
console.warn(`${item} is not a valid JSON`); |
|
} |
|
}); |
|
|
|
return { fileContents, fileNames }; |
|
} |
|
|
|
async function backupSettings() { |
|
try { |
|
const userHandles = await getAllUserHandles(); |
|
|
|
for (const handle of userHandles) { |
|
backupUserSettings(handle, true); |
|
} |
|
} catch (err) { |
|
console.error('Could not backup settings file', err); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function backupUserSettings(handle, preventDuplicates) { |
|
const userDirectories = getUserDirectories(handle); |
|
const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`); |
|
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); |
|
|
|
if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) { |
|
return; |
|
} |
|
|
|
if (!fs.existsSync(sourceFile)) { |
|
return; |
|
} |
|
|
|
fs.copyFileSync(sourceFile, backupFile); |
|
removeOldBackups(userDirectories.backups, `settings_${handle}`); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isDuplicateBackup(handle, sourceFile) { |
|
const latestBackup = getLatestBackup(handle); |
|
if (!latestBackup) { |
|
return false; |
|
} |
|
return areFilesEqual(latestBackup, sourceFile); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function areFilesEqual(file1, file2) { |
|
if (!fs.existsSync(file1) || !fs.existsSync(file2)) { |
|
return false; |
|
} |
|
|
|
const content1 = fs.readFileSync(file1); |
|
const content2 = fs.readFileSync(file2); |
|
return content1.toString() === content2.toString(); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getLatestBackup(handle) { |
|
const userDirectories = getUserDirectories(handle); |
|
const backupFiles = fs.readdirSync(userDirectories.backups) |
|
.filter(x => x.startsWith(getFilePrefix(handle))) |
|
.map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs })); |
|
const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name; |
|
if (!latestBackup) { |
|
return null; |
|
} |
|
return path.join(userDirectories.backups, latestBackup); |
|
} |
|
|
|
export const router = express.Router(); |
|
|
|
router.post('/save', function (request, response) { |
|
try { |
|
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); |
|
writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8'); |
|
triggerAutoSave(request.user.profile.handle); |
|
response.send({ result: 'ok' }); |
|
} catch (err) { |
|
console.error(err); |
|
response.send(err); |
|
} |
|
}); |
|
|
|
|
|
router.post('/get', (request, response) => { |
|
let settings; |
|
try { |
|
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); |
|
settings = fs.readFileSync(pathToSettings, 'utf8'); |
|
} catch (e) { |
|
return response.sendStatus(500); |
|
} |
|
|
|
|
|
const { fileContents: novelai_settings, fileNames: novelai_setting_names } |
|
= readPresetsFromDirectory(request.user.directories.novelAI_Settings, { |
|
sortFunction: sortByName(request.user.directories.novelAI_Settings), |
|
removeFileExtension: true, |
|
}); |
|
|
|
|
|
const { fileContents: openai_settings, fileNames: openai_setting_names } |
|
= readPresetsFromDirectory(request.user.directories.openAI_Settings, { |
|
sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true, |
|
}); |
|
|
|
|
|
const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names } |
|
= readPresetsFromDirectory(request.user.directories.textGen_Settings, { |
|
sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true, |
|
}); |
|
|
|
|
|
const { fileContents: koboldai_settings, fileNames: koboldai_setting_names } |
|
= readPresetsFromDirectory(request.user.directories.koboldAI_Settings, { |
|
sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true, |
|
}); |
|
|
|
const worldFiles = fs |
|
.readdirSync(request.user.directories.worlds) |
|
.filter(file => path.extname(file).toLowerCase() === '.json') |
|
.sort((a, b) => a.localeCompare(b)); |
|
const world_names = worldFiles.map(item => path.parse(item).name); |
|
|
|
const themes = readAndParseFromDirectory(request.user.directories.themes); |
|
const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI); |
|
const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies); |
|
|
|
const instruct = readAndParseFromDirectory(request.user.directories.instruct); |
|
const context = readAndParseFromDirectory(request.user.directories.context); |
|
const sysprompt = readAndParseFromDirectory(request.user.directories.sysprompt); |
|
const reasoning = readAndParseFromDirectory(request.user.directories.reasoning); |
|
|
|
response.send({ |
|
settings, |
|
koboldai_settings, |
|
koboldai_setting_names, |
|
world_names, |
|
novelai_settings, |
|
novelai_setting_names, |
|
openai_settings, |
|
openai_setting_names, |
|
textgenerationwebui_presets, |
|
textgenerationwebui_preset_names, |
|
themes, |
|
movingUIPresets, |
|
quickReplyPresets, |
|
instruct, |
|
context, |
|
sysprompt, |
|
reasoning, |
|
enable_extensions: ENABLE_EXTENSIONS, |
|
enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE, |
|
enable_accounts: ENABLE_ACCOUNTS, |
|
}); |
|
}); |
|
|
|
router.post('/get-snapshots', async (request, response) => { |
|
try { |
|
const snapshots = fs.readdirSync(request.user.directories.backups); |
|
const userFilesPattern = getFilePrefix(request.user.profile.handle); |
|
const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern)); |
|
|
|
const result = userSnapshots.map(x => { |
|
const stat = fs.statSync(path.join(request.user.directories.backups, x)); |
|
return { date: stat.ctimeMs, name: x, size: stat.size }; |
|
}); |
|
|
|
response.json(result); |
|
} catch (error) { |
|
console.error(error); |
|
response.sendStatus(500); |
|
} |
|
}); |
|
|
|
router.post('/load-snapshot', getFileNameValidationFunction('name'), async (request, response) => { |
|
try { |
|
const userFilesPattern = getFilePrefix(request.user.profile.handle); |
|
|
|
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { |
|
return response.status(400).send({ error: 'Invalid snapshot name' }); |
|
} |
|
|
|
const snapshotName = request.body.name; |
|
const snapshotPath = path.join(request.user.directories.backups, snapshotName); |
|
|
|
if (!fs.existsSync(snapshotPath)) { |
|
return response.sendStatus(404); |
|
} |
|
|
|
const content = fs.readFileSync(snapshotPath, 'utf8'); |
|
|
|
response.send(content); |
|
} catch (error) { |
|
console.error(error); |
|
response.sendStatus(500); |
|
} |
|
}); |
|
|
|
router.post('/make-snapshot', async (request, response) => { |
|
try { |
|
backupUserSettings(request.user.profile.handle, false); |
|
response.sendStatus(204); |
|
} catch (error) { |
|
console.error(error); |
|
response.sendStatus(500); |
|
} |
|
}); |
|
|
|
router.post('/restore-snapshot', getFileNameValidationFunction('name'), async (request, response) => { |
|
try { |
|
const userFilesPattern = getFilePrefix(request.user.profile.handle); |
|
|
|
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { |
|
return response.status(400).send({ error: 'Invalid snapshot name' }); |
|
} |
|
|
|
const snapshotName = request.body.name; |
|
const snapshotPath = path.join(request.user.directories.backups, snapshotName); |
|
|
|
if (!fs.existsSync(snapshotPath)) { |
|
return response.sendStatus(404); |
|
} |
|
|
|
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); |
|
fs.rmSync(pathToSettings, { force: true }); |
|
fs.copyFileSync(snapshotPath, pathToSettings); |
|
|
|
response.sendStatus(204); |
|
} catch (error) { |
|
console.error(error); |
|
response.sendStatus(500); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
export async function init() { |
|
await backupSettings(); |
|
} |
|
|