mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Implement data clean-up dialog (#4072)
* [wip] Add user data cleanup service * Add clean-up report viewer * Fix review comments * Add function comments * Implement item actions * Fix UI styles * Add placeholder for empty results, update category description view * Add displayEmptyPlaceholder method to show message when results list is empty * Adjust menu buttons row * Delete char-scoped data bank attachments on character deletion * Data Bank: Handle character attachments on rename * Remove line breaks in description strings * Drop the category when the last item is deleted * Skip invalid hashes instead of bailing
This commit is contained in:
393
public/scripts/data-maid.js
Normal file
393
public/scripts/data-maid.js
Normal file
@@ -0,0 +1,393 @@
|
||||
import { getRequestHeaders } from '../script.js';
|
||||
import { t } from './i18n.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { humanFileSize, timestampToMoment } from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} DataMaidReportResult
|
||||
* @property {import('../../src/endpoints/data-maid.js').DataMaidSanitizedReport} report - The sanitized report of the Data Maid.
|
||||
* @property {string} token - The token to use for the Data Maid report.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data Maid Dialog class for managing the cleanup dialog interface.
|
||||
*/
|
||||
class DataMaidDialog {
|
||||
constructor() {
|
||||
this.token = null;
|
||||
this.container = null;
|
||||
this.isScanning = false;
|
||||
|
||||
this.DATA_MAID_CATEGORIES = {
|
||||
files: {
|
||||
name: t`Files`,
|
||||
description: t`Files that are not associated with chat messages or Data Bank. WILL DELETE MANUAL UPLOADS!`,
|
||||
},
|
||||
images: {
|
||||
name: t`Images`,
|
||||
description: t`Images that are not associated with chat messages. WILL DELETE MANUAL UPLOADS!`,
|
||||
},
|
||||
chats: {
|
||||
name: t`Chats`,
|
||||
description: t`Chat files associated with deleted characters.`,
|
||||
},
|
||||
groupChats: {
|
||||
name: t`Group Chats`,
|
||||
description: t`Chat files associated with deleted groups.`,
|
||||
},
|
||||
avatarThumbnails: {
|
||||
name: t`Avatar Thumbnails`,
|
||||
description: t`Thumbnails for avatars of missing or deleted characters.`,
|
||||
},
|
||||
backgroundThumbnails: {
|
||||
name: t`Background Thumbnails`,
|
||||
description: t`Thumbnails for missing or deleted backgrounds.`,
|
||||
},
|
||||
chatBackups: {
|
||||
name: t`Chat Backups`,
|
||||
description: t`Automatically generated chat backups.`,
|
||||
},
|
||||
settingsBackups: {
|
||||
name: t`Settings Backups`,
|
||||
description: t`Automatically generated settings backups.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to the Data Maid report.
|
||||
* @returns {Promise<DataMaidReportResult>}
|
||||
* @private
|
||||
*/
|
||||
async getReport() {
|
||||
const response = await fetch('/api/data-maid/report', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching Data Maid report: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the Data Maid process by sending a request to the server.
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async finalize() {
|
||||
const response = await fetch('/api/data-maid/finalize', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ token: this.token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error finalizing Data Maid: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the dialog UI elements and event listeners.
|
||||
* @private
|
||||
*/
|
||||
async setupDialogUI() {
|
||||
const template = await renderTemplateAsync('dataMaidDialog');
|
||||
this.container = document.createElement('div');
|
||||
this.container.classList.add('dataMaidDialogContainer');
|
||||
this.container.innerHTML = template;
|
||||
|
||||
const startButton = this.container.querySelector('.dataMaidStartButton');
|
||||
startButton.addEventListener('click', () => this.handleScanClick());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the scan button click event.
|
||||
* @private
|
||||
*/
|
||||
async handleScanClick() {
|
||||
if (this.isScanning) {
|
||||
toastr.warning(t`The scan is already running. Please wait for it to finish.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resultsList = this.container.querySelector('.dataMaidResultsList');
|
||||
resultsList.innerHTML = '';
|
||||
this.showSpinner();
|
||||
this.isScanning = true;
|
||||
|
||||
const report = await this.getReport();
|
||||
|
||||
this.hideSpinner();
|
||||
await this.renderReport(report, resultsList);
|
||||
this.token = report.token;
|
||||
} catch (error) {
|
||||
this.hideSpinner();
|
||||
toastr.error(t`An error has occurred. Check the console for details.`);
|
||||
console.error('Error generating Data Maid report:', error);
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the loading spinner and hides the placeholder.
|
||||
* @private
|
||||
*/
|
||||
showSpinner() {
|
||||
const spinner = this.container.querySelector('.dataMaidSpinner');
|
||||
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
|
||||
placeholder.classList.add('displayNone');
|
||||
spinner.classList.remove('displayNone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the loading spinner.
|
||||
* @private
|
||||
*/
|
||||
hideSpinner() {
|
||||
const spinner = this.container.querySelector('.dataMaidSpinner');
|
||||
spinner.classList.add('displayNone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Data Maid report into the results list.
|
||||
* @param {DataMaidReportResult} report
|
||||
* @param {Element} resultsList
|
||||
* @private
|
||||
*/
|
||||
async renderReport(report, resultsList) {
|
||||
for (const [prop, data] of Object.entries(this.DATA_MAID_CATEGORIES)) {
|
||||
const category = await this.renderCategory(prop, data.name, data.description, report.report[prop]);
|
||||
if (!category) {
|
||||
continue;
|
||||
}
|
||||
resultsList.appendChild(category);
|
||||
}
|
||||
this.displayEmptyPlaceholder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a placeholder message if no items are found in the results list.
|
||||
* @private
|
||||
*/
|
||||
displayEmptyPlaceholder() {
|
||||
const resultsList = this.container.querySelector('.dataMaidResultsList');
|
||||
if (resultsList.children.length === 0) {
|
||||
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
|
||||
placeholder.classList.remove('displayNone');
|
||||
placeholder.textContent = t`No items found to clean up. Come back later!`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single Data Maid category into a DOM element.
|
||||
* @param {string} prop Property name for the category
|
||||
* @param {string} name Name of the category
|
||||
* @param {string} description Description of the category
|
||||
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
|
||||
* @return {Promise<Element|null>} A promise that resolves to a DOM element containing the rendered category
|
||||
* @private
|
||||
*/
|
||||
async renderCategory(prop, name, description, items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewModel = {
|
||||
name: name,
|
||||
description: description,
|
||||
totalSize: humanFileSize(items.reduce((sum, item) => sum + item.size, 0)),
|
||||
totalItems: items.length,
|
||||
items: items.sort((a, b) => b.mtime - a.mtime).map(item => ({
|
||||
...item,
|
||||
size: humanFileSize(item.size),
|
||||
date: timestampToMoment(item.mtime).format('L LT'),
|
||||
})),
|
||||
};
|
||||
|
||||
const template = await renderTemplateAsync('dataMaidCategory', viewModel);
|
||||
const categoryElement = document.createElement('div');
|
||||
categoryElement.innerHTML = template;
|
||||
categoryElement.querySelectorAll('.dataMaidItemView').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
if (hash) {
|
||||
await this.view(prop, hash);
|
||||
}
|
||||
});
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidItemDownload').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
if (hash) {
|
||||
await this.download(items, hash);
|
||||
}
|
||||
});
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidDeleteAll').forEach(button => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete all files in this category. THIS CANNOT BE UNDONE!`);
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashes = items.map(item => item.hash).filter(hash => hash);
|
||||
await this.delete(hashes);
|
||||
|
||||
categoryElement.remove();
|
||||
this.displayEmptyPlaceholder();
|
||||
});
|
||||
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidItemDelete').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
if (hash) {
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete the file. THIS CANNOT BE UNDONE!`);
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
if (await this.delete([hash])) {
|
||||
item.remove();
|
||||
items.splice(items.findIndex(i => i.hash === hash), 1);
|
||||
if (items.length === 0) {
|
||||
categoryElement.remove();
|
||||
this.displayEmptyPlaceholder();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return categoryElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the URL for viewing an item by its hash.
|
||||
* @param {string} hash Hash of the item to view
|
||||
* @returns {string} URL to view the item
|
||||
* @private
|
||||
*/
|
||||
getViewUrl(hash) {
|
||||
return `/api/data-maid/view?hash=${encodeURIComponent(hash)}&token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an item by its hash.
|
||||
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
|
||||
* @param {string} hash Hash of the item to download
|
||||
* @private
|
||||
*/
|
||||
async download(items, hash) {
|
||||
const item = items.find(i => i.hash === hash);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const url = this.getViewUrl(hash);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = item?.name || hash;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the item view for a specific hash.
|
||||
* @param {string} prop Property name for the category
|
||||
* @param {string} hash Item hash to view
|
||||
* @private
|
||||
*/
|
||||
async view(prop, hash) {
|
||||
const url = this.getViewUrl(hash);
|
||||
const isImage = ['images', 'avatarThumbnails', 'backgroundThumbnails'].includes(prop);
|
||||
const element = isImage
|
||||
? await this.getViewElement(url)
|
||||
: await this.getTextViewElement(url);
|
||||
await callGenericPopup(element, POPUP_TYPE.DISPLAY, '', { large: true, wide: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an item by its file path hash.
|
||||
* @param {string[]} hashes Hashes of items to delete
|
||||
* @return {Promise<boolean>} True if the deletion was successful, false otherwise
|
||||
* @private
|
||||
*/
|
||||
async delete(hashes) {
|
||||
try {
|
||||
const response = await fetch('/api/data-maid/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ hashes: hashes, token: this.token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error deleting item: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an image element for viewing images.
|
||||
* @param {string} url View URL
|
||||
* @returns {Promise<HTMLElement>} Image element
|
||||
* @private
|
||||
*/
|
||||
async getViewElement(url) {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.classList.add('dataMaidImageView');
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an iframe element for viewing text content.
|
||||
* @param {string} url View URL
|
||||
* @returns {Promise<HTMLTextAreaElement>} Frame element
|
||||
* @private
|
||||
*/
|
||||
async getTextViewElement(url) {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
const element = document.createElement('textarea');
|
||||
element.classList.add('dataMaidTextView');
|
||||
element.readOnly = true;
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Data Maid dialog and handles the interaction.
|
||||
*/
|
||||
async open() {
|
||||
await this.setupDialogUI();
|
||||
await callGenericPopup(this.container, POPUP_TYPE.TEXT, '', { wide: true, large: true });
|
||||
|
||||
if (this.token) {
|
||||
await this.finalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initDataMaid() {
|
||||
const dataMaidButton = document.getElementById('data_maid_button');
|
||||
if (!dataMaidButton) {
|
||||
console.warn('Data Maid button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
dataMaidButton.addEventListener('click', () => new DataMaidDialog().open());
|
||||
}
|
@@ -216,8 +216,37 @@ function cleanUpAttachments() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up character attachments when a character is deleted.
|
||||
* @param {{character: import('../../char-data.js').v1CharData}} data Event data
|
||||
*/
|
||||
function cleanUpCharacterAttachments(data) {
|
||||
const avatar = data?.character?.avatar;
|
||||
if (!avatar) return;
|
||||
if (Array.isArray(extension_settings?.character_attachments?.[avatar])) {
|
||||
delete extension_settings.character_attachments[avatar];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle character rename event to update character attachments.
|
||||
* @param {string} oldAvatar Old avatar name
|
||||
* @param {string} newAvatar New avatar name
|
||||
*/
|
||||
function handleCharacterRename(oldAvatar, newAvatar) {
|
||||
if (!oldAvatar || !newAvatar) return;
|
||||
if (Array.isArray(extension_settings?.character_attachments?.[oldAvatar])) {
|
||||
extension_settings.character_attachments[newAvatar] = extension_settings.character_attachments[oldAvatar];
|
||||
delete extension_settings.character_attachments[oldAvatar];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
jQuery(async () => {
|
||||
eventSource.on(event_types.APP_READY, cleanUpAttachments);
|
||||
eventSource.on(event_types.CHARACTER_DELETED, cleanUpCharacterAttachments);
|
||||
eventSource.on(event_types.CHARACTER_RENAMED, handleCharacterRename);
|
||||
const manageButton = await renderExtensionTemplateAsync('attachments', 'manage-button', {});
|
||||
const attachButton = await renderExtensionTemplateAsync('attachments', 'attach-button', {});
|
||||
$('#data_bank_wand_container').append(manageButton);
|
||||
|
68
public/scripts/templates/dataMaidCategory.html
Normal file
68
public/scripts/templates/dataMaidCategory.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<div class="dataMaidCategory inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<div class="dataMaidCategoryHeader">
|
||||
<div class="dataMaidCategoryDetails">
|
||||
<div class="dataMaidCategoryName" data-i18n="{{name}}">
|
||||
{{name}}
|
||||
</div>
|
||||
<small>{{description}}</small>
|
||||
<div class="dataMaidCategoryInfo">
|
||||
<small>
|
||||
<i class="fa-solid fa-file-alt fa-sm"></i>
|
||||
{{totalItems}}
|
||||
</small>
|
||||
<span>∣</span>
|
||||
<small>
|
||||
<i class="fa-solid fa-hdd fa-sm"></i>
|
||||
{{totalSize}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dataMaidDeleteAll right_menu_button" title="Delete all items in this category" data-i18n="[title]Delete all items in this category">
|
||||
<i class="fa-solid fa-fw fa-broom"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
|
||||
</div>
|
||||
<div class="dataMaidCategoryContent inline-drawer-content">
|
||||
<div class="flex-container">
|
||||
{{#each items}}
|
||||
{{#with this}}
|
||||
<div class="dataMaidItem" data-hash="{{hash}}">
|
||||
<div class="dataMaidItemHeader">
|
||||
<div class="dataMaidItemName">
|
||||
{{#if parent}}
|
||||
<span class="dataMaidItemParent">({{parent}})</span>
|
||||
<span>/</span>
|
||||
{{/if}}
|
||||
<b>{{name}}</b>
|
||||
</div>
|
||||
<div class="dataMaidItemActions">
|
||||
<button class="dataMaidItemView menu_button menu_button_icon margin0" title="View item content" data-i18n="[title]View item content">
|
||||
<i class="fa-solid fa-fw fa-eye"></i>
|
||||
</button>
|
||||
<button class="dataMaidItemDownload menu_button menu_button_icon margin0" title="Download item" data-i18n="[title]Download item">
|
||||
<i class="fa-solid fa-fw fa-download"></i>
|
||||
</button>
|
||||
<button class="dataMaidItemDelete menu_button menu_button_icon margin0" title="Delete this item" data-i18n="[title]Delete this item">
|
||||
<i class="fa-solid fa-fw fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dataMaidItemInfo">
|
||||
<small>
|
||||
<i class="fa-solid fa-file fa-sm"></i>
|
||||
{{size}}
|
||||
</small>
|
||||
<span>∣</span>
|
||||
<small>
|
||||
<i class="fa-solid fa-calendar fa-sm"></i>
|
||||
{{date}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{{/with}}
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
25
public/scripts/templates/dataMaidDialog.html
Normal file
25
public/scripts/templates/dataMaidDialog.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="dataMaidDialog">
|
||||
<div class="dataMaidDialogHeader">
|
||||
<div class="dataMaidHeaderInfo info-block warning margin0">
|
||||
<small data-i18n="Once deleted, the files will be gone forever!">
|
||||
Once deleted, the files will be gone forever!
|
||||
</small>
|
||||
<br>
|
||||
<small data-i18n="Make sure to back up your data in advance.">
|
||||
Make sure to back up your data in advance.
|
||||
</small>
|
||||
</div>
|
||||
<button class="menu_button menu_button_icon dataMaidStartButton">
|
||||
<i class="fa fa-cog"></i>
|
||||
<span data-i18n="Scan">Scan</span>
|
||||
</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="dataMaidPlaceholder" data-i18n="No results yet. Tap 'Scan' to start scanning.">
|
||||
No results yet. Tap 'Scan' to start scanning.
|
||||
</div>
|
||||
<div class="displayNone dataMaidSpinner">
|
||||
<i class="fa-solid fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="dataMaidResultsList"></div>
|
||||
</div>
|
Reference in New Issue
Block a user