Add the ability to register external images into the module using the ImageApp

This commit is contained in:
Oliver 2026-02-07 18:29:20 -07:00
parent 16e61a4855
commit 3fdbdf842c
5 changed files with 144 additions and 43 deletions

View file

@ -48,9 +48,11 @@
"ImageApp": {
"title": {
"upload": "Upload New Image",
"register": "Register External Image",
"edit-named": "Editing Image: {name}",
"edit-generic": "Editing Image"
},
"toggle-upload-mode": "Toggle Upload Mode",
"image-label": "Image",
"clear": "Clear",
"preview-placeholder": "Select an image to see a preview"
@ -73,7 +75,8 @@
"error": {
"db-out-of-date": "Database out of date, please try again.",
"document-ID-404": "Cannot find {dbType} with ID: {id}",
"no-upload-permission": "Cannot save due to missing the \"Upload Files\" permission."
"no-upload-permission": "Cannot save due to missing the \"Upload Files\" permission.",
"cant-find-image": "Cannot find image at location: {url}"
}
}
}

View file

@ -1,8 +1,10 @@
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(
@ -27,6 +29,13 @@ export class ImageApp extends
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,
@ -34,6 +43,7 @@ export class ImageApp extends
closeOnSubmit: true,
},
actions: {
toggleUploadMode: this.#toggleUploadMode,
removeEditingImage: this.#removeEditingImage,
},
};
@ -47,6 +57,8 @@ export class ImageApp extends
// #endregion Options
// #region Instance Data
#isExternal = false;
/** The artist that is being edited, or the default artist values */
_doc = { name: ``, path: ``, tags: [], artists: [] };
@ -61,6 +73,9 @@ export class ImageApp extends
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
@ -76,7 +91,11 @@ export class ImageApp extends
if (options.parts?.includes(`header`)) {
this.element.querySelector(`input[type="file"]`)?.addEventListener(
`change`,
this.#changeImage.bind(this),
this.#changeImageInput.bind(this),
);
this.element.querySelector(`file-picker`)?.addEventListener(
`change`,
this.#changeFilePickerInput.bind(this),
);
};
};
@ -88,11 +107,38 @@ export class ImageApp extends
// 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
if (!this._docID) {
else if (!this._docID) {
const hash = this._doc.hash;
const extension = determineFileExtension(this.#file);
const path = `storage/tokens/${hash}.${extension}`;
if (!this.#file) {
// TODO: ERROR
@ -105,12 +151,12 @@ export class ImageApp extends
);
await uploadFile(`tokens`, file);
const images = await getFile(this.constructor.dbPath);
const images = await getFile(`storage/db/images.json`);
images[hash] = {
name: data.name,
tags: data.tags,
artists: data.artists,
path: this.#file ? path : ``,
path: `storage/tokens/${hash}.${extension}`,
};
await uploadJson(`db`, `images.json`, images);
@ -148,17 +194,21 @@ export class ImageApp extends
};
};
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: this._doc.path.startsWith(`blob`)
? this._doc.path
: filePath(this._doc.path),
imageURL: path,
docID: this._docID,
image: this._doc,
artists: this.#artistCache,
external: this.#isExternal,
};
return ctx;
@ -168,7 +218,7 @@ export class ImageApp extends
// #region Actions
/** @type {File | null} */
#file = null;
async #changeImage(event) {
async #changeImageInput(event) {
/** @type {File} */
let file = this.#file = event.target.files[0];
@ -214,6 +264,38 @@ export class ImageApp extends
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;
@ -222,5 +304,11 @@ export class ImageApp extends
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
};

View file

@ -43,15 +43,17 @@ export function DBConnectorMixin(HandlebarsApp) {
return super.render(options, ...args);
};
async _fetchDocument() {
async _fetchDocument(silent = false) {
if (!this._docID) { return }
const documents = await getFile(this.dbPath);
if (documents[this._docID] == null) {
ui.notifications.error(_loc(
`IT.notifs.error.document-ID-404`,
{ id: this._docID, dbType: this.dbType },
));
if (!silent) {
ui.notifications.error(_loc(
`IT.notifs.error.document-ID-404`,
{ id: this._docID, dbType: this.dbType },
));
}
return false;
};

View file

@ -5,6 +5,10 @@
grid-template-rows: auto auto;
}
file-picker {
flex-grow: 1;
}
header, footer {
grid-column: 1 / -1;
}

View file

@ -1,29 +1,33 @@
<header>
<div class="row">
<label for="{{meta.idp}}-image">
{{ localize "IT.apps.ImageApp.image-label" }}
</label>
{{#if docID}}
<input
type="text"
id="{{meta.idp}}-image"
value="{{imageURL}}"
disabled
readonly
>
<button
type="button"
data-action="removeEditingImage"
>
{{ localize "IT.apps.ImageApp.clear" }}
</button>
{{else}}
<input
type="file"
id="{{meta.idp}}-image"
accept="{{imageTypes}}"
name="file"
>
{{/if}}
</div>
<header class="row">
<label for="{{meta.idp}}-image">
{{ localize "IT.apps.ImageApp.image-label" }}
</label>
{{#if external}}
<file-picker
id="{{meta.idp}}-image"
type="image"
name="path"
></file-picker>
{{else if docID}}
<input
type="text"
id="{{meta.idp}}-image"
value="{{imageURL}}"
disabled
readonly
>
<button
type="button"
data-action="removeEditingImage"
>
{{ localize "IT.apps.ImageApp.clear" }}
</button>
{{else}}
<input
type="file"
id="{{meta.idp}}-image"
accept="{{imageTypes}}"
name="file"
>
{{/if}}
</header>