1 // 📧🏷️ Étiquette ∷ model.js
2 // ====================================================================
4 // Copyright © 2023 Lady [@ Lady’s Computer].
6 // This Source Code Form is subject to the terms of the Mozilla Public
7 // License, v. 2.0. If a copy of the MPL was not distributed with this
8 // file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
10 import { Storage
} from "./memory.js";
11 import { taggingDiscoveryContext
} from "./names.js";
13 // TODO: Move these somewhere else and allow for modification before
14 // they are used, freezing them only once tags actually start being
15 // constructed (probably on first call to the `TagSystem` initializer
18 // Or else make them properties of the tag system itself and ∼fully
22 * Tag kinds which denote entity tags.
24 * ※ This object is not exposed.
26 const ENTITY_TAG_KINDS
= new Set([
33 * Tag kinds which denote relationship tags.
35 * ※ This object is not exposed.
37 const RELATIONSHIP_TAG_KINDS
= new Set([
39 "FamilialRelationship Tag",
42 "RomanticRelationshipTag",
43 "SexualRelationshipTag",
47 * Tag kinds which denote setting tags.
49 * ※ This object is not exposed.
51 const SETTING_TAG_KINDS
= new Set([
59 * Tag kinds which denote conceptual tags.
61 * ※ This object is not exposed.
63 const CONCEPTUAL_TAG_KINDS
= new Set(function* () {
64 yield "ConceptualTag";
65 yield* RELATIONSHIP_TAG_KINDS
;
69 * All recognized tag kinds.
71 * ※ This object is not exposed.
73 const TAG_KINDS
= new Set(function* () {
76 yield* CONCEPTUAL_TAG_KINDS
;
77 yield* ENTITY_TAG_KINDS
;
79 yield* SETTING_TAG_KINDS
;
83 * Tag kinds which can be in canon.
85 * ※ This object is not exposed.
87 const HAS_IN_CANON
= new Set(function* () {
88 yield* ENTITY_TAG_KINDS
;
89 yield* SETTING_TAG_KINDS
;
93 * Tag kinds which can be involved in relationship tags.
95 * ※ This object is not exposed.
97 const INVOLVABLE_IN_RELATIONSHIP
= new Set(function* () {
99 yield* RELATIONSHIP_TAG_KINDS
;
103 * Properties which take literal values instead of identifiers.
105 * These are the label terms.
107 const LITERAL_TERMS
= new Set([
114 * Properties to skip when diffing.
116 * These are all inverses of properties included in diffs and cannot be
119 const SKIP_IN_DIFF
= new Set([
128 * `Tag`s are not assigned identifiers and do not have side·effects on
129 * other tags in the `TagSystem` until they are persisted with
130 * `::persist`, at which point changes to their relationships are
133 * `Tag`s are also not kept up‐to‐date, but persisting an outdated
134 * `Tag` will *not* undo subsequent changes.
136 * ※ This class is not itself directly exposed, although bound
137 * versions of it are via `TagSystem::Tag`.
140 /** The `TagSystem` this `Tag` belongs to. */
143 /** The `Storage` managed by this `Tag`’s `TagSystem`. */
147 * The 30‐bit W·R·M·G base32 identifier with leading checksum which
148 * has been assigned to this `Tag`.
150 * Will be `null` if this `Tag` has not been persisted. Otherwise,
151 * the format is `cxx-xxxx` (`c` = checksum; `x` = digit).
155 /** The kind of this `Tag`. */
159 * The data which was attached to this `Tag` the last time it was
160 * persisted or retrieved from storage.
162 * Diffing with this will reveal changes.
164 #persistedData
= null;
166 /** The current (modified) data associated with this `Tag`. */
170 * Returns whether or not the provided value is a tag which shares a
171 * storage with this tag.
173 * Sharing a storage also implies sharing a `TagSystem`.
177 // Try to compare the provided value’s internal store with
178 // the provided storage.
179 return $.#storage
== this.#storage
;
181 // The provided value was not a `Tag`.
187 * Constructs a new `Tag` of the provided kind and with the provided
190 * ※ The first two arguments of this constructor are bound when
191 * generating the value of `TagSystem::Tag`. It isn’t possible to
192 * access this constructor in its unbound form from outside this
195 * ☡ This constructor throws if the provided kind is not supported.
197 constructor(system
, storage
, kind
= "Tag", prefLabel
= "") {
198 const kindString
= `${kind}`;
199 if (TAG_KINDS
.has(kindString
)) {
200 // The provided kind is one of the recognized tag kinds.
201 this.#system
= system
;
202 this.#storage
= storage
;
203 this.#kind
= kindString
;
204 this.#data
.prefLabel
= prefLabel
;
206 // The provided kind is not supported.
207 throw new RangeError(
208 `Cannot construct Tag: Unrecognized kind: ${kind}.`,
214 * Yields the tags in the `TagSystem` associated with this
217 * ※ The first two arguments of this function are bound when
218 * generating the value of `TagSystem::Tag`. It isn’t possible to
219 * access this function in its unbound form from outside this module.
221 static *all(system
, storage
) {
222 for (const instance
of storage
.values()) {
223 // Iterate over the entries and yield the ones which are `Tag`s
224 // in this `TagSystem`.
225 if (Tag
.getSystem(instance
) == system
) {
226 // The current instance is a `Tag` in this `TagSystem`.
229 // The current instance is not a `Tag` in this `TagSystem`.
236 * Returns a new `Tag` resolved from the provided I·R·I.
238 * ※ The first two arguments of this function are bound when
239 * generating the value of `TagSystem::Tag`. It isn’t possible to
240 * access this function in its unbound form from outside this module.
242 * ☡ This function throws if the I·R·I is not in the `.iriSpace` of
243 * the `TagSystem` associated with this constructor.
245 * ※ If the I·R·I is not recognized, this function returns `null`.
247 static fromIRI(system
, storage
, iri
) {
248 const name
= `${iri}`;
249 const prefix
= `${system.iriSpace}`;
250 if (!name
.startsWith(prefix
)) {
251 // The I·R·I does not begin with the expected prefix.
252 throw new RangeError(
253 `I·R·I did not begin with the expected prefix: ${iri}`,
256 // The I·R·I begins with the expected prefix.
257 const identifier
= name
.substring(prefix
.length
);
259 // Attempt to resolve the identifier.
260 const instance
= storage
.get(identifier
);
261 return Tag
.getSystem(instance
) == system
? instance
: null;
263 // Do not throw for bad identifiers.
270 * Returns a new `Tag` resolved from the provided identifier.
272 * ※ The first two arguments of this function are bound when
273 * generating the value of `TagSystem::Tag`. It isn’t possible to
274 * access this function in its unbound form from outside this module.
276 * ☡ This function throws if the identifier is invalid.
278 * ※ If the identifier is valid but not recognized, this function
281 static fromIdentifier(system
, storage
, identifier
) {
282 const instance
= storage
.get(identifier
);
283 return Tag
.getSystem(instance
) == system
? instance
: null;
287 * Returns a new `Tag` resolved from the provided Tag U·R·I.
289 * ※ The first two arguments of this function are bound when
290 * generating the value of `TagSystem::Tag`. It isn’t possible to
291 * access this function in its unbound form from outside this module.
293 * ☡ This function throws if the provided Tag U·R·I does not match
294 * the tagging entity of this constructor’s `TagSystem`.
296 * ※ If the specific component of the Tag U·R·I is not recognized,
297 * this function returns `null`.
299 static fromTagURI(system
, storage
, tagURI
) {
300 const tagName
= `${tagURI}`;
301 const tagPrefix
= `tag:${system.taggingEntity}:`;
302 if (!tagName
.startsWith(tagPrefix
)) {
303 // The Tag U·R·I does not begin with the expected prefix.
304 throw new RangeError(
305 `Tag U·R·I did not begin with the expected prefix: ${tagURI}`,
308 // The I·R·I begins with the expected prefix.
309 const identifier
= tagName
.substring(tagPrefix
.length
);
311 // Attempt to resolve the identifier.
312 const instance
= storage
.get(identifier
);
313 return Tag
.getSystem(instance
) == system
? instance
: null;
315 // Do not throw for bad identifiers.
322 * Returns the `TagSystem` that the provided value belongs to.
324 * ※ This function can be used to check if the provided value has
325 * private tag features.
327 * ※ This function is not exposed.
329 static getSystem($) {
330 return !(#system
in Object($)) ? null : $.#system
;
334 * Yields the tag identifiers in the `TagSystem` associated with this
337 * ※ The first two arguments of this function are bound when
338 * generating the value of `TagSystem::Tag`. It isn’t possible to
339 * access this function in its unbound form from outside this module.
341 static *identifiers(system
, storage
) {
342 for (const [identifier
, instance
] of storage
.entries()) {
343 // Iterate over the entries and yield the ones which are `Tag`s
344 // in this `TagSystem`.
345 if (Tag
.getSystem(instance
) == system
) {
346 // The current instance is a `Tag` in this `TagSystem`.
349 // The current instance is not a `Tag` in this `TagSystem`.
356 * Returns a new `Tag` constructed from the provided data and with
357 * the provided identifier.
359 * ※ This function will not work if called directly from `Tag` (and
360 * nor is it available *to* be called as such from outside this
361 * module). It must be called from a `TagSystem::Tag` bound
364 * ※ This function is not really intended for public usage.
366 static [Storage
.toInstance
](_system
, _storage
, data
, identifier
) {
367 const tag
= new this(data
.kind
);
368 tag
.#identifier
= `${identifier}`;
369 tag
.#persistedData
= tagData(data
);
370 tag
.#data
= tagData(data
);
375 // Overwrite the default `::constructor` method to instead give the
376 // actual (bound) constructor which was used to generate a given
378 Object
.defineProperties(this.prototype, {
383 // All `Tag`s are constructed via the `.Tag` constructor
384 // available in their `TagSystem`; return it.
385 return this.#system
.Tag
;
393 * Adds the provided label(s) to this `Tag` as alternate labels, then
394 * returns this `Tag`.
396 addAltLabel(...labels
) {
397 const altLabels
= this.#data
.altLabel
;
398 let objectLabels
= null; // initialized on first use
399 for (const $ of labels
) {
400 // Iterate over each provided label and attempt to add it.
401 const literal
= langString($);
402 if (Object(literal
) === literal
) {
403 // The current label is a language‐tagged string.
404 objectLabels
??= [...function* () {
405 for (const altLabel
of altLabels
) {
406 // Iterate over the existing labels and yield the
407 // language‐tagged strings.
408 if (Object(altLabel
) === altLabel
) {
409 // The current existing label is a language‐tagged
413 // The current existing label is not a language‐tagged
420 objectLabels
.some((objectLabel
) =>
421 objectLabel
["@value"] == literal
["@value"] &&
422 objectLabel
["@language"] == literal
["@language"]
425 // There is a match with the current label in the existing
429 // There is no match and this label must be added.
430 altLabels
.add(literal
);
431 objectLabels
.push(literal
);
434 // The current label is a simple string.
435 altLabels
.add(literal
);
442 * Adds the provided tags to the list of tags that this `Tag` is
443 * narrower than, then returns this `Tag`.
445 * Arguments may be string identifiers or objects with an
446 * `.identifier` property.
448 addBroaderTag(...tags
) {
449 const storage
= this.#storage
;
450 const broader
= this.#data
.broader
;
451 for (const $ of tags
) {
452 // Iterate over each tag and attempt to set it as broader than
454 const identifier
= toIdentifier($);
455 if (identifier
== null) {
456 // ☡ The current tag has no identifier.
458 "Cannot assign broader to Tag: Identifier must not be nullish.",
460 } else if (broader
.has(identifier
)) {
461 // Short‐circuit: The identifier is already something this
462 // `Tag` is narrower than.
465 // The current tag has an identifier.
466 const tag
= storage
.get(identifier
);
468 // ☡ The current tag has not been persisted to this `Tag`’s
470 throw new RangeError(
471 `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`,
473 } else if (!this.#isTagInStorage(tag
)) {
474 // ☡ The current tag is not a tag in the correct tag system.
476 `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
479 // The current tag is a tag in the correct tag system; add
481 broader
.add(identifier
);
489 * Adds the provided label(s) to this `Tag` as hidden labels, then
490 * returns this `Tag`.
492 addHiddenLabel(...labels
) {
493 const hiddenLabels
= this.#data
.hiddenLabel
;
494 let objectLabels
= null; // initialized on first use
495 for (const $ of labels
) {
496 // Iterate over each provided label and attempt to add it.
497 const literal
= langString($);
498 if (Object(literal
) === literal
) {
499 // The current label is a language‐tagged string.
500 objectLabels
??= [...function* () {
501 for (const hiddenLabel
of hiddenLabels
) {
502 // Iterate over the existing labels and yield the
503 // language‐tagged strings.
504 if (Object(hiddenLabel
) === hiddenLabel
) {
505 // The current existing label is a language‐tagged
509 // The current existing label is not a language‐tagged
516 objectLabels
.some((objectLabel
) =>
517 objectLabel
["@value"] == literal
["@value"] &&
518 objectLabel
["@language"] == literal
["@language"]
521 // There is a match with the current label in the existing
525 // There is no match and this label must be added.
526 hiddenLabels
.add(literal
);
527 objectLabels
.push(literal
);
530 // The current label is a simple string.
531 hiddenLabels
.add(literal
);
538 * Adds the provided tags to the list of tags that this `Tag` is in
539 * canon with, then returns this `Tag`.
541 * Arguments may be string identifiers or objects with an
542 * `.identifier` property.
544 * ☡ This method will throw if a provided argument does not indicate
545 * a canon tag, or if this `Tag` is not of a kind which can be placed
548 addInCanonTag(...tags
) {
549 const storage
= this.#storage
;
550 const kind
= this.#kind
;
551 const inCanon
= this.#data
.inCanon
;
552 if (!HAS_IN_CANON
.has(kind
)) {
553 // ☡ This is not an entity tag, setting tag, or recognized
556 `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`,
559 // This has a kind which can be placed in canon.
560 for (const $ of tags
) {
561 // Iterate over each tag and attempt to set this `Tag` in canon
563 const identifier
= toIdentifier($);
564 if (identifier
== null) {
565 // ☡ The current tag has no identifier.
567 "Cannot put Tag in canon: Identifier must not be nullish.",
569 } else if (inCanon
.has(identifier
)) {
570 // Short‐circuit: The identifier is already something this
571 // `Tag` is in canon of.
574 // The current tag has an identifier.
575 const tag
= storage
.get(identifier
);
577 // ☡ The current tag has not been persisted to this `Tag`’s
579 throw new RangeError(
580 `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`,
583 // ※ If the first check succeeds, then the current tag
584 // must have `Tag` private class features.
585 !this.#isTagInStorage(tag
) || tag
.#kind
!= "CanonTag"
587 // ☡ The current tag is not a canon tag in the correct
590 `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`,
593 // The current tag is a canon tag in the correct tag
594 // system; add its identifier.
595 inCanon
.add(identifier
);
604 * Adds the provided tags to the list of tags that this `Tag`
605 * involves, then returns this `Tag`.
607 * Arguments may be string identifiers or objects with an
608 * `.identifier` property.
610 * ☡ This method will throw if this `Tag` is not a conceptual tag, or
611 * if this `Tag` is a relationship tag and a provided argument does
612 * not indicate a character or relationship tag.
614 addInvolvesTag(...tags
) {
615 const storage
= this.#storage
;
616 const kind
= this.#kind
;
617 const involves
= this.#data
.involves
;
618 if (!CONCEPTUAL_TAG_KINDS
.has(kind
)) {
619 // ☡ This is not a conceptual tag or recognized subclass.
621 `Cannot involve Tag: Incorrect Tag type: ${kind}.`,
624 // This is a conceptual tag.
625 for (const $ of tags
) {
626 // Iterate over each tag and attempt to set this `Tag` as
628 const identifier
= toIdentifier($);
629 if (identifier
== null) {
630 // ☡ The current tag has no identifier.
632 "Cannot involve Tag: Identifier must not be nullish.",
634 } else if (involves
.has(identifier
)) {
635 // Short‐circuit: The identifier is already something this
639 // The current tag has an identifier.
640 const tag
= storage
.get(identifier
);
642 // ☡ The current tag has not been persisted to this `Tag`’s
644 throw new RangeError(
645 `Cannot involve Tag: Identifier is not persisted: ${identifier}.`,
648 // ※ If the first check succeeds, then the current tag
649 // must have `Tag` private class features.
650 !this.#isTagInStorage(tag
) ||
651 RELATIONSHIP_TAG_KINDS
.has(kind
) &&
652 !INVOLVABLE_IN_RELATIONSHIP
.has(tag
.#kind
)
654 // ☡ The current tag is in the correct tag system and
657 `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`,
660 // The current tag is an involvable tag in the correct tag
661 // system; add its identifier.
662 involves
.add(identifier
);
670 /** Yields the alternative labels of this `Tag`. */
672 yield* this.#data
.altLabel
;
675 /** Returns the authority (domain) name for this `Tag`. */
676 get authorityName() {
677 return this.#system
.authorityName
;
680 /** Yields `Tag`s which are broader than this `Tag`. */
682 const storage
= this.#storage
;
683 for (const identifier
of this.#data
.broader
) {
684 // Iterate over the broader tags and yield them if possible.
685 const tag
= storage
.get(identifier
);
686 if (!this.#isTagInStorage(tag
)) {
687 // The broader tag no longer appears in storage; perhaps it was
691 // The broader tag exists and is constructable from storage.
697 /** Yields `Tag`s which are broader than this `Tag`, transitively. */
698 *broaderTransitiveTags() {
699 const storage
= this.#storage
;
700 const encountered
= new Set();
701 let pending
= new Set(this.#data
.broader
);
702 while (pending
.size
> 0) {
703 // Loop until all broader tags have been encountered.
704 const processing
= pending
;
706 for (const identifier
of processing
) {
707 // Iterate over the broader tags and yield them if possible.
708 if (!encountered
.has(identifier
)) {
709 // The broader tag has not been encountered before.
710 encountered
.add(identifier
);
711 const tag
= storage
.get(identifier
);
712 if (!this.#isTagInStorage(tag
)) {
713 // The broader tag no longer appears in storage; perhaps it
717 // The broader tag exists and is constructable from
720 for (const transitive
of tag
.#data
.broader
) {
721 // Iterate over the broader tags of the current broader
722 // tag and add them to pending as needed.
723 if (!encountered
.has(transitive
)) {
724 // The broader broader tag has not been encountered
726 pending
.add(transitive
);
728 // The broader broader tag has already been
735 // The broader tag has already been encountered.
743 * Removes the provided string label(s) from this `Tag` as alternate
744 * labels, then returns this `Tag`.
746 deleteAltLabel(...labels
) {
747 const altLabels
= this.#data
.altLabel
;
748 let objectLabels
= null; // initialized on first use
749 for (const $ of labels
) {
750 // Iterate over each provided label and attempt to remove it.
751 const literal
= langString($);
752 if (Object(literal
) === literal
) {
753 // The current label is a language‐tagged string.
754 objectLabels
??= [...function* () {
755 for (const altLabel
of altLabels
) {
756 // Iterate over the existing labels and yield the
757 // language‐tagged strings.
758 if (Object(altLabel
) === altLabel
) {
759 // The current existing label is a language‐tagged
763 // The current existing label is not a language‐tagged
769 const existing
= objectLabels
.find((objectLabel
) =>
770 objectLabel
["@value"] == literal
["@value"] &&
771 objectLabel
["@language"] == literal
["@language"]
773 altLabels
.delete(existing
);
775 // The current label is a simple string.
776 altLabels
.delete(literal
);
783 * Removes the provided tags from the list of tags that this `Tag` is
784 * narrower than, then returns this `Tag`.
786 * Arguments may be string identifiers or objects with an
787 * `.identifier` property.
789 deleteBroaderTag(...tags
) {
790 const broader
= this.#data
.broader
;
791 for (const $ of tags
) {
792 // Iterate over the provided tags and delete them.
793 broader
.delete(toIdentifier($));
799 * Removes the provided string label(s) from this `Tag` as hidden
800 * labels, then returns this `Tag`.
802 deleteHiddenLabel(...labels
) {
803 const hiddenLabels
= this.#data
.hiddenLabel
;
804 let objectLabels
= null; // initialized on first use
805 for (const $ of labels
) {
806 // Iterate over each provided label and attempt to remove it.
807 const literal
= langString($);
808 if (Object(literal
) === literal
) {
809 // The current label is a language‐tagged string.
810 objectLabels
??= [...function* () {
811 for (const hiddenLabel
of hiddenLabels
) {
812 // Iterate over the existing labels and yield the
813 // language‐tagged strings.
814 if (Object(hiddenLabel
) === hiddenLabel
) {
815 // The current existing label is a language‐tagged
819 // The current existing label is not a language‐tagged
825 const existing
= objectLabels
.find((objectLabel
) =>
826 objectLabel
["@value"] == literal
["@value"] &&
827 objectLabel
["@language"] == literal
["@language"]
829 hiddenLabels
.delete(existing
);
831 // The current label is a simple string.
832 hiddenLabels
.delete(literal
);
839 * Removes the provided tags from the list of tags that this `Tag` is
840 * in canon with, then returns this `Tag`.
842 * Arguments may be string identifiers or objects with an
843 * `.identifier` property.
845 deleteInCanonTag(...tags
) {
846 const inCanon
= this.#data
.inCanon
;
847 for (const $ of tags
) {
848 // Iterate over the provided tags and delete them.
849 inCanon
.delete(toIdentifier($));
855 * Removes the provided tags from the list of tags that this `Tag`
856 * involves, then returns this `Tag`.
858 * Arguments may be string identifiers or objects with an
859 * `.identifier` property.
861 deleteInvolvesTag(...tags
) {
862 const involves
= this.#data
.involves
;
863 for (const $ of tags
) {
864 // Iterate over the provided tags and delete them.
865 involves
.delete(toIdentifier($));
870 /** Yields `Tag`s that are in canon of this `Tag`. */
872 const storage
= this.#storage
;
873 if (this.#kind
== "CanonTag") {
874 // This is a canon tag.
875 for (const identifier
of this.#data
.hasInCanon
) {
876 // Iterate over the tags in canon and yield them if possible.
877 const tag
= storage
.get(identifier
);
879 !this.#isTagInStorage(tag
) || !HAS_IN_CANON
.has(tag
.#kind
)
881 // The tag in canon no longer appears in storage; perhaps it
885 // The tag in canon exists and is constructable from storage.
894 /** Yields the hidden labels of this `Tag`. */
896 yield* this.#data
.hiddenLabel
;
899 /** Returns the identifier of this `Tag`. */
901 return this.#identifier
;
904 /** Yields `Tag`s that this `Tag` is in canon of. */
906 const storage
= this.#storage
;
907 if (HAS_IN_CANON
.has(this.#kind
)) {
908 // This tag can be placed in canon.
909 for (const identifier
of this.#data
.inCanon
) {
910 // Iterate over the canon tags and yield them if possible.
911 const tag
= storage
.get(identifier
);
912 if (!this.#isTagInStorage(tag
) || tag
.#kind
!= "CanonTag") {
913 // The canon tag no longer appears in storage; perhaps it was
917 // The canon tag exists and is constructable from storage.
922 // This tag cannot be placed in canon.
927 /** Yields `Tag`s which involve this `Tag`. */
929 const storage
= this.#storage
;
930 for (const identifier
of this.#data
.involvedIn
) {
931 // Iterate over the involving tags and yield them if possible.
932 const tag
= storage
.get(identifier
);
933 const tagKind
= tag
.#kind
;
935 !this.#isTagInStorage(tag
) ||
936 !CONCEPTUAL_TAG_KINDS
.has(tagKind
) ||
937 RELATIONSHIP_TAG_KINDS
.has(tagKind
) &&
938 !INVOLVABLE_IN_RELATIONSHIP
.has(this.#kind
)
940 // The including tag no longer appears in storage; perhaps it
944 // The including tag exists and is constructable from storage.
950 /** Yields `Tag`s that this `Tag` involves. */
952 const storage
= this.#storage
;
953 const kind
= this.#kind
;
954 if (CONCEPTUAL_TAG_KINDS
.has(kind
)) {
955 // This tag can involve other tags.
956 for (const identifier
of this.#data
.involves
) {
957 // Iterate over the involved and yield them if possible.
958 const tag
= storage
.get(identifier
);
960 !this.#isTagInStorage(tag
) ||
961 RELATIONSHIP_TAG_KINDS
.has(kind
) &&
962 !INVOLVABLE_IN_RELATIONSHIP
.has(tag
.#kind
)
964 // The involved tag no longer appears in storage; perhaps it
968 // The involved tag exists and is constructable from storage.
973 // This tag cannot involve other tags.
978 /** Returns the I·R·I for this `Tag`. */
980 const { identifier
, iriSpace
} = this;
981 return identifier
== null ? null : `${iriSpace}${identifier}`;
984 /** Returns the I·R·I space for this `Tag`. */
986 return this.#system
.iriSpace
;
989 /** Returns the kind of this `Tag`. */
994 /** Yields `Tag`s which are narrower than this `Tag`. */
996 const storage
= this.#storage
;
997 for (const identifier
of this.#data
.narrower
) {
998 const tag
= storage
.get(identifier
);
999 if (!this.#isTagInStorage(tag
)) {
1000 // The narrower tag no longer appears in storage; perhaps it
1004 // The narrower tag exists and is constructable from storage.
1011 * Yields `Tag`s which are narrower than this `Tag`, transitively.
1013 *narrowerTransitiveTags() {
1014 const storage
= this.#storage
;
1015 const encountered
= new Set();
1016 let pending
= new Set(this.#data
.narrower
);
1017 while (pending
.size
> 0) {
1018 // Loop until all narrower tags have been encountered.
1019 const processing
= pending
;
1020 pending
= new Set();
1021 for (const identifier
of processing
) {
1022 // Iterate over the narrower tags and yield them if possible.
1023 if (!encountered
.has(identifier
)) {
1024 // The narrower tag has not been encountered before.
1025 encountered
.add(identifier
);
1026 const tag
= storage
.get(identifier
);
1027 if (!this.#isTagInStorage(tag
)) {
1028 // The narrower tag no longer appears in storage; perhaps
1032 // The narrower tag exists and is constructable from
1035 for (const transitive
of tag
.#data
.narrower
) {
1036 // Iterate over the narrower tags of the current narrower
1037 // tag and add them to pending as needed.
1038 if (!encountered
.has(transitive
)) {
1039 // The narrower narrower tag has not been encountered
1041 pending
.add(transitive
);
1043 // The narrower narrower tag has already been
1050 // The narrower tag has already been encountered.
1058 * Persist this `Tag` to storage and return an ActivityStreams
1059 * serialization of a Tag Activity representing any changes, or
1060 * `null` if no changes were made.
1062 * If the second argument is `true`, the `Tag` will be persisted but
1063 * no serialization will be made. This is somewhat more efficient.
1065 * ※ Persistence can imply side‐effects on other objects, which are
1066 * not noted explicitly in the activity. For example, marking a tag
1067 * as broader than another causes the other tag to reciprocally be
1068 * marked as narrower.
1070 * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower`
1071 * will never appear in the predicates of generated activities.
1073 persist(silent
= false) {
1074 const system
= this.#system
;
1075 const storage
= this.#storage
;
1076 const persistedData
= this.#persistedData
;
1077 const data
= this.#data
;
1079 for (const [key
, value
] of Object
.entries(data
)) {
1080 // Iterate over each entry of the tag data and create a diff
1081 // with the last persisted information.
1082 if (SKIP_IN_DIFF
.has(key
) || silent
&& LITERAL_TERMS
.has(key
)) {
1083 // The current property is one which is skipped in diffs.
1085 // In a silent persist, this includes any literal terms.
1088 // The current property should be diffed.
1089 const persisted
= persistedData
?.[key
] ?? null;
1090 if (persisted
== null) {
1091 // There is no persisted data for the current property yet.
1094 new: value
instanceof Set
1098 } else if (value
instanceof Set
) {
1099 // The current property is set‐valued.
1100 let values
= null; // initialized on first use
1101 const oldValues
= new Set(persisted
);
1102 const newValues
= new Set(value
);
1103 for (const existing
of persisted
) {
1104 // Iterate over each persisted property and either remove
1105 // it from the list of new values or add it to the list of
1108 // ※ Some special handling is required here for
1109 // language‐tagged strings.
1111 value
.has(existing
) ||
1112 Object(existing
) === existing
&&
1113 (values
??= [...value
]).some(($) =>
1114 `${$}` == `${existing}` &&
1115 $.language
== existing
.language
1118 // The value is in both the old and new version of the
1120 oldValues
.delete(existing
);
1121 newValues
.delete(existing
);
1123 // The value is not shared.
1132 `${value}` != `${persisted}` ||
1133 value
.language
!= persisted
.language
1135 // The current property is (optionally language‐tagged)
1136 // string‐valued and the value changed.
1138 old
: new Set([persisted
]),
1139 new: new Set([value
]),
1142 // The current property did not change.
1150 const identifier
= this.#identifier
;
1151 if (identifier
!= null) {
1152 // This `Tag` has already been persisted; use its existing
1153 // identifier and persist.
1154 storage
.set(identifier
, this);
1156 // This `Tag` has not been persisted yet; save the new
1157 // identifier after persisting.
1158 this.#identifier
= storage
.add(this);
1160 const persistedIdentifier
= this.#identifier
;
1161 this.#persistedData
= tagData(data
); // cloning here is necessary
1163 const [term
, inverse
] of [
1164 ["broader", "narrower"],
1165 ["inCanon", "hasInCanon"],
1166 ["involves", "involvedIn"],
1169 // Iterate over each term referencing other tags and update the
1170 // inverse property on those tags if possible.
1171 for (const referencedIdentifier
of diffs
[term
].old
) {
1172 // Iterate over the removed tags and remove this `Tag` from
1173 // their inverse property.
1174 const referenced
= storage
.get(referencedIdentifier
);
1176 // Try removing this `Tag`.
1177 referenced
.#data
[inverse
].delete(persistedIdentifier
);
1178 storage
.set(referencedIdentifier
, referenced
);
1180 // Removal failed, possibly because the other tag was
1185 for (const referencedIdentifier
of diffs
[term
].new) {
1186 const referenced
= storage
.get(referencedIdentifier
);
1188 // Try adding this `Tag`.
1189 referenced
.#data
[inverse
].add(persistedIdentifier
);
1190 storage
.set(referencedIdentifier
, referenced
);
1192 // Adding failed, possibly because the other tag was deleted.
1198 // This is a silent persist.
1201 // This is not a silent persist; an activity needs to be
1202 // generated if a change was made.
1204 "@context": taggingDiscoveryContext
,
1207 identifier
== null ? "Create" : "Update",
1209 context
: `${system.iri}`,
1210 object
: `${this.iri}`,
1211 endTime
: new Date().toISOString(),
1213 const statements
= {
1217 const { unstates
, states
} = statements
;
1218 if (identifier
== null) {
1219 // This is a Create activity.
1220 states
.push({ predicate
: "a", object
: `${this.kind}` });
1222 // This is an Update activity.
1229 }] of Object
.entries(diffs
)
1231 // Iterate over the diffs of each term and state/unstate
1232 // things as needed.
1233 for (const oldValue
of oldValues
) {
1234 // Iterate over removals and unstate them.
1235 if (LITERAL_TERMS
.has(term
)) {
1236 // This is a literal term; push the change wrapped in an
1240 object
: Object(oldValue
) === oldValue
1241 ? { ...langString(oldValue
) }
1242 : { "@value": `${oldValue}` },
1245 // This is a named term; attempt to get its I·R·I and
1248 // Attempt to resolve the value and push the change.
1249 const tag
= storage
.get(oldValue
);
1250 if (!this.#isTagInStorage(tag
)) {
1251 // The value did not resolve to a tag in storage.
1254 // The value resolved; push its I·R·I.
1261 // Value resolution failed for some reason; perhaps the
1267 for (const newValue
of newValues
) {
1268 // Iterate over additions and state them.
1269 if (LITERAL_TERMS
.has(term
)) {
1270 // This is a literal term; push the change wrapped in an
1274 object
: Object(newValue
) === newValue
1275 ? { ...langString(newValue
) }
1276 : { "@value": `${newValue}` },
1279 // This is a named term; attempt to get its I·R·I and
1282 // Attempt to resolve the value and push the change.
1283 const tag
= storage
.get(newValue
);
1284 if (!this.#isTagInStorage(tag
)) {
1285 // The value did not resolve to a tag in storage.
1288 // The value resolved; push its I·R·I.
1295 // Value resolution failed for some reason; perhaps the
1302 if (unstates
.length
== 0) {
1303 // Nothing was unstated.
1304 delete statements
.unstates
;
1306 // Things were stated.
1309 if (states
.length
== 0) {
1310 // Nothing was stated.
1311 delete statements
.states
;
1313 // Things were stated.
1320 !Object
.hasOwn(activity
, "states") &&
1321 !Object
.hasOwn(activity
, "unstates")
1323 // No meaningful changes were actually persisted.
1326 // There were meaningful changes persisted regarding this `Tag`.
1332 /** Returns the preferred label for this `Tag`. */
1334 return this.#data
.prefLabel
;
1337 /** Sets the preferred label of this `Tag` to the provided label. */
1339 this.#data
.prefLabel
= langString($);
1342 /** Returns the Tag U·R·I for this `Tag`. */
1344 const { identifier
} = this;
1345 return identifier
== null
1347 : `tag:${this.taggingEntity}:${identifier}`;
1350 /** Returns the tagging entity (domain and date) for this `Tag`. */
1351 get taggingEntity() {
1352 return this.#system
.taggingEntity
;
1355 /** Returns the string form of the preferred label of this `Tag`. */
1357 return `${this.#data.prefLabel}`;
1361 * Returns a new object whose enumerable own properties contain the
1362 * data from this object needed for storage.
1364 * ※ This method is not really intended for public usage.
1366 [Storage
.toObject
]() {
1367 const data
= this.#data
;
1368 return Object
.assign(Object
.create(null), {
1377 * Returns the provided value converted into a `String` object with
1378 * `.["@value"]` and `.["@language"]` properties.
1380 * The same object will be returned for every call with an equivalent
1383 * TODO: Ideally this would be extracted more fully into an R·D·F
1386 * ※ This function is not exposed.
1391 * Returns the language string object corresponding to the provided
1392 * value and language.
1394 const getLangString
= (value
, language
= "") => {
1395 const valueMap
= languageMap
[language
] ??= Object
.create(null);
1396 const literal
= valueMap
[value
]?.deref();
1397 if (literal
!= null) {
1398 // There is already an object corresponding to the provided value
1402 // No object already exists corresponding to the provided value
1403 // and language; create one.
1404 const result
= Object
.preventExtensions(
1405 Object
.create(String
.prototype, {
1411 enumerable
: !!language
,
1412 value
: language
|| null,
1414 language
: { enumerable
: false, get: getLanguage
},
1415 toString
: { enumerable
: false, value
: toString
},
1416 valueOf
: { enumerable
: false, value
: valueOf
},
1419 const ref
= new WeakRef(result
);
1420 langStringRegistry
.register(result
, { ref
, language
, value
});
1421 valueMap
[value
] = ref
;
1426 /** Returns the `.["@language"]` of this object. */
1427 const getLanguage
= Object
.defineProperty(
1429 return this["@language"] || null;
1432 { value
: "get language" },
1436 * A `FinalizationRegistry` for language string objects.
1438 * This simply cleans up the corresponding `WeakRef` in the language
1441 const langStringRegistry
= new FinalizationRegistry(
1442 ({ ref
, language
, value
}) => {
1443 const valueMap
= languageMap
[language
];
1444 if (valueMap
?.[value
] === ref
) {
1445 delete valueMap
[value
];
1453 * An object whose own values are an object mapping values to
1454 * language string objects for the language specified by the key.
1456 const languageMap
= Object
.create(null);
1458 /** Returns the `.["@value"]` of this object. */
1459 const toString = function () {
1460 return this["@value"];
1464 * Returns this object if it has a `.["@language"]`; otherwise, its
1467 const valueOf = function () {
1468 return this["@language"] ? this : this["@value"];
1478 `${$["@language"] ?? ""}`,
1480 : getLangString(`${$["@value"]}`)
1482 ? getLangString(`${$}`, `${$.language ?? ""}`)
1483 : getLangString(`${$}`)
1484 : getLangString(`${$ ?? ""}`),
1489 * Returns a normalized tag data object derived from the provided
1492 * ※ The properties of this function need to match the term names used
1493 * in the ActivityStreams serialization.
1495 * ※ This function is not exposed.
1497 const tagData
= ($) => {
1498 const data
= Object($);
1500 // prefLabel intentionally not set here
1510 let prefLabel
= langString(data
.prefLabel
);
1511 return Object
.preventExtensions(Object
.create(null, {
1514 get: () => prefLabel
,
1516 prefLabel
= langString($);
1523 ? Array
.from(altLabel
, langString
)
1531 ? Array
.from(hiddenLabel
, langString
)
1539 ? Array
.from(broader
, toIdentifier
)
1547 ? Array
.from(narrower
, toIdentifier
)
1555 ? Array
.from(inCanon
, toIdentifier
)
1563 ? Array
.from(hasInCanon
, toIdentifier
)
1571 ? Array
.from(involves
, toIdentifier
)
1579 ? Array
.from(involvedIn
, toIdentifier
)
1587 * Returns an identifier corresponding to the provided object.
1589 * This is either the value of its `.identifier` or its string value.
1591 * ※ This function is not exposed.
1593 const toIdentifier
= ($) =>
1596 : Object($) === $ && "identifier" in $
1601 * A tag system, with storage.
1603 * The `::Tag` constructor available on any `TagSystem` instance can be
1604 * used to create new `Tag`s within the system.
1606 export class TagSystem
{
1607 /** The cached bound `Tag` constructor for this `TagSystem`. */
1610 /** The domain of this `TagSystem`. */
1613 /** The date of this `TagSystem`. */
1616 /** The identifier of this `TagSystem`. */
1619 /** The internal `Storage` of this `TagSystem`. */
1620 #storage
= new Storage();
1623 * Constructs a new `TagSystem` with the provided domain and date.
1625 * Only actual, lowercased domain names are allowed for the domain,
1626 * and the date must be “full” (include month and day components).
1627 * This is for alignment with general best practices for Tag URI’s.
1629 * ☡ This constructor throws if provided with an invalid date.
1631 constructor(domain
, date
, identifier
= "") {
1632 const domainString
= `${domain}`;
1633 const dateString
= `${date}`;
1634 this.#identifier
= `${identifier}`;
1636 // If the identifier is a valid storage I·D, reserve it.
1637 this.#storage
.delete(this.#identifier
);
1639 // The identifier is not a valid storage I·D, so no worries.
1643 !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
1646 // ☡ The domain is invalid.
1647 throw new RangeError(`Invalid domain: ${domain}.`);
1649 !/^\d{4}-\d{2}-\d{2}$/u.test(dateString
) ||
1650 dateString
!= new Date(dateString
).toISOString().split("T")[0]
1652 // ☡ The date is invalid.
1653 throw new RangeError(`Invalid date: ${date}.`);
1655 // The domain and date are 🆗.
1656 this.#domain
= domainString
;
1657 this.#date
= dateString
;
1662 * Returns a bound constructor for constructing `Tags` in this
1666 if (this.#Tag
!= null) {
1667 // A bound constructor has already been generated; return it.
1670 // No bound constructor has been created yet.
1671 const storage
= this.#storage
;
1672 const BoundTag
= Tag
.bind(undefined, this, storage
);
1673 return this.#Tag
= Object
.defineProperties(BoundTag
, {
1677 value
: Tag
.all
.bind(BoundTag
, this, storage
),
1683 value
: Tag
.fromIRI
.bind(BoundTag
, this, storage
),
1689 value
: Tag
.fromIdentifier
.bind(BoundTag
, this, storage
),
1695 value
: Tag
.fromTagURI
.bind(BoundTag
, this, storage
),
1701 value
: Tag
.identifiers
.bind(BoundTag
, this, storage
),
1704 name
: { value
: `${this.tagURI}#${Tag.name}` },
1705 prototype: { value
: Tag
.prototype },
1706 [Storage
.toInstance
]: {
1709 value
: Tag
[Storage
.toInstance
].bind(BoundTag
, this, storage
),
1716 /** Returns the authority name (domain) for this `TagSystem`. */
1717 get authorityName() {
1718 return this.#domain
;
1721 /** Returns the date of this `TagSystem`, as a string. */
1727 * Yields the entities in this `TagSystem`.
1729 * ※ Entities can hypothetically be anything. If you specifically
1730 * want the `Tag`s, use `::Tag.all` instead.
1733 yield* this.#storage
.values();
1737 * Returns the identifier of this `TagSystem`.
1739 * ※ Often this is just the empty string.
1742 return this.#identifier
;
1745 /** Yields the identifiers in use in this `TagSystem`. */
1747 yield* this.#storage
.keys();
1750 /** Returns the I·R·I for this `TagSystem`. */
1752 return `${this.iriSpace}${this.identifier}`;
1756 * Returns the prefix used for I·R·I’s of `Tag`s in this `TagSystem`.
1759 return `https://${this.authorityName}/tag:${this.taggingEntity}:`;
1762 /** Returns the Tag U·R·I for this `TagSystem`. */
1764 return `tag:${this.taggingEntity}:${this.identifier}`;
1768 * Returns the tagging entity (domain and date) for this `TagSystem`.
1770 get taggingEntity() {
1771 return `${this.authorityName},${this.date}`;