| 1 | import { getRequestHeaders } from '../script.js'; |
| 2 | import { VIDEO_EXTENSIONS } from './constants.js'; |
| 3 | import { t } from './i18n.js'; |
| 4 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; |
| 5 | import { renderTemplateAsync } from './templates.js'; |
| 6 | import { 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 | */ |
| 17 | class 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 | |
| 396 | export 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 | } |