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 * ※ If the I·R·I is not recognized, this function returns `null`.
244 static fromIRI(system
, storage
, iri
) {
245 const name
= `${iri}`;
247 `https://${system.authorityName}/tag:${system.taggingEntity}:`;
248 if (!name
.startsWith(prefix
)) {
249 // The I·R·I does not begin with the expected prefix.
252 // The I·R·I begins with the expected prefix.
253 const identifier
= name
.substring(prefix
.length
);
255 // Attempt to resolve the identifier.
256 const instance
= storage
.get(identifier
);
257 return Tag
.getSystem(instance
) == system
? instance
: null;
259 // Do not throw for bad identifiers.
266 * Returns a new `Tag` resolved from the provided identifier.
268 * ※ The first two arguments of this function are bound when
269 * generating the value of `TagSystem::Tag`. It isn’t possible to
270 * access this function in its unbound form from outside this module.
272 * ☡ This function throws if the identifier is invalid.
274 * ※ If the identifier is valid but not recognized, this function
277 static fromIdentifier(system
, storage
, identifier
) {
278 const instance
= storage
.get(identifier
);
279 return Tag
.getSystem(instance
) == system
? instance
: null;
283 * Returns a new `Tag` resolved from the provided Tag U·R·I.
285 * ※ The first two arguments of this function are bound when
286 * generating the value of `TagSystem::Tag`. It isn’t possible to
287 * access this function in its unbound form from outside this module.
289 * ☡ This function throws if the provided Tag U·R·I does not match
290 * the tagging entity of this constructor’s `TagSystem`.
292 * ※ If the specific component of the Tag U·R·I is not recognized,
293 * this function returns `null`.
295 static fromTagURI(system
, storage
, tagURI
) {
296 const tagName
= `${tagURI}`;
297 const tagPrefix
= `tag:${system.taggingEntity}:`;
298 if (!tagName
.startsWith(tagPrefix
)) {
299 // The Tag U·R·I does not begin with the expected prefix.
300 throw new RangeError(
301 `Tag U·R·I did not begin with the expected prefix: ${tagName}`,
304 // The I·R·I begins with the expected prefix.
305 const identifier
= tagName
.substring(tagPrefix
.length
);
307 // Attempt to resolve the identifier.
308 const instance
= storage
.get(identifier
);
309 return Tag
.getSystem(instance
) == system
? instance
: null;
311 // Do not throw for bad identifiers.
318 * Returns the `TagSystem` that the provided value belongs to.
320 * ※ This function can be used to check if the provided value has
321 * private tag features.
323 * ※ This function is not exposed.
325 static getSystem($) {
326 return !(#system
in Object($)) ? null : $.#system
;
330 * Yields the tag identifiers in the `TagSystem` associated with this
333 * ※ The first two arguments of this function are bound when
334 * generating the value of `TagSystem::Tag`. It isn’t possible to
335 * access this function in its unbound form from outside this module.
337 static *identifiers(system
, storage
) {
338 for (const [identifier
, instance
] of storage
.entries()) {
339 // Iterate over the entries and yield the ones which are `Tag`s
340 // in this `TagSystem`.
341 if (Tag
.getSystem(instance
) == system
) {
342 // The current instance is a `Tag` in this `TagSystem`.
345 // The current instance is not a `Tag` in this `TagSystem`.
352 * Returns a new `Tag` constructed from the provided data and with
353 * the provided identifier.
355 * ※ This function will not work if called directly from `Tag` (and
356 * nor is it available *to* be called as such from outside this
357 * module). It must be called from a `TagSystem::Tag` bound
360 * ※ This function is not really intended for public usage.
362 static [Storage
.toInstance
](_system
, _storage
, data
, identifier
) {
363 const tag
= new this(data
.kind
);
364 tag
.#identifier
= `${identifier}`;
365 tag
.#persistedData
= tagData(data
);
366 tag
.#data
= tagData(data
);
371 // Overwrite the default `::constructor` method to instead give the
372 // actual (bound) constructor which was used to generate a given
374 Object
.defineProperties(this.prototype, {
379 // All `Tag`s are constructed via the `.Tag` constructor
380 // available in their `TagSystem`; return it.
381 return this.#system
.Tag
;
388 /** Adds the provided label(s) to this `Tag` as alternate labels. */
389 addAltLabel(...labels
) {
390 const altLabels
= this.#data
.altLabel
;
391 let objectLabels
= null; // initialized on first use
392 for (const $ of labels
) {
393 // Iterate over each provided label and attempt to add it.
394 const literal
= langString($);
395 if (Object(literal
) === literal
) {
396 // The current label is a language‐tagged string.
397 objectLabels
??= [...function* () {
398 for (const altLabel
of altLabels
) {
399 // Iterate over the existing labels and yield the
400 // language‐tagged strings.
401 if (Object(altLabel
) === altLabel
) {
402 // The current existing label is a language‐tagged
406 // The current existing label is not a language‐tagged
413 objectLabels
.some((objectLabel
) =>
414 objectLabel
["@value"] == literal
["@value"] &&
415 objectLabel
["@language"] == literal
["@language"]
418 // There is a match with the current label in the existing
422 // There is no match and this label must be added.
423 altLabels
.add(literal
);
424 objectLabels
.push(literal
);
427 // The current label is a simple string.
428 altLabels
.add(literal
);
434 * Adds the provided tags to the list of tags that this `Tag` is
437 * Arguments may be string identifiers or objects with an
438 * `.identifier` property.
440 addBroaderTag(...tags
) {
441 const storage
= this.#storage
;
442 const broader
= this.#data
.broader
;
443 for (const $ of tags
) {
444 // Iterate over each tag and attempt to set it as broader than
446 const identifier
= toIdentifier($);
447 if (identifier
== null) {
448 // ☡ The current tag has no identifier.
450 "Cannot assign broader to Tag: Identifier must not be nullish.",
452 } else if (broader
.has(identifier
)) {
453 // Short‐circuit: The identifier is already something this
454 // `Tag` is narrower than.
457 // The current tag has an identifier.
458 const tag
= storage
.get(identifier
);
460 // ☡ The current tag has not been persisted to this `Tag`’s
462 throw new RangeError(
463 `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`,
465 } else if (!this.#isTagInStorage(tag
)) {
466 // ☡ The current tag is not a tag in the correct tag system.
468 `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
471 // The current tag is a tag in the correct tag system; add
473 broader
.add(identifier
);
479 /** Adds the provided label(s) to this `Tag` as hidden labels. */
480 addHiddenLabel(...labels
) {
481 const hiddenLabels
= this.#data
.hiddenLabel
;
482 let objectLabels
= null; // initialized on first use
483 for (const $ of labels
) {
484 // Iterate over each provided label and attempt to add it.
485 const literal
= langString($);
486 if (Object(literal
) === literal
) {
487 // The current label is a language‐tagged string.
488 objectLabels
??= [...function* () {
489 for (const hiddenLabel
of hiddenLabels
) {
490 // Iterate over the existing labels and yield the
491 // language‐tagged strings.
492 if (Object(hiddenLabel
) === hiddenLabel
) {
493 // The current existing label is a language‐tagged
497 // The current existing label is not a language‐tagged
504 objectLabels
.some((objectLabel
) =>
505 objectLabel
["@value"] == literal
["@value"] &&
506 objectLabel
["@language"] == literal
["@language"]
509 // There is a match with the current label in the existing
513 // There is no match and this label must be added.
514 hiddenLabels
.add(literal
);
515 objectLabels
.push(literal
);
518 // The current label is a simple string.
519 hiddenLabels
.add(literal
);
525 * Adds the provided tags to the list of tags that this `Tag` is in
528 * Arguments may be string identifiers or objects with an
529 * `.identifier` property.
531 * ☡ This method will throw if a provided argument does not indicate
532 * a canon tag, or if this `Tag` is not of a kind which can be placed
535 addInCanonTag(...tags
) {
536 const storage
= this.#storage
;
537 const kind
= this.#kind
;
538 const inCanon
= this.#data
.inCanon
;
539 if (!HAS_IN_CANON
.has(kind
)) {
540 // ☡ This is not an entity tag, setting tag, or recognized
543 `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`,
546 // This has a kind which can be placed in canon.
547 for (const $ of tags
) {
548 // Iterate over each tag and attempt to set this `Tag` in canon
550 const identifier
= toIdentifier($);
551 if (identifier
== null) {
552 // ☡ The current tag has no identifier.
554 "Cannot put Tag in canon: Identifier must not be nullish.",
556 } else if (inCanon
.has(identifier
)) {
557 // Short‐circuit: The identifier is already something this
558 // `Tag` is in canon of.
561 // The current tag has an identifier.
562 const tag
= storage
.get(identifier
);
564 // ☡ The current tag has not been persisted to this `Tag`’s
566 throw new RangeError(
567 `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`,
570 // ※ If the first check succeeds, then the current tag
571 // must have `Tag` private class features.
572 !this.#isTagInStorage(tag
) || tag
.#kind
!= "CanonTag"
574 // ☡ The current tag is not a canon tag in the correct
577 `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`,
580 // The current tag is a canon tag in the correct tag
581 // system; add its identifier.
582 inCanon
.add(identifier
);
590 * Adds the provided tags to the list of tags that this `Tag`
593 * Arguments may be string identifiers or objects with an
594 * `.identifier` property.
596 * ☡ This method will throw if this `Tag` is not a conceptual tag, or
597 * if this `Tag` is a relationship tag and a provided argument does
598 * not indicate a character or relationship tag.
600 addInvolvesTag(...tags
) {
601 const storage
= this.#storage
;
602 const kind
= this.#kind
;
603 const involves
= this.#data
.involves
;
604 if (!CONCEPTUAL_TAG_KINDS
.has(kind
)) {
605 // ☡ This is not a conceptual tag or recognized subclass.
607 `Cannot involve Tag: Incorrect Tag type: ${kind}.`,
610 // This is a conceptual tag.
611 for (const $ of tags
) {
612 // Iterate over each tag and attempt to set this `Tag` as
614 const identifier
= toIdentifier($);
615 if (identifier
== null) {
616 // ☡ The current tag has no identifier.
618 "Cannot involve Tag: Identifier must not be nullish.",
620 } else if (involves
.has(identifier
)) {
621 // Short‐circuit: The identifier is already something this
625 // The current tag has an identifier.
626 const tag
= storage
.get(identifier
);
628 // ☡ The current tag has not been persisted to this `Tag`’s
630 throw new RangeError(
631 `Cannot involve Tag: Identifier is not persisted: ${identifier}.`,
634 // ※ If the first check succeeds, then the current tag
635 // must have `Tag` private class features.
636 !this.#isTagInStorage(tag
) ||
637 RELATIONSHIP_TAG_KINDS
.has(kind
) &&
638 !INVOLVABLE_IN_RELATIONSHIP
.has(tag
.#kind
)
640 // ☡ The current tag is in the correct tag system and
643 `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`,
646 // The current tag is an involvable tag in the correct tag
647 // system; add its identifier.
648 involves
.add(identifier
);
655 /** Yields the alternative labels of this `Tag`. */
657 yield* this.#data
.altLabel
;
660 /** Returns the authority (domain) name for this `Tag`. */
661 get authorityName() {
662 return this.#system
.authorityName
;
665 /** Yields `Tag`s which are broader than this `Tag`. */
667 const storage
= this.#storage
;
668 for (const identifier
of this.#data
.broader
) {
669 // Iterate over the broader tags and yield them if possible.
670 const tag
= storage
.get(identifier
);
671 if (!this.#isTagInStorage(tag
)) {
672 // The broader tag no longer appears in storage; perhaps it was
676 // The broader tag exists and is constructable from storage.
682 /** Yields `Tag`s which are broader than this `Tag`, transitively. */
683 *broaderTransitiveTags() {
684 const storage
= this.#storage
;
685 const encountered
= new Set();
686 let pending
= new Set(this.#data
.broader
);
687 while (pending
.size
> 0) {
688 // Loop until all broader tags have been encountered.
689 const processing
= pending
;
691 for (const identifier
of processing
) {
692 // Iterate over the broader tags and yield them if possible.
693 if (!encountered
.has(identifier
)) {
694 // The broader tag has not been encountered before.
695 encountered
.add(identifier
);
696 const tag
= storage
.get(identifier
);
697 if (!this.#isTagInStorage(tag
)) {
698 // The broader tag no longer appears in storage; perhaps it
702 // The broader tag exists and is constructable from
705 for (const transitive
of tag
.#data
.broader
) {
706 // Iterate over the broader tags of the current broader
707 // tag and add them to pending as needed.
708 if (!encountered
.has(transitive
)) {
709 // The broader broader tag has not been encountered
711 pending
.add(transitive
);
713 // The broader broader tag has already been
720 // The broader tag has already been encountered.
728 * Removes the provided string label(s) from this `Tag` as alternate
731 deleteAltLabel(...labels
) {
732 const altLabels
= this.#data
.altLabel
;
733 let objectLabels
= null; // initialized on first use
734 for (const $ of labels
) {
735 // Iterate over each provided label and attempt to remove it.
736 const literal
= langString($);
737 if (Object(literal
) === literal
) {
738 // The current label is a language‐tagged string.
739 objectLabels
??= [...function* () {
740 for (const altLabel
of altLabels
) {
741 // Iterate over the existing labels and yield the
742 // language‐tagged strings.
743 if (Object(altLabel
) === altLabel
) {
744 // The current existing label is a language‐tagged
748 // The current existing label is not a language‐tagged
754 const existing
= objectLabels
.find((objectLabel
) =>
755 objectLabel
["@value"] == literal
["@value"] &&
756 objectLabel
["@language"] == literal
["@language"]
758 altLabels
.delete(existing
);
760 // The current label is a simple string.
761 altLabels
.delete(literal
);
767 * Removes the provided tags from the list of tags that this `Tag` is
770 * Arguments may be string identifiers or objects with an
771 * `.identifier` property.
773 deleteBroaderTag(...tags
) {
774 const broader
= this.#data
.broader
;
775 for (const $ of tags
) {
776 // Iterate over the provided tags and delete them.
777 broader
.delete(toIdentifier($));
782 * Removes the provided string label(s) from this `Tag` as hidden
785 deleteHiddenLabel(...labels
) {
786 const hiddenLabels
= this.#data
.hiddenLabel
;
787 let objectLabels
= null; // initialized on first use
788 for (const $ of labels
) {
789 // Iterate over each provided label and attempt to remove it.
790 const literal
= langString($);
791 if (Object(literal
) === literal
) {
792 // The current label is a language‐tagged string.
793 objectLabels
??= [...function* () {
794 for (const hiddenLabel
of hiddenLabels
) {
795 // Iterate over the existing labels and yield the
796 // language‐tagged strings.
797 if (Object(hiddenLabel
) === hiddenLabel
) {
798 // The current existing label is a language‐tagged
802 // The current existing label is not a language‐tagged
808 const existing
= objectLabels
.find((objectLabel
) =>
809 objectLabel
["@value"] == literal
["@value"] &&
810 objectLabel
["@language"] == literal
["@language"]
812 hiddenLabels
.delete(existing
);
814 // The current label is a simple string.
815 hiddenLabels
.delete(literal
);
821 * Removes the provided tags from the list of tags that this `Tag` is
824 * Arguments may be string identifiers or objects with an
825 * `.identifier` property.
827 deleteInCanonTag(...tags
) {
828 const inCanon
= this.#data
.inCanon
;
829 for (const $ of tags
) {
830 // Iterate over the provided tags and delete them.
831 inCanon
.delete(toIdentifier($));
836 * Removes the provided tags from the list of tags that this `Tag`
839 * Arguments may be string identifiers or objects with an
840 * `.identifier` property.
842 deleteInvolvesTag(...tags
) {
843 const involves
= this.#data
.involves
;
844 for (const $ of tags
) {
845 // Iterate over the provided tags and delete them.
846 involves
.delete(toIdentifier($));
850 /** Yields `Tag`s that are in canon of this `Tag`. */
852 const storage
= this.#storage
;
853 if (this.#kind
== "CanonTag") {
854 // This is a canon tag.
855 for (const identifier
of this.#data
.hasInCanon
) {
856 // Iterate over the tags in canon and yield them if possible.
857 const tag
= storage
.get(identifier
);
859 !this.#isTagInStorage(tag
) || !HAS_IN_CANON
.has(tag
.#kind
)
861 // The tag in canon no longer appears in storage; perhaps it
865 // The tag in canon exists and is constructable from storage.
874 /** Yields the hidden labels of this `Tag`. */
876 yield* this.#data
.hiddenLabel
;
879 /** Returns the identifier of this `Tag`. */
881 return this.#identifier
;
884 /** Yields `Tag`s that this `Tag` is in canon of. */
886 const storage
= this.#storage
;
887 if (HAS_IN_CANON
.has(this.#kind
)) {
888 // This tag can be placed in canon.
889 for (const identifier
of this.#data
.inCanon
) {
890 // Iterate over the canon tags and yield them if possible.
891 const tag
= storage
.get(identifier
);
892 if (!this.#isTagInStorage(tag
) || tag
.#kind
!= "CanonTag") {
893 // The canon tag no longer appears in storage; perhaps it was
897 // The canon tag exists and is constructable from storage.
902 // This tag cannot be placed in canon.
907 /** Yields `Tag`s which involve this `Tag`. */
909 const storage
= this.#storage
;
910 for (const identifier
of this.#data
.involvedIn
) {
911 // Iterate over the involving tags and yield them if possible.
912 const tag
= storage
.get(identifier
);
913 const tagKind
= tag
.#kind
;
915 !this.#isTagInStorage(tag
) ||
916 !CONCEPTUAL_TAG_KINDS
.has(tagKind
) ||
917 RELATIONSHIP_TAG_KINDS
.has(tagKind
) &&
918 !INVOLVABLE_IN_RELATIONSHIP
.has(this.#kind
)
920 // The including tag no longer appears in storage; perhaps it
924 // The including tag exists and is constructable from storage.
930 /** Yields `Tag`s that this `Tag` involves. */
932 const storage
= this.#storage
;
933 const kind
= this.#kind
;
934 if (CONCEPTUAL_TAG_KINDS
.has(kind
)) {
935 // This tag can involve other tags.
936 for (const identifier
of this.#data
.involves
) {
937 // Iterate over the involved and yield them if possible.
938 const tag
= storage
.get(identifier
);
940 !this.#isTagInStorage(tag
) ||
941 RELATIONSHIP_TAG_KINDS
.has(kind
) &&
942 !INVOLVABLE_IN_RELATIONSHIP
.has(tag
.#kind
)
944 // The involved tag no longer appears in storage; perhaps it
948 // The involved tag exists and is constructable from storage.
953 // This tag cannot involve other tags.
958 /** Returns the I·R·I for this `Tag`. */
960 const tagURI
= this.tagURI
;
961 return tagURI
== null
963 : `https://${this.authorityName}/${tagURI}`;
966 /** Returns the kind of this `Tag`. */
971 /** Yields `Tag`s which are narrower than this `Tag`. */
973 const storage
= this.#storage
;
974 for (const identifier
of this.#data
.narrower
) {
975 const tag
= storage
.get(identifier
);
976 if (!this.#isTagInStorage(tag
)) {
977 // The narrower tag no longer appears in storage; perhaps it
981 // The narrower tag exists and is constructable from storage.
988 * Yields `Tag`s which are narrower than this `Tag`, transitively.
990 *narrowerTransitiveTags() {
991 const storage
= this.#storage
;
992 const encountered
= new Set();
993 let pending
= new Set(this.#data
.narrower
);
994 while (pending
.size
> 0) {
995 // Loop until all narrower tags have been encountered.
996 const processing
= pending
;
998 for (const identifier
of processing
) {
999 // Iterate over the narrower tags and yield them if possible.
1000 if (!encountered
.has(identifier
)) {
1001 // The narrower tag has not been encountered before.
1002 encountered
.add(identifier
);
1003 const tag
= storage
.get(identifier
);
1004 if (!this.#isTagInStorage(tag
)) {
1005 // The narrower tag no longer appears in storage; perhaps
1009 // The narrower tag exists and is constructable from
1012 for (const transitive
of tag
.#data
.narrower
) {
1013 // Iterate over the narrower tags of the current narrower
1014 // tag and add them to pending as needed.
1015 if (!encountered
.has(transitive
)) {
1016 // The narrower narrower tag has not been encountered
1018 pending
.add(transitive
);
1020 // The narrower narrower tag has already been
1027 // The narrower tag has already been encountered.
1035 * Persist this `Tag` to storage and return an ActivityStreams
1036 * serialization of a Tag Activity representing any changes, or
1037 * `null` if no changes were made.
1039 * If the second argument is `true`, the `Tag` will be persisted but
1040 * no serialization will be made. This is somewhat more efficient.
1042 * ※ Persistence can imply side‐effects on other objects, which are
1043 * not noted explicitly in the activity. For example, marking a tag
1044 * as broader than another causes the other tag to reciprocally be
1045 * marked as narrower.
1047 * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower`
1048 * will never appear in the predicates of generated activities.
1050 persist(silent
= false) {
1051 const system
= this.#system
;
1052 const storage
= this.#storage
;
1053 const persistedData
= this.#persistedData
;
1054 const data
= this.#data
;
1056 for (const [key
, value
] of Object
.entries(data
)) {
1057 // Iterate over each entry of the tag data and create a diff
1058 // with the last persisted information.
1059 if (SKIP_IN_DIFF
.has(key
) || silent
&& LITERAL_TERMS
.has(key
)) {
1060 // The current property is one which is skipped in diffs.
1062 // In a silent persist, this includes any literal terms.
1065 // The current property should be diffed.
1066 const persisted
= persistedData
?.[key
] ?? null;
1067 if (persisted
== null) {
1068 // There is no persisted data for the current property yet.
1071 new: value
instanceof Set
1075 } else if (value
instanceof Set
) {
1076 // The current property is set‐valued.
1077 let values
= null; // initialized on first use
1078 const oldValues
= new Set(persisted
);
1079 const newValues
= new Set(value
);
1080 for (const existing
of persisted
) {
1081 // Iterate over each persisted property and either remove
1082 // it from the list of new values or add it to the list of
1085 // ※ Some special handling is required here for
1086 // language‐tagged strings.
1088 value
.has(existing
) ||
1089 Object(existing
) === existing
&&
1090 (values
??= [...value
]).some(($) =>
1091 `${$}` == `${existing}` &&
1092 $.language
== existing
.language
1095 // The value is in both the old and new version of the
1097 oldValues
.delete(existing
);
1098 newValues
.delete(existing
);
1100 // The value is not shared.
1109 `${value}` != `${persisted}` ||
1110 value
.language
!= persisted
.language
1112 // The current property is (optionally language‐tagged)
1113 // string‐valued and the value changed.
1115 old
: new Set([persisted
]),
1116 new: new Set([value
]),
1119 // The current property did not change.
1127 const identifier
= this.#identifier
;
1128 if (identifier
!= null) {
1129 // This `Tag` has already been persisted; use its existing
1130 // identifier and persist.
1131 storage
.set(identifier
, this);
1133 // This `Tag` has not been persisted yet; save the new
1134 // identifier after persisting.
1135 this.#identifier
= storage
.add(this);
1137 const persistedIdentifier
= this.#identifier
;
1138 this.#persistedData
= tagData(data
); // cloning here is necessary
1140 const [term
, inverse
] of [
1141 ["broader", "narrower"],
1142 ["inCanon", "hasInCanon"],
1143 ["involves", "involvedIn"],
1146 // Iterate over each term referencing other tags and update the
1147 // inverse property on those tags if possible.
1148 for (const referencedIdentifier
of diffs
[term
].old
) {
1149 // Iterate over the removed tags and remove this `Tag` from
1150 // their inverse property.
1151 const referenced
= storage
.get(referencedIdentifier
);
1153 // Try removing this `Tag`.
1154 referenced
.#data
[inverse
].delete(persistedIdentifier
);
1155 storage
.set(referencedIdentifier
, referenced
);
1157 // Removal failed, possibly because the other tag was
1162 for (const referencedIdentifier
of diffs
[term
].new) {
1163 const referenced
= storage
.get(referencedIdentifier
);
1165 // Try adding this `Tag`.
1166 referenced
.#data
[inverse
].add(persistedIdentifier
);
1167 storage
.set(referencedIdentifier
, referenced
);
1169 // Adding failed, possibly because the other tag was deleted.
1175 // This is a silent persist.
1178 // This is not a silent persist; an activity needs to be
1179 // generated if a change was made.
1181 "@context": taggingDiscoveryContext
,
1184 identifier
== null ? "Create" : "Update",
1186 context
: `${system.iri}`,
1187 object
: `${this.iri}`,
1188 endTime
: new Date().toISOString(),
1190 const statements
= {
1194 const { unstates
, states
} = statements
;
1195 if (identifier
== null) {
1196 // This is a Create activity.
1197 states
.push({ predicate
: "a", object
: `${this.kind}` });
1199 // This is an Update activity.
1206 }] of Object
.entries(diffs
)
1208 // Iterate over the diffs of each term and state/unstate
1209 // things as needed.
1210 for (const oldValue
of oldValues
) {
1211 // Iterate over removals and unstate them.
1212 if (LITERAL_TERMS
.has(term
)) {
1213 // This is a literal term; push the change wrapped in an
1217 object
: Object(oldValue
) === oldValue
1218 ? { ...langString(oldValue
) }
1219 : { "@value": `${oldValue}` },
1222 // This is a named term; attempt to get its I·R·I and
1225 // Attempt to resolve the value and push the change.
1226 const tag
= storage
.get(oldValue
);
1227 if (!this.#isTagInStorage(tag
)) {
1228 // The value did not resolve to a tag in storage.
1231 // The value resolved; push its I·R·I.
1238 // Value resolution failed for some reason; perhaps the
1244 for (const newValue
of newValues
) {
1245 // Iterate over additions and state them.
1246 if (LITERAL_TERMS
.has(term
)) {
1247 // This is a literal term; push the change wrapped in an
1251 object
: Object(newValue
) === newValue
1252 ? { ...langString(newValue
) }
1253 : { "@value": `${newValue}` },
1256 // This is a named term; attempt to get its I·R·I and
1259 // Attempt to resolve the value and push the change.
1260 const tag
= storage
.get(newValue
);
1261 if (!this.#isTagInStorage(tag
)) {
1262 // The value did not resolve to a tag in storage.
1265 // The value resolved; push its I·R·I.
1272 // Value resolution failed for some reason; perhaps the
1279 if (unstates
.length
== 0) {
1280 // Nothing was unstated.
1281 delete statements
.unstates
;
1283 // Things were stated.
1286 if (states
.length
== 0) {
1287 // Nothing was stated.
1288 delete statements
.states
;
1290 // Things were stated.
1297 !Object
.hasOwn(activity
, "states") &&
1298 !Object
.hasOwn(activity
, "unstates")
1300 // No meaningful changes were actually persisted.
1303 // There were meaningful changes persisted regarding this `Tag`.
1309 /** Returns the preferred label for this `Tag`. */
1311 return this.#data
.prefLabel
;
1314 /** Sets the preferred label of this `Tag` to the provided label. */
1316 this.#data
.prefLabel
= langString($);
1319 /** Returns the Tag U·R·I for this `Tag`. */
1321 const { identifier
} = this;
1322 return identifier
== null
1324 : `tag:${this.taggingEntity}:${identifier}`;
1327 /** Returns the tagging entity (domain and date) for this `Tag`. */
1328 get taggingEntity() {
1329 return this.#system
.taggingEntity
;
1332 /** Returns the string form of the preferred label of this `Tag`. */
1334 return `${this.#data.prefLabel}`;
1338 * Returns a new object whose enumerable own properties contain the
1339 * data from this object needed for storage.
1341 * ※ This method is not really intended for public usage.
1343 [Storage
.toObject
]() {
1344 const data
= this.#data
;
1345 return Object
.assign(Object
.create(null), {
1354 * Returns the provided value converted into either a plain string
1355 * primitive or an object with `.["@value"]` and `.["@language"]`
1358 * TODO: Ideally this would be extracted more fully into an R·D·F
1361 * ※ This function is not exposed.
1365 /** Returns the `.["@language"]` of this object. */
1366 const getLanguage
= Object
.defineProperty(
1368 return this["@language"];
1371 { value
: "get language" },
1374 /** Returns the `.["@value"]` of this object. */
1375 const toString = function () {
1376 return this["@value"];
1379 /** Returns the `.["@value"]` of this object. */
1380 const valueOf = function () {
1381 return this["@value"];
1389 ? Object
.preventExtensions(
1390 Object
.create(String
.prototype, {
1393 value
: `${$["@value"]}`,
1397 value
: `${$["@language"]}`,
1399 language
: { enumerable
: false, get: getLanguage
},
1400 toString
: { enumerable
: false, value
: toString
},
1401 valueOf
: { enumerable
: false, value
: valueOf
},
1406 ? Object
.preventExtensions(
1407 Object
.create(String
.prototype, {
1408 "@value": { enumerable
: true, value
: `${$}` },
1411 value
: `${$.language}`,
1413 language
: { enumerable
: false, get: getLanguage
},
1414 toString
: { enumerable
: false, value
: toString
},
1415 valueOf
: { enumerable
: false, value
: valueOf
},
1424 * Returns a normalized tag data object derived from the provided
1427 * ※ The properties of this function need to match the term names used
1428 * in the ActivityStreams serialization.
1430 * ※ This function is not exposed.
1432 const tagData
= ($) => {
1433 const data
= Object($);
1435 // prefLabel intentionally not set here
1445 let prefLabel
= langString(data
.prefLabel
);
1446 return Object
.preventExtensions(Object
.create(null, {
1449 get: () => prefLabel
,
1451 prefLabel
= langString($);
1458 ? Array
.from(altLabel
, langString
)
1466 ? Array
.from(hiddenLabel
, langString
)
1474 ? Array
.from(broader
, toIdentifier
)
1482 ? Array
.from(narrower
, toIdentifier
)
1490 ? Array
.from(inCanon
, toIdentifier
)
1498 ? Array
.from(hasInCanon
, toIdentifier
)
1506 ? Array
.from(involves
, toIdentifier
)
1514 ? Array
.from(involvedIn
, toIdentifier
)
1522 * Returns an identifier corresponding to the provided object.
1524 * This is either the value of its `.identifier` or its string value.
1526 * ※ This function is not exposed.
1528 const toIdentifier
= ($) =>
1531 : Object($) === $ && "identifier" in $
1536 * A tag system, with storage.
1538 * The `::Tag` constructor available on any `TagSystem` instance can be
1539 * used to create new `Tag`s within the system.
1541 export class TagSystem
{
1542 /** The cached bound `Tag` constructor for this `TagSystem`. */
1545 /** The domain of this `TagSystem`. */
1548 /** The date of this `TagSystem`. */
1551 /** The identifier of this `TagSystem`. */
1554 /** The internal `Storage` of this `TagSystem`. */
1555 #storage
= new Storage();
1558 * Constructs a new `TagSystem` with the provided domain and date.
1560 * Only actual, lowercased domain names are allowed for the domain,
1561 * and the date must be “full” (include month and day components).
1562 * This is for alignment with general best practices for Tag URI’s.
1564 * ☡ This constructor throws if provided with an invalid date.
1566 constructor(domain
, date
, identifier
= "") {
1567 const domainString
= `${domain}`;
1568 const dateString
= `${date}`;
1569 this.#identifier
= `${identifier}`;
1571 // If the identifier is a valid storage I·D, reserve it.
1572 this.#storage
.delete(this.#identifier
);
1574 // The identifier is not a valid storage I·D, so no worries.
1578 !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
1581 // ☡ The domain is invalid.
1582 throw new RangeError(`Invalid domain: ${domain}.`);
1584 !/^\d{4}-\d{2}-\d{2}$/u.test(dateString
) ||
1585 dateString
!= new Date(dateString
).toISOString().split("T")[0]
1587 // ☡ The date is invalid.
1588 throw new RangeError(`Invalid date: ${date}.`);
1590 // The domain and date are 🆗.
1591 this.#domain
= domainString
;
1592 this.#date
= dateString
;
1597 * Returns a bound constructor for constructing `Tags` in this
1601 if (this.#Tag
!= null) {
1602 // A bound constructor has already been generated; return it.
1605 // No bound constructor has been created yet.
1606 const storage
= this.#storage
;
1607 const BoundTag
= Tag
.bind(undefined, this, storage
);
1608 return this.#Tag
= Object
.defineProperties(BoundTag
, {
1612 value
: Tag
.all
.bind(BoundTag
, this, storage
),
1618 value
: Tag
.fromIRI
.bind(BoundTag
, this, storage
),
1624 value
: Tag
.fromIdentifier
.bind(BoundTag
, this, storage
),
1630 value
: Tag
.fromTagURI
.bind(BoundTag
, this, storage
),
1636 value
: Tag
.identifiers
.bind(BoundTag
, this, storage
),
1639 name
: { value
: `${this.tagURI}#${Tag.name}` },
1640 prototype: { value
: Tag
.prototype },
1641 [Storage
.toInstance
]: {
1644 value
: Tag
[Storage
.toInstance
].bind(BoundTag
, this, storage
),
1651 /** Returns the authority name (domain) for this `TagSystem`. */
1652 get authorityName() {
1653 return this.#domain
;
1656 /** Returns the date of this `TagSystem`, as a string. */
1662 * Yields the entities in this `TagSystem`.
1664 * ※ Entities can hypothetically be anything. If you specifically
1665 * want the `Tag`s, use `::Tag.all` instead.
1668 yield* this.#storage
.values();
1672 * Returns the identifier of this `TagSystem`.
1674 * ※ Often this is just the empty string.
1677 return this.#identifier
;
1680 /** Yields the identifiers in use in this `TagSystem`. */
1682 yield* this.#storage
.keys();
1685 /** Returns the I·R·I for this `TagSystem`. */
1687 return `https://${this.authorityName}/${this.tagURI}`;
1690 /** Returns the Tag U·R·I for this `TagSystem`. */
1692 return `tag:${this.taggingEntity}:${this.identifier}`;
1696 * Returns the tagging entity (domain and date) for this `TagSystem`.
1698 get taggingEntity() {
1699 return `${this.authorityName},${this.date}`;