Blame Raw
Cohee · e3f41666 · · 404 lines (14.2 KB)
1 contributor
1import { getRequestHeaders } from '../script.js';
2import { VIDEO_EXTENSIONS } from './constants.js';
3import { t } from './i18n.js';
4import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
5import { renderTemplateAsync } from './templates.js';
6import { humanFileSize, timestampToMoment } from './utils.js';
7
8/**
9 * @typedef {object} DataMaidReportResult
10 * @property {import('../../src/endpoints/data-maid.js').DataMaidSanitizedReport} report - The sanitized report of the Data Maid.
11 * @property {string} token - The token to use for the Data Maid report.
12 */
13
14/**
15 * Data Maid Dialog class for managing the cleanup dialog interface.
16 */
17class DataMaidDialog {
18 constructor() {
19 this.token = null;
20 this.container = null;
21 this.isScanning = false;
22
23 this.DATA_MAID_CATEGORIES = {
24 files: {
25 name: t`Files`,
26 description: t`Files that are not associated with chat messages or Data Bank. WILL DELETE MANUAL UPLOADS!`,
27 },
28 images: {
29 name: t`Images`,
30 description: t`Images that are not associated with chat messages. WILL DELETE MANUAL UPLOADS!`,
31 },
32 chats: {
33 name: t`Chats`,
34 description: t`Chat files associated with deleted characters.`,
35 },
36 groupChats: {
37 name: t`Group Chats`,
38 description: t`Chat files associated with deleted groups.`,
39 },
40 avatarThumbnails: {
41 name: t`Avatar Thumbnails`,
42 description: t`Thumbnails for avatars of missing or deleted characters.`,
43 },
44 backgroundThumbnails: {
45 name: t`Background Thumbnails`,
46 description: t`Thumbnails for missing or deleted backgrounds.`,
47 },
48 personaThumbnails: {
49 name: t`Persona Thumbnails`,
50 description: t`Thumbnails for missing or deleted personas.`,
51 },
52 chatBackups: {
53 name: t`Chat Backups`,
54 description: t`Automatically generated chat backups.`,
55 },
56 settingsBackups: {
57 name: t`Settings Backups`,
58 description: t`Automatically generated settings backups.`,
59 },
60 };
61 }
62
63 /**
64 * Returns a promise that resolves to the Data Maid report.
65 * @returns {Promise<DataMaidReportResult>}
66 * @private
67 */
68 async getReport() {
69 const response = await fetch('/api/data-maid/report', {
70 method: 'POST',
71 headers: getRequestHeaders({ omitContentType: true }),
72 });
73
74 if (!response.ok) {
75 throw new Error(`Error fetching Data Maid report: ${response.statusText}`);
76 }
77
78 return await response.json();
79 }
80
81 /**
82 * Finalizes the Data Maid process by sending a request to the server.
83 * @returns {Promise<void>}
84 * @private
85 */
86 async finalize() {
87 const response = await fetch('/api/data-maid/finalize', {
88 method: 'POST',
89 headers: getRequestHeaders(),
90 body: JSON.stringify({ token: this.token }),
91 });
92
93 if (!response.ok) {
94 throw new Error(`Error finalizing Data Maid: ${response.statusText}`);
95 }
96 }
97
98 /**
99 * Sets up the dialog UI elements and event listeners.
100 * @private
101 */
102 async setupDialogUI() {
103 const template = await renderTemplateAsync('dataMaidDialog');
104 this.container = document.createElement('div');
105 this.container.classList.add('dataMaidDialogContainer');
106 this.container.innerHTML = template;
107
108 const startButton = this.container.querySelector('.dataMaidStartButton');
109 startButton.addEventListener('click', () => this.handleScanClick());
110 }
111
112 /**
113 * Handles the scan button click event.
114 * @private
115 */
116 async handleScanClick() {
117 if (this.isScanning) {
118 toastr.warning(t`The scan is already running. Please wait for it to finish.`);
119 return;
120 }
121
122 try {
123 const resultsList = this.container.querySelector('.dataMaidResultsList');
124 resultsList.innerHTML = '';
125 this.showSpinner();
126 this.isScanning = true;
127
128 const report = await this.getReport();
129
130 this.hideSpinner();
131 await this.renderReport(report, resultsList);
132 this.token = report.token;
133 } catch (error) {
134 this.hideSpinner();
135 toastr.error(t`An error has occurred. Check the console for details.`);
136 console.error('Error generating Data Maid report:', error);
137 } finally {
138 this.isScanning = false;
139 }
140 }
141
142 /**
143 * Shows the loading spinner and hides the placeholder.
144 * @private
145 */
146 showSpinner() {
147 const spinner = this.container.querySelector('.dataMaidSpinner');
148 const placeholder = this.container.querySelector('.dataMaidPlaceholder');
149 placeholder.classList.add('displayNone');
150 spinner.classList.remove('displayNone');
151 }
152
153 /**
154 * Hides the loading spinner.
155 * @private
156 */
157 hideSpinner() {
158 const spinner = this.container.querySelector('.dataMaidSpinner');
159 spinner.classList.add('displayNone');
160 }
161
162 /**
163 * Renders the Data Maid report into the results list.
164 * @param {DataMaidReportResult} report
165 * @param {Element} resultsList
166 * @private
167 */
168 async renderReport(report, resultsList) {
169 for (const [prop, data] of Object.entries(this.DATA_MAID_CATEGORIES)) {
170 const category = await this.renderCategory(prop, data.name, data.description, report.report[prop]);
171 if (!category) {
172 continue;
173 }
174 resultsList.appendChild(category);
175 }
176 this.displayEmptyPlaceholder();
177 }
178
179 /**
180 * Displays a placeholder message if no items are found in the results list.
181 * @private
182 */
183 displayEmptyPlaceholder() {
184 const resultsList = this.container.querySelector('.dataMaidResultsList');
185 if (resultsList.children.length === 0) {
186 const placeholder = this.container.querySelector('.dataMaidPlaceholder');
187 placeholder.classList.remove('displayNone');
188 placeholder.textContent = t`No items found to clean up. Come back later!`;
189 }
190 }
191
192 /**
193 * Renders a single Data Maid category into a DOM element.
194 * @param {string} prop Property name for the category
195 * @param {string} name Name of the category
196 * @param {string} description Description of the category
197 * @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
198 * @return {Promise<Element|null>} A promise that resolves to a DOM element containing the rendered category
199 * @private
200 */
201 async renderCategory(prop, name, description, items) {
202 if (!Array.isArray(items) || items.length === 0) {
203 return null;
204 }
205
206 const viewModel = {
207 name: name,
208 description: description,
209 totalSize: humanFileSize(items.reduce((sum, item) => sum + item.size, 0)),
210 totalItems: items.length,
211 items: items.sort((a, b) => b.mtime - a.mtime).map(item => ({
212 ...item,
213 size: humanFileSize(item.size),
214 date: timestampToMoment(item.mtime).format('L LT'),
215 })),
216 };
217
218 const template = await renderTemplateAsync('dataMaidCategory', viewModel);
219 const categoryElement = document.createElement('div');
220 categoryElement.innerHTML = template;
221 categoryElement.querySelectorAll('.dataMaidItemView').forEach(button => {
222 button.addEventListener('click', async () => {
223 const item = button.closest('.dataMaidItem');
224 const hash = item?.getAttribute('data-hash');
225 const itemName = items.find(i => i.hash === hash)?.name;
226 if (hash) {
227 await this.view(prop, hash, itemName);
228 }
229 });
230 });
231 categoryElement.querySelectorAll('.dataMaidItemDownload').forEach(button => {
232 button.addEventListener('click', async () => {
233 const item = button.closest('.dataMaidItem');
234 const hash = item?.getAttribute('data-hash');
235 if (hash) {
236 await this.download(items, hash);
237 }
238 });
239 });
240 categoryElement.querySelectorAll('.dataMaidDeleteAll').forEach(button => {
241 button.addEventListener('click', async (event) => {
242 event.stopPropagation();
243 const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete all files in this category. THIS CANNOT BE UNDONE!`);
244 if (!confirm) {
245 return;
246 }
247
248 const hashes = items.map(item => item.hash).filter(hash => hash);
249 await this.delete(hashes);
250
251 categoryElement.remove();
252 this.displayEmptyPlaceholder();
253 });
254 });
255 categoryElement.querySelectorAll('.dataMaidItemDelete').forEach(button => {
256 button.addEventListener('click', async () => {
257 const item = button.closest('.dataMaidItem');
258 const hash = item?.getAttribute('data-hash');
259 if (hash) {
260 const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete the file. THIS CANNOT BE UNDONE!`);
261 if (!confirm) {
262 return;
263 }
264 if (await this.delete([hash])) {
265 item.remove();
266 items.splice(items.findIndex(i => i.hash === hash), 1);
267 if (items.length === 0) {
268 categoryElement.remove();
269 this.displayEmptyPlaceholder();
270 }
271 }
272 }
273 });
274 });
275 return categoryElement;
276 }
277
278 /**
279 * Constructs the URL for viewing an item by its hash.
280 * @param {string} hash Hash of the item to view
281 * @returns {string} URL to view the item
282 * @private
283 */
284 getViewUrl(hash) {
285 return `/api/data-maid/view?hash=${encodeURIComponent(hash)}&token=${encodeURIComponent(this.token)}`;
286 }
287
288 /**
289 * Downloads an item by its hash.
290 * @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
291 * @param {string} hash Hash of the item to download
292 * @private
293 */
294 async download(items, hash) {
295 const item = items.find(i => i.hash === hash);
296 if (!item) {
297 return;
298 }
299 const url = this.getViewUrl(hash);
300 const a = document.createElement('a');
301 a.href = url;
302 a.download = item?.name || hash;
303 document.body.appendChild(a);
304 a.click();
305 document.body.removeChild(a);
306 }
307
308 /**
309 * Opens the item view for a specific hash.
310 * @param {string} prop Property name for the category
311 * @param {string} hash Item hash to view
312 * @param {string} name Name of the item to view
313 * @private
314 */
315 async view(prop, hash, name) {
316 const url = this.getViewUrl(hash);
317 const isImage = ['images', 'avatarThumbnails', 'backgroundThumbnails'].includes(prop);
318 const element = isImage
319 ? await this.getViewElement(url, name)
320 : await this.getTextViewElement(url);
321 await callGenericPopup(element, POPUP_TYPE.DISPLAY, '', { large: true, wide: true });
322 }
323
324 /**
325 * Deletes an item by its file path hash.
326 * @param {string[]} hashes Hashes of items to delete
327 * @return {Promise<boolean>} True if the deletion was successful, false otherwise
328 * @private
329 */
330 async delete(hashes) {
331 try {
332 const response = await fetch('/api/data-maid/delete', {
333 method: 'POST',
334 headers: getRequestHeaders(),
335 body: JSON.stringify({ hashes: hashes, token: this.token }),
336 });
337
338 if (!response.ok) {
339 throw new Error(`Error deleting item: ${response.statusText}`);
340 }
341
342 return true;
343 } catch (error) {
344 console.error('Error deleting item:', error);
345 return false;
346 }
347 }
348
349 /**
350 * Gets a media element for viewing images or videos.
351 * @param {string} url View URL
352 * @param {string} name Name of the file
353 * @returns {Promise<HTMLElement>} Image element
354 * @private
355 */
356 async getViewElement(url, name) {
357 const isVideo = VIDEO_EXTENSIONS.includes(name.split('.').pop());
358 const mediaElement = document.createElement(isVideo ? 'video' : 'img');
359 if (mediaElement instanceof HTMLVideoElement) {
360 mediaElement.controls = true;
361 }
362 mediaElement.src = url;
363 mediaElement.classList.add('dataMaidImageView');
364 return mediaElement;
365 }
366
367 /**
368 * Gets an iframe element for viewing text content.
369 * @param {string} url View URL
370 * @returns {Promise<HTMLTextAreaElement>} Frame element
371 * @private
372 */
373 async getTextViewElement(url) {
374 const response = await fetch(url);
375 const text = await response.text();
376 const element = document.createElement('textarea');
377 element.classList.add('dataMaidTextView');
378 element.readOnly = true;
379 element.textContent = text;
380 return element;
381 }
382
383 /**
384 * Opens the Data Maid dialog and handles the interaction.
385 */
386 async open() {
387 await this.setupDialogUI();
388 await callGenericPopup(this.container, POPUP_TYPE.TEXT, '', { wide: true, large: true });
389
390 if (this.token) {
391 await this.finalize();
392 }
393 }
394}
395
396export function initDataMaid() {
397 const dataMaidButton = document.getElementById('data_maid_button');
398 if (!dataMaidButton) {
399 console.warn('Data Maid button not found');
400 return;
401 }
402
403 dataMaidButton.addEventListener('click', () => new DataMaidDialog().open());
404}