Files
2025-01-04 00:34:03 +01:00

1082 lines
37 KiB
JavaScript

/**
* @typedef {ApplicationOptions} FilePickerOptions
* @property {"image"|"audio"|"video"|"text"|"imagevideo"|"font"|"folder"|"any"} [type="any"] A type of file to target
* @property {string} [current] The current file path being modified, if any
* @property {string} [activeSource=data] A current file source in "data", "public", or "s3"
* @property {Function} [callback] A callback function to trigger once a file has been selected
* @property {boolean} [allowUpload=true] A flag which permits explicitly disallowing upload, true by default
* @property {HTMLElement} [field] An HTML form field that the result of this selection is applied to
* @property {HTMLButtonElement} [button] An HTML button element which triggers the display of this picker
* @property {Record<string, FavoriteFolder>} [favorites] The picker display mode in FilePicker.DISPLAY_MODES
* @property {string} [displayMode] The picker display mode in FilePicker.DISPLAY_MODES
* @property {boolean} [tileSize=false] Display the tile size configuration.
* @property {string[]} [redirectToRoot] Redirect to the root directory rather than starting in the source directory
* of one of these files.
*/
/**
* The FilePicker application renders contents of the server-side public directory.
* This app allows for navigating and uploading files to the public path.
*
* @param {FilePickerOptions} [options={}] Options that configure the behavior of the FilePicker
*/
class FilePicker extends Application {
constructor(options={}) {
super(options);
/**
* The full requested path given by the user
* @type {string}
*/
this.request = options.current;
/**
* The file sources which are available for browsing
* @type {object}
*/
this.sources = Object.entries({
data: {
target: "",
label: game.i18n.localize("FILES.SourceUser"),
icon: "fas fa-database"
},
public: {
target: "",
label: game.i18n.localize("FILES.SourceCore"),
icon: "fas fa-server"
},
s3: {
buckets: [],
bucket: "",
target: "",
label: game.i18n.localize("FILES.SourceS3"),
icon: "fas fa-cloud"
}
}).reduce((obj, s) => {
if ( game.data.files.storages.includes(s[0]) ) obj[s[0]] = s[1];
return obj;
}, {});
/**
* Track the active source tab which is being browsed
* @type {string}
*/
this.activeSource = options.activeSource || "data";
/**
* A callback function to trigger once a file has been selected
* @type {Function}
*/
this.callback = options.callback;
/**
* The latest set of results browsed from the server
* @type {object}
*/
this.results = {};
/**
* The general file type which controls the set of extensions which will be accepted
* @type {string}
*/
this.type = options.type ?? "any";
/**
* The target HTML element this file picker is bound to
* @type {HTMLElement}
*/
this.field = options.field;
/**
* A button which controls the display of the picker UI
* @type {HTMLElement}
*/
this.button = options.button;
/**
* The display mode of the FilePicker UI
* @type {string}
*/
this.displayMode = options.displayMode || this.constructor.LAST_DISPLAY_MODE;
/**
* The current set of file extensions which are being filtered upon
* @type {string[]}
*/
this.extensions = FilePicker.#getExtensions(this.type);
// Infer the source
const [source, target] = this._inferCurrentDirectory(this.request);
this.activeSource = source;
this.sources[source].target = target;
// Track whether we have loaded files
this._loaded = false;
}
/**
* The allowed values for the type of this FilePicker instance.
* @type {string[]}
*/
static FILE_TYPES = ["image", "audio", "video", "text", "imagevideo", "font", "folder", "any"];
/**
* Record the last-browsed directory path so that re-opening a different FilePicker instance uses the same target
* @type {string}
*/
static LAST_BROWSED_DIRECTORY = "";
/**
* Record the last-configured tile size which can automatically be applied to new FilePicker instances
* @type {number|null}
*/
static LAST_TILE_SIZE = null;
/**
* Record the last-configured display mode so that re-opening a different FilePicker instance uses the same mode.
* @type {string}
*/
static LAST_DISPLAY_MODE = "list";
/**
* Enumerate the allowed FilePicker display modes
* @type {string[]}
*/
static DISPLAY_MODES = ["list", "thumbs", "tiles", "images"];
/**
* Cache the names of S3 buckets which can be used
* @type {Array|null}
*/
static S3_BUCKETS = null;
/**
* @typedef FavoriteFolder
* @property {string} source The source of the folder (e.g. "data", "public")
* @property {string} path The full path to the folder
* @property {string} label The label for the path
*/
/**
* Get favorite folders for quick access
* @type {Record<string, FavoriteFolder>}
*/
static get favorites() {
return game.settings.get("core", "favoritePaths");
}
/* -------------------------------------------- */
/**
* Add the given path for the source to the favorites
* @param {string} source The source of the folder (e.g. "data", "public")
* @param {string} path The path to a folder
* @returns {Promise<void>}
*/
static async setFavorite(source, path ) {
const favorites = foundry.utils.deepClone(this.favorites);
// Standardize all paths to end with a "/".
// Has the side benefit of ensuring that the root path which is normally an empty string has content.
path = path.endsWith("/") ? path : `${path}/`;
const alreadyFavorite = Object.keys(favorites).includes(`${source}-${path}`);
if ( alreadyFavorite ) return ui.notifications.info(game.i18n.format("FILES.AlreadyFavorited", {path}));
let label;
if ( path === "/" ) label = "root";
else {
const directories = path.split("/");
label = directories[directories.length - 2]; // Get the final part of the path for the label
}
favorites[`${source}-${path}`] = {source, path, label};
await game.settings.set("core", "favoritePaths", favorites);
}
/* -------------------------------------------- */
/**
* Remove the given path from the favorites
* @param {string} source The source of the folder (e.g. "data", "public")
* @param {string} path The path to a folder
* @returns {Promise<void>}
*/
static async removeFavorite(source, path) {
const favorites = foundry.utils.deepClone(this.favorites);
delete favorites[`${source}-${path}`];
await game.settings.set("core", "favoritePaths", favorites);
}
/* -------------------------------------------- */
/**
* @override
* @returns {FilePickerOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/apps/filepicker.html",
classes: ["filepicker"],
width: 546,
tabs: [{navSelector: ".tabs"}],
dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}],
tileSize: false,
filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".filepicker-body"}]
});
}
/* -------------------------------------------- */
/**
* Given a current file path, determine the directory it belongs to
* @param {string} target The currently requested target path
* @returns {string[]} An array of the inferred source and target directory path
*/
_inferCurrentDirectory(target) {
// Determine target
const ignored = [CONST.DEFAULT_TOKEN].concat(this.options.redirectToRoot ?? []);
if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY;
let source = "data";
// Check for s3 matches
const s3Match = this.constructor.matchS3URL(target);
if ( s3Match ) {
this.sources.s3.bucket = s3Match.groups.bucket;
source = "s3";
target = s3Match.groups.key;
}
// Non-s3 URL matches
else if ( ["http://", "https://"].some(c => target.startsWith(c)) ) target = "";
// Local file matches
else {
const p0 = target.split("/").shift();
const publicDirs = ["cards", "css", "fonts", "icons", "lang", "scripts", "sounds", "ui"];
if ( publicDirs.includes(p0) ) source = "public";
}
// If the preferred source is not available, use the next available source.
if ( !this.sources[source] ) {
source = game.data.files.storages[0];
// If that happens to be S3, pick the first available bucket.
if ( source === "s3" ) {
this.sources.s3.bucket = game.data.files.s3.buckets?.[0] ?? null;
target = "";
}
}
// Split off the file name and retrieve just the directory path
let parts = target.split("/");
if ( parts[parts.length - 1].indexOf(".") !== -1 ) parts.pop();
const dir = parts.join("/");
return [source, dir];
}
/* -------------------------------------------- */
/**
* Get the valid file extensions for a given named file picker type
* @param {string} type
* @returns {string[]}
*/
static #getExtensions(type) {
// Identify allowed extensions
let types = [
CONST.IMAGE_FILE_EXTENSIONS,
CONST.AUDIO_FILE_EXTENSIONS,
CONST.VIDEO_FILE_EXTENSIONS,
CONST.TEXT_FILE_EXTENSIONS,
CONST.FONT_FILE_EXTENSIONS,
CONST.GRAPHICS_FILE_EXTENSIONS
].flatMap(extensions => Object.keys(extensions));
if ( type === "folder" ) types = [];
else if ( type === "font" ) types = Object.keys(CONST.FONT_FILE_EXTENSIONS);
else if ( type === "text" ) types = Object.keys(CONST.TEXT_FILE_EXTENSIONS);
else if ( type === "graphics" ) types = Object.keys(CONST.GRAPHICS_FILE_EXTENSIONS);
else if ( type === "image" ) types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
else if ( type === "audio" ) types = Object.keys(CONST.AUDIO_FILE_EXTENSIONS);
else if ( type === "video" ) types = Object.keys(CONST.VIDEO_FILE_EXTENSIONS);
else if ( type === "imagevideo") {
types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS).concat(Object.keys(CONST.VIDEO_FILE_EXTENSIONS));
}
return types.map(t => `.${t.toLowerCase()}`);
}
/* -------------------------------------------- */
/**
* Test a URL to see if it matches a well known s3 key pattern
* @param {string} url An input URL to test
* @returns {RegExpMatchArray|null} A regular expression match
*/
static matchS3URL(url) {
const endpoint = game.data.files.s3?.endpoint;
if ( !endpoint ) return null;
// Match new style S3 urls
const s3New = new RegExp(`^${endpoint.protocol}//(?<bucket>.*).${endpoint.host}/(?<key>.*)`);
const matchNew = url.match(s3New);
if ( matchNew ) return matchNew;
// Match old style S3 urls
const s3Old = new RegExp(`^${endpoint.protocol}//${endpoint.host}/(?<bucket>[^/]+)/(?<key>.*)`);
return url.match(s3Old);
}
/* -------------------------------------------- */
/* FilePicker Properties */
/* -------------------------------------------- */
/** @override */
get title() {
let type = this.type || "file";
return game.i18n.localize(type === "imagevideo" ? "FILES.TitleImageVideo" : `FILES.Title${type.capitalize()}`);
}
/* -------------------------------------------- */
/**
* Return the source object for the currently active source
* @type {object}
*/
get source() {
return this.sources[this.activeSource];
}
/* -------------------------------------------- */
/**
* Return the target directory for the currently active source
* @type {string}
*/
get target() {
return this.source.target;
}
/* -------------------------------------------- */
/**
* Return a flag for whether the current user is able to upload file content
* @type {boolean}
*/
get canUpload() {
if ( this.type === "folder" ) return false;
if ( this.options.allowUpload === false ) return false;
if ( !["data", "s3"].includes(this.activeSource) ) return false;
return !game.user || game.user.can("FILES_UPLOAD");
}
/* -------------------------------------------- */
/**
* Return the upload URL to which the FilePicker should post uploaded files
* @type {string}
*/
static get uploadURL() {
return foundry.utils.getRoute("upload");
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
async getData(options={}) {
const result = this.result;
const source = this.source;
let target = decodeURIComponent(source.target);
const isS3 = this.activeSource === "s3";
// Sort directories alphabetically and store their paths
let dirs = result.dirs.map(d => ({
name: decodeURIComponent(d.split("/").pop()),
path: d,
private: result.private || result.privateDirs.includes(d)
}));
dirs = dirs.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
// Sort files alphabetically and store their client URLs
let files = result.files.map(f => {
let img = f;
if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg";
else if ( foundry.audio.AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg";
else if ( !ImageHelper.hasImageExtension(f) ) img = "icons/svg/book.svg";
return {
name: decodeURIComponent(f.split("/").pop()),
url: f,
img: img
};
});
files = files.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
// Return rendering data
return {
bucket: isS3 ? source.bucket : null,
buckets: isS3 ? source.buckets.map(b => ({ value: b, label: b })) : null,
canGoBack: this.activeSource !== "",
canUpload: this.canUpload,
canSelect: !this.options.tileSize,
cssClass: [this.displayMode, result.private ? "private": "public"].join(" "),
dirs: dirs,
displayMode: this.displayMode,
extensions: this.extensions,
files: files,
isS3: isS3,
noResults: dirs.length + files.length === 0,
selected: this.type === "folder" ? target : this.request,
source: source,
sources: this.sources,
target: target,
tileSize: this.options.tileSize ? (this.constructor.LAST_TILE_SIZE || canvas.dimensions.size) : null,
user: game.user,
submitText: this.type === "folder" ? "FILES.SelectFolder" : "FILES.SelectFile",
favorites: this.constructor.favorites
};
}
/* -------------------------------------------- */
/** @inheritDoc */
setPosition(pos={}) {
const currentPosition = super.setPosition(pos);
const element = this.element[0];
const content = element.querySelector(".window-content");
const lists = element.querySelectorAll(".filepicker-body > ol");
const scroll = content.scrollHeight - content.offsetHeight;
if ( (scroll > 0) && lists.length ) {
let maxHeight = Number(getComputedStyle(lists[0]).maxHeight.slice(0, -2));
maxHeight -= Math.ceil(scroll / lists.length);
lists.forEach(list => list.style.maxHeight = `${maxHeight}px`);
}
return currentPosition;
}
/* -------------------------------------------- */
/**
* Browse to a specific location for this FilePicker instance
* @param {string} [target] The target within the currently active source location.
* @param {object} [options] Browsing options
*/
async browse(target, options={}) {
// If the user does not have permission to browse, do not proceed
if ( game.world && !game.user.can("FILES_BROWSE") ) return;
// Configure browsing parameters
target = typeof target === "string" ? target : this.target;
const source = this.activeSource;
options = foundry.utils.mergeObject({
type: this.type,
extensions: this.extensions,
wildcard: false
}, options);
// Determine the S3 buckets which may be used
if ( source === "s3" ) {
if ( this.constructor.S3_BUCKETS === null ) {
const buckets = await this.constructor.browse("s3", "");
this.constructor.S3_BUCKETS = buckets.dirs;
}
this.sources.s3.buckets = this.constructor.S3_BUCKETS;
if ( !this.source.bucket ) this.source.bucket = this.constructor.S3_BUCKETS[0];
options.bucket = this.source.bucket;
}
// Avoid browsing certain paths
if ( target.startsWith("/") ) target = target.slice(1);
if ( target === CONST.DEFAULT_TOKEN ) target = this.constructor.LAST_BROWSED_DIRECTORY;
// Request files from the server
const result = await this.constructor.browse(source, target, options).catch(error => {
ui.notifications.warn(error);
return this.constructor.browse(source, "", options);
});
// Populate browser content
this.result = result;
this.source.target = result.target;
if ( source === "s3" ) this.source.bucket = result.bucket;
this.constructor.LAST_BROWSED_DIRECTORY = result.target;
this._loaded = true;
// Render the application
this.render(true);
return result;
}
/* -------------------------------------------- */
/**
* Browse files for a certain directory location
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments
* @param {string} [options.bucket] A bucket within which to search if using the S3 source
* @param {string[]} [options.extensions] An Array of file extensions to filter on
* @param {boolean} [options.wildcard] The requested dir represents a wildcard path
*
* @returns {Promise} A Promise which resolves to the directories and files contained in the location
*/
static async browse(source, target, options={}) {
const data = {action: "browseFiles", storage: source, target: target};
return FilePicker.#manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* Configure metadata settings regarding a certain file system path
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments which modify the request
* @returns {Promise<object>}
*/
static async configurePath(source, target, options={}) {
const data = {action: "configurePath", storage: source, target: target};
return FilePicker.#manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* Create a subdirectory within a given source. The requested subdirectory path must not already exist.
* @param {string} source The source location in which to browse. See FilePicker#sources for details
* @param {string} target The target within the source location
* @param {object} options Optional arguments which modify the request
* @returns {Promise<object>}
*/
static async createDirectory(source, target, options={}) {
const data = {action: "createDirectory", storage: source, target: target};
return FilePicker.#manageFiles(data, options);
}
/* -------------------------------------------- */
/**
* General dispatcher method to submit file management commands to the server
* @param {object} data Request data dispatched to the server
* @param {object} options Options dispatched to the server
* @returns {Promise<object>} The server response
*/
static async #manageFiles(data, options) {
return new Promise((resolve, reject) => {
game.socket.emit("manageFiles", data, options, result => {
if ( result.error ) return reject(new Error(result.error));
resolve(result);
});
});
}
/* -------------------------------------------- */
/**
* Dispatch a POST request to the server containing a directory path and a file to upload
* @param {string} source The data source to which the file should be uploaded
* @param {string} path The destination path
* @param {File} file The File object to upload
* @param {object} [body={}] Additional file upload options sent in the POST body
* @param {object} [options] Additional options to configure how the method behaves
* @param {boolean} [options.notify=true] Display a UI notification when the upload is processed
* @returns {Promise<object>} The response object
*/
static async upload(source, path, file, body={}, {notify=true}={}) {
// Create the form data to post
const fd = new FormData();
fd.set("source", source);
fd.set("target", path);
fd.set("upload", file);
Object.entries(body).forEach(o => fd.set(...o));
const notifications = Object.fromEntries(["ErrorSomethingWrong", "WarnUploadModules", "ErrorTooLarge"].map(key => {
const i18n = `FILES.${key}`;
return [key, game.i18n.localize(i18n)];
}));
// Dispatch the request
try {
const request = await fetch(this.uploadURL, {method: "POST", body: fd});
const response = await request.json();
// Attempt to obtain the response
if ( response.error ) {
ui.notifications.error(response.error);
return false;
} else if ( !response.path ) {
if ( notify ) ui.notifications.error(notifications.ErrorSomethingWrong);
else console.error(notifications.ErrorSomethingWrong);
return;
}
// Check for uploads to system or module directories.
const [packageType, packageId, folder] = response.path.split("/");
if ( ["modules", "systems"].includes(packageType) ) {
let pkg;
if ( packageType === "modules" ) pkg = game.modules.get(packageId);
else if ( packageId === game.system.id ) pkg = game.system;
if ( !pkg?.persistentStorage || (folder !== "storage") ) {
if ( notify ) ui.notifications.warn(notifications.WarnUploadModules);
else console.warn(notifications.WarnUploadModules);
}
}
// Display additional response messages
if ( response.message ) {
if ( notify ) ui.notifications.info(response.message);
else console.info(response.message);
}
return response;
}
catch(e) {
if ( (e instanceof foundry.utils.HttpError) && (e.code === 413) ) {
if ( notify ) ui.notifications.error(notifications.ErrorTooLarge);
else console.error(notifications.ErrorTooLarge);
return;
}
return {};
}
}
/* -------------------------------------------- */
/**
* A convenience function that uploads a file to a given package's persistent /storage/ directory
* @param {string} packageId The id of the package to which the file should be uploaded.
* Only supports Systems and Modules.
* @param {string} path The relative destination path in the package's storage directory
* @param {File} file The File object to upload
* @param {object} [body={}] Additional file upload options sent in the POST body
* @param {object} [options] Additional options to configure how the method behaves
* @param {boolean} [options.notify=true] Display a UI notification when the upload is processed
* @returns {Promise<object>} The response object
*/
static async uploadPersistent(packageId, path, file, body={}, {notify=true}={}) {
let pack = game.system.id === packageId ? game.system : game.modules.get(packageId);
if ( !pack ) throw new Error(`Package ${packageId} not found`);
if ( !pack.persistentStorage ) throw new Error(`Package ${packageId} does not have persistent storage enabled. `
+ "Set the \"persistentStorage\" flag to true in the package manifest.");
const source = "data";
const target = `${pack.type}s/${pack.id}/storage/${path}`;
return this.upload(source, target, file, body, {notify});
}
/* -------------------------------------------- */
/** @inheritDoc */
render(force, options) {
if ( game.world && !game.user.can("FILES_BROWSE") ) return this;
this.position.height = null;
this.element.css({height: ""});
this._tabs[0].active = this.activeSource;
if ( !this._loaded ) {
this.browse();
return this;
}
else return super.render(force, options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
const header = html.find("header.filepicker-header");
const form = html[0];
// Change the directory
const target = header.find('input[name="target"]');
target.on("keydown", this.#onRequestTarget.bind(this));
target[0].focus();
// Header Control Buttons
html.find(".current-dir button").click(this.#onClickDirectoryControl.bind(this));
// Change the S3 bucket
html.find('select[name="bucket"]').change(this.#onChangeBucket.bind(this));
// Change the tile size.
form.elements.tileSize?.addEventListener("change", this._onChangeTileSize.bind(this));
// Activate display mode controls
const modes = html.find(".display-modes");
modes.on("click", ".display-mode", this.#onChangeDisplayMode.bind(this));
for ( let li of modes[0].children ) {
li.classList.toggle("active", li.dataset.mode === this.displayMode);
}
// Upload new file
if ( this.canUpload ) form.upload.onchange = ev => this.#onUpload(ev);
// Directory-level actions
html.find(".directory").on("click", "li", this.#onPick.bind(this));
// Directory-level actions
html.find(".favorites").on("click", "a", this.#onClickFavorite.bind(this));
// Flag the current pick
let li = form.querySelector(`.file[data-path="${encodeURIComponent(this.request)}"]`);
if ( li ) li.classList.add("picked");
// Form submission
form.onsubmit = ev => this._onSubmit(ev);
// Intersection Observer to lazy-load images
const files = html.find(".files-list");
const observer = new IntersectionObserver(this.#onLazyLoadImages.bind(this), {root: files[0]});
files.find("li.file").each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle a click event to change the display mode of the File Picker
* @param {MouseEvent} event The triggering click event
*/
#onChangeDisplayMode(event) {
event.preventDefault();
const a = event.currentTarget;
if ( !this.constructor.DISPLAY_MODES.includes(a.dataset.mode) ) {
throw new Error("Invalid display mode requested");
}
if ( a.dataset.mode === this.displayMode ) return;
this.constructor.LAST_DISPLAY_MODE = this.displayMode = a.dataset.mode;
this.render();
}
/* -------------------------------------------- */
/** @override */
_onChangeTab(event, tabs, active) {
this.activeSource = active;
this.browse(this.source.target);
}
/* -------------------------------------------- */
/** @override */
_canDragStart(selector) {
return game.user?.isGM && (canvas.activeLayer instanceof TilesLayer);
}
/* -------------------------------------------- */
/** @override */
_canDragDrop(selector) {
return this.canUpload;
}
/* -------------------------------------------- */
/** @override */
_onDragStart(event) {
const li = event.currentTarget;
// Get the tile size ratio
const tileSize = parseInt(li.closest("form").tileSize.value) || canvas.dimensions.size;
const ratio = canvas.dimensions.size / tileSize;
// Set drag data
const dragData = {
type: "Tile",
texture: {src: li.dataset.path},
fromFilePicker: true,
tileSize
};
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
// Create the drag preview for the image
const img = li.querySelector("img");
const w = img.naturalWidth * ratio * canvas.stage.scale.x;
const h = img.naturalHeight * ratio * canvas.stage.scale.y;
const preview = DragDrop.createDragImage(img, w, h);
event.dataTransfer.setDragImage(preview, w/2, h/2);
}
/* -------------------------------------------- */
/** @override */
async _onDrop(event) {
if ( !this.canUpload ) return;
const form = event.currentTarget.closest("form");
form.disabled = true;
const target = form.target.value;
// Process the data transfer
const data = TextEditor.getDragEventData(event);
const files = event.dataTransfer.files;
if ( !files || !files.length || data.fromFilePicker ) return;
// Iterate over dropped files
for ( let upload of files ) {
const name = upload.name.toLowerCase();
try {
this.#validateExtension(name);
} catch(err) {
ui.notifications.error(err, {console: true});
continue;
}
const response = await this.constructor.upload(this.activeSource, target, upload, {
bucket: form.bucket ? form.bucket.value : null
});
if ( response ) this.request = response.path;
}
// Re-enable the form
form.disabled = false;
return this.browse(target);
}
/* -------------------------------------------- */
/**
* Validate that the extension of the uploaded file is permitted for this file-picker instance.
* This is an initial client-side test, the MIME type will be further checked by the server.
* @param {string} name The file name attempted for upload
*/
#validateExtension(name) {
const ext = `.${name.split(".").pop()}`;
if ( !this.extensions.includes(ext) ) {
const msg = game.i18n.format("FILES.ErrorDisallowedExtension", {name, ext, allowed: this.extensions.join(" ")});
throw new Error(msg);
}
}
/* -------------------------------------------- */
/**
* Handle user submission of the address bar to request an explicit target
* @param {KeyboardEvent} event The originating keydown event
*/
#onRequestTarget(event) {
if ( event.key === "Enter" ) {
event.preventDefault();
return this.browse(event.target.value);
}
}
/* -------------------------------------------- */
/**
* Handle user interaction with the favorites
* @param {PointerEvent} event The originating click event
*/
async #onClickFavorite(event) {
const action = event.currentTarget.dataset.action;
const source = event.currentTarget.dataset.source || this.activeSource;
const path = event.currentTarget.dataset.path || this.target;
switch (action) {
case "goToFavorite":
this.activeSource = source;
await this.browse(path);
break;
case "setFavorite":
await this.constructor.setFavorite(source, path);
break;
case "removeFavorite":
await this.constructor.removeFavorite(source, path);
break;
}
this.render(true);
}
/* -------------------------------------------- */
/**
* Handle requests from the IntersectionObserver to lazily load an image file
* @param {...any} args
*/
#onLazyLoadImages(...args) {
if ( this.displayMode === "list" ) return;
return SidebarTab.prototype._onLazyLoadImage.call(this, ...args);
}
/* -------------------------------------------- */
/**
* Handle file or folder selection within the file picker
* @param {Event} event The originating click event
*/
#onPick(event) {
const li = event.currentTarget;
const form = li.closest("form");
if ( li.classList.contains("dir") ) return this.browse(li.dataset.path);
for ( let l of li.parentElement.children ) {
l.classList.toggle("picked", l === li);
}
if ( form.file ) form.file.value = li.dataset.path;
}
/* -------------------------------------------- */
/**
* Handle backwards navigation of the folder structure.
* @param {PointerEvent} event The triggering click event
*/
#onClickDirectoryControl(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
switch (action) {
case "back":
let target = this.target.split("/");
target.pop();
return this.browse(target.join("/"));
case "mkdir":
return this.#createDirectoryDialog(this.source);
case "toggle-privacy":
let isPrivate = !this.result.private;
const data = {private: isPrivate, bucket: this.result.bucket};
return this.constructor.configurePath(this.activeSource, this.target, data).then(r => {
this.result.private = r.private;
this.render();
});
}
}
/* -------------------------------------------- */
/**
* Present the user with a dialog to create a subdirectory within their currently browsed file storage location.
* @param {object} source The data source being browsed
*/
#createDirectoryDialog(source) {
const form = `<form><div class="form-group">
<label>Directory Name</label>
<input type="text" name="dirname" placeholder="directory-name" required/>
</div></form>`;
return Dialog.confirm({
title: game.i18n.localize("FILES.CreateSubfolder"),
content: form,
yes: async html => {
const dirname = html.querySelector("input").value;
const path = [source.target, dirname].filterJoin("/");
try {
await this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket});
} catch( err ) {
ui.notifications.error(err.message);
}
return this.browse(this.target);
},
options: {jQuery: false}
});
}
/* -------------------------------------------- */
/**
* Handle changes to the bucket selector
* @param {Event} event The S3 bucket select change event
*/
#onChangeBucket(event) {
event.preventDefault();
const select = event.currentTarget;
this.sources.s3.bucket = select.value;
return this.browse("/");
}
/* -------------------------------------------- */
/**
* Handle changes to the tile size.
* @param {Event} event The triggering event.
* @protected
*/
_onChangeTileSize(event) {
this.constructor.LAST_TILE_SIZE = event.currentTarget.valueAsNumber;
}
/* -------------------------------------------- */
/** @override */
_onSearchFilter(event, query, rgx, html) {
for ( let ol of html.querySelectorAll(".directory") ) {
let matched = false;
for ( let li of ol.children ) {
let match = rgx.test(SearchFilter.cleanQuery(li.dataset.name));
if ( match ) matched = true;
li.style.display = !match ? "none" : "";
}
ol.style.display = matched ? "" : "none";
}
this.setPosition({height: "auto"});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onSubmit(ev) {
ev.preventDefault();
let path = ev.target.file.value;
if ( !path ) return ui.notifications.error("You must select a file to proceed.");
// Update the target field
if ( this.field ) {
this.field.value = path;
this.field.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
// Trigger a callback and close
if ( this.callback ) this.callback(path, this);
return this.close();
}
/* -------------------------------------------- */
/**
* Handle file upload
* @param {Event} ev The file upload event
*/
async #onUpload(ev) {
const form = ev.target.form;
const upload = form.upload.files[0];
const name = upload.name.toLowerCase();
// Validate file extension
try {
this.#validateExtension(name);
} catch(err) {
ui.notifications.error(err, {console: true});
return false;
}
// Dispatch the request
const target = form.target.value;
const options = { bucket: form.bucket ? form.bucket.value : null };
const response = await this.constructor.upload(this.activeSource, target, upload, options);
// Handle errors
if ( response.error ) {
return ui.notifications.error(response.error);
}
// Flag the uploaded file as the new request
this.request = response.path;
return this.browse(target);
}
/* -------------------------------------------- */
/* Factory Methods
/* -------------------------------------------- */
/**
* Bind the file picker to a new target field.
* Assumes the user will provide a HTMLButtonElement which has the data-target and data-type attributes
* The data-target attribute should provide the name of the input field which should receive the selected file
* The data-type attribute is a string in ["image", "audio"] which sets the file extensions which will be accepted
*
* @param {HTMLButtonElement} button The button element
*/
static fromButton(button) {
if ( !(button instanceof HTMLButtonElement ) ) throw new Error("You must pass an HTML button");
let type = button.getAttribute("data-type");
const form = button.form;
const field = form[button.dataset.target] || null;
const current = field?.value || "";
return new FilePicker({field, type, current, button});
}
}