const fs = require('fs'); const path = require('path'); const express = require('express'); const _ = require('lodash'); const writeFileAtomicSync = require('write-file-atomic').sync; const { SETTINGS_FILE } = require('../constants'); const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util'); const { jsonParser } = require('../express-common'); const { getAllUserHandles, getUserDirectories } = require('../users'); const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); // 10 minutes const AUTOSAVE_INTERVAL = 10 * 60 * 1000; /** * Map of functions to trigger settings autosave for a user. * @type {Map} */ const AUTOSAVE_FUNCTIONS = new Map(); /** * Triggers autosave for a user every 10 minutes. * @param {string} handle User handle * @returns {void} */ 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(); } } /** * Reads and parses files from a directory. * @param {string} directoryPath Path to the directory * @param {string} fileExtension File extension * @returns {Array} Parsed files */ 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 { // skip } }); return parsedFiles; } /** * Gets a sort function for sorting strings. * @param {*} _ * @returns {(a: string, b: string) => number} Sort function */ function sortByName(_) { return (a, b) => a.localeCompare(b); } /** * Gets backup file prefix for user settings. * @param {string} handle User handle * @returns {string} File prefix */ 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 { // skip console.log(`${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.log('Could not backup settings file', err); } } /** * Makes a backup of the user's settings file. * @param {string} handle User handle * @param {boolean} preventDuplicates Prevent duplicate backups * @returns {void} */ 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}`); } /** * Checks if the backup would be a duplicate. * @param {string} handle User handle * @param {string} sourceFile Source file path * @returns {boolean} True if the backup is a duplicate */ function isDuplicateBackup(handle, sourceFile) { const latestBackup = getLatestBackup(handle); if (!latestBackup) { return false; } return areFilesEqual(latestBackup, sourceFile); } /** * Returns true if the two files are equal. * @param {string} file1 File path * @param {string} file2 File path */ 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(); } /** * Gets the latest backup file for a user. * @param {string} handle User handle * @returns {string|null} Latest backup file. Null if no backup exists. */ 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); } const router = express.Router(); router.post('/save', jsonParser, 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.log(err); response.send(err); } }); // Wintermute's code router.post('/get', jsonParser, (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); } // NovelAI Settings const { fileContents: novelai_settings, fileNames: novelai_setting_names } = readPresetsFromDirectory(request.user.directories.novelAI_Settings, { sortFunction: sortByName(request.user.directories.novelAI_Settings), removeFileExtension: true, }); // OpenAI Settings const { fileContents: openai_settings, fileNames: openai_setting_names } = readPresetsFromDirectory(request.user.directories.openAI_Settings, { sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true, }); // TextGenerationWebUI Settings const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names } = readPresetsFromDirectory(request.user.directories.textGen_Settings, { sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true, }); //Kobold 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); 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, enable_extensions: ENABLE_EXTENSIONS, enable_accounts: ENABLE_ACCOUNTS, }); }); router.post('/get-snapshots', jsonParser, 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.log(error); response.sendStatus(500); } }); router.post('/load-snapshot', jsonParser, 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.log(error); response.sendStatus(500); } }); router.post('/make-snapshot', jsonParser, async (request, response) => { try { backupUserSettings(request.user.profile.handle, false); response.sendStatus(204); } catch (error) { console.log(error); response.sendStatus(500); } }); router.post('/restore-snapshot', jsonParser, 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.log(error); response.sendStatus(500); } }); /** * Initializes the settings endpoint */ async function init() { await backupSettings(); } module.exports = { router, init };