Initial
This commit is contained in:
826
resources/app/common/packages/base-package.mjs
Normal file
826
resources/app/common/packages/base-package.mjs
Normal file
@@ -0,0 +1,826 @@
|
||||
import DataModel from "../abstract/data.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {
|
||||
COMPENDIUM_DOCUMENT_TYPES, DOCUMENT_OWNERSHIP_LEVELS,
|
||||
PACKAGE_AVAILABILITY_CODES,
|
||||
PACKAGE_TYPES,
|
||||
SYSTEM_SPECIFIC_COMPENDIUM_TYPES,
|
||||
USER_ROLES
|
||||
} from "../constants.mjs";
|
||||
import {isNewerVersion, logCompatibilityWarning, mergeObject} from "../utils/module.mjs";
|
||||
import BaseFolder from "../documents/folder.mjs";
|
||||
import {ObjectField} from "../data/fields.mjs";
|
||||
import {DataModelValidationFailure} from "../data/validation-failure.mjs";
|
||||
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining package compatibility versions.
|
||||
* @property {string} minimum The Package will not function before this version
|
||||
* @property {string} verified Verified compatible up to this version
|
||||
* @property {string} maximum The Package will not function after this version
|
||||
*/
|
||||
export class PackageCompatibility extends fields.SchemaField {
|
||||
constructor(options) {
|
||||
super({
|
||||
minimum: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
verified: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
maximum: new fields.StringField({required: false, blank: false, initial: undefined})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining package relationships.
|
||||
* @property {RelatedPackage[]} systems Systems that this Package supports
|
||||
* @property {RelatedPackage[]} requires Packages that are required for base functionality
|
||||
* @property {RelatedPackage[]} recommends Packages that are recommended for optimal functionality
|
||||
*/
|
||||
export class PackageRelationships extends fields.SchemaField {
|
||||
/** @inheritdoc */
|
||||
constructor(options) {
|
||||
super({
|
||||
systems: new PackageRelationshipField(new RelatedPackage({packageType: "system"})),
|
||||
requires: new PackageRelationshipField(new RelatedPackage()),
|
||||
recommends: new PackageRelationshipField(new RelatedPackage()),
|
||||
conflicts: new PackageRelationshipField(new RelatedPackage()),
|
||||
flags: new fields.ObjectField()
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A SetField with custom casting behavior.
|
||||
*/
|
||||
class PackageRelationshipField extends fields.SetField {
|
||||
/** @override */
|
||||
_cast(value) {
|
||||
return value instanceof Array ? value : [value];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining a related Package.
|
||||
* It may be required to be a specific type of package, by passing the packageType option to the constructor.
|
||||
*/
|
||||
export class RelatedPackage extends fields.SchemaField {
|
||||
constructor({packageType, ...options}={}) {
|
||||
let typeOptions = {choices: PACKAGE_TYPES, initial:"module"};
|
||||
if ( packageType ) typeOptions = {choices: [packageType], initial: packageType};
|
||||
super({
|
||||
id: new fields.StringField({required: true, blank: false}),
|
||||
type: new fields.StringField(typeOptions),
|
||||
manifest: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
compatibility: new PackageCompatibility(),
|
||||
reason: new fields.StringField({required: false, blank: false, initial: undefined})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining the folder structure of the included compendium packs.
|
||||
*/
|
||||
export class PackageCompendiumFolder extends fields.SchemaField {
|
||||
constructor({depth=1, ...options}={}) {
|
||||
const schema = {
|
||||
name: new fields.StringField({required: true, blank: false}),
|
||||
sorting: new fields.StringField({required: false, blank: false, initial: undefined,
|
||||
choices: BaseFolder.SORTING_MODES}),
|
||||
color: new fields.ColorField(),
|
||||
packs: new fields.SetField(new fields.StringField({required: true, blank: false}))
|
||||
};
|
||||
if ( depth < 4 ) schema.folders = new fields.SetField(new PackageCompendiumFolder(
|
||||
{depth: depth+1, options}));
|
||||
super(schema, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special ObjectField which captures a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS.
|
||||
*/
|
||||
export class CompendiumOwnershipField extends ObjectField {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get _defaults() {
|
||||
return mergeObject(super._defaults, {
|
||||
initial: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
|
||||
validationError: "is not a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS"
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_validateType(value, options) {
|
||||
for ( let [k, v] of Object.entries(value) ) {
|
||||
if ( !(k in USER_ROLES) ) throw new Error(`Compendium ownership key "${k}" is not a valid choice in USER_ROLES`);
|
||||
if ( !(v in DOCUMENT_OWNERSHIP_LEVELS) ) throw new Error(`Compendium ownership value "${v}" is not a valid
|
||||
choice in DOCUMENT_OWNERSHIP_LEVELS`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special SetField which provides additional validation and initialization behavior specific to compendium packs.
|
||||
*/
|
||||
export class PackageCompendiumPacks extends fields.SetField {
|
||||
|
||||
/** @override */
|
||||
_cleanType(value, options) {
|
||||
return value.map(v => {
|
||||
v = this.element.clean(v, options);
|
||||
if ( v.path ) v.path = v.path.replace(/\.db$/, ""); // Strip old NEDB extensions
|
||||
else v.path = `packs/${v.name}`; // Auto-populate a default pack path
|
||||
return v;
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
initialize(value, model, options={}) {
|
||||
const packs = new Set();
|
||||
const packageName = model._source.id;
|
||||
for ( let v of value ) {
|
||||
try {
|
||||
const pack = this.element.initialize(v, model, options);
|
||||
pack.packageType = model.constructor.type;
|
||||
pack.packageName = packageName;
|
||||
pack.id = `${model.constructor.type === "world" ? "world" : packageName}.${pack.name}`;
|
||||
packs.add(pack);
|
||||
} catch(err) {
|
||||
logger.warn(err.message);
|
||||
}
|
||||
}
|
||||
return packs;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extend the logic for validating the complete set of packs to ensure uniqueness.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_validateElements(value, options) {
|
||||
const packNames = new Set();
|
||||
const duplicateNames = new Set();
|
||||
const packPaths = new Set();
|
||||
const duplicatePaths = new Set();
|
||||
for ( const pack of value ) {
|
||||
if ( packNames.has(pack.name) ) duplicateNames.add(pack.name);
|
||||
packNames.add(pack.name);
|
||||
if ( pack.path ) {
|
||||
if ( packPaths.has(pack.path) ) duplicatePaths.add(pack.path);
|
||||
packPaths.add(pack.path);
|
||||
}
|
||||
}
|
||||
return super._validateElements(value, {...options, duplicateNames, duplicatePaths});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate each individual compendium pack, ensuring its name and path are unique.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_validateElement(value, {duplicateNames, duplicatePaths, ...options}={}) {
|
||||
if ( duplicateNames.has(value.name) ) {
|
||||
return new DataModelValidationFailure({
|
||||
invalidValue: value.name,
|
||||
message: `Duplicate Compendium name "${value.name}" already declared by some other pack`,
|
||||
unresolved: true
|
||||
});
|
||||
}
|
||||
if ( duplicatePaths.has(value.path) ) {
|
||||
return new DataModelValidationFailure({
|
||||
invalidValue: value.path,
|
||||
message: `Duplicate Compendium path "${value.path}" already declared by some other pack`,
|
||||
unresolved: true
|
||||
});
|
||||
}
|
||||
return this.element.validate(value, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data schema used to define a Package manifest.
|
||||
* Specific types of packages extend this schema with additional fields.
|
||||
*/
|
||||
export default class BasePackage extends DataModel {
|
||||
/**
|
||||
* @param {PackageManifestData} data Source data for the package
|
||||
* @param {object} [options={}] Options which affect DataModel construction
|
||||
*/
|
||||
constructor(data, options={}) {
|
||||
const {availability, locked, exclusive, owned, tags, hasStorage} = data;
|
||||
super(data, options);
|
||||
|
||||
/**
|
||||
* An availability code in PACKAGE_AVAILABILITY_CODES which defines whether this package can be used.
|
||||
* @type {number}
|
||||
*/
|
||||
this.availability = availability ?? this.constructor.testAvailability(this);
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is currently locked.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.locked = locked ?? false;
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is a free Exclusive pack
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.exclusive = exclusive ?? false;
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is owned, if it is protected.
|
||||
* @type {boolean|null}
|
||||
*/
|
||||
this.owned = owned ?? false;
|
||||
|
||||
/**
|
||||
* A set of Tags that indicate what kind of Package this is, provided by the Website
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.tags = tags ?? [];
|
||||
|
||||
/**
|
||||
* A flag which tracks if this package has files stored in the persistent storage folder
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.hasStorage = hasStorage ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the package type in CONST.PACKAGE_TYPES that this class represents.
|
||||
* Each BasePackage subclass must define this attribute.
|
||||
* @virtual
|
||||
* @type {string}
|
||||
*/
|
||||
static type = "package";
|
||||
|
||||
/**
|
||||
* The type of this package instance. A value in CONST.PACKAGE_TYPES.
|
||||
* @type {string}
|
||||
*/
|
||||
get type() {
|
||||
return this.constructor.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical identifier for this package
|
||||
* @return {string}
|
||||
* @deprecated
|
||||
*/
|
||||
get name() {
|
||||
logCompatibilityWarning("You are accessing BasePackage#name which is now deprecated in favor of id.",
|
||||
{since: 10, until: 13});
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag which defines whether this package is unavailable to be used.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get unavailable() {
|
||||
return this.availability > PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this Package incompatible with the currently installed core Foundry VTT software version?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get incompatibleWithCoreVersion() {
|
||||
return this.constructor.isIncompatibleWithCoreVersion(this.availability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a given availability is incompatible with the core version.
|
||||
* @param {number} availability The availability value to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isIncompatibleWithCoreVersion(availability) {
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
return (availability >= codes.REQUIRES_CORE_DOWNGRADE) && (availability <= codes.REQUIRES_CORE_UPGRADE_UNSTABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The named collection to which this package type belongs
|
||||
* @type {string}
|
||||
*/
|
||||
static get collection() {
|
||||
return `${this.type}s`;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
const optionalString = {required: false, blank: false, initial: undefined};
|
||||
return {
|
||||
|
||||
// Package metadata
|
||||
id: new fields.StringField({required: true, blank: false, validate: this.validateId}),
|
||||
title: new fields.StringField({required: true, blank: false}),
|
||||
description: new fields.StringField({required: true}),
|
||||
authors: new fields.SetField(new fields.SchemaField({
|
||||
name: new fields.StringField({required: true, blank: false}),
|
||||
email: new fields.StringField(optionalString),
|
||||
url: new fields.StringField(optionalString),
|
||||
discord: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
url: new fields.StringField(optionalString),
|
||||
license: new fields.StringField(optionalString),
|
||||
readme: new fields.StringField(optionalString),
|
||||
bugs: new fields.StringField(optionalString),
|
||||
changelog: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
media: new fields.SetField(new fields.SchemaField({
|
||||
type: new fields.StringField(optionalString),
|
||||
url: new fields.StringField(optionalString),
|
||||
caption: new fields.StringField(optionalString),
|
||||
loop: new fields.BooleanField({required: false, blank: false, initial: false}),
|
||||
thumbnail: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
|
||||
// Package versioning
|
||||
version: new fields.StringField({required: true, blank: false, initial: "0"}),
|
||||
compatibility: new PackageCompatibility(),
|
||||
|
||||
// Included content
|
||||
scripts: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
esmodules: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
styles: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
languages: new fields.SetField(new fields.SchemaField({
|
||||
lang: new fields.StringField({required: true, blank: false, validate: Intl.getCanonicalLocales,
|
||||
validationError: "must be supported by the Intl.getCanonicalLocales function"
|
||||
}),
|
||||
name: new fields.StringField({required: false}),
|
||||
path: new fields.StringField({required: true, blank: false}),
|
||||
system: new fields.StringField(optionalString),
|
||||
module: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
packs: new PackageCompendiumPacks(new fields.SchemaField({
|
||||
name: new fields.StringField({required: true, blank: false, validate: this.validateId}),
|
||||
label: new fields.StringField({required: true, blank: false}),
|
||||
banner: new fields.StringField({...optionalString, nullable: true}),
|
||||
path: new fields.StringField({required: false}),
|
||||
type: new fields.StringField({required: true, blank: false, choices: COMPENDIUM_DOCUMENT_TYPES,
|
||||
validationError: "must be a value in CONST.COMPENDIUM_DOCUMENT_TYPES"}),
|
||||
system: new fields.StringField(optionalString),
|
||||
ownership: new CompendiumOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
}, {validate: BasePackage.#validatePack})),
|
||||
packFolders: new fields.SetField(new PackageCompendiumFolder()),
|
||||
|
||||
// Package relationships
|
||||
relationships: new PackageRelationships(),
|
||||
socket: new fields.BooleanField(),
|
||||
|
||||
// Package downloading
|
||||
manifest: new fields.StringField(),
|
||||
download: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
protected: new fields.BooleanField(),
|
||||
exclusive: new fields.BooleanField(),
|
||||
persistentStorage: new fields.BooleanField(),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check the given compatibility data against the current installation state and determine its availability.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data to test.
|
||||
* @param {object} [options]
|
||||
* @param {ReleaseData} [options.release] A specific software release for which to test availability.
|
||||
* Tests against the current release by default.
|
||||
* @returns {number}
|
||||
*/
|
||||
static testAvailability({ compatibility }, { release }={}) {
|
||||
release ??= globalThis.release ?? game.release;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
const {minimum, maximum, verified} = compatibility;
|
||||
const isGeneration = version => Number.isInteger(Number(version));
|
||||
|
||||
// Require a certain minimum core version.
|
||||
if ( minimum && isNewerVersion(minimum, release.version) ) {
|
||||
const generation = Number(minimum.split(".").shift());
|
||||
const isStable = generation <= release.maxStableGeneration;
|
||||
const exists = generation <= release.maxGeneration;
|
||||
if ( isStable ) return codes.REQUIRES_CORE_UPGRADE_STABLE;
|
||||
return exists ? codes.REQUIRES_CORE_UPGRADE_UNSTABLE : codes.UNKNOWN;
|
||||
}
|
||||
|
||||
// Require a certain maximum core version.
|
||||
if ( maximum ) {
|
||||
const compatible = isGeneration(maximum)
|
||||
? release.generation <= Number(maximum)
|
||||
: !isNewerVersion(release.version, maximum);
|
||||
if ( !compatible ) return codes.REQUIRES_CORE_DOWNGRADE;
|
||||
}
|
||||
|
||||
// Require a certain compatible core version.
|
||||
if ( verified ) {
|
||||
const compatible = isGeneration(verified)
|
||||
? Number(verified) >= release.generation
|
||||
: !isNewerVersion(release.version, verified);
|
||||
const sameGeneration = release.generation === Number(verified.split(".").shift());
|
||||
if ( compatible ) return codes.VERIFIED;
|
||||
return sameGeneration ? codes.UNVERIFIED_BUILD : codes.UNVERIFIED_GENERATION;
|
||||
}
|
||||
|
||||
// FIXME: Why do we not check if all of this package's dependencies are satisfied?
|
||||
// Proposal: Check all relationships.requires and set MISSING_DEPENDENCY if any dependencies are not VERIFIED,
|
||||
// UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, or if they do not satisfy the given compatibility range for the
|
||||
// relationship.
|
||||
|
||||
// No compatible version is specified.
|
||||
return codes.UNKNOWN;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test that the dependencies of a package are satisfied as compatible.
|
||||
* This method assumes that all packages in modulesCollection have already had their own availability tested.
|
||||
* @param {Collection<string,Module>} modulesCollection A collection which defines the set of available modules
|
||||
* @returns {Promise<boolean>} Are all required dependencies satisfied?
|
||||
* @internal
|
||||
*/
|
||||
async _testRequiredDependencies(modulesCollection) {
|
||||
const requirements = this.relationships.requires;
|
||||
for ( const {id, type, manifest, compatibility} of requirements ) {
|
||||
if ( type !== "module" ) continue; // Only test modules
|
||||
let pkg;
|
||||
|
||||
// If the requirement specifies an explicit remote manifest URL, we need to load it
|
||||
if ( manifest ) {
|
||||
try {
|
||||
pkg = await this.constructor.fromRemoteManifest(manifest, {strict: true});
|
||||
} catch(err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise the dependency must belong to the known modulesCollection
|
||||
else pkg = modulesCollection.get(id);
|
||||
if ( !pkg ) return false;
|
||||
|
||||
// Ensure that the package matches the required compatibility range
|
||||
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) ) return false;
|
||||
|
||||
// Test compatibility of the dependency
|
||||
if ( pkg.unavailable ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test compatibility of a package's supported systems.
|
||||
* @param {Collection<string, System>} systemCollection A collection which defines the set of available systems.
|
||||
* @returns {Promise<boolean>} True if all supported systems which are currently installed
|
||||
* are compatible or if the package has no supported systems.
|
||||
* Returns false otherwise, or if no supported systems are
|
||||
* installed.
|
||||
* @internal
|
||||
*/
|
||||
async _testSupportedSystems(systemCollection) {
|
||||
const systems = this.relationships.systems;
|
||||
if ( !systems?.size ) return true;
|
||||
let supportedSystem = false;
|
||||
for ( const { id, compatibility } of systems ) {
|
||||
const pkg = systemCollection.get(id);
|
||||
if ( !pkg ) continue;
|
||||
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) || pkg.unavailable ) return false;
|
||||
supportedSystem = true;
|
||||
}
|
||||
return supportedSystem;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if a dependency is within the given compatibility range.
|
||||
* @param {PackageCompatibility} compatibility The compatibility range declared for the dependency, if any
|
||||
* @param {BasePackage} dependency The known dependency package
|
||||
* @returns {boolean} Is the dependency compatible with the required range?
|
||||
*/
|
||||
static testDependencyCompatibility(compatibility, dependency) {
|
||||
if ( !compatibility ) return true;
|
||||
const {minimum, maximum} = compatibility;
|
||||
if ( minimum && isNewerVersion(minimum, dependency.version) ) return false;
|
||||
if ( maximum && isNewerVersion(dependency.version, maximum) ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static cleanData(source={}, { installed, ...options }={}) {
|
||||
|
||||
// Auto-assign language name
|
||||
for ( let l of source.languages || [] ) {
|
||||
l.name = l.name ?? l.lang;
|
||||
}
|
||||
|
||||
// Identify whether this package depends on a single game system
|
||||
let systemId = undefined;
|
||||
if ( this.type === "system" ) systemId = source.id;
|
||||
else if ( this.type === "world" ) systemId = source.system;
|
||||
else if ( source.relationships?.systems?.length === 1 ) systemId = source.relationships.systems[0].id;
|
||||
|
||||
// Auto-configure some package data
|
||||
for ( const pack of source.packs || [] ) {
|
||||
if ( !pack.system && systemId ) pack.system = systemId; // System dependency
|
||||
if ( typeof pack.ownership === "string" ) pack.ownership = {PLAYER: pack.ownership};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean unsupported non-module dependencies in requires or recommends.
|
||||
* @deprecated since v11
|
||||
*/
|
||||
["requires", "recommends"].forEach(rel => {
|
||||
const pkgs = source.relationships?.[rel];
|
||||
if ( !Array.isArray(pkgs) ) return;
|
||||
const clean = [];
|
||||
for ( const pkg of pkgs ) {
|
||||
if ( !pkg.type || (pkg.type === "module") ) clean.push(pkg);
|
||||
}
|
||||
const diff = pkgs.length - clean.length;
|
||||
if ( diff ) {
|
||||
source.relationships[rel] = clean;
|
||||
this._logWarning(
|
||||
source.id,
|
||||
`The ${this.type} "${source.id}" has a ${rel} relationship on a non-module, which is not supported.`,
|
||||
{ since: 11, until: 13, stack: false, installed });
|
||||
}
|
||||
});
|
||||
return super.cleanData(source, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that a Package ID is allowed.
|
||||
* @param {string} id The candidate ID
|
||||
* @throws An error if the candidate ID is invalid
|
||||
*/
|
||||
static validateId(id) {
|
||||
const allowed = /^[A-Za-z0-9-_]+$/;
|
||||
if ( !allowed.test(id) ) {
|
||||
throw new Error("Package and compendium pack IDs may only be alphanumeric with hyphens or underscores.");
|
||||
}
|
||||
const prohibited = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
if ( prohibited.test(id) ) throw new Error(`The ID "${id}" uses an operating system prohibited value.`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate a single compendium pack object
|
||||
* @param {PackageCompendiumData} packData Candidate compendium packs data
|
||||
* @throws An error if the data is invalid
|
||||
*/
|
||||
static #validatePack(packData) {
|
||||
if ( SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(packData.type) && !packData.system ) {
|
||||
throw new Error(`The Compendium pack "${packData.name}" of the "${packData.type}" type must declare the "system"`
|
||||
+ " upon which it depends.");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A wrapper around the default compatibility warning logger which handles some package-specific interactions.
|
||||
* @param {string} packageId The package ID being logged
|
||||
* @param {string} message The warning or error being logged
|
||||
* @param {object} options Logging options passed to foundry.utils.logCompatibilityWarning
|
||||
* @param {object} [options.installed] Is the package installed?
|
||||
* @internal
|
||||
*/
|
||||
static _logWarning(packageId, message, { installed, ...options }={}) {
|
||||
logCompatibilityWarning(message, options);
|
||||
if ( installed ) globalThis.packages?.warnings?.add(packageId, {type: this.type, level: "warning", message});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A set of package manifest keys that are migrated.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static migratedKeys = new Set([
|
||||
/** @deprecated since 10 until 13 */
|
||||
"name", "dependencies", "minimumCoreVersion", "compatibleCoreVersion"
|
||||
]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data, { installed }={}) {
|
||||
this._migrateNameToId(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateDependenciesNameToId(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateToRelationships(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateCompatibility(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateMediaURL(data, {since: 11, until: 13, stack: false, installed});
|
||||
this._migrateOwnership(data, {since: 11, until: 13, stack: false, installed});
|
||||
this._migratePackIDs(data, {since: 12, until: 14, stack: false, installed});
|
||||
this._migratePackEntityToType(data, {since: 9, stack: false, installed});
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateNameToId(data, logOptions) {
|
||||
if ( data.name && !data.id ) {
|
||||
data.id = data.name;
|
||||
delete data.name;
|
||||
if ( this.type !== "world" ) {
|
||||
const warning = `The ${this.type} "${data.id}" is using "name" which is deprecated in favor of "id"`;
|
||||
this._logWarning(data.id, warning, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateDependenciesNameToId(data, logOptions) {
|
||||
if ( data.relationships ) return;
|
||||
if ( data.dependencies ) {
|
||||
let hasDependencyName = false;
|
||||
for ( const dependency of data.dependencies ) {
|
||||
if ( dependency.name && !dependency.id ) {
|
||||
hasDependencyName = true;
|
||||
dependency.id = dependency.name;
|
||||
delete dependency.name;
|
||||
}
|
||||
}
|
||||
if ( hasDependencyName ) {
|
||||
const msg = `The ${this.type} "${data.id}" contains dependencies using "name" which is deprecated in favor of "id"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateToRelationships(data, logOptions) {
|
||||
if ( data.relationships ) return;
|
||||
data.relationships = {
|
||||
requires: [],
|
||||
systems: []
|
||||
};
|
||||
|
||||
// Dependencies -> Relationships.Requires
|
||||
if ( data.dependencies ) {
|
||||
for ( const d of data.dependencies ) {
|
||||
const relationship = {
|
||||
"id": d.id,
|
||||
"type": d.type,
|
||||
"manifest": d.manifest,
|
||||
"compatibility": {
|
||||
"compatible": d.version
|
||||
}
|
||||
};
|
||||
d.type === "system" ? data.relationships.systems.push(relationship) : data.relationships.requires.push(relationship);
|
||||
}
|
||||
const msg = `The ${this.type} "${data.id}" contains "dependencies" which is deprecated in favor of "relationships.requires"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
delete data.dependencies;
|
||||
}
|
||||
|
||||
// V9: system -> relationships.systems
|
||||
else if ( data.system && (this.type === "module") ) {
|
||||
data.system = data.system instanceof Array ? data.system : [data.system];
|
||||
const newSystems = data.system.map(id => ({id})).filter(s => !data.relationships.systems.find(x => x.id === s.id));
|
||||
data.relationships.systems = data.relationships.systems.concat(newSystems);
|
||||
const msg = `${this.type} "${data.id}" contains "system" which is deprecated in favor of "relationships.systems"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
delete data.system;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateCompatibility(data, logOptions) {
|
||||
if ( !data.compatibility && (data.minimumCoreVersion || data.compatibleCoreVersion) ) {
|
||||
this._logWarning(data.id, `The ${this.type} "${data.id}" is using the old flat core compatibility fields which `
|
||||
+ `are deprecated in favor of the new "compatibility" object`,
|
||||
logOptions);
|
||||
data.compatibility = {
|
||||
minimum: data.minimumCoreVersion,
|
||||
verified: data.compatibleCoreVersion
|
||||
};
|
||||
delete data.minimumCoreVersion;
|
||||
delete data.compatibleCoreVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateMediaURL(data, logOptions) {
|
||||
if ( !data.media ) return;
|
||||
let hasMediaLink = false;
|
||||
for ( const media of data.media ) {
|
||||
if ( "link" in media ) {
|
||||
hasMediaLink = true;
|
||||
media.url = media.link;
|
||||
delete media.link;
|
||||
}
|
||||
}
|
||||
if ( hasMediaLink ) {
|
||||
const msg = `${this.type} "${data.id}" declares media.link which is unsupported, media.url should be used`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateOwnership(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
let hasPrivatePack = false;
|
||||
for ( const pack of data.packs ) {
|
||||
if ( pack.private && !("ownership" in pack) ) {
|
||||
pack.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
|
||||
hasPrivatePack = true;
|
||||
}
|
||||
delete pack.private;
|
||||
}
|
||||
if ( hasPrivatePack ) {
|
||||
const msg = `${this.type} "${data.id}" uses pack.private which has been replaced with pack.ownership`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migratePackIDs(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
for ( const pack of data.packs ) {
|
||||
const slugified = pack.name.replace(/[^A-Za-z0-9-_]/g, "");
|
||||
if ( pack.name !== slugified ) {
|
||||
const msg = `The ${this.type} "${data.id}" contains a pack with an invalid name "${pack.name}". `
|
||||
+ "Pack names containing any character that is non-alphanumeric or an underscore will cease loading in "
|
||||
+ "version 14 of the software.";
|
||||
pack.name = slugified;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migratePackEntityToType(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
let hasPackEntity = false;
|
||||
for ( const pack of data.packs ) {
|
||||
if ( ("entity" in pack) && !("type" in pack) ) {
|
||||
pack.type = pack.entity;
|
||||
hasPackEntity = true;
|
||||
}
|
||||
delete pack.entity;
|
||||
}
|
||||
if ( hasPackEntity ) {
|
||||
const msg = `${this.type} "${data.id}" uses pack.entity which has been replaced with pack.type`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the latest Package manifest from a provided remote location.
|
||||
* @param {string} manifestUrl A remote manifest URL to load
|
||||
* @param {object} options Additional options which affect package construction
|
||||
* @param {boolean} [options.strict=true] Whether to construct the remote package strictly
|
||||
* @return {Promise<ServerPackage>} A Promise which resolves to a constructed ServerPackage instance
|
||||
* @throws An error if the retrieved manifest data is invalid
|
||||
*/
|
||||
static async fromRemoteManifest(manifestUrl, {strict=true}={}) {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user