/** * 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} */ 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; } } }