Haay's picture
Upload 926 files
519a20c verified
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());
}