Add user snapshot settings management

This commit is contained in:
Cohee 2024-04-11 01:44:48 +03:00
parent c92df1168d
commit 1990a2d9bd
4 changed files with 301 additions and 11 deletions

View File

@ -0,0 +1,31 @@
<div class="padding5">
<h3 class="title_restorable">
<span data-i18n="Settings Snapshots">Settings Snapshots</span>
<div class="makeSnapshotButton menu_button menu_button_icon" title="Record a snapshot of your current settings.">
<i class="fa-fw fa-solid fa-camera"></i>
<span data-i18n="Make a Snapshot">Make a Snapshot</span>
</div>
</h3>
<hr>
<div class="snapshotList flex-container flexFlowColumn">
</div>
<div class="template_element snapshotTemplate">
<div class="snapshot inline-drawer wide100p">
<div class="inline-drawer-toggle inline-drawer-header flexGap10">
<div class="flex-container flexFlowColumn flexNoGap justifyLeft">
<span class="snapshotName"></span>
<div class="flex-container flexGap10">
<small class="snapshotDate"></small>
<small>(<span class="snapshotSize"></span>)</small>
</div>
</div>
<div class="expander"></div>
<div class="menu_button fa-solid fa-recycle snapshotRestoreButton" title="Restore this snapshot"></div>
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
<div class="inline-drawer-content">
<textarea class="text_pole textarea_compact fontsize80p snapshotContent" readonly placeholder="Loading..." rows="25"></textarea>
</div>
</div>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { getRequestHeaders, renderTemplate } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { humanFileSize } from './utils.js';
/**
* @type {import('../../src/users.js').UserViewModel} Logged in user
@ -435,6 +436,157 @@ async function changeName(handle, name, callback) {
}
}
/**
* Restore a settings snapshot.
* @param {string} name Snapshot name
* @param {function} callback Success callback
*/
async function restoreSnapshot(name, callback) {
try {
const confirm = await callGenericPopup(
`Are you sure you want to restore the settings from "${name}"?`,
POPUP_TYPE.CONFIRM,
'',
{ okButton: 'Restore', cancelButton: 'Cancel', wide: false, large: false },
);
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
throw new Error('Restore snapshot cancelled');
}
const response = await fetch('/api/settings/restore-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to restore snapshot');
throw new Error('Failed to restore snapshot');
}
callback();
} catch (error) {
console.error('Error restoring snapshot:', error);
}
}
/**
* Load the content of a settings snapshot.
* @param {string} name Snapshot name
* @returns {Promise<string>} Snapshot content
*/
async function loadSnapshotContent(name) {
try {
const response = await fetch('/api/settings/load-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to load snapshot content');
throw new Error('Failed to load snapshot content');
}
return response.text();
} catch (error) {
console.error('Error loading snapshot content:', error);
}
}
/**
* Gets a list of settings snapshots.
* @returns {Promise<Snapshot[]>} List of snapshots
* @typedef {Object} Snapshot
* @property {string} name Snapshot name
* @property {number} date Date in milliseconds
* @property {number} size File size in bytes
*/
async function getSnapshots() {
try {
const response = await fetch('/api/settings/get-snapshots', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to get settings snapshots');
throw new Error('Failed to get settings snapshots');
}
const snapshots = await response.json();
return snapshots;
} catch (error) {
console.error('Error getting settings snapshots:', error);
return [];
}
}
/**
* Make a snapshot of the current settings.
* @param {function} callback Success callback
* @returns {Promise<void>}
*/
async function makeSnapshot(callback) {
try {
const response = await fetch('/api/settings/make-snapshot', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to make snapshot');
throw new Error('Failed to make snapshot');
}
toastr.success('Snapshot created successfully', 'Snapshot Created');
callback();
} catch (error) {
console.error('Error making snapshot:', error);
}
}
/**
* Open the settings snapshots view.
*/
async function viewSettingsSnapshots() {
const template = $(renderTemplate('snapshotsView'));
async function renderSnapshots() {
const snapshots = await getSnapshots();
template.find('.snapshotList').empty();
for (const snapshot of snapshots.sort((a, b) => b.date - a.date)) {
const snapshotBlock = template.find('.snapshotTemplate .snapshot').clone();
snapshotBlock.find('.snapshotName').text(snapshot.name);
snapshotBlock.find('.snapshotDate').text(new Date(snapshot.date).toLocaleString());
snapshotBlock.find('.snapshotSize').text(humanFileSize(snapshot.size));
snapshotBlock.find('.snapshotRestoreButton').on('click', async (e) => {
e.stopPropagation();
restoreSnapshot(snapshot.name, () => location.reload());
});
snapshotBlock.find('.inline-drawer-toggle').on('click', async () => {
const contentBlock = snapshotBlock.find('.snapshotContent');
if (!contentBlock.val()) {
const content = await loadSnapshotContent(snapshot.name);
contentBlock.val(content);
}
});
template.find('.snapshotList').append(snapshotBlock);
}
}
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false });
template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots));
renderSnapshots();
}
async function openUserProfile() {
await getCurrentUser();
const template = $(renderTemplate('userProfile'));
@ -445,6 +597,7 @@ async function openUserProfile() {
template.find('.userCreated').text(new Date(currentUser.created).toLocaleString());
template.find('.hasPassword').toggle(currentUser.password);
template.find('.noPassword').toggle(!currentUser.password);
template.find('.userSettingsSnapshotsButton').on('click', () => viewSettingsSnapshots());
template.find('.userChangeNameButton').on('click', async () => changeName(currentUser.handle, currentUser.name, async () => {
await getCurrentUser();
template.find('.userName').text(currentUser.name);

View File

@ -10,6 +10,12 @@ const { getAllUserHandles, getUserDirectories } = require('../users');
const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true);
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
/**
* 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)
@ -31,10 +37,24 @@ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
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,
@ -70,22 +90,31 @@ async function backupSettings() {
const userHandles = await getAllUserHandles();
for (const handle of userHandles) {
const userDirectories = getUserDirectories(handle);
const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `settings_${handle}_${generateTimestamp()}.json`);
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
if (!fs.existsSync(sourceFile)) {
continue;
}
fs.copyFileSync(sourceFile, backupFile);
removeOldBackups(`settings_${handle}`);
backupUserSettings(handle);
}
} catch (err) {
console.log('Could not backup settings file', err);
}
}
/**
* Makes a backup of the user's settings file.
* @param {string} handle User handle
* @returns {void}
*/
function backupUserSettings(handle) {
const userDirectories = getUserDirectories(handle);
const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
if (!fs.existsSync(sourceFile)) {
return;
}
fs.copyFileSync(sourceFile, backupFile);
removeOldBackups(`settings_${handle}`);
}
const router = express.Router();
router.post('/save', jsonParser, function (request, response) {
@ -168,6 +197,84 @@ router.post('/get', jsonParser, (request, response) => {
});
});
router.post('/get-snapshots', jsonParser, async (request, response) => {
try {
const snapshots = fs.readdirSync(PUBLIC_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(PUBLIC_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(PUBLIC_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);
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(PUBLIC_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
*/

View File

@ -561,7 +561,6 @@ function createRouteHandler(directoryFn) {
const filePath = decodeURIComponent(req.params[0]);
return res.sendFile(filePath, { root: directory });
} catch (error) {
console.error(error);
return res.sendStatus(404);
}
};