import { __ID__, filePath } from "../consts.mjs"; import { convertToWebp, determineFileExtension, getFile, hashFile, lastModifiedAt, uploadFile, uploadJson } from "../utils/fs.mjs"; import { imagePath } from "../utils/imagePath.mjs"; import { DBConnectorMixin } from "./mixins/DBConnector.mjs"; const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; const { fetchWithTimeout } = foundry.utils; export class ImageApp extends DBConnectorMixin( HandlebarsApplicationMixin( ApplicationV2 )) { // #region Options static dbType = `Image`; static dbPath = `storage/db/images.json`; static DEFAULT_OPTIONS = { tag: `form`, classes: [ __ID__, `ImageApp`, ], position: { width: 600, }, window: { contentClasses: [ `standard-form`, ], controls: [ { icon: `fa-solid fa-arrow-up-right-from-square`, label: `IT.apps.ImageApp.toggle-upload-mode`, action: `toggleUploadMode`, } ], }, form: { handler: this.#onSubmit, submitOnChange: false, closeOnSubmit: true, }, actions: { toggleUploadMode: this.#toggleUploadMode, removeEditingImage: this.#removeEditingImage, }, }; static PARTS = { header: { template: filePath(`templates/ImageApp/header.hbs`) }, preview: { template: filePath(`templates/ImageApp/preview.hbs`) }, form: { template: filePath(`templates/ImageApp/form.hbs`) }, footer: { template: filePath(`templates/partials/footer.hbs`) }, }; // #endregion Options // #region Instance Data #isExternal = false; /** The artist that is being edited, or the default artist values */ _doc = { name: ``, path: ``, tags: [], artists: [] }; get title() { if (this._docID) { if (this._doc.name) { return _loc( `IT.apps.ImageApp.title.edit-named`, { name: this._doc.name }, ); } else { return _loc(`IT.apps.ImageApp.title.edit-generic`); } }; if (this.#isExternal) { return _loc(`IT.apps.ImageApp.title.register`); } return _loc(`IT.apps.ImageApp.title.upload`); }; // #endregion Instance Data // #region Lifecycle _onFirstRender() { if (this._doc?.name) { this._updateFrame({ window: { title: this.title } }); }; }; _onRender(context, options) { if (options.parts?.includes(`header`)) { this.element.querySelector(`input[type="file"]`)?.addEventListener( `change`, this.#changeImageInput.bind(this), ); this.element.querySelector(`file-picker`)?.addEventListener( `change`, this.#changeFilePickerInput.bind(this), ); }; }; /** @this {ImageApp} */ static async #onSubmit(event, element, formData) { const data = formData.object; // Validate the DB hasn't been updated since opening if (!(await this.isAbleToSave())) { return }; // Add the external image into the DB if (this.#isExternal) { const path = data.path; const response = await fetchWithTimeout(path); const type = response.headers.get(`Content-Type`) if (!Object.values(CONST.IMAGE_FILE_EXTENSIONS).includes(type)) { // TODO: error return; }; const blob = await response.blob(); const hash = await hashFile(blob); const images = await getFile(`storage/db/images.json`); images[hash] = { name: data.name, tags: data.tags, artists: data.artists, external: true, path, }; await uploadJson(`db`, `images.json`, images); Hooks.callAll(`${__ID__}.imageUploaded`, hash, images[hash]); } // Upload new image to server else if (!this._docID) { const hash = this._doc.hash; const extension = determineFileExtension(this.#file); if (!this.#file) { // TODO: ERROR return; } const file = new File( [this.#file], `${hash}.${extension}`, { type: this.#file.type }, ); await uploadFile(`tokens`, file); const images = await getFile(`storage/db/images.json`); images[hash] = { name: data.name, tags: data.tags, artists: data.artists, path: `storage/tokens/${hash}.${extension}`, }; await uploadJson(`db`, `images.json`, images); Hooks.callAll(`${__ID__}.imageUploaded`, hash, images[hash]); } else { const images = await getFile(this.constructor.dbPath); Object.assign(images[this._docID], { name: data.name, tage: data.tags, artists: data.artists, }); await uploadJson(`db`, `images.json`, images); Hooks.callAll(`${__ID__}.imageUpdated`, this._docID, images[this._docID]); }; }; // #endregion Lifecycle // #region Data Prep #lastArtistEdit = null; #artistCache = []; async _prepareContext() { // Get all of the available artists for the dropdown const lastModified = await lastModifiedAt(`storage/db/artists.json`); if ( this.#lastArtistEdit == null || lastModified !== this.#lastArtistEdit ) { this.#artistCache = []; const artistDB = await getFile(`storage/db/artists.json`); for (const [id, artist] of Object.entries(artistDB)) { this.#artistCache.push({ value: id, label: artist.name }); }; }; let path = imagePath(this._doc); if (this.#isExternal || this._doc.path.startsWith(`blob`)) { path = this._doc.path; }; const ctx = { meta: { idp: this.id, }, imageTypes: Object.values(CONST.IMAGE_FILE_EXTENSIONS).join(`,`), imageURL: path, docID: this._docID, image: this._doc, artists: this.#artistCache, external: this.#isExternal, }; return ctx; }; // #endregion Data Prep // #region Actions /** @type {File | null} */ #file = null; async #changeImageInput(event) { /** @type {File} */ let file = this.#file = event.target.files[0]; // Prevent memory leaks URL.revokeObjectURL(this._doc.path); if (!file) { this.#file = null; this._docID = null; this._doc = { path: ``, name: ``, artists: [], tags: [] }; this._updateFrame({ window: { title: this.title } }); await this.render({ parts: [`header`, `preview`, `form`] }); return; }; // Ensure we don't already have the file uploaded const webp = await convertToWebp(file); let extension; if (webp) { file = this.#file = webp; extension = `webp`; } else { extension = determineFileExtension(file); }; const hash = await hashFile(file); const path = `storage/tokens/${hash}.${extension}`; const lastModified = await lastModifiedAt(path); if (lastModified) { this._docID = hash; this._doc.hash = hash; await this._fetchDocument(); this._updateFrame({ window: { title: this.title } }); await this.render({ parts: [`header`, `preview`, `form`] }); return; }; // Temporarily blob the image so it can be displayed in-app const url = URL.createObjectURL(file); this._doc.name = file.name.replace(`.${extension}`, ``); this._doc.path = url; this._doc.hash = hash; await this.render({ parts: [`preview`, `form`] }); }; async #changeFilePickerInput(event) { const picker = event.currentTarget; const path = picker.value; if (!path) { this._docID = null; this._doc = { path, name: ``, artists: [], tags: [] }; this.render({ parts: [ `preview`, `form` ] }); return; }; let hash; try { const response = await fetchWithTimeout(path); const blob = await response.blob(); hash = await hashFile(blob); } catch { ui.notifications.error(_loc(`IT.notifs.error.cant-find-image`, { url: path })); picker.value = ``; return; }; this._docID = hash; await this._fetchDocument(true); if (this._doc.path !== path) { this._docID = null; this._doc = { path, name: ``, artists: [], tags: [] }; }; this.render({ parts: [ `preview`, `form` ] }); }; /** @this {ImageApp} */ static async #removeEditingImage() { this.#file = null; this._docID = null; this._doc = { path: ``, artists: [], tags: [] }; this._updateFrame({ window: { title: this.title } }); await this.render({ parts: [`header`, `preview`, `form`] }); }; /** @this {ImageApp} */ static async #toggleUploadMode() { this.#isExternal = !this.#isExternal; ImageApp.#removeEditingImage.call(this); }; // #endregion Actions };