From: Lady Date: Mon, 29 May 2023 05:18:55 +0000 (-0700) Subject: Initial Tag model X-Git-Url: https://git.ladys.computer/Etiquette/commitdiff_plain/6623cbf39b85515219a3ab012e27d97884ea8aa3 Initial Tag model Work is still needed here to allow `TagSystem`s to replay the activities generated by persisting tags, but the `Tag` part of this is close and working. --- diff --git a/dev-deps.js b/dev-deps.js index ed20df8..4710b49 100644 --- a/dev-deps.js +++ b/dev-deps.js @@ -9,7 +9,10 @@ export { assert, + assertArrayIncludes, assertEquals, + assertFalse, + assertObjectMatch, assertStrictEquals, assertThrows, } from "https://deno.land/std@0.189.0/testing/asserts.ts"; 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}`; + } +} diff --git a/model.test.js b/model.test.js new file mode 100644 index 0000000..7944238 --- /dev/null +++ b/model.test.js @@ -0,0 +1,1000 @@ +// šŸ“§šŸ·ļø Ɖ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 . + +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", + ); + }); + }); +}); diff --git a/names.js b/names.js new file mode 100644 index 0000000..086e344 --- /dev/null +++ b/names.js @@ -0,0 +1,14 @@ +// šŸ“§šŸ·ļø Ɖtiquette āˆ· names.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 . + +export const taggingContext = + "https://ns.1024.gdn/Tagging/context.jsonld"; + +export const taggingDiscoveryContext = + "https://ns.1024.gdn/Tagging/discovery.context.jsonld";