Files
Foundry-VTT-Docker/resources/app/client/data/abstract/directory-collection-mixin.js
2025-01-04 00:34:03 +01:00

286 lines
10 KiB
JavaScript

/**
* A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting.
* @param {typeof Collection} BaseCollection The base collection class to extend
* @returns {typeof DirectoryCollection} A Collection mixed with DirectoryCollection functionality
* @category - Mixins
* @mixin
*/
function DirectoryCollectionMixin(BaseCollection) {
/**
* An extension of the Collection class which adds behaviors specific to tree-based collections of entries and folders.
* @extends {Collection}
*/
return class DirectoryCollection extends BaseCollection {
/**
* Reference the set of Folders which contain documents in this collection
* @type {Collection<string, Folder>}
*/
get folders() {
throw new Error("You must implement the folders getter for this DirectoryCollection");
}
/* -------------------------------------------- */
/**
* The built tree structure of the DocumentCollection
* @type {object}
*/
get tree() {
if ( !this.#tree ) this.initializeTree();
return this.#tree;
}
/**
* The built tree structure of the DocumentCollection. Lazy initialized.
* @type {object}
*/
#tree;
/* -------------------------------------------- */
/**
* The current search mode for this collection
* @type {string}
*/
get searchMode() {
const searchModes = game.settings.get("core", "collectionSearchModes");
return searchModes[this.collection ?? this.name] || CONST.DIRECTORY_SEARCH_MODES.NAME;
}
/**
* Toggle the search mode for this collection between "name" and "full" text search
*/
toggleSearchMode() {
const name = this.collection ?? this.name;
const searchModes = game.settings.get("core", "collectionSearchModes");
searchModes[name] = searchModes[name] === CONST.DIRECTORY_SEARCH_MODES.FULL
? CONST.DIRECTORY_SEARCH_MODES.NAME
: CONST.DIRECTORY_SEARCH_MODES.FULL;
game.settings.set("core", "collectionSearchModes", searchModes);
}
/* -------------------------------------------- */
/**
* The current sort mode used to order the top level entries in this collection
* @type {string}
*/
get sortingMode() {
const sortingModes = game.settings.get("core", "collectionSortingModes");
return sortingModes[this.collection ?? this.name] || "a";
}
/**
* Toggle the sorting mode for this collection between "a" (Alphabetical) and "m" (Manual by sort property)
*/
toggleSortingMode() {
const name = this.collection ?? this.name;
const sortingModes = game.settings.get("core", "collectionSortingModes");
const updatedSortingMode = sortingModes[name] === "a" ? "m" : "a";
sortingModes[name] = updatedSortingMode;
game.settings.set("core", "collectionSortingModes", sortingModes);
this.initializeTree();
}
/* -------------------------------------------- */
/**
* The maximum depth of folder nesting which is allowed in this collection
* @returns {number}
*/
get maxFolderDepth() {
return CONST.FOLDER_MAX_DEPTH;
}
/* -------------------------------------------- */
/**
* Return a reference to list of entries which are visible to the User in this tree
* @returns {Array<*>}
* @private
*/
_getVisibleTreeContents() {
return this.contents;
}
/* -------------------------------------------- */
/**
* Initialize the tree by categorizing folders and entries into a hierarchical tree structure.
*/
initializeTree() {
const folders = this.folders.contents;
const entries = this._getVisibleTreeContents();
this.#tree = this.#buildTree(folders, entries);
}
/* -------------------------------------------- */
/**
* Given a list of Folders and a list of Entries, set up the Folder tree
* @param {Folder[]} folders The Array of Folder objects to organize
* @param {Object[]} entries The Array of Entries objects to organize
* @returns {object} A tree structure containing the folders and entries
*/
#buildTree(folders, entries) {
const handled = new Set();
const createNode = (root, folder, depth) => {
return {root, folder, depth, visible: false, children: [], entries: []};
};
// Create the tree structure
const tree = createNode(true, null, 0);
const depths = [[tree]];
// Iterate by folder depth, populating content
for ( let depth = 1; depth <= this.maxFolderDepth + 1; depth++ ) {
const allowChildren = depth <= this.maxFolderDepth;
depths[depth] = [];
const nodes = depths[depth - 1];
if ( !nodes.length ) break;
for ( const node of nodes ) {
const folder = node.folder;
if ( !node.root ) { // Ensure we don't encounter any infinite loop
if ( handled.has(folder.id) ) continue;
handled.add(folder.id);
}
// Classify content for this folder
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren});
node.entries = classified.entries;
node.children = classified.folders.map(folder => createNode(false, folder, depth));
depths[depth].push(...node.children);
// Update unassigned content
folders = classified.unassignedFolders;
entries = classified.unassignedEntries;
}
}
// Populate left-over folders at the root level of the tree
for ( const folder of folders ) {
const node = createNode(false, folder, 1);
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren: false});
node.entries = classified.entries;
entries = classified.unassignedEntries;
depths[1].push(node);
}
// Populate left-over entries at the root level of the tree
if ( entries.length ) {
tree.entries.push(...entries);
}
// Sort the top level entries and folders
const sort = this.sortingMode === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
tree.entries.sort(sort);
tree.children.sort((a, b) => sort(a.folder, b.folder));
// Recursively filter visibility of the tree
const filterChildren = node => {
node.children = node.children.filter(child => {
filterChildren(child);
return child.visible;
});
node.visible = node.root || game.user.isGM || ((node.children.length + node.entries.length) > 0);
// Populate some attributes of the Folder document
if ( node.folder ) {
node.folder.displayed = node.visible;
node.folder.depth = node.depth;
node.folder.children = node.children;
}
};
filterChildren(tree);
return tree;
}
/* -------------------------------------------- */
/**
* Creates the list of Folder options in this Collection in hierarchical order
* for populating the options of a select tag.
* @returns {{id: string, name: string}[]}
* @internal
*/
_formatFolderSelectOptions() {
const options = [];
const traverse = node => {
if ( !node ) return;
const folder = node.folder;
if ( folder?.visible ) options.push({
id: folder.id,
name: `${"─".repeat(folder.depth - 1)} ${folder.name}`.trim()
});
node.children.forEach(traverse);
};
traverse(this.tree);
return options;
}
/* -------------------------------------------- */
/**
* Populate a single folder with child folders and content
* This method is called recursively when building the folder tree
* @param {Folder|null} folder A parent folder being populated or null for the root node
* @param {Folder[]} folders Remaining unassigned folders which may be children of this one
* @param {Object[]} entries Remaining unassigned entries which may be children of this one
* @param {object} [options={}] Options which configure population
* @param {boolean} [options.allowChildren=true] Allow additional child folders
*/
#classifyFolderContent(folder, folders, entries, {allowChildren = true} = {}) {
const sort = folder?.sorting === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
// Determine whether an entry belongs to a folder, via folder ID or folder reference
function folderMatches(entry) {
if ( entry.folder?._id ) return entry.folder._id === folder?._id;
return (entry.folder === folder) || (entry.folder === folder?._id);
}
// Partition folders into children and unassigned folders
const [unassignedFolders, subfolders] = folders.partition(f => allowChildren && folderMatches(f));
subfolders.sort(sort);
// Partition entries into folder contents and unassigned entries
const [unassignedEntries, contents] = entries.partition(e => folderMatches(e));
contents.sort(sort);
// Return the classified content
return {folders: subfolders, entries: contents, unassignedFolders, unassignedEntries};
}
/* -------------------------------------------- */
/**
* Sort two Entries by name, alphabetically.
* @param {Object} a Some Entry
* @param {Object} b Some other Entry
* @returns {number} The sort order between entries a and b
* @protected
*/
static _sortAlphabetical(a, b) {
if ( a.name === undefined ) throw new Error(`Missing name property for ${a.constructor.name} ${a.id}`);
if ( b.name === undefined ) throw new Error(`Missing name property for ${b.constructor.name} ${b.id}`);
return a.name.localeCompare(b.name, game.i18n.lang);
}
/* -------------------------------------------- */
/**
* Sort two Entries using their numeric sort fields.
* @param {Object} a Some Entry
* @param {Object} b Some other Entry
* @returns {number} The sort order between Entries a and b
* @protected
*/
static _sortStandard(a, b) {
if ( a.sort === undefined ) throw new Error(`Missing sort property for ${a.constructor.name} ${a.id}`);
if ( b.sort === undefined ) throw new Error(`Missing sort property for ${b.constructor.name} ${b.id}`);
return a.sort - b.sort;
}
}
}