Compare commits

..

1 commit

Author SHA1 Message Date
c70b8f938a Initial framework to make URL DB imports work 2026-02-07 00:32:47 -07:00
18 changed files with 178 additions and 393 deletions

View file

@ -29,7 +29,7 @@ jobs:
LATEST_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/latest/${{env.MANIFEST}}"
- name: Set up default storage
run: mkdir -p storage/db && mv importable/artists.json storage/db && mv importable/images.json storage/db
run: mkdir -p storage/db && mv importable/*.json storage/db
- name: Compress files
run: zip -r release.zip langs module styles storage templates README.md assets LICENSE ${{env.MANIFEST}}

View file

@ -1,27 +1,2 @@
# Image Tagger
A Foundry VTT module to help you find images that fit what you're wanting faster!
# tokens
## How It Works
When you upload a new image using the module, you can give it:
- a name
- any number of tags
- any number of artists
Then, when you are looking for an image for something, you can use the integrated
Art Browser in order to search based on name, tags, and/or artists!
## Image Optimization
Included in the module is an automatic conversion of any static images (png, jpeg,
etc.) into an optimized webp format! If you upload a webp or gif image, they will
not be converted at all, they will stay how you have them.
This optimization is done so that the storage on your server or computer is reduced,
as well as making it so all of the images load faster on your player's computers.
# Disclaimer:
This module is currently in beta testing, as such there may be bugs and missing
features. To report bugs or request features you can:
- open an issue on the
[primary issue tracker](https://git.varify.ca/Foundry/image-tagger/issues)
- send an email to [hello@varify.ca](mailto:hello@varify.ca)
- or, open an issue on [Github](https://github.com/Eldritch-Oliver/Foundry-Image-Tagger/issues/new)

View file

@ -1,150 +0,0 @@
{
"min_tag_frequency": 3,
"ignorePatterns": [
"**/svg/**",
"**/dice/**"
],
"skipFiles": [
"vtt.png",
"vtt-512.png",
"anvil.png",
"fvtt.icns",
"fvtt.ico",
"logo-scifi.png",
"logo-scifi-blank.png",
"LICENSE"
],
"replacements": {
"abilities": "ability",
"antlers": "antler",
"arrows": "arrow",
"barrels": "barrel",
"beams": "beam",
"birds": "bird",
"bolts": "bolt",
"books": "book",
"bows": "bow",
"boxes": "box",
"bubbles": "bubble",
"bullets": "bullet",
"cards": "card",
"chains": "chain",
"claws": "claw",
"clouds": "cloud",
"clubs": "club",
"coins": "coin",
"documents": "document",
"eggs": "egg",
"eyes": "eye",
"fangs": "fang",
"flags": "flag",
"flowers": "flower",
"grains": "grain",
"guns": "gun",
"hammers": "hammer",
"hands": "hand",
"herbs": "herb",
"horns": "horn",
"lights": "light",
"maces": "mace",
"magical": "magic",
"mushrooms": "mushroom",
"orbs": "orb",
"projectiles": "projectile",
"ribs": "rib",
"roots": "root",
"ropes": "rope",
"runes": "rune",
"scales": "scale",
"sickles": "sickle",
"slimes": "slime",
"spirits": "spirit",
"stems": "stem",
"stick": "stick",
"symbols": "symbol",
"tentacles": "tentacle",
"tips": "tip",
"traps": "trap",
"vines": "vine",
"waves": "wave",
"webs": "web",
"weapons": "weapon",
"wands": "wand",
"swords": "sword",
"cutlasses": "cutlass",
"tools": "tool",
"containers": "container",
"skills": "skill",
"creatures": "creature",
"sundries": "sundry",
"consumables": "consumable",
"gems": "gem",
"daggers": "dagger",
"commodities": "commodity",
"feathers": "feather",
"dynamite": "explosive",
"bomb": "explosive",
"bones": "bone",
"potions": "potion",
"bags": "bag",
"axes": "axe",
"scrolls": "scroll",
"glow": "glowing",
"materials": "material",
"plants": "plant",
"polearms": "polearm",
"staves": "stave",
"academics": "academic",
"aliens": "alien",
"amphibians": "amphibian",
"wings": "wing"
},
"removals": [
"",
"white",
"yellow",
"blue",
"red",
"green",
"black",
"brown",
"teal",
"silver",
"purple",
"gray",
"grey",
"orange",
"lime",
"crimson",
"magenta",
"gold",
"cyan",
"pink",
"tan",
"violet",
"beige",
"simple",
"ringed",
"hollow",
"guard",
"pressure",
"gas",
"fuse",
"cloth",
"sharp",
"worn",
"svg",
"rough",
"control",
"3d",
"admission",
"alient",
"alpha",
"and",
"token",
"assorted",
"ember",
"misc",
"mix"
]
}

View file

@ -1,9 +1,7 @@
import { globSync } from "glob";
import crypto from "crypto";
import { createReadStream } from "fs";
import { readFile, writeFile } from "fs/promises";
const config = JSON.parse(await readFile(`importable/config.json`, `utf8`));
import { writeFile } from "fs/promises";
async function hashFile(path) {
return new Promise((resolve) => {
@ -20,61 +18,67 @@ async function hashFile(path) {
});
};
const skipFiles = [
`vtt.png`,
`vtt-512.png`,
`anvil.png`,
`fvtt.icns`,
`fvtt.ico`,
`logo-scifi.png`,
`logo-scifi-blank.png`,
`LICENSE`,
];
const tagRenames = {
weapons: `weapon`,
wands: `wand`,
swords: `sword`,
cutlasses: `cutlass`,
daggers: `dagger`,
commodities: `commodity`,
feathers: `feather`,
dynamite: `explosive`,
bomb: `explosive`,
}
const purgeTags = new Set([
// All of the colours
`white`, `yellow`, `blue`, `red`, `green`, `black`, `brown`, `teal`, `silver`, `purple`, `gray`, `grey`, `orange`, `lime`, `crimson`, `magenta`, `gold`, `cyan`, `pink`, `tan`, `violet`,
// Terms that just suck as tags
`simple`, `ringed`, `hollow`, `guard`, `pressure`, `gas`, `fuse`,
`cloth`, `sharp`, `worn`,
]);
async function main() {
const imageList = globSync(
`foundry/public/icons/**/*`,
{
nodir: true,
ignore: config.ignorePatterns,
});
const imageList = globSync(`foundry/public/icons/**/*`, { nodir: true });
const images = {};
const allTags = {};
for (const path of imageList) {
if (config.skipFiles.some(file => path.endsWith(file))) continue;
if (skipFiles.some(file => path.endsWith(file))) continue;
const hash = await hashFile(path);
const filename = path.split(`/`).at(-1);
const tags = Array.from(new Set(
const tags = new Set(
path
.replace(`foundry/public/icons/`, ``)
.replace(/\.[a-z]+$/i, ``)
.replaceAll(`-`, `/`)
.split(`/`)
.map(tag => config.replacements[tag] ?? tag)
.filter(tag => !config.removals.includes(tag))
));
tags.forEach(t => {
allTags[t] ??= 0;
allTags[t]++;
});
.map(tag => tagRenames[tag] ?? tag)
.filter(tag => !purgeTags.has(tag))
);
images[hash] = {
name: filename,
tags,
tags: Array.from(tags),
artists: [`FVTT`],
path: path.replace(`foundry/public/`, ``),
external: true
};
};
const sortedTags = Object.entries(allTags)
.sort((a,b) => a[0].localeCompare(b[0]))
// Remove all of the tags that have too low of frequency
const removeTags = new Set();
for (const [tag, count] of sortedTags) {
if (count <= config.min_tag_frequency) {
removeTags.add(tag);
};
};
for (const image of Object.values(images)) {
image.tags = image.tags.filter(t => !removeTags.has(t));
};
console.log(`Removed ${removeTags.size} tags from images due to frequency requirements.`);
await writeFile(
`importable/images.json`,
JSON.stringify(images),

File diff suppressed because one or more lines are too long

View file

@ -12,15 +12,15 @@
"tags": "Tags",
"select": "Select",
"links": "Links",
"sort-by": "Sort By",
"page-reset-warning":"Changing any of these will reset your page to 1."
"sort-by": "Sort By"
},
"apps": {
"ArtBrowser": {
"selected": "{current}/{required} Selected",
"upload-image": "Upload Image",
"no-results": "No results were found for that search",
"select-image": "Select image"
"select-image": "Select image",
"page-reset-warning": "Changing any of these will reset your page to 1."
},
"ArtistBrowser": {
"sort-options": {
@ -48,11 +48,9 @@
"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"
@ -76,7 +74,7 @@
"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.",
"cant-find-image": "Cannot find image at location: {url}"
"invalid-import-type": "Invalid import type: {type}"
}
}
}

View file

@ -9,10 +9,9 @@
"maximum": 13
},
"authors": [
{ "name": "Oliver", "email": "hello@varify.ca" }
{ "name": "Oliver" }
],
"url": "https://git.varify.ca/Foundry/image-tagger",
"bugs": "https://git.varify.ca/Foundry/image-tagger/issues",
"manifest": "",
"download": "",
"esmodules": [

View file

@ -6,6 +6,7 @@ import { ImageApp } from "./apps/ImageApp.mjs";
// Utils
import { convertToWebp, getFileSize, hashFile, lastModifiedAt } from "./utils/fs.mjs";
import { importFromURL } from "./utils/imports.mjs";
export const api = foundry.utils.deepFreeze({
Apps: {
@ -21,5 +22,6 @@ export const api = foundry.utils.deepFreeze({
lastModifiedAt,
getFileSize,
},
importFromURL,
},
});

View file

@ -272,7 +272,6 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
event.stopPropagation();
const data = (new FormDataExtended(event.currentTarget)).object;
this.filters = data;
this.#page = 1;
this.render({ parts: [`images`] });
};

View file

@ -56,7 +56,7 @@ export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
if (this.#page == page) { return };
this.#page = page;
if (this.rendered) {
await this.render({ parts: [`list`] });
await this.render({ parts: [`images`] });
return;
};
return;
@ -152,24 +152,6 @@ export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
const allArtists = Object.entries(deepClone(this.#artistDB.data));
const allImages = Object.values(this.#imagesDB.data);
/*
This collates all of the image data into the required summary data for the
display of the artists. Because this does the collation all in one iteration
it is a more performant way of collecting all of the information once the
databases get larger
*/
const summary = {};
for (const image of allImages) {
for (const artistID of image.artists) {
summary[artistID] ??= { count: 0, tags: {} };
summary[artistID].count++;
for (const tag of image.tags) {
summary[artistID].tags[tag] ??= { name: tag, count: 0 };
summary[artistID].tags[tag].count++;
};
};
};
const artists = [];
for (const [id, artist] of allArtists) {
// Check if it matches the required filters
@ -180,8 +162,20 @@ export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
// Populate ephemeral data for rendering
artist.id = id;
artist.imageCount = summary[id].count;
artist.commonTags = Object.values(summary[id].tags)
let imageCount = 0;
let tags = {};
for (const image of allImages) {
if (!image.artists.includes(id)) continue;
imageCount++;
for (const tag of image.tags) {
tags[tag] ??= { name: tag, count: 0 };
tags[tag].count++;
};
};
artist.imageCount = imageCount;
artist.commonTags = Object.values(tags)
.sort((a, b) => b.count - a.count)
.slice(0, 5);
@ -206,7 +200,6 @@ export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
event.preventDefault();
event.stopPropagation();
const data = (new FormDataExtended(event.currentTarget)).object;
this.#page = 1;
this.filters = data;
this.render({ parts: [`list`] });
};

View file

@ -1,10 +1,8 @@
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(
@ -29,13 +27,6 @@ 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,
@ -43,7 +34,6 @@ export class ImageApp extends
closeOnSubmit: true,
},
actions: {
toggleUploadMode: this.#toggleUploadMode,
removeEditingImage: this.#removeEditingImage,
},
};
@ -57,8 +47,6 @@ 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: [] };
@ -73,9 +61,6 @@ 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
@ -91,11 +76,7 @@ export class ImageApp extends
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.#changeImage.bind(this),
);
};
};
@ -107,38 +88,11 @@ 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
else if (!this._docID) {
if (!this._docID) {
const hash = this._doc.hash;
const extension = determineFileExtension(this.#file);
const path = `storage/tokens/${hash}.${extension}`;
if (!this.#file) {
// TODO: ERROR
@ -151,12 +105,12 @@ export class ImageApp extends
);
await uploadFile(`tokens`, file);
const images = await getFile(`storage/db/images.json`);
const images = await getFile(this.constructor.dbPath);
images[hash] = {
name: data.name,
tags: data.tags,
artists: data.artists,
path: `storage/tokens/${hash}.${extension}`,
path: this.#file ? path : ``,
};
await uploadJson(`db`, `images.json`, images);
@ -194,21 +148,17 @@ 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: path,
imageURL: this._doc.path.startsWith(`blob`)
? this._doc.path
: filePath(this._doc.path),
docID: this._docID,
image: this._doc,
artists: this.#artistCache,
external: this.#isExternal,
};
return ctx;
@ -218,7 +168,7 @@ export class ImageApp extends
// #region Actions
/** @type {File | null} */
#file = null;
async #changeImageInput(event) {
async #changeImage(event) {
/** @type {File} */
let file = this.#file = event.target.files[0];
@ -264,38 +214,6 @@ 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;
@ -304,11 +222,5 @@ 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,17 +43,15 @@ export function DBConnectorMixin(HandlebarsApp) {
return super.render(options, ...args);
};
async _fetchDocument(silent = false) {
async _fetchDocument() {
if (!this._docID) { return }
const documents = await getFile(this.dbPath);
if (documents[this._docID] == null) {
if (!silent) {
ui.notifications.error(_loc(
`IT.notifs.error.document-ID-404`,
{ id: this._docID, dbType: this.dbType },
));
}
ui.notifications.error(_loc(
`IT.notifs.error.document-ID-404`,
{ id: this._docID, dbType: this.dbType },
));
return false;
};

View file

@ -147,7 +147,6 @@ export async function uploadFile(path, file) {
};
export function determineFileExtension(file) {
if (!file) return null;
for (const [short, long] of Object.entries(CONST.IMAGE_FILE_EXTENSIONS)) {
if (long === file.type) {
return short;

70
module/utils/imports.mjs Normal file
View file

@ -0,0 +1,70 @@
import { getFile } from "./fs.mjs";
const { Dialog } = foundry.applications.api;
const { fetchJsonWithTimeout, mergeObject } = foundry.utils;
const TRUSTED_DOMAINS = new Set([
`git.varify.ca`,
`cdn.varify.ca`,
window.location.host,
]);
/**
* Imports an existing JSON DB into the module's current database.
*
* @param {string} url The URL pointing to a raw JSON file
* @param {`keep`|`replace`|`merge`} type The type of import that should be performed
*/
export async function importFromURL(url, type) {
if (![`keep`, `replace`, `merge`].includes(type)) {
ui.notifications.error(_loc(`IT.notifs.error.invalid-import-type`, { type }));
return false;
};
const domain = new URL(url);
if (!TRUSTED_DOMAINS.has(domain.host)) {
const confirmed = await Dialog.confirm({
content: `An import is trying to happen from: <br>${url}<br>Do you trust this website?`,
rejectClose: false,
});
if (!confirmed) {
return false;
};
};
let data;
try {
data = await fetchJsonWithTimeout(url);
} catch (err) {
throw err;
};
const images = await getFile(`storage/db/images.json`);
const artists = await getFile(`storage/db/artists.json`);
switch (type) {
case `keep`: {
importKeepExisting(data, images, artists);
break;
};
case `replace`: {
importReplaceExisting(data, images, artists);
break;
};
case `merge`: {
await importMerge(data, images, artists);
break;
};
};
return true;
};
async function importKeepExisting(data, imageDB, artistDB) {};
async function importReplaceExisting(data, imageDB, artistDB) {};
async function importMerge(data, imageDB, artistDB) {
throw `Merge-based importing is not implemented yet`;
};

View file

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

View file

@ -1,6 +1,6 @@
<form autocomplete="off" class="filters">
<p>
{{localize "IT.common.page-reset-warning"}}
{{localize "IT.apps.ArtBrowser.page-reset-warning"}}
</p>
<hr>

View file

@ -1,10 +1,4 @@
<form autocomplete="off" class="filters">
<p>
{{localize "IT.common.page-reset-warning"}}
</p>
<hr>
<label for="{{meta.idp}}-name">
{{localize "IT.common.name"}}
</label>

View file

@ -1,33 +1,29 @@
<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>
<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>