--- /dev/null
+// 📧🏷️ É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 <https://mozilla.org/MPL/2.0/>.
+
+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}`;
+ }
+}
--- /dev/null
+// 📧🏷️ Étiquette ∷ model.test.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 <https://mozilla.org/MPL/2.0/>.
+
+import {
+ assert,
+ assertArrayIncludes,
+ assertEquals,
+ assertFalse,
+ assertObjectMatch,
+ assertStrictEquals,
+ assertThrows,
+ beforeEach,
+ describe,
+ it,
+} from "./dev-deps.js";
+import { TagSystem } from "./model.js";
+
+describe("TagSystem", () => {
+ it("[[Call]] throws", () => {
+ assertThrows(() => {
+ TagSystem();
+ });
+ });
+
+ it("[[Construct]] creates a new TagSystem", () => {
+ assertStrictEquals(
+ Object.getPrototypeOf(new TagSystem("example", "1972-12-31")),
+ TagSystem.prototype,
+ );
+ });
+
+ it("[[Construct]] uses the identifier if provided", () => {
+ assertStrictEquals(
+ new TagSystem("example", "1972-12-31", "etaoin").identifier,
+ "etaoin",
+ );
+ });
+
+ it("[[Construct]] uses an empty identifier if none is provided", () => {
+ assertStrictEquals(
+ new TagSystem("example", "1972-12-31").identifier,
+ "",
+ );
+ });
+
+ it("[[Construct]] throws if provided an invalid domain", () => {
+ assertThrows(() => {
+ new TagSystem("example@example", "1972-12-31");
+ });
+ assertThrows(() => {
+ new TagSystem("0.0.0.0", "1972-12-31");
+ });
+ });
+
+ it("[[Construct]] throws if provided an invalid date", () => {
+ assertThrows(() => {
+ new TagSystem("example", "1969");
+ });
+ assertThrows(() => {
+ new TagSystem("example", "1972-12-31T00:00:00Z");
+ });
+ });
+
+ describe("::Tag", () => {
+ let Tag;
+ let system;
+
+ beforeEach(() => {
+ system = new TagSystem("example", "1972-12-31");
+ Tag = system.Tag;
+ });
+
+ it("[[Get]] returns the same value every time", () => {
+ assertStrictEquals(Tag, system.Tag);
+ });
+
+ it("[[Call]] throws", () => {
+ assertThrows(() => {
+ Tag();
+ });
+ });
+
+ it("[[Construct]] returns a new Tag", () => {
+ assertStrictEquals(
+ Object.getPrototypeOf(new Tag()),
+ Tag.prototype,
+ );
+ });
+
+ it('[[Construct]] defaults the kind to "Tag"', () => {
+ assertStrictEquals(new Tag().kind, "Tag");
+ });
+
+ it("[[Construct]] correctly sets the tag kind", () => {
+ assertStrictEquals(
+ new Tag("RelationshipTag").kind,
+ "RelationshipTag",
+ );
+ });
+
+ it("[[Construct]] defaults the preferred label to the empty string", () => {
+ assertStrictEquals(new Tag().prefLabel, "");
+ });
+
+ it("[[Construct]] correctly sets the preferred label to a simple string", () => {
+ assertStrictEquals(
+ new Tag("RelationshipTag", "Shadow, Me").prefLabel,
+ "Shadow, Me",
+ );
+ });
+
+ it("[[Construct]] initializes tag identifiers to null", () => {
+ assertStrictEquals(
+ new Tag().identifier,
+ null,
+ );
+ });
+
+ it("[[Construct]] correctly sets the preferred label to a language‐tagged string", () => {
+ assertEquals(
+ {
+ ...new Tag("RelationshipTag", {
+ "@value": "Shadow, Me",
+ "@language": "en",
+ }).prefLabel,
+ },
+ { "@value": "Shadow, Me", "@language": "en" },
+ );
+ });
+
+ it("[[Construct]] throws if the tag kind is not recognized", () => {
+ assertThrows(() => {
+ new Tag("NotATag");
+ });
+ });
+
+ describe(".all", () => {
+ it("[[Call]] yields all the persisted tags", () => {
+ const tags = new Set(function* () {
+ let i = 0;
+ while (i++ < 5) {
+ // Generate 5 tags and remember their identifiers.
+ const tag = new Tag();
+ tag.persist();
+ yield tag.identifier;
+ }
+ }());
+ for (const tag of Tag.all()) {
+ assertStrictEquals(
+ Object.getPrototypeOf(tag),
+ Tag.prototype,
+ );
+ }
+ assertEquals(
+ new Set(Array.from(Tag.all(), (tag) => tag.identifier)),
+ tags,
+ );
+ });
+ });
+
+ describe(".fromIRI", () => {
+ it("[[Call]] returns the persisted tag with the given I·R·I", () => {
+ const tag = new Tag();
+ tag.persist();
+ const { identifier, iri } = tag;
+ const retrieved = Tag.fromIRI(iri);
+ assertStrictEquals(
+ Object.getPrototypeOf(retrieved),
+ Tag.prototype,
+ );
+ assertStrictEquals(retrieved.identifier, identifier);
+ });
+
+ it("[[Call]] returns null if no tag with the given I·R·I has been persisted", () => {
+ assertStrictEquals(
+ Tag.fromIRI(
+ `https://${system.authorityName}/tag:${system.taggingEntity}:000-0000`,
+ ),
+ null,
+ );
+ });
+
+ it("[[Call]] returns null if passed an invalid I·R·I", () => {
+ assertStrictEquals(Tag.fromIRI(`bad iri`), null);
+ });
+ });
+
+ describe(".fromIdentifier", () => {
+ it("[[Call]] returns the persisted tag with the given identifier", () => {
+ const tag = new Tag();
+ tag.persist();
+ const { identifier } = tag;
+ const retrieved = Tag.fromIdentifier(identifier);
+ assertStrictEquals(
+ Object.getPrototypeOf(retrieved),
+ Tag.prototype,
+ );
+ assertStrictEquals(retrieved.identifier, identifier);
+ });
+
+ it("[[Call]] returns null if no tag with the given identifier has been persisted", () => {
+ assertStrictEquals(Tag.fromIdentifier("000-0000"), null);
+ });
+
+ it("[[Call]] throws if passed an invalid identifier", () => {
+ assertThrows(() => {
+ Tag.fromIdentifier(""); // wrong format
+ });
+ assertThrows(() => {
+ Tag.fromIdentifier("100-0000"); // bad checksum
+ });
+ });
+ });
+
+ describe(".fromTagURI", () => {
+ it("[[Call]] returns the persisted tag with the given Tag U·R·I", () => {
+ const tag = new Tag();
+ tag.persist();
+ const { identifier, tagURI } = tag;
+ const retrieved = Tag.fromTagURI(tagURI);
+ assertStrictEquals(
+ Object.getPrototypeOf(retrieved),
+ Tag.prototype,
+ );
+ assertStrictEquals(retrieved.identifier, identifier);
+ });
+
+ it("[[Call]] returns null if no tag with the given Tag U·R·I has been persisted", () => {
+ assertStrictEquals(
+ Tag.fromIRI(`tag:${system.taggingEntity}:`),
+ null,
+ );
+ assertStrictEquals(
+ Tag.fromIRI(`tag:${system.taggingEntity}:000-0000`),
+ null,
+ );
+ });
+
+ it("[[Call]] throws if passed an invalid Tag U·R·I", () => {
+ assertThrows(() => {
+ Tag.fromTagURI(""); // wrong format
+ });
+ assertThrows(() => {
+ Tag.fromTagURI(
+ "tag:unexample,1970-01-01:Z", // incorrect tagging entity
+ );
+ });
+ });
+ });
+
+ describe(".getSystem", () => {
+ it("[[Has]] is not present", () => {
+ assertFalse("getSystem" in Tag);
+ });
+ });
+
+ describe(".identifiers", () => {
+ it("[[Call]] yields all the persisted identifiers", () => {
+ const tags = new Set(function* () {
+ let i = 0;
+ while (i++ < 5) {
+ // Generate 5 tags and remember their identifiers.
+ const tag = new Tag();
+ tag.persist();
+ yield tag.identifier;
+ }
+ }());
+ assertEquals(
+ new Set(Tag.identifiers()),
+ tags,
+ );
+ });
+ });
+
+ // `.[Storage.toInstance]` is tested by `.fromIdentifier`.
+
+ describe("::addAltLabel", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag();
+ tag.addAltLabel();
+ assertEquals([...tag.altLabels()], []);
+ });
+
+ it("[[Call]] adds the provided alternative labels", () => {
+ const tag = new Tag();
+ tag.addAltLabel(
+ "one",
+ { "@value": "two" },
+ { "@value": "three", "@language": "en" },
+ );
+ assertEquals(
+ Array.from(
+ tag.altLabels(),
+ ($) => typeof $ == "string" ? $ : { ...$ },
+ ),
+ [
+ "one",
+ "two",
+ { "@value": "three", "@language": "en" },
+ ],
+ );
+ });
+ });
+
+ describe("::addBroaderTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag();
+ tag.addBroaderTag();
+ assertEquals([...tag.broaderTags()], []);
+ });
+
+ it("[[Call]] adds the provided broader tags", () => {
+ const broader = new Tag();
+ broader.persist();
+ const broader2 = new Tag();
+ broader2.persist();
+ const tag = new Tag();
+ tag.addBroaderTag(broader, broader2);
+ assertEquals(
+ Array.from(tag.broaderTags(), ($) => $.identifier),
+ [broader.identifier, broader2.identifier],
+ );
+ });
+
+ it("[[Call]] throws when adding a non‐persisted tag", () => {
+ const tag = new Tag();
+ assertThrows(() => {
+ tag.addBroaderTag(new Tag());
+ });
+ });
+
+ it("[[Call]] throws when adding an unrecognized identifier", () => {
+ const tag = new Tag();
+ assertThrows(() => {
+ tag.addBroaderTag("000-0000"); // not persisted
+ });
+ assertThrows(() => {
+ tag.addBroaderTag(""); // bad format
+ });
+ });
+ });
+
+ describe("::addHiddenLabel", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag();
+ tag.addHiddenLabel();
+ assertEquals([...tag.hiddenLabels()], []);
+ });
+
+ it("[[Call]] adds the provided hidden labels", () => {
+ const tag = new Tag();
+ tag.addHiddenLabel(
+ "one",
+ { "@value": "two" },
+ { "@value": "three", "@language": "en" },
+ );
+ assertEquals(
+ Array.from(
+ tag.hiddenLabels(),
+ ($) => typeof $ == "string" ? $ : { ...$ },
+ ),
+ [
+ "one",
+ "two",
+ { "@value": "three", "@language": "en" },
+ ],
+ );
+ });
+ });
+
+ describe("::addInCanonTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag("EntityTag");
+ tag.addInCanonTag();
+ assertEquals([...tag.inCanonTags()], []);
+ });
+
+ it("[[Call]] adds the provided canon tags", () => {
+ const canon = new Tag("CanonTag");
+ canon.persist();
+ const canon2 = new Tag("CanonTag");
+ canon2.persist();
+ const tag = new Tag("EntityTag");
+ tag.addInCanonTag(canon, canon2);
+ assertEquals(
+ Array.from(tag.inCanonTags(), ($) => $.identifier),
+ [canon.identifier, canon2.identifier],
+ );
+ });
+
+ it("[[Call]] throws when this is not a tag which can be placed in canon", () => {
+ assertThrows(() => {
+ new Tag().addInCanonTag();
+ });
+ });
+
+ it("[[Call]] throws when provided with a non‐canon tag", () => {
+ const notCanon = new Tag();
+ notCanon.persist();
+ const tag = new Tag("EntityTag");
+ assertThrows(() => {
+ tag.addInCanonTag(notCanon);
+ });
+ });
+
+ it("[[Call]] throws when adding a non‐persisted tag", () => {
+ const tag = new Tag("EntityTag");
+ assertThrows(() => {
+ tag.addInCanonTag(new Tag("CanonTag"));
+ });
+ });
+
+ it("[[Call]] throws when adding an unrecognized identifier", () => {
+ const tag = new Tag("EntityTag");
+ assertThrows(() => {
+ tag.addInCanonTag("000-0000"); // not persisted
+ });
+ assertThrows(() => {
+ tag.addInCanonTag(""); // bad format
+ });
+ });
+ });
+
+ describe("::addInvolvesTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag("ConceptualTag");
+ tag.addInvolvesTag();
+ assertEquals([...tag.involvesTags()], []);
+ });
+
+ it("[[Call]] adds the provided tags", () => {
+ const involved = new Tag();
+ involved.persist();
+ const involved2 = new Tag();
+ involved2.persist();
+ const tag = new Tag("ConceptualTag");
+ tag.addInvolvesTag(involved, involved2);
+ assertEquals(
+ Array.from(tag.involvesTags(), ($) => $.identifier),
+ [involved.identifier, involved2.identifier],
+ );
+ });
+
+ it("[[Call]] throws when this is not a conceptual tag", () => {
+ assertThrows(() => {
+ new Tag().addInvolvesTag();
+ });
+ });
+
+ it("[[Call]] throws when this is a relationship tag and provided with a non‐involvable tag", () => {
+ const notInvolved = new Tag();
+ notInvolved.persist();
+ const tag = new Tag("RelationshipTag");
+ assertThrows(() => {
+ tag.addInvolvesTag(notInvolved);
+ });
+ });
+
+ it("[[Call]] throws when adding a non‐persisted tag", () => {
+ const tag = new Tag("ConceptualTag");
+ assertThrows(() => {
+ tag.addInvolvesTag(new Tag());
+ });
+ });
+
+ it("[[Call]] throws when adding an unrecognized identifier", () => {
+ const tag = new Tag("ConceptualTag");
+ assertThrows(() => {
+ tag.addInvolvesTag("000-0000"); // not persisted
+ });
+ assertThrows(() => {
+ tag.addInvolvesTag(""); // bad format
+ });
+ });
+ });
+
+ // `::altLabels` is tested by `::addAltLabel`.
+
+ describe("::authorityName", () => {
+ it("[[Get]] returns the authority name of the tag system", () => {
+ assertStrictEquals(
+ new Tag().authorityName,
+ system.authorityName,
+ );
+ });
+ });
+
+ // `::broaderTags` is tested by `::addBroaderTag`.
+
+ describe("::broaderTransitiveTags", () => {
+ it("[[Call]] returns broader tags transitively", () => {
+ const superBroad = new Tag();
+ superBroad.persist();
+ const broad = new Tag();
+ broad.addBroaderTag(superBroad);
+ broad.persist();
+ const tag = new Tag();
+ tag.addBroaderTag(broad);
+ assertEquals(
+ Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+ [broad.identifier, superBroad.identifier],
+ );
+ });
+
+ it("[[Call]] cannot recurse infinitely", () => {
+ const tag = new Tag();
+ tag.persist();
+ const broad = new Tag();
+ broad.addBroaderTag(tag);
+ broad.persist();
+ tag.addBroaderTag(broad);
+ tag.persist();
+ assertEquals(
+ Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+ [broad.identifier, tag.identifier],
+ );
+ });
+ });
+
+ describe("::deleteAltLabel", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag();
+ tag.addAltLabel("etaoin");
+ tag.deleteAltLabel();
+ assertEquals([...tag.altLabels()], ["etaoin"]);
+ });
+
+ it("[[Call]] deletes only the provided hidden labels", () => {
+ const tag = new Tag();
+ tag.addAltLabel(
+ "one",
+ "two",
+ { "@value": "three", "@language": "en" },
+ "four",
+ );
+ tag.deleteAltLabel(
+ "one",
+ { "@value": "two" },
+ { "@value": "three", "@language": "en" },
+ { "@value": "four", "@language": "en" },
+ );
+ assertEquals([...tag.altLabels()], ["four"]);
+ });
+ });
+
+ describe("::deleteBroaderTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const broader = new Tag();
+ broader.persist();
+ const tag = new Tag();
+ tag.addBroaderTag(broader);
+ tag.deleteBroaderTag();
+ assertEquals(
+ Array.from(tag.broaderTags(), ($) => $.identifier),
+ [broader.identifier],
+ );
+ });
+
+ it("[[Call]] deletes only the provided broader tags", () => {
+ const superBroader = new Tag();
+ superBroader.persist();
+ const broader = new Tag();
+ broader.addBroaderTag(superBroader);
+ broader.persist();
+ const broader2 = new Tag();
+ broader2.addBroaderTag(superBroader);
+ broader2.persist();
+ const tag = new Tag();
+ tag.addBroaderTag(broader, broader2);
+ tag.deleteBroaderTag(broader, superBroader, "000-0000", "");
+ assertEquals(
+ Array.from(tag.broaderTags(), ($) => $.identifier),
+ [broader2.identifier],
+ );
+ });
+ });
+
+ describe("::deleteHiddenLabel", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const tag = new Tag();
+ tag.addHiddenLabel("etaoin");
+ tag.deleteHiddenLabel();
+ assertEquals([...tag.hiddenLabels()], ["etaoin"]);
+ });
+
+ it("[[Call]] deletes only the provided alternative labels", () => {
+ const tag = new Tag();
+ tag.addHiddenLabel(
+ "one",
+ "two",
+ { "@value": "three", "@language": "en" },
+ "four",
+ );
+ tag.deleteHiddenLabel(
+ "one",
+ { "@value": "two" },
+ { "@value": "three", "@language": "en" },
+ { "@value": "four", "@language": "en" },
+ );
+ assertEquals([...tag.hiddenLabels()], ["four"]);
+ });
+ });
+
+ describe("::deleteInCanonTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const canon = new Tag("CanonTag");
+ canon.persist();
+ const tag = new Tag("EntityTag");
+ tag.addInCanonTag(canon);
+ tag.deleteInCanonTag();
+ assertEquals(
+ Array.from(tag.inCanonTags(), ($) => $.identifier),
+ [canon.identifier],
+ );
+ });
+
+ it("[[Call]] deletes only the provided canon tags", () => {
+ const canon = new Tag("CanonTag");
+ canon.persist();
+ const canon2 = new Tag("CanonTag");
+ canon2.persist();
+ const tag = new Tag("EntityTag");
+ tag.addInCanonTag(canon, canon2);
+ tag.deleteInCanonTag(canon, "000-0000", "");
+ assertEquals(
+ Array.from(tag.inCanonTags(), ($) => $.identifier),
+ [canon2.identifier],
+ );
+ });
+ });
+
+ describe("::deleteInvolvesTag", () => {
+ it("[[Call]] does nothing if called with no arguments", () => {
+ const involved = new Tag();
+ involved.persist();
+ const tag = new Tag("ConceptualTag");
+ tag.addInvolvesTag(involved);
+ tag.deleteInvolvesTag();
+ assertEquals(
+ Array.from(tag.involvesTags(), ($) => $.identifier),
+ [involved.identifier],
+ );
+ });
+
+ it("[[Call]] deletes only the provided involved tags", () => {
+ const character = new Tag("CharacterTag");
+ character.persist();
+ const involved = new Tag("RelationshipTag");
+ involved.addInvolvesTag(character);
+ involved.persist();
+ const involved2 = new Tag("RelationshipTag");
+ involved2.addInvolvesTag(character);
+ involved2.persist();
+ const tag = new Tag("RelationshipTag");
+ tag.addInvolvesTag(involved, involved2);
+ tag.deleteInvolvesTag(involved, character, "000-0000", "");
+ assertEquals(
+ Array.from(tag.involvesTags(), ($) => $.identifier),
+ [involved2.identifier],
+ );
+ });
+ });
+
+ describe("::hasInCanonTags", () => {
+ it("[[Call]] yields the persisted tags which have this tag in canon", () => {
+ const canon = new Tag("CanonTag");
+ canon.persist();
+ const entity = new Tag("EntityTag");
+ entity.addInCanonTag(canon);
+ entity.persist();
+ const entity2 = new Tag("EntityTag");
+ entity2.addInCanonTag(canon);
+ entity2.persist();
+ const tag = Tag.fromIdentifier(canon.identifier); // reload
+ assertEquals(
+ Array.from(tag.hasInCanonTags(), ($) => $.identifier),
+ [entity.identifier, entity2.identifier],
+ );
+ });
+ });
+
+ // `::hiddenLabels` is tested by `::addHiddenLabel`.
+
+ // `::identifier` is tested by a `.fromIdentifier`.
+
+ // `::inCanonTags` is tested by `::addInCanonTag`.
+
+ describe("::involvedInTags", () => {
+ it("[[Call]] yields the persisted tags which involve this tag", () => {
+ const involved = new Tag();
+ involved.persist();
+ const conceptual = new Tag("ConceptualTag");
+ conceptual.addInvolvesTag(involved);
+ conceptual.persist();
+ const conceptual2 = new Tag("ConceptualTag");
+ conceptual2.addInvolvesTag(involved);
+ conceptual2.persist();
+ const tag = Tag.fromIdentifier(involved.identifier); // reload
+ assertEquals(
+ Array.from(tag.involvedInTags(), ($) => $.identifier),
+ [conceptual.identifier, conceptual2.identifier],
+ );
+ });
+ });
+
+ // `::involvesTags` is tested by `::addInvolvesTag`.
+
+ // `::iri` is tested by a `.fromIRI`.
+
+ // `::kind` is tested by the constructor.
+
+ describe("::narrowerTags", () => {
+ it("[[Call]] yields the persisted tags which are narrower than this tag", () => {
+ const broader = new Tag();
+ broader.persist();
+ const narrower = new Tag();
+ narrower.addBroaderTag(broader);
+ narrower.persist();
+ const narrower2 = new Tag();
+ narrower2.addBroaderTag(broader);
+ narrower2.persist();
+ const tag = Tag.fromIdentifier(broader.identifier); // reload
+ assertEquals(
+ Array.from(tag.narrowerTags(), ($) => $.identifier),
+ [narrower.identifier, narrower2.identifier],
+ );
+ });
+ });
+
+ describe("::narrowerTransitiveTags", () => {
+ it("[[Call]] returns narrower tags transitively", () => {
+ const broad = new Tag();
+ broad.persist();
+ const narrow = new Tag();
+ narrow.addBroaderTag(broad);
+ narrow.persist();
+ const superNarrow = new Tag();
+ superNarrow.addBroaderTag(narrow);
+ superNarrow.persist();
+ const tag = Tag.fromIdentifier(broad.identifier); // reload
+ assertEquals(
+ Array.from(
+ tag.narrowerTransitiveTags(),
+ ($) => $.identifier,
+ ),
+ [narrow.identifier, superNarrow.identifier],
+ );
+ });
+
+ it("[[Call]] cannot recurse infinitely", () => {
+ const tag = new Tag();
+ tag.persist();
+ const broad = new Tag();
+ broad.addBroaderTag(tag);
+ broad.persist();
+ tag.addBroaderTag(broad);
+ tag.persist();
+ assertEquals(
+ Array.from(tag.broaderTransitiveTags(), ($) => $.identifier),
+ [broad.identifier, tag.identifier],
+ );
+ });
+ });
+
+ describe("::persist", () => {
+ it("[[Call]] returns an object with expected properties if there were changes", () => {
+ const tag = new Tag();
+ const activity = tag.persist();
+ assertObjectMatch(
+ activity,
+ {
+ "@context":
+ "https://ns.1024.gdn/Tagging/discovery.context.jsonld",
+ context: system.iri,
+ object: tag.iri,
+ },
+ );
+ assertArrayIncludes(activity["@type"], ["TagActivity"]);
+ assert("endTime" in activity);
+ });
+
+ it("[[Call]] returns a Create activity with a type predicate for new objects", () => {
+ const activity = new Tag().persist();
+ assertEquals(activity["@type"], ["TagActivity", "Create"]);
+ assertArrayIncludes(activity.states, [{
+ predicate: "a",
+ object: "Tag",
+ }]);
+ });
+
+ it("[[Call]] returns an Update activity for old objects", () => {
+ const tag = new Tag();
+ tag.persist();
+ tag.prefLabel = "etaoin";
+ const activity = tag.persist();
+ assertEquals(activity["@type"], ["TagActivity", "Update"]);
+ });
+
+ it("[[Call]] states and unstates changes", () => {
+ const broader1 = new Tag();
+ broader1.persist();
+ const broader2 = new Tag();
+ broader2.persist();
+ const tag = new Tag();
+ tag.addBroaderTag(broader1);
+ tag.persist();
+ tag.prefLabel = "etaoin";
+ tag.deleteBroaderTag(broader1);
+ tag.addBroaderTag(broader2);
+ const activity = tag.persist();
+ assertObjectMatch(activity, {
+ unstates: [
+ { predicate: "prefLabel", object: { "@value": "" } },
+ { predicate: "broader", object: broader1.iri },
+ ],
+ states: [
+ { predicate: "prefLabel", object: { "@value": "etaoin" } },
+ { predicate: "broader", object: broader2.iri },
+ ],
+ });
+ });
+
+ it("[[Call]] doesn’t state if there are no additions", () => {
+ const tag = new Tag();
+ tag.addAltLabel("etaoin");
+ tag.persist();
+ tag.deleteAltLabel("etaoin");
+ const activity = tag.persist();
+ assertFalse("state" in activity);
+ });
+
+ it("[[Call]] doesn’t unstate if there are no removals", () => {
+ const tag = new Tag();
+ tag.persist();
+ tag.addAltLabel("etaoin");
+ const activity = tag.persist();
+ assertFalse("unstate" in activity);
+ });
+
+ it("[[Call]] returns null if no meaningful changes were made", () => {
+ const tag = new Tag();
+ tag.persist();
+ const activity = tag.persist();
+ assertStrictEquals(activity, null);
+ });
+ });
+
+ describe("::prefLabel", () => {
+ it("[[Set]] sets the preferred label", () => {
+ const tag = new Tag();
+ tag.prefLabel = "one";
+ assertStrictEquals(tag.prefLabel, "one");
+ tag.prefLabel = { "@value": "two" };
+ assertStrictEquals(tag.prefLabel, "two");
+ tag.prefLabel = { "@value": "three", "@language": "en" };
+ assertEquals(
+ { ...tag.prefLabel },
+ { "@value": "three", "@language": "en" },
+ );
+ });
+ });
+
+ // `::tagURI` is tested by a `.fromTagURI`.
+
+ describe("::taggingEntity", () => {
+ it("[[Get]] returns the tagging entity of the tag system", () => {
+ assertStrictEquals(
+ new Tag().taggingEntity,
+ system.taggingEntity,
+ );
+ });
+ });
+
+ describe("::toString", () => {
+ it("[[Get]] returns the string value of the preferred label", () => {
+ const tag = new Tag();
+ tag.prefLabel = { "@value": "etaoin", "@language": "zxx" };
+ assertStrictEquals(tag.toString(), "etaoin");
+ });
+ });
+
+ // `::[Storage.toObject]` is tested by `::persist`.
+ });
+
+ describe("::authorityName", () => {
+ it("[[Get]] returns the authority name", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(system.authorityName, "etaoin.example");
+ });
+ });
+
+ describe("::authorityName", () => {
+ it("[[Get]] returns the date", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(system.date, "1972-12-31");
+ });
+ });
+
+ describe("::identifiers", () => {
+ it("[[Get]] yields the extant entities", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ const tags = new Set(function* () {
+ let i = 0;
+ while (i++ < 5) {
+ // Generate 5 tags and remember their identifiers.
+ const tag = new system.Tag();
+ tag.persist();
+ yield tag.identifier;
+ }
+ }());
+ assertEquals(
+ new Set(Array.from(system.entities(), ($) => $.identifier)),
+ tags,
+ );
+ });
+ });
+
+ describe("::identifier", () => {
+ it("[[Get]] returns the identifier", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(system.identifier, "");
+ const system2 = new TagSystem(
+ "etaoin.example",
+ "1972-12-31",
+ "etaoin",
+ );
+ assertStrictEquals(system2.identifier, "etaoin");
+ });
+ });
+
+ describe("::identifiers", () => {
+ it("[[Get]] yields the identifiers in use", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ const tags = new Set(function* () {
+ let i = 0;
+ while (i++ < 5) {
+ // Generate 5 tags and remember their identifiers.
+ const tag = new system.Tag();
+ tag.persist();
+ yield tag.identifier;
+ }
+ }());
+ assertEquals(new Set(system.identifiers()), tags);
+ });
+ });
+
+ describe("::iri", () => {
+ it("[[Get]] returns the I·R·I", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(
+ system.iri,
+ "https://etaoin.example/tag:etaoin.example,1972-12-31:",
+ );
+ const system2 = new TagSystem(
+ "etaoin.example",
+ "1972-12-31",
+ "etaoin",
+ );
+ assertStrictEquals(
+ system2.iri,
+ "https://etaoin.example/tag:etaoin.example,1972-12-31:etaoin",
+ );
+ });
+ });
+
+ describe("::tagURI", () => {
+ it("[[Get]] returns the Tag U·R·I", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(
+ system.tagURI,
+ "tag:etaoin.example,1972-12-31:",
+ );
+ const system2 = new TagSystem(
+ "etaoin.example",
+ "1972-12-31",
+ "etaoin",
+ );
+ assertStrictEquals(
+ system2.tagURI,
+ "tag:etaoin.example,1972-12-31:etaoin",
+ );
+ });
+ });
+
+ describe("::taggingEntity", () => {
+ it("[[Get]] returns the tagging entity", () => {
+ const system = new TagSystem("etaoin.example", "1972-12-31");
+ assertStrictEquals(
+ system.taggingEntity,
+ "etaoin.example,1972-12-31",
+ );
+ });
+ });
+});