X-Git-Url: https://git.ladys.computer/Etiquette/blobdiff_plain/9f6c704b8d1ebe70e97b0d934d04c71e8ae0ed5f..6623cbf39b85515219a3ab012e27d97884ea8aa3:/model.js diff --git a/model.js b/model.js new file mode 100644 index 0000000..9f7962d --- /dev/null +++ b/model.js @@ -0,0 +1,1689 @@ +// šŸ“§šŸ·ļø Ɖtiquette ∷ model.js +// ==================================================================== +// +// Copyright Ā© 2023 Lady [@ Lady’s Computer]. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at . + +import { Storage } from "./memory.js"; +import { taggingDiscoveryContext } from "./names.js"; + +// TODO: Move these somewhere else and allow for modification before +// they are used, freezing them only once tags actually start being +// constructed (probably on first call to the `TagSystem` initializer +// for convenience). +// +// Or else make them properties of the tag system itself and ∼fully +// modifiable. + +/** + * Tag kinds which denote entity tags. + * + * ※ This object is not exposed. + */ +const ENTITY_TAG_KINDS = new Set([ + "EntityTag", + "CharacterTag", + "InanimateEntityTag", +]); + +/** + * Tag kinds which denote relationship tags. + * + * ※ This object is not exposed. + */ +const RELATIONSHIP_TAG_KINDS = new Set([ + "RelationshipTag", + "FamilialRelationship Tag", + "FriendshipTag", + "RivalryTag", + "RomanticRelationshipTag", + "SexualRelationshipTag", +]); + +/** + * Tag kinds which denote setting tags. + * + * ※ This object is not exposed. + */ +const SETTING_TAG_KINDS = new Set([ + "SettingTag", + "LocationTag", + "TimePeriodTag", + "UniverseTag", +]); + +/** + * Tag kinds which denote conceptual tags. + * + * ※ This object is not exposed. + */ +const CONCEPTUAL_TAG_KINDS = new Set(function* () { + yield "ConceptualTag"; + yield* RELATIONSHIP_TAG_KINDS; +}()); + +/** + * All recognized tag kinds. + * + * ※ This object is not exposed. + */ +const TAG_KINDS = new Set(function* () { + yield "Tag"; + yield "CanonTag"; + yield* CONCEPTUAL_TAG_KINDS; + yield* ENTITY_TAG_KINDS; + yield "GenreTag"; + yield* SETTING_TAG_KINDS; +}()); + +/** + * Tag kinds which can be in canon. + * + * ※ This object is not exposed. + */ +const HAS_IN_CANON = new Set(function* () { + yield* ENTITY_TAG_KINDS; + yield* SETTING_TAG_KINDS; +}()); + +/** + * Tag kinds which can be involved in relationship tags. + * + * ※ This object is not exposed. + */ +const INVOLVABLE_IN_RELATIONSHIP = new Set(function* () { + yield "CharacterTag"; + yield* RELATIONSHIP_TAG_KINDS; +}()); + +/** + * Properties which take literal values instead of identifiers. + * + * These are the label terms. + */ +const LITERAL_TERMS = new Set([ + "prefLabel", + "altLabel", + "hiddenLabel", +]); + +/** + * Properties to skip when diffing. + * + * These are all inverses of properties included in diffs and cannot be + * changed manually. + */ +const SKIP_IN_DIFF = new Set([ + "hasInCanon", + "isIncludedIn", + "narrower", +]); + +/** + * A tag. + * + * `Tag`s are not assigned identifiers and do not have sideĀ·effects on + * other tags in the `TagSystem` until they are persisted with + * `::persist`, at which point changes to their relationships are + * applied. + * + * `Tag`s are also not kept up‐to‐date, but persisting an outdated + * `Tag` will *not* undo subsequent changes. + * + * ※ This class is not itself directly exposed, although bound + * versions of it are via `TagSystem::Tag`. + */ +class Tag { + /** The `TagSystem` this `Tag` belongs to. */ + #system; + + /** The `Storage` managed by this `Tag`’s `TagSystem`. */ + #storage; + + /** + * The 30‐bit WĀ·RĀ·MĀ·G base32 identifier with leading checksum which + * has been assigned to this `Tag`. + * + * Will be `null` if this `Tag` has not been persisted. Otherwise, + * the format is `cxx-xxxx` (`c` = checksum; `x` = digit). + */ + #identifier = null; + + /** The kind of this `Tag`. */ + #kind = "Tag"; + + /** + * The data which was attached to this `Tag` the last time it was + * persisted or retrieved from storage. + * + * Diffing with this will reveal changes. + */ + #persistedData = null; + + /** The current (modified) data associated with this `Tag`. */ + #data = tagData(); + + /** + * Returns whether or not the provided value is a tag which shares a + * storage with this tag. + * + * Sharing a storage also implies sharing a `TagSystem`. + */ + #isTagInStorage($) { + try { + // Try to compare the provided value’s internal store with + // the provided storage. + return $.#storage == this.#storage; + } catch { + // The provided value was not a `Tag`. + return false; + } + } + + /** + * Constructs a new `Tag` of the provided kind and with the provided + * preferred label. + * + * ※ The first two arguments of this constructor are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this constructor in its unbound form from outside this + * module. + * + * ☔ This constructor throws if the provided kind is not supported. + */ + constructor(system, storage, kind = "Tag", prefLabel = "") { + const kindString = `${kind}`; + if (TAG_KINDS.has(kindString)) { + // The provided kind is one of the recognized tag kinds. + this.#system = system; + this.#storage = storage; + this.#kind = kindString; + this.#data.prefLabel = prefLabel; + } else { + // The provided kind is not supported. + throw new RangeError( + `Cannot construct Tag: Unrecognized kind: ${kind}.`, + ); + } + } + + /** + * Yields the tags in the `TagSystem` associated with this + * constructor. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. + */ + static *all(system, storage) { + for (const instance of storage.values()) { + // Iterate over the entries and yield the ones which are `Tag`s + // in this `TagSystem`. + if (Tag.getSystem(instance) == system) { + // The current instance is a `Tag` in this `TagSystem`. + yield instance; + } else { + // The current instance is not a `Tag` in this `TagSystem`. + /* do nothing */ + } + } + } + + /** + * Returns a new `Tag` resolved from the provided IĀ·RĀ·I. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. + * + * ※ If the IĀ·RĀ·I is not recognized, this function returns `null`. + */ + static fromIRI(system, storage, iri) { + const name = `${iri}`; + const prefix = + `https://${system.authorityName}/tag:${system.taggingEntity}:`; + if (!name.startsWith(prefix)) { + // The IĀ·RĀ·I does not begin with the expected prefix. + return null; + } else { + // The IĀ·RĀ·I begins with the expected prefix. + const identifier = name.substring(prefix.length); + try { + // Attempt to resolve the identifier. + const instance = storage.get(identifier); + return Tag.getSystem(instance) == system ? instance : null; + } catch { + // Do not throw for bad identifiers. + return null; + } + } + } + + /** + * Returns a new `Tag` resolved from the provided identifier. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. + * + * ☔ This function throws if the identifier is invalid. + * + * ※ If the identifier is valid but not recognized, this function + * returns `null`. + */ + static fromIdentifier(system, storage, identifier) { + const instance = storage.get(identifier); + return Tag.getSystem(instance) == system ? instance : null; + } + + /** + * Returns a new `Tag` resolved from the provided Tag UĀ·RĀ·I. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. + * + * ☔ This function throws if the provided Tag UĀ·RĀ·I does not match + * the tagging entity of this constructor’s `TagSystem`. + * + * ※ If the specific component of the Tag UĀ·RĀ·I is not recognized, + * this function returns `null`. + */ + static fromTagURI(system, storage, tagURI) { + const tagName = `${tagURI}`; + const tagPrefix = `tag:${system.taggingEntity}:`; + if (!tagName.startsWith(tagPrefix)) { + // The Tag UĀ·RĀ·I does not begin with the expected prefix. + throw new RangeError( + `Tag UĀ·RĀ·I did not begin with the expected prefix: ${tagName}`, + ); + } else { + // The IĀ·RĀ·I begins with the expected prefix. + const identifier = tagName.substring(tagPrefix.length); + try { + // Attempt to resolve the identifier. + const instance = storage.get(identifier); + return Tag.getSystem(instance) == system ? instance : null; + } catch { + // Do not throw for bad identifiers. + return null; + } + } + } + + /** + * Returns the `TagSystem` that the provided value belongs to. + * + * ※ This function can be used to check if the provided value has + * private tag features. + * + * ※ This function is not exposed. + */ + static getSystem($) { + return !(#system in Object($)) ? null : $.#system; + } + + /** + * Yields the tag identifiers in the `TagSystem` associated with this + * constructor. + * + * ※ The first two arguments of this function are bound when + * generating the value of `TagSystem::Tag`. It isn’t possible to + * access this function in its unbound form from outside this module. + */ + static *identifiers(system, storage) { + for (const [identifier, instance] of storage.entries()) { + // Iterate over the entries and yield the ones which are `Tag`s + // in this `TagSystem`. + if (Tag.getSystem(instance) == system) { + // The current instance is a `Tag` in this `TagSystem`. + yield identifier; + } else { + // The current instance is not a `Tag` in this `TagSystem`. + /* do nothing */ + } + } + } + + /** + * Returns a new `Tag` constructed from the provided data and with + * the provided identifier. + * + * ※ This function will not work if called directly from `Tag` (and + * nor is it available *to* be called as such from outside this + * module). It must be called from a `TagSystem::Tag` bound + * constructor. + * + * ※ This function is not really intended for public usage. + */ + static [Storage.toInstance](_system, _storage, data, identifier) { + const tag = new this(data.kind); + tag.#identifier = `${identifier}`; + tag.#persistedData = tagData(data); + tag.#data = tagData(data); + return tag; + } + + static { + // Overwrite the default `::constructor` method to instead give the + // actual (bound) constructor which was used to generate a given + // `Tag`. + Object.defineProperties(this.prototype, { + constructor: { + configurable: true, + enumerable: false, + get() { + // All `Tag`s are constructed via the `.Tag` constructor + // available in their `TagSystem`; return it. + return this.#system.Tag; + }, + set: undefined, + }, + }); + } + + /** Adds the provided label(s) to this `Tag` as alternate labels. */ + addAltLabel(...labels) { + const altLabels = this.#data.altLabel; + let objectLabels = null; // initialized on first use + for (const $ of labels) { + // Iterate over each provided label and attempt to add it. + const literal = langString($); + if (Object(literal) === literal) { + // The current label is a language‐tagged string. + objectLabels ??= [...function* () { + for (const altLabel of altLabels) { + // Iterate over the existing labels and yield the + // language‐tagged strings. + if (Object(altLabel) === altLabel) { + // The current existing label is a language‐tagged + // string. + yield altLabel; + } else { + // The current existing label is not a language‐tagged + // string. + /* do nothing */ + } + } + }()]; + if ( + objectLabels.some((objectLabel) => + objectLabel["@value"] == literal["@value"] && + objectLabel["@language"] == literal["@language"] + ) + ) { + // There is a match with the current label in the existing + // labels. + /* do nothing */ + } else { + // There is no match and this label must be added. + altLabels.add(literal); + objectLabels.push(literal); + } + } else { + // The current label is a simple string. + altLabels.add(literal); + } + } + } + + /** + * Adds the provided tags to the list of tags that this `Tag` is + * narrower than. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + addBroaderTag(...tags) { + const storage = this.#storage; + const broader = this.#data.broader; + for (const $ of tags) { + // Iterate over each tag and attempt to set it as broader than + // this `Tag`. + const identifier = toIdentifier($); + if (identifier == null) { + // ☔ The current tag has no identifier. + throw new TypeError( + "Cannot assign broader to Tag: Identifier must not be nullish.", + ); + } else if (broader.has(identifier)) { + // Short‐circuit: The identifier is already something this + // `Tag` is narrower than. + /* do nothing */ + } else { + // The current tag has an identifier. + const tag = storage.get(identifier); + if (tag == null) { + // ☔ The current tag has not been persisted to this `Tag`’s + // storage. + throw new RangeError( + `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`, + ); + } else if (!this.#isTagInStorage(tag)) { + // ☔ The current tag is not a tag in the correct tag system. + throw new TypeError( + `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`, + ); + } else { + // The current tag is a tag in the correct tag system; add + // its identifier. + broader.add(identifier); + } + } + } + } + + /** Adds the provided label(s) to this `Tag` as hidden labels. */ + addHiddenLabel(...labels) { + const hiddenLabels = this.#data.hiddenLabel; + let objectLabels = null; // initialized on first use + for (const $ of labels) { + // Iterate over each provided label and attempt to add it. + const literal = langString($); + if (Object(literal) === literal) { + // The current label is a language‐tagged string. + objectLabels ??= [...function* () { + for (const hiddenLabel of hiddenLabels) { + // Iterate over the existing labels and yield the + // language‐tagged strings. + if (Object(hiddenLabel) === hiddenLabel) { + // The current existing label is a language‐tagged + // string. + yield hiddenLabel; + } else { + // The current existing label is not a language‐tagged + // string. + /* do nothing */ + } + } + }()]; + if ( + objectLabels.some((objectLabel) => + objectLabel["@value"] == literal["@value"] && + objectLabel["@language"] == literal["@language"] + ) + ) { + // There is a match with the current label in the existing + // labels. + /* do nothing */ + } else { + // There is no match and this label must be added. + hiddenLabels.add(literal); + objectLabels.push(literal); + } + } else { + // The current label is a simple string. + hiddenLabels.add(literal); + } + } + } + + /** + * Adds the provided tags to the list of tags that this `Tag` is in + * canon with. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + * + * ☔ This method will throw if a provided argument does not indicate + * a canon tag, or if this `Tag` is not of a kind which can be placed + * in canon. + */ + addInCanonTag(...tags) { + const storage = this.#storage; + const kind = this.#kind; + const inCanon = this.#data.inCanon; + if (!HAS_IN_CANON.has(kind)) { + // ☔ This is not an entity tag, setting tag, or recognized + // subclass. + throw new TypeError( + `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`, + ); + } else { + // This has a kind which can be placed in canon. + for (const $ of tags) { + // Iterate over each tag and attempt to set this `Tag` in canon + // of it. + const identifier = toIdentifier($); + if (identifier == null) { + // ☔ The current tag has no identifier. + throw new TypeError( + "Cannot put Tag in canon: Identifier must not be nullish.", + ); + } else if (inCanon.has(identifier)) { + // Short‐circuit: The identifier is already something this + // `Tag` is in canon of. + /* do nothing */ + } else { + // The current tag has an identifier. + const tag = storage.get(identifier); + if (tag == null) { + // ☔ The current tag has not been persisted to this `Tag`’s + // storage. + throw new RangeError( + `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`, + ); + } else if ( + // ※ If the first check succeeds, then the current tag + // must have `Tag` private class features. + !this.#isTagInStorage(tag) || tag.#kind != "CanonTag" + ) { + // ☔ The current tag is not a canon tag in the correct + // tag system. + throw new TypeError( + `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`, + ); + } else { + // The current tag is a canon tag in the correct tag + // system; add its identifier. + inCanon.add(identifier); + } + } + } + } + } + + /** + * Adds the provided tags to the list of tags that this `Tag` + * involves. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + * + * ☔ This method will throw if this `Tag` is not a conceptual tag, or + * if this `Tag` is a relationship tag and a provided argument does + * not indicate a character or relationship tag. + */ + addInvolvesTag(...tags) { + const storage = this.#storage; + const kind = this.#kind; + const involves = this.#data.involves; + if (!CONCEPTUAL_TAG_KINDS.has(kind)) { + // ☔ This is not a conceptual tag or recognized subclass. + throw new TypeError( + `Cannot involve Tag: Incorrect Tag type: ${kind}.`, + ); + } else { + // This is a conceptual tag. + for (const $ of tags) { + // Iterate over each tag and attempt to set this `Tag` as + // involving it. + const identifier = toIdentifier($); + if (identifier == null) { + // ☔ The current tag has no identifier. + throw new TypeError( + "Cannot involve Tag: Identifier must not be nullish.", + ); + } else if (involves.has(identifier)) { + // Short‐circuit: The identifier is already something this + // `Tag` involves. + /* do nothing */ + } else { + // The current tag has an identifier. + const tag = storage.get(identifier); + if (tag == null) { + // ☔ The current tag has not been persisted to this `Tag`’s + // storage. + throw new RangeError( + `Cannot involve Tag: Identifier is not persisted: ${identifier}.`, + ); + } else if ( + // ※ If the first check succeeds, then the current tag + // must have `Tag` private class features. + !this.#isTagInStorage(tag) || + RELATIONSHIP_TAG_KINDS.has(kind) && + !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind) + ) { + // ☔ The current tag is in the correct tag system and + // includable. + throw new TypeError( + `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`, + ); + } else { + // The current tag is an involvable tag in the correct tag + // system; add its identifier. + involves.add(identifier); + } + } + } + } + } + + /** Yields the alternative labels of this `Tag`. */ + *altLabels() { + yield* this.#data.altLabel; + } + + /** Returns the authority (domain) name for this `Tag`. */ + get authorityName() { + return this.#system.authorityName; + } + + /** Yields `Tag`s which are broader than this `Tag`. */ + *broaderTags() { + const storage = this.#storage; + for (const identifier of this.#data.broader) { + // Iterate over the broader tags and yield them if possible. + const tag = storage.get(identifier); + if (!this.#isTagInStorage(tag)) { + // The broader tag no longer appears in storage; perhaps it was + // deleted. + /* do nothing */ + } else { + // The broader tag exists and is constructable from storage. + yield tag; + } + } + } + + /** Yields `Tag`s which are broader than this `Tag`, transitively. */ + *broaderTransitiveTags() { + const storage = this.#storage; + const encountered = new Set(); + let pending = new Set(this.#data.broader); + while (pending.size > 0) { + // Loop until all broader tags have been encountered. + const processing = pending; + pending = new Set(); + for (const identifier of processing) { + // Iterate over the broader tags and yield them if possible. + if (!encountered.has(identifier)) { + // The broader tag has not been encountered before. + encountered.add(identifier); + const tag = storage.get(identifier); + if (!this.#isTagInStorage(tag)) { + // The broader tag no longer appears in storage; perhaps it + // was deleted. + /* do nothing */ + } else { + // The broader tag exists and is constructable from + // storage. + yield tag; + for (const transitive of tag.#data.broader) { + // Iterate over the broader tags of the current broader + // tag and add them to pending as needed. + if (!encountered.has(transitive)) { + // The broader broader tag has not been encountered + // yet. + pending.add(transitive); + } else { + // The broader broader tag has already been + // encountered. + /* do nothing */ + } + } + } + } else { + // The broader tag has already been encountered. + /* do nothing */ + } + } + } + } + + /** + * Removes the provided string label(s) from this `Tag` as alternate + * labels. + */ + deleteAltLabel(...labels) { + const altLabels = this.#data.altLabel; + let objectLabels = null; // initialized on first use + for (const $ of labels) { + // Iterate over each provided label and attempt to remove it. + const literal = langString($); + if (Object(literal) === literal) { + // The current label is a language‐tagged string. + objectLabels ??= [...function* () { + for (const altLabel of altLabels) { + // Iterate over the existing labels and yield the + // language‐tagged strings. + if (Object(altLabel) === altLabel) { + // The current existing label is a language‐tagged + // string. + yield altLabel; + } else { + // The current existing label is not a language‐tagged + // string. + /* do nothing */ + } + } + }()]; + const existing = objectLabels.find((objectLabel) => + objectLabel["@value"] == literal["@value"] && + objectLabel["@language"] == literal["@language"] + ); + altLabels.delete(existing); + } else { + // The current label is a simple string. + altLabels.delete(literal); + } + } + } + + /** + * Removes the provided tags from the list of tags that this `Tag` is + * narrower than. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + deleteBroaderTag(...tags) { + const broader = this.#data.broader; + for (const $ of tags) { + // Iterate over the provided tags and delete them. + broader.delete(toIdentifier($)); + } + } + + /** + * Removes the provided string label(s) from this `Tag` as hidden + * labels. + */ + deleteHiddenLabel(...labels) { + const hiddenLabels = this.#data.hiddenLabel; + let objectLabels = null; // initialized on first use + for (const $ of labels) { + // Iterate over each provided label and attempt to remove it. + const literal = langString($); + if (Object(literal) === literal) { + // The current label is a language‐tagged string. + objectLabels ??= [...function* () { + for (const hiddenLabel of hiddenLabels) { + // Iterate over the existing labels and yield the + // language‐tagged strings. + if (Object(hiddenLabel) === hiddenLabel) { + // The current existing label is a language‐tagged + // string. + yield hiddenLabel; + } else { + // The current existing label is not a language‐tagged + // string. + /* do nothing */ + } + } + }()]; + const existing = objectLabels.find((objectLabel) => + objectLabel["@value"] == literal["@value"] && + objectLabel["@language"] == literal["@language"] + ); + hiddenLabels.delete(existing); + } else { + // The current label is a simple string. + hiddenLabels.delete(literal); + } + } + } + + /** + * Removes the provided tags from the list of tags that this `Tag` is + * in canon with. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + deleteInCanonTag(...tags) { + const inCanon = this.#data.inCanon; + for (const $ of tags) { + // Iterate over the provided tags and delete them. + inCanon.delete(toIdentifier($)); + } + } + + /** + * Removes the provided tags from the list of tags that this `Tag` + * involves. + * + * Arguments may be string identifiers or objects with an + * `.identifier` property. + */ + deleteInvolvesTag(...tags) { + const involves = this.#data.involves; + for (const $ of tags) { + // Iterate over the provided tags and delete them. + involves.delete(toIdentifier($)); + } + } + + /** Yields `Tag`s that are in canon of this `Tag`. */ + *hasInCanonTags() { + const storage = this.#storage; + if (this.#kind == "CanonTag") { + // This is a canon tag. + for (const identifier of this.#data.hasInCanon) { + // Iterate over the tags in canon and yield them if possible. + const tag = storage.get(identifier); + if ( + !this.#isTagInStorage(tag) || !HAS_IN_CANON.has(tag.#kind) + ) { + // The tag in canon no longer appears in storage; perhaps it + // was deleted. + /* do nothing */ + } else { + // The tag in canon exists and is constructable from storage. + yield tag; + } + } + } else { + /* do nothing */ + } + } + + /** Yields the hidden labels of this `Tag`. */ + *hiddenLabels() { + yield* this.#data.hiddenLabel; + } + + /** Returns the identifier of this `Tag`. */ + get identifier() { + return this.#identifier; + } + + /** Yields `Tag`s that this `Tag` is in canon of. */ + *inCanonTags() { + const storage = this.#storage; + if (HAS_IN_CANON.has(this.#kind)) { + // This tag can be placed in canon. + for (const identifier of this.#data.inCanon) { + // Iterate over the canon tags and yield them if possible. + const tag = storage.get(identifier); + if (!this.#isTagInStorage(tag) || tag.#kind != "CanonTag") { + // The canon tag no longer appears in storage; perhaps it was + // deleted. + /* do nothing */ + } else { + // The canon tag exists and is constructable from storage. + yield tag; + } + } + } else { + // This tag cannot be placed in canon. + /* do nothing */ + } + } + + /** Yields `Tag`s which involve this `Tag`. */ + *involvedInTags() { + const storage = this.#storage; + for (const identifier of this.#data.involvedIn) { + // Iterate over the involving tags and yield them if possible. + const tag = storage.get(identifier); + const tagKind = tag.#kind; + if ( + !this.#isTagInStorage(tag) || + !CONCEPTUAL_TAG_KINDS.has(tagKind) || + RELATIONSHIP_TAG_KINDS.has(tagKind) && + !INVOLVABLE_IN_RELATIONSHIP.has(this.#kind) + ) { + // The including tag no longer appears in storage; perhaps it + // was deleted. + /* do nothing */ + } else { + // The including tag exists and is constructable from storage. + yield tag; + } + } + } + + /** Yields `Tag`s that this `Tag` involves. */ + *involvesTags() { + const storage = this.#storage; + const kind = this.#kind; + if (CONCEPTUAL_TAG_KINDS.has(kind)) { + // This tag can involve other tags. + for (const identifier of this.#data.involves) { + // Iterate over the involved and yield them if possible. + const tag = storage.get(identifier); + if ( + !this.#isTagInStorage(tag) || + RELATIONSHIP_TAG_KINDS.has(kind) && + !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind) + ) { + // The involved tag no longer appears in storage; perhaps it + // was deleted. + /* do nothing */ + } else { + // The involved tag exists and is constructable from storage. + yield tag; + } + } + } else { + // This tag cannot involve other tags. + /* do nothing */ + } + } + + /** Returns the IĀ·RĀ·I for this `Tag`. */ + get iri() { + const tagURI = this.tagURI; + return tagURI == null + ? null + : `https://${this.authorityName}/${tagURI}`; + } + + /** Returns the kind of this `Tag`. */ + get kind() { + return this.#kind; + } + + /** Yields `Tag`s which are narrower than this `Tag`. */ + *narrowerTags() { + const storage = this.#storage; + for (const identifier of this.#data.narrower) { + const tag = storage.get(identifier); + if (!this.#isTagInStorage(tag)) { + // The narrower tag no longer appears in storage; perhaps it + // was deleted. + /* do nothing */ + } else { + // The narrower tag exists and is constructable from storage. + yield tag; + } + } + } + + /** + * Yields `Tag`s which are narrower than this `Tag`, transitively. + */ + *narrowerTransitiveTags() { + const storage = this.#storage; + const encountered = new Set(); + let pending = new Set(this.#data.narrower); + while (pending.size > 0) { + // Loop until all narrower tags have been encountered. + const processing = pending; + pending = new Set(); + for (const identifier of processing) { + // Iterate over the narrower tags and yield them if possible. + if (!encountered.has(identifier)) { + // The narrower tag has not been encountered before. + encountered.add(identifier); + const tag = storage.get(identifier); + if (!this.#isTagInStorage(tag)) { + // The narrower tag no longer appears in storage; perhaps + // it was deleted. + /* do nothing */ + } else { + // The narrower tag exists and is constructable from + // storage. + yield tag; + for (const transitive of tag.#data.narrower) { + // Iterate over the narrower tags of the current narrower + // tag and add them to pending as needed. + if (!encountered.has(transitive)) { + // The narrower narrower tag has not been encountered + // yet. + pending.add(transitive); + } else { + // The narrower narrower tag has already been + // encountered. + /* do nothing */ + } + } + } + } else { + // The narrower tag has already been encountered. + /* do nothing */ + } + } + } + } + + /** + * Persist this `Tag` to storage and return an ActivityStreams + * serialization of a Tag Activity representing any changes, or + * `null` if no changes were made. + * + * ※ Persistence can imply side‐effects on other objects, which are + * not noted explicitly in the activity. For example, marking a tag + * as broader than another causes the other tag to reciprocally be + * marked as narrower. + * + * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower` + * will never appear in the predicates of generated activities. + */ + persist() { + const system = this.#system; + const storage = this.#storage; + const persistedData = this.#persistedData; + const data = this.#data; + const diffs = {}; + for (const [key, value] of Object.entries(data)) { + // Iterate over each entry of the tag data and create a diff with + // the last persisted information. + if (SKIP_IN_DIFF.has(key)) { + // The current property is one which is skipped in diffs. + /* do nothing */ + } else { + // The current property should be diffed. + const persisted = persistedData?.[key] ?? null; + if (persisted == null) { + // There is no persisted data for the current property yet. + diffs[key] = { + old: new Set(), + new: value instanceof Set + ? new Set(value) + : new Set([value]), + }; + } else if (value instanceof Set) { + // The current property is set‐valued. + let values = null; // initialized on first use + const oldValues = new Set(persisted); + const newValues = new Set(value); + for (const existing of persisted) { + // Iterate over each persisted property and either remove + // it from the list of new values or add it to the list of + // removed ones. + // + // ※ Some special handling is required here for + // language‐tagged strings. + if ( + value.has(existing) || + Object(existing) === existing && + (values ??= [...value]).some(($) => + `${$}` == `${existing}` && + $.language == existing.language + ) + ) { + // The value is in both the old and new version of the + // data. + oldValues.delete(existing); + newValues.delete(existing); + } else { + // The value is not shared. + /* do nothing */ + } + } + diffs[key] = { + old: oldValues, + new: newValues, + }; + } else if ( + `${value}` != `${persisted}` || + value.language != persisted.language + ) { + // The current property is (optionally language‐tagged) + // string‐valued and the value changed. + diffs[key] = { + old: new Set([persisted]), + new: new Set([value]), + }; + } else { + // The current property did not change. + diffs[key] = { + old: new Set(), + new: new Set(), + }; + } + } + } + const identifier = this.#identifier; + if (identifier != null) { + // This `Tag` has already been persisted; use its existing + // identifier and persist. + storage.set(identifier, this); + } else { + // This `Tag` has not been persisted yet; save the new + // identifier after persisting. + this.#identifier = storage.add(this); + } + const persistedIdentifier = this.#identifier; + this.#persistedData = tagData(data); // need to clone here + for ( + const [term, inverse] of [ + ["broader", "narrower"], + ["inCanon", "hasInCanon"], + ["involves", "involvedIn"], + ] + ) { + // Iterate over each term referencing other tags and update the + // inverse property on those tags if possible. + for (const referencedIdentifier of diffs[term].old) { + // Iterate over the removed tags and remove this `Tag` from + // their inverse property. + const referenced = storage.get(referencedIdentifier); + try { + // Try removing this `Tag`. + referenced.#data[inverse].delete(persistedIdentifier); + storage.set(referencedIdentifier, referenced); + } catch { + // Removal failed, possibly because the other tag was + // deleted. + /* do nothing */ + } + } + for (const referencedIdentifier of diffs[term].new) { + const referenced = storage.get(referencedIdentifier); + try { + // Try adding this `Tag`. + referenced.#data[inverse].add(persistedIdentifier); + storage.set(referencedIdentifier, referenced); + } catch { + // Adding failed, possibly because the other tag was deleted. + /* do nothing */ + } + } + } + const activity = { + "@context": taggingDiscoveryContext, + "@type": [ + "TagActivity", + identifier == null ? "Create" : "Update", + ], + context: `${system.iri}`, + object: `${this.iri}`, + endTime: new Date().toISOString(), + ...(() => { + const statements = { + unstates: [], + states: [], + }; + const { unstates, states } = statements; + if (identifier == null) { + // This is a Create activity. + states.push({ predicate: "a", object: `${this.kind}` }); + } else { + // This is an Update activity. + /* do nothing */ + } + for ( + const [term, { + old: oldValues, + new: newValues, + }] of Object.entries(diffs) + ) { + // Iterate over the diffs of each term and state/unstate + // things as needed. + for (const oldValue of oldValues) { + // Iterate over removals and unstate them. + if (LITERAL_TERMS.has(term)) { + // This is a literal term; push the change wrapped in an + // object. + unstates.push({ + predicate: term, + object: Object(oldValue) === oldValue + ? { ...langString(oldValue) } + : { "@value": `${oldValue}` }, + }); + } else { + // This is a named term; attempt to get its IĀ·RĀ·I and + // push it. + try { + // Attempt to resolve the value and push the change. + const tag = storage.get(oldValue); + if (!this.#isTagInStorage(tag)) { + // The value did not resolve to a tag in storage. + /* do nothing */ + } else { + // The value resolved; push its IĀ·RĀ·I. + unstates.push({ + predicate: term, + object: tag.iri, + }); + } + } catch { + // Value resolution failed for some reason; perhaps the + // tag was deleted. + /* do nothing */ + } + } + } + for (const newValue of newValues) { + // Iterate over additions and state them. + if (LITERAL_TERMS.has(term)) { + // This is a literal term; push the change wrapped in an + // object. + states.push({ + predicate: term, + object: Object(newValue) === newValue + ? { ...langString(newValue) } + : { "@value": `${newValue}` }, + }); + } else { + // This is a named term; attempt to get its IĀ·RĀ·I and + // push it. + try { + // Attempt to resolve the value and push the change. + const tag = storage.get(newValue); + if (!this.#isTagInStorage(tag)) { + // The value did not resolve to a tag in storage. + /* do nothing */ + } else { + // The value resolved; push its IĀ·RĀ·I. + states.push({ + predicate: term, + object: tag.iri, + }); + } + } catch { + // Value resolution failed for some reason; perhaps the + // tag was deleted. + /* do nothing */ + } + } + } + } + if (unstates.length == 0) { + // Nothing was unstated. + delete statements.unstates; + } else { + // Things were stated. + /* do nothing */ + } + if (states.length == 0) { + // Nothing was stated. + delete statements.states; + } else { + // Things were stated. + /* do nothing */ + } + return statements; + })(), + }; + if ( + !Object.hasOwn(activity, "states") && + !Object.hasOwn(activity, "unstates") + ) { + // No meaningful changes were actually persisted. + return null; + } else { + // There were meaningful changes persisted regarding this `Tag`. + return activity; + } + } + + /** Returns the preferred label for this `Tag`. */ + get prefLabel() { + return this.#data.prefLabel; + } + + /** Sets the preferred label of this `Tag` to the provided label. */ + set prefLabel($) { + this.#data.prefLabel = langString($); + } + + /** Returns the Tag UĀ·RĀ·I for this `Tag`. */ + get tagURI() { + const { identifier } = this; + return identifier == null + ? null + : `tag:${this.taggingEntity}:${identifier}`; + } + + /** Returns the tagging entity (domain and date) for this `Tag`. */ + get taggingEntity() { + return this.#system.taggingEntity; + } + + /** Returns the string form of the preferred label of this `Tag`. */ + toString() { + return `${this.#data.prefLabel}`; + } + + /** + * Returns a new object whose enumerable own properties contain the + * data from this object needed for storage. + * + * ※ This method is not really intended for public usage. + */ + [Storage.toObject]() { + const data = this.#data; + return Object.assign(Object.create(null), { + ...data, + kind: this.#kind, + }); + } +} + +const { + /** + * Returns the provided value converted into either a plain string + * primitive or an object with `.["@value"]` and `.["@language"]` + * properties. + * + * TODO: Ideally this would be extracted more fully into an RĀ·DĀ·F + * library. + * + * ※ This function is not exposed. + */ + langString, +} = (() => { + /** Returns the `.["@language"]` of this object. */ + const getLanguage = Object.defineProperty( + function () { + return this["@language"]; + }, + "name", + { value: "get language" }, + ); + + /** Returns the `.["@value"]` of this object. */ + const toString = function () { + return this["@value"]; + }; + + /** Returns the `.["@value"]` of this object. */ + const valueOf = function () { + return this["@value"]; + }; + + return { + langString: ($) => + Object($) === $ + ? "@value" in $ + ? "@language" in $ + ? Object.preventExtensions( + Object.create(String.prototype, { + "@value": { + enumerable: true, + value: `${$["@value"]}`, + }, + "@language": { + enumerable: true, + value: `${$["@language"]}`, + }, + language: { enumerable: false, get: getLanguage }, + toString: { enumerable: false, value: toString }, + valueOf: { enumerable: false, value: valueOf }, + }), + ) + : `${$["@value"]}` + : "language" in $ + ? Object.preventExtensions( + Object.create(String.prototype, { + "@value": { enumerable: true, value: `${$}` }, + "@language": { + enumerable: true, + value: `${$.language}`, + }, + language: { enumerable: false, get: getLanguage }, + toString: { enumerable: false, value: toString }, + valueOf: { enumerable: false, value: valueOf }, + }), + ) + : `${$}` + : `${$ ?? ""}`, + }; +})(); + +/** + * Returns a normalized tag data object derived from the provided + * object. + * + * ※ The properties of this function need to match the term names used + * in the ActivityStreams serialization. + * + * ※ This function is not exposed. + */ +const tagData = ($) => { + const data = Object($); + const { + // prefLabel intentionally not set here + altLabel, + hiddenLabel, + broader, + narrower, + inCanon, + hasInCanon, + involves, + involvedIn, + } = data; + let prefLabel = langString(data.prefLabel); + return Object.preventExtensions(Object.create(null, { + prefLabel: { + enumerable: true, + get: () => prefLabel, + set: ($) => { + prefLabel = langString($); + }, + }, + altLabel: { + enumerable: true, + value: new Set( + altLabel != null + ? Array.from(altLabel, langString) + : undefined, + ), + }, + hiddenLabel: { + enumerable: true, + value: new Set( + hiddenLabel != null + ? Array.from(hiddenLabel, langString) + : undefined, + ), + }, + broader: { + enumerable: true, + value: new Set( + broader != null + ? Array.from(broader, toIdentifier) + : undefined, + ), + }, + narrower: { + enumerable: true, + value: new Set( + narrower != null + ? Array.from(narrower, toIdentifier) + : undefined, + ), + }, + inCanon: { + enumerable: true, + value: new Set( + inCanon != null + ? Array.from(inCanon, toIdentifier) + : undefined, + ), + }, + hasInCanon: { + enumerable: true, + value: new Set( + hasInCanon != null + ? Array.from(hasInCanon, toIdentifier) + : undefined, + ), + }, + involves: { + enumerable: true, + value: new Set( + involves != null + ? Array.from(involves, toIdentifier) + : undefined, + ), + }, + involvedIn: { + enumerable: true, + value: new Set( + involvedIn != null + ? Array.from(involvedIn, toIdentifier) + : undefined, + ), + }, + })); +}; + +/** + * Returns an identifier corresponding to the provided object. + * + * This is either the value of its `.identifier` or its string value. + * + * ※ This function is not exposed. + */ +const toIdentifier = ($) => + $ == null + ? null + : Object($) === $ && "identifier" in $ + ? $.identifier + : `${$}`; + +/** + * A tag system, with storage. + * + * The `::Tag` constructor available on any `TagSystem` instance can be + * used to create new `Tag`s within the system. + */ +export class TagSystem { + /** The cached bound `Tag` constructor for this `TagSystem`. */ + #Tag = null; + + /** The domain of this `TagSystem`. */ + #domain; + + /** The date of this `TagSystem`. */ + #date; + + /** The identifier of this `TagSystem`. */ + #identifier; + + /** The internal `Storage` of this `TagSystem`. */ + #storage = new Storage(); + + /** + * Constructs a new `TagSystem` with the provided domain and date. + * + * Only actual, lowercased domain names are allowed for the domain, + * and the date must be ā€œfullā€ (include month and day components). + * This is for alignment with general best practices for Tag URI’s. + * + * ☔ This constructor throws if provided with an invalid date. + */ + constructor(domain, date, identifier = "") { + const domainString = `${domain}`; + const dateString = `${date}`; + this.#identifier = `${identifier}`; + try { + // If the identifier is a valid storage IĀ·D, reserve it. + this.#storage.delete(this.#identifier); + } catch { + // The identifier is not a valid storage IĀ·D, so no worries. + /* do nothing */ + } + if ( + !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u + .test(domainString) + ) { + // ☔ The domain is invalid. + throw new RangeError(`Invalid domain: ${domain}.`); + } else if ( + !/^\d{4}-\d{2}-\d{2}$/u.test(dateString) || + dateString != new Date(dateString).toISOString().split("T")[0] + ) { + // ☔ The date is invalid. + throw new RangeError(`Invalid date: ${date}.`); + } else { + // The domain and date are šŸ†—. + this.#domain = domainString; + this.#date = dateString; + } + } + + /** + * Returns a bound constructor for constructing `Tags` in this + * `TagSystem`. + */ + get Tag() { + if (this.#Tag != null) { + // A bound constructor has already been generated; return it. + return this.#Tag; + } else { + // No bound constructor has been created yet. + const storage = this.#storage; + const BoundTag = Tag.bind(undefined, this, storage); + return this.#Tag = Object.defineProperties(BoundTag, { + all: { + configurable: true, + enumerable: false, + value: Tag.all.bind(BoundTag, this, storage), + writable: true, + }, + fromIRI: { + configurable: true, + enumerable: false, + value: Tag.fromIRI.bind(BoundTag, this, storage), + writable: true, + }, + fromIdentifier: { + configurable: true, + enumerable: false, + value: Tag.fromIdentifier.bind(BoundTag, this, storage), + writable: true, + }, + fromTagURI: { + configurable: true, + enumerable: false, + value: Tag.fromTagURI.bind(BoundTag, this, storage), + writable: true, + }, + identifiers: { + configurable: true, + enumerable: false, + value: Tag.identifiers.bind(BoundTag, this, storage), + writable: true, + }, + name: { value: `${this.tagURI}#${Tag.name}` }, + prototype: { value: Tag.prototype }, + [Storage.toInstance]: { + configurable: true, + enumerable: false, + value: Tag[Storage.toInstance].bind(BoundTag, this, storage), + writable: true, + }, + }); + } + } + + /** Returns the authority name (domain) for this `TagSystem`. */ + get authorityName() { + return this.#domain; + } + + /** Returns the date of this `TagSystem`, as a string. */ + get date() { + return this.#date; + } + + /** + * Yields the entities in this `TagSystem`. + * + * ※ Entities can hypothetically be anything. If you specifically + * want the `Tag`s, use `::Tag.all` instead. + */ + *entities() { + yield* this.#storage.values(); + } + + /** + * Returns the identifier of this `TagSystem`. + * + * ※ Often this is just the empty string. + */ + get identifier() { + return this.#identifier; + } + + /** Yields the identifiers in use in this `TagSystem`. */ + *identifiers() { + yield* this.#storage.keys(); + } + + /** Returns the IĀ·RĀ·I for this `TagSystem`. */ + get iri() { + return `https://${this.authorityName}/${this.tagURI}`; + } + + /** Returns the Tag UĀ·RĀ·I for this `TagSystem`. */ + get tagURI() { + return `tag:${this.taggingEntity}:${this.identifier}`; + } + + /** + * Returns the tagging entity (domain and date) for this `TagSystem`. + */ + get taggingEntity() { + return `${this.authorityName},${this.date}`; + } +}