Add user snapshot settings management
This commit is contained in:
parent
c92df1168d
commit
1990a2d9bd
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue