1082 lines
37 KiB
JavaScript
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});
|
|
}
|
|
}
|