Compare commits

..

35 commits

Author SHA1 Message Date
c51f3bcc30 Fix the pagination buttons and the setPage method on the Artist Browser (closes #36) 2026-02-08 23:58:09 -07:00
b1995a1dce Make the page reset when changing filters (closes #32) 2026-02-08 23:57:34 -07:00
92a5282e36 Improve the performance of the ArtistBrowser's data collation (closes #26) 2026-02-08 23:27:05 -07:00
6e4aba02dc Update Readme to include the possibility of making the issue on Github instead of just Forgejo/email 2026-02-08 16:18:09 -07:00
4ceebd3e0d Update module.json and README 2026-02-07 22:54:05 -07:00
62cbd9b9d8 Make the file extension algorithm return if a file wasn't provided 2026-02-07 20:42:25 -07:00
b983b74816 Update the images db based on the new script requirements 2026-02-07 20:41:13 -07:00
beb7795138 Update the importable script to use a JSON-based config instead of consts in the Javascript, and make it rename a lot more and remove tags if they aren't used a certain amount 2026-02-07 20:40:48 -07:00
3fdbdf842c Add the ability to register external images into the module using the ImageApp 2026-02-07 18:29:20 -07:00
16e61a4855 Update the release action to include the pre-created database with Foundry's images in the archive (closes #17) 2026-02-07 00:31:15 -07:00
46c3cd09f6 Update the structure of the importable files to be able to copied directly for the release asset 2026-02-07 00:23:54 -07:00
9df92bfb76 Add a dev hook that sets the API to be globally available on ready 2026-02-07 00:23:10 -07:00
920549c43b Add localization strings for everything I didn't localize already 2026-02-06 20:37:39 -07:00
4a31ed76cd Add a config option for setting the webp quality 2026-02-03 23:14:29 -07:00
73601c579c Convert image uploads into webp files unless it's a gif (closes #11) 2026-02-03 18:20:43 -07:00
4a2c40397f Hide the Create/Upload buttons when the user doesn't have that permission (closes #16) 2026-02-03 16:52:39 -07:00
177a7bb637 Make the API publicly accessible (closes #25) 2026-02-03 16:32:14 -07:00
e98584d648 Set the default image name to be the original file name, and move all of the strings into the localization file (closes #10) 2026-02-02 23:06:13 -07:00
3a8a2092f7 Add the database sizes into the sidebar (closes #21) 2026-02-02 22:45:19 -07:00
1e68187959 Update scripts submodule 2026-02-02 21:28:47 -07:00
fc99bfeb3d Version bump 2026-02-02 21:23:54 -07:00
9f87adf04c Add me to the authors array 2026-02-01 23:41:59 -07:00
0a44573b39 Remove global scope API (closes #20) 2026-02-01 23:35:00 -07:00
d721553462 Set version to 0.1.0 and add draft release CD pipeline 2026-02-01 23:30:11 -07:00
68b5876d0b Rename the lock file 2026-02-01 23:18:31 -07:00
bf7a89e4a7 Make it so external images are handled correctly by the module 2026-02-01 23:14:05 -07:00
ef63860922 Add an importable file for the default Foundry images 2026-02-01 23:13:04 -07:00
b1a6752238 Update URL in the manifest 2026-02-01 21:02:47 -07:00
d5a114a44a Create the most basic version of the ArtistBrowser app 2026-02-01 18:31:11 -07:00
1c32a71db9 Genericize the paginated data browser to allow working with artists easier 2026-02-01 18:30:36 -07:00
4d138cbdfe Update the token app render hook to also do the combat turn marker source 2026-02-01 17:12:30 -07:00
cf8c82784b Add the ArtBrowser open when using Foundry's editImage action 2026-02-01 16:28:29 -07:00
516c0d0c5e Add hook for the Token configuration of the image source 2026-02-01 16:05:57 -07:00
7777967fcc Rename the main JS file 2026-02-01 15:42:10 -07:00
acc1858f44 Rename the ID from token-browser to image-tagger 2026-02-01 15:40:10 -07:00
63 changed files with 1346 additions and 292 deletions

View file

@ -0,0 +1,40 @@
on: [ workflow_dispatch ]
env:
MANIFEST: "module.json"
jobs:
create-draft-release:
name: "Create Draft Release"
runs-on: act
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: true
- name: Install dependencies
run: npm clean-install
- id: version
run: cat ${{env.MANIFEST}} | echo version=`jq -r ".version"` >> "$FORGEJO_OUTPUT"
- name: Assert that the tag doesn't exist
run: node scripts/src/tagExists.mjs
env:
TAG_NAME: "v${{steps.version.outputs.version}}"
- name: Update manifest
run: node scripts/src/prepareManifest.mjs
env:
DOWNLOAD_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/v${{steps.version.outputs.version}}/release.zip"
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
- name: Compress files
run: zip -r release.zip langs module styles storage templates README.md assets LICENSE ${{env.MANIFEST}}
- name: Create forgejo release
run: node scripts/src/createForgejoRelease.mjs
env:
TAG: "v${{steps.version.outputs.version}}"

View file

@ -1,2 +1,27 @@
# tokens
# Image Tagger
A Foundry VTT module to help you find images that fit what you're wanting faster!
## 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,4 +0,0 @@
Disclaimer: All icons have been optimized and scaled for the web unless otherwise noted.
icons/layout/grid.svg (https://thenounproject.com/icon/grid-2442978/)
icons/layout/list.svg (https://thenounproject.com/icon/list-5060696/)

View file

@ -1 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M77.641 100a8.6 8.6 0 0 1-4.332-2.36 7.42 7.42 0 0 1-1.86-5.109V78.88a6.87 6.87 0 0 1 4-6.648A9 9 0 0 1 79 71.469c4.41-.09 8.829 0 13.25 0 3.95 0 6.84 2.211 7.61 6q.042.111.11.211v16.09c-.052.18-.11.352-.161.531a6.6 6.6 0 0 1-2.758 4.23 26.5 26.5 0 0 1-3.281 1.47zM22.359 0c1.094.375 2.156.84 3.172 1.39a6.76 6.76 0 0 1 3 6q.059 6.902 0 13.81a6.92 6.92 0 0 1-4.121 6.652 8.4 8.4 0 0 1-3.32.687c-4.551.07-9.102.051-13.648 0a7.15 7.15 0 0 1-7.301-5.8 3 3 0 0 0-.14-.38V6.232c0-.149.108-.301.148-.45A6.6 6.6 0 0 1 2.922 1.47 26 26 0 0 1 6.23 0zM100 22.359a21.3 21.3 0 0 1-1.398 3.18 6.74 6.74 0 0 1-6 3q-6.901.058-13.81 0h-.003a6.8 6.8 0 0 1-6.52-3.918 8.7 8.7 0 0 1-.808-3.52c-.09-4.55-.059-9.101 0-13.648V7.45A7.134 7.134 0 0 1 77.35.121a2 2 0 0 0 .29-.12h16.128q.183.075.371.128a6.5 6.5 0 0 1 4.34 2.723A26 26 0 0 1 100 6.23zM0 77.641a19.7 19.7 0 0 1 1.34-3.121 6.83 6.83 0 0 1 6.058-3.07h13.813a6.8 6.8 0 0 1 6.52 3.922c.512 1.101.789 2.3.808 3.519.09 4.55.059 9.102 0 13.648a7.14 7.14 0 0 1-5.91 7.352 1.7 1.7 0 0 0-.289.12H6.231a3 3 0 0 0-.371-.132 6.56 6.56 0 0 1-4.39-2.79A25 25 0 0 1 0 93.77zm0-35.789a21.5 21.5 0 0 1 1.531-3.281 6.2 6.2 0 0 1 5.29-2.79h14.69a7.077 7.077 0 0 1 7 7.25c0 4.66.059 9.321 0 14a7.006 7.006 0 0 1-7 7.23c-4.82.11-9.64.103-14.449.001-3.558-.07-6-2.16-6.91-5.64 0-.149-.101-.29-.16-.442q.007-8.179.008-16.328m100 16.296a20.4 20.4 0 0 1-1.48 3.223 6.29 6.29 0 0 1-5.34 2.84c-4.922 0-9.852.12-14.77 0a6.944 6.944 0 0 1-7-7.223c-.059-4.66 0-9.32 0-14a7.07 7.07 0 0 1 1.984-5.086 7.1 7.1 0 0 1 5.016-2.164c4.82 0 9.64-.078 14.449 0a7.006 7.006 0 0 1 6.922 5.723q.053.182.14.359v16.328zM41.852 100a22.7 22.7 0 0 1-3.402-1.629 6 6 0 0 1-2.64-5c-.09-3.371-.06-6.871-.071-10.37v-4.552a7.1 7.1 0 0 1 2.168-5.011 7.08 7.08 0 0 1 5.082-1.989h14a7.1 7.1 0 0 1 5.086 1.985 7.1 7.1 0 0 1 2.156 5.015c0 4.871.09 9.739 0 14.61-.078 3.55-2.238 6-5.711 6.84q-.182.057-.36.14H41.853zM58 0a9.2 9.2 0 0 1 3.61 1.602A6.48 6.48 0 0 1 64.2 6.52c.089 4 .058 7.93.07 11.89v3a7.12 7.12 0 0 1-7.348 7.121h-13.81c-4.19 0-7.19-2.84-7.288-7-.13-4.87-.11-9.738 0-14.608C35.87 3.43 38 1 41.5.142A3 3 0 0 0 41.85 0zm6.211 50c0 2.55.09 5.11 0 7.66a6.75 6.75 0 0 1-6.512 6.531q-7.699.181-15.398 0a6.706 6.706 0 0 1-6.472-6.48 349 349 0 0 1 0-15.398v-.004a6.695 6.695 0 0 1 6.46-6.48 330 330 0 0 1 15.48 0 6.75 6.75 0 0 1 6.43 6.433c.11 2.578 0 5.16 0 7.738z"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M41.668 9.375H87.5c4.027 0 7.293 4.027 7.293 7.293V25a7.294 7.294 0 0 1-7.293 7.293H41.668c-4.027 0-7.293-4.027-7.293-7.293v-8.332a7.294 7.294 0 0 1 7.293-7.293m-29.168 0h8.332c4.027 0 7.293 4.027 7.293 7.293V25a7.294 7.294 0 0 1-7.293 7.293H12.5c-4.027 0-7.293-4.027-7.293-7.293v-8.332A7.294 7.294 0 0 1 12.5 9.375m29.168 58.332H87.5c4.027 0 7.293 4.027 7.293 7.293v8.332a7.294 7.294 0 0 1-7.293 7.293H41.668c-4.027 0-7.293-4.027-7.293-7.293V75a7.294 7.294 0 0 1 7.293-7.293m-29.168 0h8.332c4.027 0 7.293 4.027 7.293 7.293v8.332a7.294 7.294 0 0 1-7.293 7.293H12.5c-4.027 0-7.293-4.027-7.293-7.293V75a7.294 7.294 0 0 1 7.293-7.293m29.168-29.164h29.168c4.027 0 7.293 4.027 7.293 7.293v8.332a7.294 7.294 0 0 1-7.293 7.293H41.668c-4.027 0-7.293-4.027-7.293-7.293v-8.332a7.294 7.294 0 0 1 7.293-7.293m-29.168 0h8.332c4.027 0 7.293 4.027 7.293 7.293v8.332a7.294 7.294 0 0 1-7.293 7.293H12.5c-4.027 0-7.293-4.027-7.293-7.293v-8.332a7.294 7.294 0 0 1 7.293-7.293"/></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

5
dev/main.mjs Normal file
View file

@ -0,0 +1,5 @@
import { __ID__ } from "../module/consts.mjs";
Hooks.on(`ready`, () => {
globalThis.IT = game.modules.get(__ID__).api;
});

8
importable/artists.json Normal file
View file

@ -0,0 +1,8 @@
{
"FVTT": {
"name": "Foundry Defaults",
"links": [
{ "name": "FoundryVTT", "url": "https://foundryvtt.com" }
]
}
}

150
importable/config.json Normal file
View file

@ -0,0 +1,150 @@
{
"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

@ -0,0 +1,84 @@
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`));
async function hashFile(path) {
return new Promise((resolve) => {
const stream = createReadStream(path);
const hash = crypto.createHash(`sha1`);
hash.setEncoding(`hex`);
stream.on(`end`, () => {
hash.end();
resolve(hash.read());
});
stream.pipe(hash);
});
};
async function main() {
const imageList = globSync(
`foundry/public/icons/**/*`,
{
nodir: true,
ignore: config.ignorePatterns,
});
const images = {};
const allTags = {};
for (const path of imageList) {
if (config.skipFiles.some(file => path.endsWith(file))) continue;
const hash = await hashFile(path);
const filename = path.split(`/`).at(-1);
const tags = Array.from(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]++;
});
images[hash] = {
name: filename,
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),
);
};
main();

1
importable/images.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,37 @@
{
"TB": {
"IT": {
"common": {
"unsaved": "This change hasn't been saved, if you close without saving it will be undone."
"artists": "Artists",
"images": "Images",
"unsaved": "This change hasn't been saved, if you close without saving it will be undone.",
"page": {
"previous": "Prev",
"next": "Next"
},
"name": "Name",
"tags": "Tags",
"select": "Select",
"links": "Links",
"sort-by": "Sort By",
"page-reset-warning":"Changing any of these will reset your page to 1."
},
"apps": {
"ArtBrowser": {
"selected": "{current}/{required} Selected"
"selected": "{current}/{required} Selected",
"upload-image": "Upload Image",
"no-results": "No results were found for that search",
"select-image": "Select image"
},
"ArtistBrowser": {
"sort-options": {
"name-asc": "Name (A -> Z)",
"name-desc": "Name (Z -> A)",
"image-count-asc": "Image Count (0 -> 9)",
"image-count-desc": "Image Count (9 -> 0)"
},
"image-count": "{count} Images",
"common-tags": "Common Image Tags",
"create-artist": "Create New Artist"
},
"ArtistApp": {
"title": {
@ -14,12 +40,28 @@
},
"add-link": "Add New Link"
},
"ArtSidebar": {
"view": "View All",
"add-new": "Add New",
"db-size": "Database Size: {size}"
},
"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"
}
},
"settings": {
"openForEditImage": {
"name": "Open Art Browser for Documents",
"hint": "Whether or not to open the custom Art Browser when picking an image using the edit image action within Foundry for all documents. Systems and Modules that use a different edit image action will not work with this setting."
}
},
"dialogs": {
@ -33,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

@ -2,22 +2,27 @@
"id": "image-tagger",
"title": "Image Tagger",
"description": "A module that helps you tag tokens to find what you're looking for faster!",
"version": "0.0.0",
"version": "0.2.0",
"compatibility": {
"minimum": 13,
"verified": 13,
"maximum": 13
},
"url": "https://git.varify.ca/Foundry/token-browser",
"authors": [
{ "name": "Oliver", "email": "hello@varify.ca" }
],
"url": "https://git.varify.ca/Foundry/image-tagger",
"bugs": "https://git.varify.ca/Foundry/image-tagger/issues",
"manifest": "",
"download": "",
"esmodules": [
"module/token-browser.mjs"
"module/image-tagger.mjs",
"dev/main.mjs"
],
"styles": [
{
"src": "styles/main.css",
"layer": "modules.token-browser"
"layer": "modules.image-tagger"
}
],
"languages": [

View file

@ -1,25 +1,25 @@
// Applications
import { ArtBrowser } from "./apps/ArtBrowser.mjs";
import { ArtistApp } from "./apps/ArtistApp.mjs";
import { ArtistBrowser } from "./apps/ArtistBrowser.mjs";
import { ImageApp } from "./apps/ImageApp.mjs";
// Utils
import { getFile, hashFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs";
import { convertToWebp, getFileSize, hashFile, lastModifiedAt } from "./utils/fs.mjs";
const { deepFreeze } = foundry.utils;
export const api = deepFreeze({
export const api = foundry.utils.deepFreeze({
Apps: {
ArtBrowser,
ArtistBrowser,
ArtistApp,
ImageApp,
},
utils: {
fs: {
convertToWebp,
hashFile,
lastModifiedAt,
getFile,
uploadJson,
getFileSize,
},
},
});

View file

@ -8,7 +8,7 @@ const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { FormDataExtended } = foundry.applications.ux;
const { deepClone } = foundry.utils;
const PAGE_SIZE = 8;
const PAGE_SIZE = 48;
export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
@ -16,6 +16,7 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
classes: [
__ID__,
`ArtBrowser`,
`data-browser`,
],
position: {},
window: {},
@ -35,7 +36,6 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
template: filePath(`templates/ArtBrowser/images.hbs`),
templates: [
filePath(`templates/ArtBrowser/image/grid.hbs`),
filePath(`templates/ArtBrowser/image/list.hbs`),
],
},
};
@ -45,23 +45,20 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
#page = 1;
#selectCount = 0;
#onSubmit = null;
#layout = `grid`;
filters = {
name: ``,
tags: [],
artists: [],
};
constructor({
selectCount = 0,
onSubmit = null,
layout = `grid`,
...opts
} = {}) {
constructor({ selectCount = 0, onSubmit = null, ...opts } = {}) {
super(opts);
this.#selectCount = selectCount;
this.#onSubmit = onSubmit;
this.#layout = layout;
};
get page() {
return this.#page;
};
async setPage(page) {
@ -73,15 +70,6 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
return;
};
async changeLayout(layout) {
console.log(`changing layout to:`, layout)
if (![`grid`, `list`].includes(layout)) {
throw `Cannot set layout to: ${layout}`;
};
this.#layout = layout;
await this.render({ parts: [`images`] });
};
get selectMode() {
if (this.#selectCount === 0) {
return `view`;
@ -149,11 +137,6 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
if (options.parts?.includes(`images`)) {
this._updateSelectedCount();
const layoutTypeInputs = this.element.querySelectorAll(`input[name="layoutType"]`);
for (const element of layoutTypeInputs) {
element.addEventListener(`change`, this.#onLayoutChange.bind(this));
};
};
};
@ -164,7 +147,7 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
if (element) {
element.innerText = `${this.#selected.size} Selected`;
element.innerText = _loc(
`TB.apps.ArtBrowser.selected`,
`IT.apps.ArtBrowser.selected`,
{
current: this.#selected.size,
required: this.#selectCount,
@ -200,12 +183,11 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
idp: this.id,
},
is: {
multi: this.#selectCount > 1,
single: this.#selectCount === 1,
multi: this.selectMode === `multi`,
single: this.selectMode === `single`,
},
layout: this.#layout,
can: {
upload: true,
upload: game.user.can(`FILES_UPLOAD`),
},
};
};
@ -238,7 +220,6 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
};
_prepareImagesContext(ctx) {
ctx.images = [];
const allImages = Object.entries(deepClone(this.#imagesDB.data));
const images = [];
@ -256,6 +237,7 @@ export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
// Populate ephemeral data for rendering
image.id = id;
image.selected = this.#selected.has(imagePath(image));
image.path = imagePath(image);
// Convert all of the artist IDs into the actual data
image.artists = image.artists
@ -290,16 +272,10 @@ 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`] });
};
async #onLayoutChange(event) {
event.preventDefault();
event.stopPropagation();
const newLayout = event.target.value;
await this.changeLayout(newLayout);
};
/** @this {ArtBrowser} */
static async #nextPage() {
this.#page += 1;

View file

@ -1,8 +1,10 @@
import { api } from "../api.mjs";
import { __ID__, filePath } from "../consts.mjs";
import { getFileSize } from "../utils/fs.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { AbstractSidebarTab } = foundry.applications.sidebar;
const { deepClone } = foundry.utils;
export class ArtSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab) {
// #region Options
@ -30,16 +32,43 @@ export class ArtSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab) {
// #endregion Options
// #region Data Prep
_preparePartContext(partID, ctx) {
_prepareContext() {
return {
meta: {
idp: this.id,
},
can: {
upload: game.user.can(`FILES_UPLOAD`),
},
};
};
async _preparePartContext(partID, ctx) {
ctx = deepClone(ctx);
switch (partID) {
case `tokens`: {
await this._prepareTokensContext(ctx);
break;
};
case `artists`: {
await this._prepareArtistsContext(ctx);
break;
};
};
return ctx;
};
_prepareTokensContext(ctx) {};
async _prepareTokensContext(ctx) {
const size = await getFileSize(filePath(`storage/db/images.json`));
ctx.size = size.friendly;
};
_prepareArtistsContext(ctx) {};
async _prepareArtistsContext(ctx) {
const size = await getFileSize(filePath(`storage/db/artists.json`));
ctx.size = size.friendly;
};
// #endregion Data Prep
// #region Actions

View file

@ -63,11 +63,11 @@ export class ArtistApp extends
get title() {
if (this._docID && this._doc.name) {
return game.i18n.format(
`TB.apps.ArtistApp.title.edit`,
`IT.apps.ArtistApp.title.edit`,
{ name: this._doc.name },
);
};
return game.i18n.localize(`TB.apps.ArtistApp.title.create`);
return game.i18n.localize(`IT.apps.ArtistApp.title.create`);
};
// #endregion Instance Data
@ -126,7 +126,7 @@ export class ArtistApp extends
/** @this {ArtistApp} */
static async #addNewLink() {
const link = await promptViaTemplate(
`TB.dialogs.Link.title`,
`IT.dialogs.Link.title`,
`templates/Dialogs/Link.hbs`,
);
link.isNew = true;

View file

@ -0,0 +1,242 @@
import { __ID__, filePath } from "../consts.mjs";
import { getFile, lastModifiedAt } from "../utils/fs.mjs";
import { paginate } from "../utils/pagination.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { FormDataExtended } = foundry.applications.ux;
const { deepClone } = foundry.utils;
const PAGE_SIZE = 8;
export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`ArtistBrowser`,
`data-browser`,
],
position: {},
window: {},
actions: {
nextPage: this.#nextPage,
prevPage: this.#prevPage,
},
};
static PARTS = {
sidebar: {
template: filePath(`templates/ArtistBrowser/sidebar.hbs`),
},
list: {
template: filePath(`templates/ArtistBrowser/list.hbs`),
templates: [
filePath(`templates/ArtistBrowser/artist.hbs`),
],
},
};
// #endregion Options
// #region Instance Data
#page = 1;
filters = {
name: ``,
sortBy: `name-asc`,
};
constructor({ selectCount = 0, onSubmit = null, ...opts } = {}) {
super(opts);
};
get page() {
return this.#page;
};
async setPage(page) {
if (this.#page == page) { return };
this.#page = page;
if (this.rendered) {
await this.render({ parts: [`list`] });
return;
};
return;
};
// #endregion Instance Data
// #region Lifecycle
#imagesDB = {
lastModified: null,
data: undefined,
};
async #getImages() {
const newLastModified = await lastModifiedAt(`storage/db/images.json`);
if (this.#imagesDB.lastModified && newLastModified === this.#imagesDB.lastModified) {
return;
};
this.#imagesDB.lastModified = newLastModified;
this.#imagesDB.data = await getFile(`storage/db/images.json`);
};
#artistDB = {
lastModified: null,
data: undefined,
};
async #getArtists() {
const newLastModified = await lastModifiedAt(`storage/db/artists.json`);
if (this.#artistDB.lastModified && newLastModified === this.#artistDB.lastModified) {
return;
};
this.#artistDB.lastModified = newLastModified;
this.#artistDB.data = await getFile(`storage/db/artists.json`);
};
async render(...args) {
await this.#getArtists();
await this.#getImages();
return super.render(...args);
};
async _onRender(ctx, options) {
if (options.parts?.includes(`sidebar`)) {
this.element.querySelector(`form.filters`)?.addEventListener(
`change`,
this.#onFilterSubmit.bind(this),
);
};
if (options.parts?.includes(`images`)) {
this._updateSelectedCount();
};
};
// #endregion Lifecycle
// #region Data Prep
_prepareContext() {
return {
meta: {
idp: this.id,
},
can: {
upload: game.user.can(`FILES_UPLOAD`),
},
};
};
_preparePartContext(partID, ctx) {
switch (partID) {
case `sidebar`: {
this._prepareSidebarContext(ctx);
break;
};
case `list`: {
this._prepareListContext(ctx);
break;
};
};
return ctx;
};
_prepareSidebarContext(ctx) {
ctx.name = this.filters.name;
ctx.sortBy = this.filters.sortBy;
ctx.sortOptions = [
{ value: `name-asc`, label: `IT.apps.ArtistBrowser.sort-options.name-asc` },
{ value: `name-desc`, label: `IT.apps.ArtistBrowser.sort-options.name-desc` },
{ value: `image-count-asc`, label: `IT.apps.ArtistBrowser.sort-options.image-count-asc` },
{ value: `image-count-desc`, label: `IT.apps.ArtistBrowser.sort-options.image-count-desc` },
];
};
_prepareListContext(ctx) {
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
if (this.filters.name && !artist.name.includes(this.filters.name)) {
continue;
};
// Populate ephemeral data for rendering
artist.id = id;
artist.imageCount = summary[id].count;
artist.commonTags = Object.values(summary[id].tags)
.sort((a, b) => b.count - a.count)
.slice(0, 5);
artists.push(artist);
};
// Paginate after filtering and sorting to give page continuity
artists.sort(compareArtists.bind(undefined, this.filters.sortBy));
const paginated = paginate(artists, this.#page, PAGE_SIZE);
ctx.artists = paginated.page;
ctx.page = this.#page;
ctx.pages = paginated.total;
ctx.has = {
prev: paginated.prev,
next: paginated.next
};
};
// #endregion Data Prep
// #region Actions
async #onFilterSubmit(event) {
event.preventDefault();
event.stopPropagation();
const data = (new FormDataExtended(event.currentTarget)).object;
this.#page = 1;
this.filters = data;
this.render({ parts: [`list`] });
};
/** @this {ArtistBrowser} */
static async #nextPage() {
this.setPage(this.#page + 1);
};
/** @this {ArtistBrowser} */
static async #prevPage() {
this.setPage(this.#page - 1);
};
// #endregion Actions
};
function compareArtists(mode, a, b) {
switch (mode) {
case `name-asc`: {
return a.name.localeCompare(b.name);
};
case `name-desc`: {
return b.name.localeCompare(a.name);
};
case `image-count-asc`: {
return a.imageCount - b.imageCount;
};
case `image-count-desc`: {
return b.imageCount - a.imageCount;
};
};
return 0;
};

View file

@ -1,8 +1,10 @@
import { __ID__, filePath } from "../consts.mjs";
import { determineFileExtension, getFile, hashFile, lastModifiedAt, uploadFile, uploadJson } from "../utils/fs.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,21 +57,26 @@ export class ImageApp extends
// #endregion Options
// #region Instance Data
#isExternal = false;
/** The artist that is being edited, or the default artist values */
_doc = { path: ``, tags: [], artists: [] };
_doc = { name: ``, path: ``, tags: [], artists: [] };
get title() {
if (this._docID) {
if (this._doc.name) {
return _loc(
`TB.apps.ImageApp.title.edit-named`,
`IT.apps.ImageApp.title.edit-named`,
{ name: this._doc.name },
);
} else {
return _loc(`TB.apps.ImageApp.title.edit-generic`);
return _loc(`IT.apps.ImageApp.title.edit-generic`);
}
};
return _loc(`TB.apps.ImageApp.title.upload`);
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,12 +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
@ -106,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);
@ -149,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;
@ -169,9 +218,9 @@ export class ImageApp extends
// #region Actions
/** @type {File | null} */
#file = null;
async #changeImage(event) {
async #changeImageInput(event) {
/** @type {File} */
const file = this.#file = event.target.files[0];
let file = this.#file = event.target.files[0];
// Prevent memory leaks
URL.revokeObjectURL(this._doc.path);
@ -179,14 +228,22 @@ export class ImageApp extends
if (!file) {
this.#file = null;
this._docID = null;
this._doc = { path: ``, artists: [], tags: [] };
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 extension = determineFileExtension(file);
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);
@ -201,9 +258,42 @@ export class ImageApp extends
// 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`] });
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} */
@ -214,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(
`TB.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;
};
@ -73,7 +75,7 @@ export function DBConnectorMixin(HandlebarsApp) {
async isAbleToSave() {
if (!game.user.can(`FILES_UPLOAD`)) {
ui.notifications.error(
`TB.notifs.error.no-upload-permission`,
`IT.notifs.error.no-upload-permission`,
{ localize: true },
);
return false;
@ -84,7 +86,7 @@ export function DBConnectorMixin(HandlebarsApp) {
const newLastModified = await lastModifiedAt(this.dbPath);
if (newLastModified !== this.#lastModified) {
ui.notifications.error(
`TB.notifs.error.db-out-of-date`,
`IT.notifs.error.db-out-of-date`,
{ localize: true },
);
return false;

4
module/config.mjs Normal file
View file

@ -0,0 +1,4 @@
export const config = CONFIG.ImageTagger = foundry.utils.deepSeal({
WEBP_QUALITY: 0.5,
WEBP_IGNORE: [`image/gif`],
});

View file

@ -1,4 +1,4 @@
export const __ID__ = `token-browser`;
export const __ID__ = `image-tagger`;
/**
* @param {string} path

View file

@ -3,6 +3,6 @@ import { options } from "./options.mjs";
export default {
// MARK: Complex
"tb-options": options,
"tb-filePath": filePath,
"it-options": options,
"it-filePath": filePath,
};

View file

@ -0,0 +1,25 @@
import { ArtBrowser } from "../apps/ArtBrowser.mjs";
import { __ID__ } from "../consts.mjs";
Hooks.on(`getHeaderControlsDocumentSheetV2`, (sheet) => {
const original = sheet.options.actions.editImage;
sheet.options.actions.editImage = async (event, target) => {
if (!game.settings.get(__ID__, `openForEditImage`)) {
return original.call(sheet, event, target);
};
if (target.nodeName !== `IMG`) {
throw new Error(`The editImage action is available only for IMG elements.`);
};
const src = await ArtBrowser.select(1);
if (!src) { return };
target.src = src;
if (sheet.options.form.submitOnChange) {
const submit = new Event(`submit`, { cancelable: true });
sheet.form.dispatchEvent(submit);
};
};
});

View file

@ -1,13 +1,14 @@
import { api } from "../api.mjs";
import { ArtSidebar } from "../apps/ArtSidebar.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs";
import { __ID__ } from "../consts.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
import { registerUserSettings } from "../settings/user.mjs";
Hooks.on(`init`, () => {
globalThis.tb = api;
Handlebars.registerHelper(helpers);
registerUserSettings();
registerCustomComponents();
Handlebars.registerHelper(helpers);
// Art sidebar tab
CONFIG.ui.sidebar.TABS.art = {
@ -21,4 +22,6 @@ Hooks.on(`init`, () => {
const temp = CONFIG.ui.sidebar.TABS.settings;
delete CONFIG.ui.sidebar.TABS.settings;
CONFIG.ui.sidebar.TABS.settings = temp;
game.modules.get(__ID__).api = api;
});

View file

@ -0,0 +1,44 @@
import { ArtBrowser } from "../apps/ArtBrowser.mjs";
/*
This hook handles adding the button for selecting an image using this module
into the prototype token config and the token-specific config. Handling all of
the relevant locations in both apps with a single hook.
*/
Hooks.on(`renderTokenApplication`, (app, form) => {
const button = document.createElement(`button`);
button.type = `button`;
button.classList = `icon fa-solid fa-paintbrush`;
/** @type {HTMLElement | undefined} */
const textureSource = form.querySelector(`file-picker[name="texture.src"]`);
if (textureSource) {
const cloned = button.cloneNode(true);
cloned.addEventListener(`click`, async () => {
const src = await ArtBrowser.select(1);
if (!src) return
textureSource.value = src;
});
textureSource.insertAdjacentElement(`afterend`, cloned);
};
/** @type {HTMLElement | undefined} */
const turnMarkerSource = form.querySelector(`file-picker[name="turnMarker.src"]`);
if (turnMarkerSource) {
const cloned = button.cloneNode(true);
cloned.disabled = turnMarkerSource.disabled;
cloned.addEventListener(`click`, async () => {
const src = await ArtBrowser.select(1);
if (!src) return
turnMarkerSource.value = src;
});
// Ensure that the disabled state stays in sync
const observer = new MutationObserver(() => {
cloned.disabled = turnMarkerSource.disabled;
});
observer.observe(turnMarkerSource, { attributeFilter: [`disabled`] });
turnMarkerSource.insertAdjacentElement(`afterend`, cloned);
};
});

4
module/image-tagger.mjs Normal file
View file

@ -0,0 +1,4 @@
import "./hooks/init.mjs";
import "./hooks/ready.mjs";
import "./hooks/renderTokenApplication.mjs";
import "./hooks/getHeaderControlsDocumentSheetV2.mjs";

12
module/settings/user.mjs Normal file
View file

@ -0,0 +1,12 @@
import { __ID__ } from "../consts.mjs";
export function registerUserSettings() {
game.settings.register(__ID__, `openForEditImage`, {
name: `IT.settings.openForEditImage.name`,
hint: `IT.settings.openForEditImage.hint`,
scope: `user`,
type: Boolean,
default: true,
config: true,
});
};

View file

@ -1,2 +0,0 @@
import "./hooks/init.mjs";
import "./hooks/ready.mjs";

View file

@ -1,3 +1,4 @@
import { config } from "../config.mjs";
import { __ID__, devMode, filePath } from "../consts.mjs";
const { fetchJsonWithTimeout } = foundry.utils;
@ -28,6 +29,42 @@ export async function hashFile(file) {
.join(``);
};
/**
* Converts an image file into a webp file unless it is already a webp file or
* cannot be converted to a webp, in which case it doesn't modify the file at all.
*
* @param {File} file The file to convert
* @returns {Promise<File>}
*/
export async function convertToWebp(file) {
if (file.type === `image/webp`) { return null };
if (config.WEBP_IGNORE.includes(file.type)) { return null };
/** @type {HTMLImageElement} */
const image = document.createElement(`img`);
const url = URL.createObjectURL(file);
image.src = url;
await image.decode();
/** @type {HTMLCanvasElement} */
const canvas = document.createElement(`canvas`);
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext(`2d`).drawImage(image, 0, 0);
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
const name = file.name.split(`.`).slice(0, -1).join(`.`);
const webp = new File([blob], `${name}.webp`, { type: blob.type });
resolve(webp);
},
`image/webp`,
config.WEBP_QUALITY,
);
});
};
export async function lastModifiedAt(path) {
try {
const response = await fetch(filePath(path), { method: `HEAD` });
@ -37,6 +74,30 @@ export async function lastModifiedAt(path) {
};
};
export async function getFileSize(path) {
try {
const response = await fetch(filePath(path), { method: `HEAD` });
const bytes = response.headers.get(`Content-Length`);
if (!bytes) { return null }
// round the non-bytes to 2 decimal places
const kilobytes = Math.floor(bytes / 10) / 100;
const megabytes = Math.floor(bytes / 10_000) / 100;
// determine the most appropriate unit to use
let friendly = `${bytes} bytes`;
if (megabytes > 0.25) {
friendly = `${megabytes}MB`;
} else if (kilobytes > 0) {
friendly = `${kilobytes}KB`;
};
return { bytes, kilobytes, megabytes, friendly };
} catch {
return null;
};
};
export async function getFile(path) {
try {
return fetchJsonWithTimeout(filePath(path));
@ -86,6 +147,7 @@ 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;

View file

@ -1,5 +1,8 @@
import { filePath } from "../consts.mjs";
export function imagePath(image) {
if (image.external) {
return image.path;
};
return filePath(image.path);
};

95
package-lock.json generated
View file

@ -8,6 +8,7 @@
"@eslint/js": "^9.8.0",
"@stylistic/eslint-plugin": "^2.6.1",
"eslint": "^9.8.0",
"glob": "^13.0.0",
"globals": "^15.9.0",
"scripts": "file:./scripts"
}
@ -291,6 +292,29 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@seald-io/binary-search-tree": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@seald-io/binary-search-tree/-/binary-search-tree-1.0.3.tgz",
@ -1423,6 +1447,24 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -1436,6 +1478,22 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@ -1873,6 +1931,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1922,6 +1990,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
@ -2074,6 +2152,23 @@
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",

View file

@ -4,7 +4,8 @@
"@stylistic/eslint-plugin": "^2.6.1",
"eslint": "^9.8.0",
"globals": "^15.9.0",
"scripts": "file:./scripts"
"scripts": "file:./scripts",
"glob": "^13.0.0"
},
"scripts": {
"link": "node scripts/src/linkFoundry.mjs",

@ -1 +1 @@
Subproject commit 06fb33b35ff446dee613afe271b6fe2ff976735a
Subproject commit 0065967ad7f086c63c07d28d834bd7bdf31ccbd5

View file

@ -1,41 +1,9 @@
.token-browser.ArtBrowser {
> .window-content {
display: grid;
grid-template-columns: 175px auto;
gap: 0;
}
form {
--spacing: 12px;
border-right: var(--sidebar-separator);
margin-right: var(--spacing);
padding-right: var(--spacing);
}
.image-list {
--size: 100px;
list-style-type: none;
display: grid;
grid-template-columns: repeat(4, var(--size));
gap: 8px;
padding: 0;
margin: 0;
max-height: 450px;
overflow-y: auto;
}
.image-tagger.ArtBrowser {
.image {
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 4px;
overflow: hidden;
position: relative;
transition: all 100ms ease-in-out;
&:hover, &:has(input[type="checkbox"]:checked) {
background: var(--color-warm-3);
}
img {
width: 100%;
@ -59,14 +27,4 @@
position: absolute;
}
}
.paginated {
display: flex;
flex-direction: column;
gap: 6px; /* Due to the grow element, this actually means 12px */
}
.page-nav {
justify-content: center;
}
}

View file

@ -1,4 +1,4 @@
.token-browser.ArtSidebar {
.image-tagger.ArtSidebar {
flex-flow: column;
gap: 1.5rem;
padding: 1rem;
@ -16,4 +16,11 @@
h4 {
margin: 0;
}
.subtitle {
margin: 0;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-subtitle);
}
}

View file

@ -1,4 +1,4 @@
.token-browser.ArtistApp {
.image-tagger.ArtistApp {
ul li button {
padding: 0;
border: none;

View file

@ -0,0 +1,21 @@
.image-tagger.ArtistBrowser {
.artist {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--color-cool-4);
padding: 8px;
}
h2, h3 {
font-family: "Signika", "Palatino Linotype", sans-serif;
margin: 0;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
}

View file

@ -1,10 +1,14 @@
.token-browser.ImageApp {
.image-tagger.ImageApp {
> .window-content {
display: grid;
grid-template-columns: 200px auto;
grid-template-rows: auto auto;
}
file-picker {
flex-grow: 1;
}
header, footer {
grid-column: 1 / -1;
}

View file

@ -1,4 +1,4 @@
.token-browser {
.image-tagger {
> .window-content {
display: flex;
flex-direction: column;

View file

@ -0,0 +1,58 @@
.image-tagger.data-browser {
min-height: 350px;
min-width: 630px;
> .window-content {
display: grid;
grid-template-columns: 175px auto;
gap: 0;
}
.filters {
--spacing: 12px;
border-right: var(--sidebar-separator);
margin-right: var(--spacing);
padding-right: var(--spacing);
}
.entry-list {
--size: 100px;
list-style-type: none;
gap: 8px;
padding: 0;
margin: 0;
max-height: 450px;
overflow-y: auto;
&.grid {
display: grid;
grid-template-columns: repeat(4, var(--size));
};
&.list {
display: flex;
flex-direction: column;
}
}
.entry {
transition: all 100ms ease-in-out;
border-radius: 4px;
&:hover,
&[data-selected="true"]
&:has(input[type="checkbox"]:checked) {
background: var(--color-warm-3);
}
}
.paginated {
display: flex;
flex-direction: column;
gap: 6px; /* Due to the grow element, this actually means 12px */
}
.page-nav {
justify-content: center;
}
}

View file

@ -1,3 +1,3 @@
.token-browser > .window-content input[type="checkbox"] {
.image-tagger > .window-content input[type="checkbox"] {
--checkbox-checked-color: var(--color-level-success-border);
}

View file

@ -1,4 +1,4 @@
.token-browser > .window-content {
.image-tagger > .window-content {
ul {
margin: 0;
padding: 0;

View file

@ -1,38 +0,0 @@
.token-browser > .window-content {
.radio-group {
display: flex;
flex-direction: row;
input[type="radio"] {
width: 0;
height: 0;
margin: 0;
&::before, &::after {
display: none;
}
}
label {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--color-light-5);
cursor: pointer;
height: 28px;
min-width: 28px;
&:first-child {
border-radius: 4px 0 0 4px;
}
&:last-child {
border-radius: 0 4px 4px 0;
}
&:has(:checked) {
background: var(--color-cool-4);
}
}
}
}

View file

@ -1,4 +1,4 @@
.token-browser > .window-content {
.image-tagger > .window-content {
.row {
display: flex;
flex-direction: row;

View file

@ -8,11 +8,12 @@
@import url("./elements/utils.css") layer(elements);
@import url("./elements/checkbox.css") layer(elements);
@import url("./elements/lists.css") layer(elements);
@import url("./elements/radio.css") layer(elements);
/* Apps */
@import url("./apps/common.css") layer(apps);
@import url("./apps/data-browser.css") layer(apps);
@import url("./apps/ArtBrowser.css") layer(apps);
@import url("./apps/ArtistBrowser.css") layer(apps);
@import url("./apps/ArtistApp.css") layer(apps);
@import url("./apps/ArtSidebar.css") layer(apps);
@import url("./apps/ImageApp.css") layer(apps);

View file

@ -1,4 +1,4 @@
.token-browser > .window-content hr {
.image-tagger > .window-content hr {
all: initial;
display: block;
width: 100%;

View file

@ -1,4 +1,4 @@
.token-browser > .window-content {
.image-tagger > .window-content {
li {
margin: 0;
}

View file

@ -1,21 +1,21 @@
<li
class="image grid"
class="entry image grid"
data-image-id="{{image.id}}"
>
<img
src="{{tb-filePath image.path}}"
src="{{image.path}}"
alt=""
>
{{#if is.single}}
<button
data-action="select"
>
Select
{{localize "IT.common.select"}}
</button>
{{else if is.multi}}
<input
type="checkbox"
aria-label="Select image"
aria-label="IT.apps.ArtBrowser.select-image"
data-action="select"
{{checked image.selected}}
>

View file

@ -1,6 +1,6 @@
<li class="image">
<img
src="{{tb-filePath image.path}}"
src="{{it-filePath image.path}}"
alt=""
>
{{#if (eq selectMode "single")}}

View file

@ -1,33 +1,9 @@
<div class="paginated">
<div class="row">
<div class="radio-group">
<label data-tooltip="Grid layout">
<input
type="radio"
name="layoutType"
value="grid"
{{checked (eq layout "grid")}}
>
<it-icon
name="icons/layout/grid"
var:fill="currentColor"
></it-icon>
</label>
<label data-tooltip="List layout">
<input
type="radio"
name="layoutType"
value="list"
{{checked (eq layout "list")}}
>
<it-icon
name="icons/layout/list"
var:fill="currentColor"
></it-icon>
</label>
</div>
{{#if can.upload}}
<button data-action="uploadImage">Upload Image</button>
<button data-action="uploadImage">
{{localize "IT.apps.ArtBrowser.upload-image"}}
</button>
{{/if}}
{{#if is.multi}}
<div class="grow"></div>
@ -35,10 +11,10 @@
{{/if}}
</div>
{{#if images}}
<ul class="image-list image-list--{{listLayout}}">
<ul class="entry-list grid">
{{#each images as | image |}}
{{>
(concat (tb-filePath "templates/ArtBrowser/image/") @root.layout ".hbs")
(it-filePath "templates/ArtBrowser/image/grid.hbs")
image=image
is=@root.is
}}
@ -46,7 +22,7 @@
</ul>
{{else}}
<span class="placeholder">
{{ localize "" }}
{{ localize "IT.apps.ArtBrowser.no-results" }}
</span>
{{/if}}
<div class="grow"></div>
@ -55,14 +31,14 @@
data-action="prevPage"
{{disabled (not has.prev)}}
>
Prev
{{localize "IT.common.page.previous"}}
</button>
{{page}} / {{pages}}
<button
data-action="nextPage"
{{disabled (not has.next)}}
>
Next
{{localize "IT.common.page.next"}}
</button>
</div>
</div>

View file

@ -1,6 +1,12 @@
<form autocomplete="off" class="filters">
<p>
{{localize "IT.common.page-reset-warning"}}
</p>
<hr>
<label for="{{meta.idp}}-name">
Name
{{localize "IT.common.name"}}
</label>
<input
id="{{meta.idp}}-name"
@ -12,7 +18,7 @@
<hr>
<label for="{{meta.idp}}-tags">
Tags
{{localize "IT.common.tags"}}
</label>
<string-tags
id="{{meta.idp}}-tags"
@ -23,12 +29,12 @@
<hr>
<label for="{{meta.idp}}-artists">
Artists
{{localize "IT.common.artists"}}
</label>
<multi-select
id="{{meta.idp}}-artists"
name="artists"
>
{{ tb-options artists artistList }}
{{ it-options artists artistList }}
</multi-select>
</form>

View file

@ -1,17 +1,24 @@
<section>
<h4 class="divider">Artists</h4>
<div>
<h4 class="divider">{{ localize "IT.common.artists" }}</h4>
<p class="subtitle">
{{ localize "IT.apps.ArtSidebar.db-size" size=size }}
</p>
</div>
<button
type="button"
data-action="openApp"
data-app="ArtistList"
data-app="ArtistBrowser"
>
View All
</button>
<button
type="button"
data-action="openApp"
data-app="ArtistApp"
>
Add New
{{ localize "IT.apps.ArtSidebar.view" }}
</button>
{{#if can.upload}}
<button
type="button"
data-action="openApp"
data-app="ArtistApp"
>
{{ localize "IT.apps.ArtSidebar.add-new" }}
</button>
{{/if}}
</section>

View file

@ -1,17 +1,24 @@
<section>
<h4 class="divider">Tokens</h4>
<div>
<h4 class="divider">{{ localize "IT.common.images" }}</h4>
<p class="subtitle">
{{ localize "IT.apps.ArtSidebar.db-size" size=size }}
</p>
</div>
<button
type="button"
data-action="openApp"
data-app="ArtBrowser"
>
View All
</button>
<button
type="button"
data-action="openApp"
data-app="ImageApp"
>
Add New
{{ localize "IT.apps.ArtSidebar.view" }}
</button>
{{#if can.upload}}
<button
type="button"
data-action="openApp"
data-app="ImageApp"
>
{{ localize "IT.apps.ArtSidebar.add-new" }}
</button>
{{/if}}
</section>

View file

@ -1,7 +1,7 @@
<div>
<div class="row">
<label for="{{meta.idp}}-name">
Name
{{localize "IT.common.name"}}
</label>
<div class="grow"></div>
<input

View file

@ -1,11 +1,13 @@
<div>
<div class="row">
<span class="label">Links</span>
<span class="label">
{{localize "IT.common.links"}}
</span>
<div class="grow"></div>
<button
type="button"
aria-label="{{localize 'TB.apps.ArtistApp.add-link'}}"
data-tooltip="TB.apps.ArtistApp.add-link"
aria-label="{{localize 'IT.apps.ArtistApp.add-link'}}"
data-tooltip="IT.apps.ArtistApp.add-link"
data-action="addNewLink"
>
+
@ -16,7 +18,7 @@
<li
data-index="{{@key}}"
{{#if link.isNew}}
data-tooltip="TB.common.unsaved"
data-tooltip="IT.common.unsaved"
{{/if}}
>
<a
@ -27,7 +29,7 @@
{{link.name}}
</a>
{{#if link.isNew}}
<div class="large" aria-label="{{localize 'TB.common.unsaved'}}">
<div class="large" aria-label="{{localize 'IT.common.unsaved'}}">
!
</div>
{{/if}}

View file

@ -0,0 +1,28 @@
<div class="entry artist">
<div class="row">
<h2>{{artist.name}}</h2>
<div class="grow"></div>
<div>
{{localize "IT.apps.ArtistBrowser.image-count" count=artist.imageCount}}
</div>
</div>
{{#if artist.links}}
<ul class="chip-list">
{{#each artist.links as |link|}}
<li class="chip">
<a href="{{link.url}}">{{link.name}}</a>
</li>
{{/each}}
</ul>
{{/if}}
{{#if artist.commonTags}}
<section>
<h3>{{localize "IT.apps.ArtistBrowser.common-tags"}}</h3>
<ul class="chip-list">
{{#each artist.commonTags as |tag|}}
<li class="chip">{{tag.name}} ({{tag.count}})</li>
{{/each}}
</ul>
</section>
{{/if}}
</div>

View file

@ -0,0 +1,36 @@
<div class="paginated">
<div class="row">
{{#if can.upload}}
<button data-action="createArtist">
{{localize "IT.apps.ArtistBrowser.create-artist"}}
</button>
{{/if}}
</div>
{{#if artists}}
<ul class="entry-list list">
{{#each artists as | artist |}}
{{> (it-filePath "templates/ArtistBrowser/artist.hbs") artist=artist }}
{{/each}}
</ul>
{{else}}
<span class="placeholder">
{{ localize "" }}
</span>
{{/if}}
<div class="grow"></div>
<div class="row page-nav">
<button
data-action="prevPage"
{{disabled (not has.prev)}}
>
Prev
</button>
{{page}} / {{pages}}
<button
data-action="nextPage"
{{disabled (not has.next)}}
>
Next
</button>
</div>
</div>

View file

@ -0,0 +1,26 @@
<form autocomplete="off" class="filters">
<p>
{{localize "IT.common.page-reset-warning"}}
</p>
<hr>
<label for="{{meta.idp}}-name">
{{localize "IT.common.name"}}
</label>
<input
id="{{meta.idp}}-name"
type="text"
name="name"
value="{{name}}"
>
<hr>
<label for="{{meta.idp}}-sort">
{{localize "IT.common.sort-by"}}
</label>
<select name="sortBy" id="{{meta.idp}}-sort">
{{it-options sortBy sortOptions localize=true}}
</select>
</form>

View file

@ -1,6 +1,6 @@
<div class="form-group">
<label for="{{idp}}-name">
{{localize "TB.dialogs.Link.name"}}
{{localize "IT.dialogs.Link.name"}}
</label>
<input
id="{{idp}}-name"
@ -11,7 +11,7 @@
</div>
<div class="form-group">
<label for="{{idp}}-url">
{{localize "TB.dialogs.Link.url"}}
{{localize "IT.dialogs.Link.url"}}
</label>
<input
id="{{idp}}-url"

View file

@ -1,6 +1,6 @@
<div class="inputs">
<label for="{{meta.idp}}-name">
Name (Optional)
{{localize "IT.common.name"}}
</label>
<input
id="{{meta.idp}}-name"
@ -10,7 +10,7 @@
>
<label for="{{meta.idp}}-tags">
Tags (Optional)
{{localize "IT.common.tags"}}
</label>
<string-tags
id="{{meta.idp}}-tags"
@ -19,12 +19,12 @@
></string-tags>
<label for="{{meta.idp}}-artists">
Artists (Optional)
{{localize "IT.common.artists"}}
</label>
<multi-select
id="{{meta.idp}}-artists"
name="artists"
>
{{ tb-options image.artists artists }}
{{ it-options image.artists artists }}
</multi-select>
</div>

View file

@ -1,29 +1,33 @@
<header>
<div class="row">
<label for="{{meta.idp}}-image">
Image
</label>
{{#if docID}}
<input
type="text"
id="{{meta.idp}}-image"
value="{{imageURL}}"
disabled
readonly
>
<button
type="button"
data-action="removeEditingImage"
>
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>

View file

@ -6,7 +6,7 @@
>
{{else}}
<span class="placeholder">
Select an image to see the preview
{{ localize "IT.apps.ImageApp.preview-placeholder" }}
</span>
{{/if}}
</div>