1 // SPDX-FileCopyrightText: 2023, 2025 Lady <https://www.ladys.computer/about/#lady>
2 // SPDX-License-Identifier: MPL-2.0
4 * ⁌ 📧🏷️ Étiquette ∷ model.js
6 * Copyright © 2023, 2025 Lady [@ Ladys Computer].
8 * This Source Code Form is subject to the terms of the Mozilla Public
9 * License, v. 2.0. If a copy of the MPL was not distributed with this
10 * file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
13 import { identity
} from "./deps.js";
14 import { Storage
} from "./memory.js";
15 import { taggingDiscoveryContext
} from "./names.js";
16 import schema
from "./schema.js";
18 const ÉTIQUETTE
= "📧🏷️ Étiquette";
23 * `Tag´s are not assigned identifiers and do not have side·effects on
24 * other tags in the `TagSystem´ until they are persisted with
25 * `::persist´, at which point changes to their relationships are
28 * `Tag´s are also not kept up‐to‐date, but persisting an outdated
29 * `Tag´ will ⹐not⹑ undo subsequent changes.
31 * ※ This class is not itself directly exposed, although bound
32 * versions of it are via `TagSystem::Tag´.
35 /** The `TagSystem´ this `Tag´ belongs to. */
38 /** The `Storage´ managed by this `Tag´s `TagSystem´. */
41 /** The schema in use for this `Tag´. */
45 * The 30‐bit W·R·M·G base32 identifier with leading checksum which
46 * has been assigned to this `Tag´.
48 * Will be `null´ if this `Tag´ has not been persisted. Otherwise,
49 * the format is `cxx-xxxx´ (`c´ = checksum; `x´ = digit).
53 /** The kind of this `Tag´. */
57 * The data which was attached to this `Tag´ the last time it was
58 * persisted or retrieved from storage.
60 * Diffing with this will reveal changes.
62 #persistedData
= null;
64 /** The current (modified) data associated with this `Tag´. */
68 * Adds the provided label(s) to this `Tag´ as the provided
69 * predicate, then returns this `Tag´.
71 #addLabel(predicate
, ...labels
) {
72 const values
= this.#data
[predicate
];
73 for (const $ of labels
) {
74 // Iterate over each provided label and attempt to add it.
75 const literal
= langString($);
82 * Adds the provided tags to the list of tags that this `Tag´ is
83 * related to by the provided predicate, then returns this `Tag´.
85 * Arguments may be string identifiers or objects with an
86 * `.identifier´ property.
88 #addTag(predicate
, ...tags
) {
89 const storage
= this.#storage
;
90 const values
= this.#data
[predicate
];
91 for (const $ of tags
) {
92 // Iterate over each tag and attempt to state the predicate.
93 const identifier
= toIdentifier($);
94 if (identifier
== null) {
95 // ☡ The current tag has no identifier.
97 `${ÉTIQUETTE}: Cannot state ${predicate} of Tag: Identifier must not be nullish.`,
99 } else if (values
.has(identifier
)) {
100 // Short‐circuit: The identifier has already been stated with
104 // The current tag has an identifier, but it hasn¦t been stated
105 // with this predicate yet.
106 const tag
= storage
.get(identifier
);
108 // ☡ The current tag has not been persisted to this `Tag´s
110 throw new RangeError(
111 `${ÉTIQUETTE}: Cannot state ${predicate} of Tag: Identifier is not persisted: ${identifier}.`,
113 } else if (!this.#isTagInStorage(tag
)) {
114 // ☡ The current tag is not a tag in the correct tag system.
116 `${ÉTIQUETTE}: Cannot state ${predicate} of Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
119 !isObjectPredicateOK(
126 // ☡ This tag and the current tag form an invalid pair for
129 `${ÉTIQUETTE}: Cannot state ${predicate} of Tag: Not valid for domain and range: ${this.#kind}, ${tag.#kind}.`,
132 // The current tag is a tag in the correct tag system; add
134 values
.add(identifier
);
142 * Removes the provided string label(s) from this `Tag´ as the
143 * provided predicate, then returns this `Tag´.
145 #deleteLabel(predicate
, ...labels
) {
146 const values
= this.#data
[predicate
];
147 for (const $ of labels
) {
148 // Iterate over each provided label and attempt to remove it.
149 const literal
= langString($);
150 values
.delete(literal
);
156 * Removes the provided tags from the list of tags that this `Tag´ is
157 * related to by the provided predicate, then returns this `Tag´.
159 * Arguments may be string identifiers or objects with an
160 * `.identifier´ property.
162 #deleteTag(predicate
, ...tags
) {
163 const values
= this.#data
[predicate
];
164 for (const $ of tags
) {
165 // Iterate over the provided tags and delete them.
166 values
.delete(toIdentifier($));
172 * Returns whether or not the provided value is a tag which shares a
173 * storage with this tag.
175 * Sharing a storage also implies sharing a `TagSystem´.
179 // Try to compare the provided values internal store with
180 // the provided storage.
181 return $.#storage
== this.#storage
;
183 // The provided value was not a `Tag`.
189 * Yields the labels of this `Tag´ according to the provided
192 *#yieldLabels(predicate
) {
193 yield* this.#data
[predicate
];
197 * Yields the tags that this `Tag´ is related to by the provided
200 *#yieldTags(predicate
) {
201 const storage
= this.#storage
;
202 for (const identifier
of this.#data
[predicate
]) {
203 // Iterate over the tags in this predicate and yield them if
205 const tag
= storage
.get(identifier
);
207 !this.#isTagInStorage(tag
) || !isObjectPredicateOK(
214 // The tag no longer appears in storage or is not compatible;
215 // perhaps it was deleted.
218 // The tag exists and is constructable from storage.
225 * Yields the tags that this `Tag´ is related to by the provided
226 * predicate, figured transitively.
228 *#yieldTransitiveTags(transitivePredicate
, basePredicate
) {
229 const storage
= this.#storage
;
230 const encountered
= new Set();
231 let pending
= new Set(this.#data
[basePredicate
]);
232 while (pending
.size
> 0) {
233 // Loop until all tags of the predicate have been encountered.
234 const processing
= pending
;
236 for (const identifier
of processing
) {
237 // Iterate over the tags and yield them if possible.
238 if (!encountered
.has(identifier
)) {
239 // The tag has not been encountered before.
240 encountered
.add(identifier
);
241 const tag
= storage
.get(identifier
);
243 !this.#isTagInStorage(tag
) || !isObjectPredicateOK(
250 // The tag no longer appears in storage or is not
251 // compatible; perhaps it was deleted.
254 // The tag exists and is constructable from storage.
256 for (const transitive
of tag
.#data
[basePredicate
]) {
257 // Iterate over the nested tags of the current tag and
258 // add them to pending as needed.
259 if (!encountered
.has(transitive
)) {
260 // The nested tag has not been encountered yet.
261 pending
.add(transitive
);
263 // The nested tag has already been encountered.
269 // The tag has already been encountered.
277 * Constructs a new `Tag´ of the provided kind and with the provided
280 * ※ The first two arguments of this constructor are bound when
281 * generating the value of `TagSystem::Tag´. It isn¦t possible to
282 * access this constructor in its unbound form from outside this
285 * ☡ This constructor throws if the provided kind is not supported.
287 constructor(system
, storage
, schema
, kind
= "Tag", prefLabel
= "") {
288 this.#system
= system
;
289 this.#storage
= storage
;
290 this.#schema
= schema
;
291 const kindString
= `${kind}`;
292 if (!(kindString
in schema
.classes
)) {
293 // The provided kind is not supported.
294 throw new RangeError(
295 `${ÉTIQUETTE}: Cannot construct Tag: Unrecognized kind: ${kind}.`,
298 // The provided kind is one of the recognized tag kinds.
299 this.#kind
= kindString
;
300 this.#data
.prefLabel
= prefLabel
;
305 * Returns a new `Tag´ constructor for the provided system, storage,
306 * schema, created with an appropriate prototype for the properties
309 * ※ This function is not exposed.
311 static For(system
, storage
, schema
) {
314 transitiveProperties
,
317 const constructor = function (...$s
) {
318 return Reflect
.construct(
320 [system
, storage
, schema
, ...$s
],
324 Object
.defineProperties(constructor, {
325 name: { value: "TagSystem::Tag" },
329 value: Object
.create(
331 Object
.fromEntries(Array
.from(
333 for (const key
in objectProperties
) {
334 // Iterate over each object property and yield any
335 // necessary method definitions.
339 } = objectProperties
[key
];
340 if (key
in transitiveProperties
) {
341 // The current key indicates a transitive property.
343 // Transitive property methods are added by their
344 // nontransitive subproperties.
347 // The current key does not indicate a transitive
349 yield [`${key}Tags`, function* () {
350 yield* this.#yieldTags(key
);
352 if (inverseOf
== null) {
353 // The current key does not indicate an inverse
354 // property, so add and delete methods are also
356 const cased
= key
[0].toUpperCase()
358 yield [`add${cased}Tag`, function (...tags
) {
359 return this.#addTag(key
, ...tags
);
361 yield [`delete${cased}Tag`, function (...tags
) {
362 return this.#deleteTag(key
, ...tags
);
365 // The current key indicates an inverse property,
366 // so no add and delete methods are necessary.
370 subPropertyOf
!= null
371 && subPropertyOf
in transitiveProperties
373 // The current key indicates a subproperty of a
374 // transitive property; its method is also added.
375 yield [`${subPropertyOf}Tags`, function* () {
376 yield* this.#yieldTransitiveTags(
382 // The current key does not indicate a subproperty
383 // of a transitive property.
388 for (const key
in dataProperties
) {
389 // Iterate over each data property and yield any
390 // necessary method definitions.
391 if (key
!= "prefLabel") {
392 // The current key is not `"prefLabel"´.
393 const cased
= key
[0].toUpperCase()
395 yield [`${key}s`, function* () {
396 yield* this.#yieldLabels(key
);
398 yield [`add${cased}`, function (...labels
) {
399 return this.#addLabel(key
, ...labels
);
401 yield [`delete${cased}`, function (...labels
) {
402 return this.#deleteLabel(key
, ...labels
);
405 // The current key is `"prefLabel"´. This is a
406 // special case which is not handled by the schema.
411 ([key
, value
]) => [key
, {
414 value: Object
.defineProperty(value
, "name", {
424 return new TagConstructor(constructor, system
, storage
, schema
);
428 * Assigns the provided data and identifier to the provided tag.
430 * ☡ This function throws if the provided tag is not a `Tag´.
432 * ※ This function is not exposed.
434 static assignData(tag
, data
, identifier
) {
435 tag
.#identifier
= `${identifier}`;
436 tag
.#persistedData
= tagData(data
);
437 tag
.#data
= tagData(data
);
442 * Returns a new `Tag´ with the provided identifier, kind, and
445 * ※ This function exists to enable `TagSystem´s to replay Create
446 * activities, maintaining the identifier of the original.
448 * ☡ This function throws if the provided identifier is already in
451 * ※ This function is not exposed.
453 static new(system
, identifier
, kind
= "Tag", prefLabel
= "") {
454 const storage
= (new system
.Tag()).#storage
;
455 if (storage
.has(identifier
)) {
456 throw new RangeError(
457 `${ÉTIQUETTE}: Cannot create Tag: Identifier already in use: ${identifier}.`,
460 const createdTag
= new system
.Tag(kind
, prefLabel
);
461 createdTag
.#identifier
= identifier
;
462 createdTag
.persist(true);
468 * Returns the `TagSystem´ that the provided value belongs to.
470 * ※ This function can be used to check if the provided value has
471 * private tag features.
473 * ※ `Tag::system´ is an overridable, publicly‐accessible means of
474 * accessing the system.
476 * ※ This function is not exposed.
478 static getSystem($) {
479 return !(#system
in Object($)) ? null : $.#system
;
483 // Overwrite the default `::constructor´ method to instead give the
484 // actual (bound) constructor which was used to generate a given
486 Object
.defineProperties(this.prototype, {
491 // All `Tag´s are constructed via the `.Tag´ constructor
492 // available in their `TagSystem´; return it.
493 return this.#system
.Tag
;
500 /** Returns the authority (domain) name for this `Tag´. */
501 get authorityName() {
502 return this.#system
.authorityName
;
505 /** Returns the identifier of this `Tag´. */
507 return this.#identifier
;
510 /** Returns the I·R·I for this `Tag´. */
512 const { identifier
, iriSpace
} = this;
513 return identifier
== null ? null : `${iriSpace}${identifier}`;
516 /** Returns the I·R·I space for this `Tag´. */
518 return this.#system
.iriSpace
;
521 /** Returns the kind of this `Tag´. */
527 * Returns the `TagSystem´ for this `Tag´.
529 * ※ Internally, `Tag.getSystem´ is preferred.
536 * Persist this `Tag´ to storage and return an ActivityStreams
537 * serialization of a Tag Activity representing any changes, or
538 * `null´ if no changes were made.
540 * If the second argument is `true´, the `Tag´ will be persisted but
541 * no serialization will be made. This is somewhat more efficient.
543 * ※ Persistence can imply side‐effects on other objects, which are
544 * not noted explicitly in the activity. For example, marking a tag
545 * as broader than another causes the other tag to reciprocally be
546 * marked as narrower.
548 * ※ Inverse object properties will never appear in the predicates
549 * of generated activities.
551 persist(silent
= false) {
552 const system
= this.#system
;
553 const storage
= this.#storage
;
556 transitiveProperties
,
559 const persistedData
= this.#persistedData
;
560 const data
= this.#data
;
562 for (const [key
, value
] of Object
.entries(data
)) {
563 // Iterate over each entry of the tag data and create a diff
564 // with the last persisted information.
566 objectProperties
[key
]?.inverseOf
!= null
567 || silent
&& key
in dataProperties
569 // The current property is one which is skipped in diffs.
571 // In a silent persist, this includes any literal terms.
574 // The current property should be diffed.
575 const persisted
= persistedData
?.[key
] ?? null;
576 if (persisted
== null) {
577 // There is no persisted data for the current property yet.
580 new: value
instanceof Set
584 } else if (value
instanceof Set
) {
585 // The current property is set‐valued.
586 const oldValues
= new Set(persisted
);
587 const newValues
= new Set(value
);
588 for (const existing
of persisted
) {
589 // Iterate over each persisted property and either remove
590 // it from the list of new values or add it to the list of
592 if (value
.has(existing
)) {
593 // The value is in both the old and new version of the
595 oldValues
.delete(existing
);
596 newValues
.delete(existing
);
598 // The value is not shared.
602 diffs
[key
] = { old: oldValues
, new: newValues
};
604 `${value}` != `${persisted}`
605 || value
.language
!= persisted
.language
607 // The current property is (optionally language‐tagged)
608 // string‐valued and the value changed.
610 old: new Set([persisted
]),
611 new: new Set([value
]),
614 // The current property did not change.
615 diffs
[key
] = { old: new Set(), new: new Set() };
619 const identifier
= this.#identifier
;
620 if (identifier
!= null) {
621 // This `Tag´ has already been persisted; use its existing
622 // identifier and persist.
623 storage
.set(identifier
, this);
625 // This `Tag´ has not been persisted yet; save the new
626 // identifier after persisting.
627 this.#identifier
= storage
.add(this);
629 const persistedIdentifier
= this.#identifier
;
630 this.#persistedData
= tagData(data
); // cloning here is necessary
631 for (const inverse
in objectProperties
) {
632 // Iterate over each non‐transitive inverse property and update
633 // it based on its inverse on the corresponding tags if possible.
634 const term
= objectProperties
[inverse
].inverseOf
;
635 if (term
== null || term
in transitiveProperties
) {
636 // The current property is not the inverse of an non‐transitive
640 // The current property is the inverse of a non‐transitive
642 for (const referencedIdentifier
of diffs
[term
].old
) {
643 // Iterate over the removed tags and remove this `Tag´ from
644 // their inverse property.
645 const referenced
= storage
.get(referencedIdentifier
);
647 // Try removing this `Tag´.
648 referenced
.#data
[inverse
].delete(persistedIdentifier
);
649 storage
.set(referencedIdentifier
, referenced
);
651 // Removal failed, possibly because the other tag was
656 for (const referencedIdentifier
of diffs
[term
].new) {
657 const referenced
= storage
.get(referencedIdentifier
);
659 // Try adding this `Tag`.
660 referenced
.#data
[inverse
].add(persistedIdentifier
);
661 storage
.set(referencedIdentifier
, referenced
);
663 // Adding failed, possibly because the other tag was
671 // This is a silent persist.
674 // This is not a silent persist; an activity needs to be
675 // generated if a change was made.
677 "@context": taggingDiscoveryContext
,
680 identifier
== null ? "Create" : "Update",
682 context: `${system.iri}`,
683 object: `${this.iri}`,
684 endTime: new Date().toISOString(),
690 const { unstates
, states
} = statements
;
691 if (identifier
== null) {
692 // This is a Create activity.
693 states
.push({ predicate: "a", object: `${this.kind}` });
695 // This is an Update activity.
702 }] of Object
.entries(diffs
)
704 // Iterate over the diffs of each term and state/unstate
706 for (const oldValue
of oldValues
) {
707 // Iterate over removals and unstate them.
708 if (term
in dataProperties
) {
709 // This is a literal term; push it.
712 object: { ...oldValue
},
715 // This is a named term; attempt to get its I·R·I and
718 // Attempt to resolve the value and push the change.
719 const tag
= storage
.get(oldValue
);
720 if (!this.#isTagInStorage(tag
)) {
721 // The value did not resolve to a tag in storage.
724 // The value resolved; push its I·R·I.
731 // Value resolution failed for some reason; perhaps
732 // the tag was deleted.
737 for (const newValue
of newValues
) {
738 // Iterate over additions and state them.
739 if (term
in dataProperties
) {
740 // This is a literal term; push it.
743 object: { ...newValue
},
746 // This is a named term; attempt to get its I·R·I and
749 // Attempt to resolve the value and push the change.
750 const tag
= storage
.get(newValue
);
751 if (!this.#isTagInStorage(tag
)) {
752 // The value did not resolve to a tag in storage.
755 // The value resolved; push its I·R·I.
762 // Value resolution failed for some reason; perhaps
763 // the tag was deleted.
769 if (unstates
.length
== 0) {
770 // Nothing was unstated.
771 delete statements
.unstates
;
773 // Things were stated.
776 if (states
.length
== 0) {
777 // Nothing was stated.
778 delete statements
.states
;
780 // Things were stated.
787 !Object
.hasOwn(activity
, "states")
788 && !Object
.hasOwn(activity
, "unstates")
790 // No meaningful changes were actually persisted.
793 // There were meaningful changes persisted regarding this
800 /** Returns the preferred label for this `Tag´. */
802 return this.#data
.prefLabel
;
805 /** Sets the preferred label of this `Tag´ to the provided label. */
807 this.#data
.prefLabel
= langString($);
810 /** Returns the Tag U·R·I for this `Tag´. */
812 const { identifier
} = this;
813 return identifier
== null
815 : `tag:${this.taggingEntity}:${identifier}`;
818 /** Returns the tagging entity (domain and date) for this `Tag´. */
819 get taggingEntity() {
820 return this.#system
.taggingEntity
;
823 /** Returns the string form of the preferred label of this `Tag´. */
825 return `${this.#data.prefLabel}`;
829 * Returns a new object whose enumerable own properties contain the
830 * data from this object needed for storage.
832 * ※ This method is not really intended for public usage.
834 [Storage
.toObject
]() {
835 const data
= this.#data
;
836 return Object
.assign(Object
.create(null), {
845 * A `Tag´ constructor function.
847 * This class extends the identity function, meaning that the object
848 * provided as the constructor is used verbatim (with new private
851 * ※ The instance methods of this class are provided as static
852 * methods on the superclass which all `Tag´ constructors inherit
855 * ※ This class is not exposed.
860 * The exposed constructor function from which all `Tag´ constructors
863 * ☡ This constructor always throws.
867 const tagConstructorBehaviours
= Object
.create(null);
869 TagConstructor: class extends identity
{
871 * The `TagSystem´ used for `Tag´s constructed by this
876 /** The `Storage´ managed by this constructors `TagSystem´. */
879 /** The schema in use for this constructor. */
883 * Constructs a new `Tag´ constructor by adding the appropriate
884 * private fields to the provided constructor, setting its
885 * prototype, and then returning it.
887 * ※ This constructor does not modify the `name´ or `prototype´
888 * properties of the provided constructor.
890 * ※ See `Tag.For´, where this constructor is used.
892 constructor(constructor, system
, storage
, schema
) {
894 Object
.setPrototypeOf(this, TagSuper
);
895 this.#system
= system
;
896 this.#storage
= storage
;
897 this.#schema
= schema
;
901 // Define the superclass constructor which all `Tag´
902 // constructors will inherit from.
903 const superclass
= tagConstructorBehaviours
.TagSuper
=
905 throw new TypeError("Tags must belong to a System.");
907 const { prototype: staticFeatures
} = this;
908 delete staticFeatures
.constructor;
909 Object
.defineProperty(superclass
, "prototype", {
912 value: Tag
.prototype,
915 Object
.defineProperties(
917 Object
.getOwnPropertyDescriptors(staticFeatures
),
922 * Yields the tags in the `TagSystem´ associated with this
926 const system
= this.#system
;
927 const storage
= this.#storage
;
928 for (const instance
of storage
.values()) {
929 // Iterate over the entries and yield the ones which are
930 // `Tag´s in this `TagSystem´.
931 if (Tag
.getSystem(instance
) == system
) {
932 // The current instance is a `Tag´ in this `TagSystem´.
935 // The current instance is not a `Tag´ in this
943 * Returns a new `Tag´ resolved from the provided I·R·I.
945 * ☡ This function throws if the I·R·I is not in the `.iriSpace´
946 * of the `TagSystem´ associated with this constructor.
948 * ※ If the I·R·I is not recognized, this function returns
952 const system
= this.#system
;
953 const storage
= this.#storage
;
954 const name
= `${iri}`;
955 const prefix
= `${system.iriSpace}`;
956 if (!name
.startsWith(prefix
)) {
957 // The I·R·I does not begin with the expected prefix.
958 throw new RangeError(
959 `${ÉTIQUETTE}: I·R·I did not begin with the expected prefix: ${iri}`,
962 // The I·R·I begins with the expected prefix.
963 const identifier
= name
.substring(prefix
.length
);
965 // Attempt to resolve the identifier.
966 const instance
= storage
.get(identifier
);
967 return Tag
.getSystem(instance
) == system
971 // Do not throw for bad identifiers.
978 * Returns a new `Tag´ resolved from the provided identifier.
980 * ☡ This function throws if the identifier is invalid.
982 * ※ If the identifier is valid but not recognized, this
983 * function returns `undefined´.
985 fromIdentifier(identifier
) {
986 const system
= this.#system
;
987 const storage
= this.#storage
;
988 const instance
= storage
.get(identifier
);
989 return Tag
.getSystem(instance
) == system
995 * Returns a new `Tag´ resolved from the provided Tag U·R·I.
997 * ☡ This function throws if the provided Tag U·R·I does not
998 * match the tagging entity of this constructors `TagSystem´.
1000 * ※ If the specific component of the Tag U·R·I is not
1001 * recognized, this function returns `undefined´.
1003 fromTagURI(tagURI
) {
1004 const system
= this.#system
;
1005 const storage
= this.#storage
;
1006 const tagName
= `${tagURI}`;
1007 const tagPrefix
= `tag:${system.taggingEntity}:`;
1008 if (!tagName
.startsWith(tagPrefix
)) {
1009 // The Tag U·R·I does not begin with the expected prefix.
1010 throw new RangeError(
1011 `${ÉTIQUETTE}: Tag U·R·I did not begin with the expected prefix: ${tagURI}`,
1014 // The I·R·I begins with the expected prefix.
1015 const identifier
= tagName
.substring(tagPrefix
.length
);
1017 // Attempt to resolve the identifier.
1018 const instance
= storage
.get(identifier
);
1019 return Tag
.getSystem(instance
) == system
1023 // Do not throw for bad identifiers.
1030 * Yields the tag identifiers in the `TagSystem´ associated with
1034 const system
= this.#system
;
1035 const storage
= this.#storage
;
1036 for (const [identifier
, instance
] of storage
.entries()) {
1037 // Iterate over the entries and yield the ones which are
1038 // `Tag´s in this `TagSystem´.
1039 if (Tag
.getSystem(instance
) == system
) {
1040 // The current instance is a `Tag´ in this `TagSystem´.
1043 // The current instance is not a `Tag´ in this `TagSystem´.
1049 /** Returns the `TagSystem´ for this `Tag´ constructor. */
1051 return this.#system
;
1055 * Returns a new `Tag´ constructed from the provided data and
1056 * with the provided identifier.
1058 * ※ This function is not really intended for public usage.
1060 [Storage
.toInstance
](data
, identifier
) {
1061 const tag
= new this(data
.kind
);
1062 return Tag
.assignData(tag
, data
, identifier
);
1065 TagSuper: tagConstructorBehaviours
.TagSuper
,
1071 * Returns whether the provided schema, subject class, object
1072 * property, and object class are consistent.
1074 * This is hardly a full reasoner; it is tuned to the abilites and
1075 * needs of this module.
1077 isObjectPredicateOK
,
1079 const cachedClassAndSuperclasses
= new WeakMap();
1080 const cachedClassRestrictions
= new WeakMap();
1081 const cachedPredicateRestrictions
= new WeakMap();
1083 const classAndSuperclasses
= function* (
1086 touched
= new Set(),
1088 if (baseClass
== "Thing" || touched
.has(baseClass
)) {
1092 touched
.add(baseClass
);
1093 const subClassOf
= classes
[baseClass
]?.subClassOf
?? "Thing";
1095 const superclass
of (
1096 typeof subClassOf
== "string"
1098 : Array
.from(subClassOf
)
1099 ).filter(($) => typeof $ == "string")
1101 yield* classAndSuperclasses(classes
, superclass
, touched
);
1106 const getClassAndSuperclasses
= (schema
, baseClass
) => {
1107 const schemaCache
= cachedClassAndSuperclasses
.get(schema
);
1108 const cached
= schemaCache
?.[baseClass
];
1109 if (cached
!= null) {
1112 const { classes
} = schema
;
1113 const result
= [...classAndSuperclasses(classes
, baseClass
)];
1115 schemaCache
[baseClass
] = result
;
1117 cachedClassRestrictions
.set(
1119 Object
.assign(Object
.create(null), { [baseClass
]: result
}),
1126 const getClassRestrictions
= (schema
, domain
) => {
1127 const schemaCache
= cachedClassRestrictions
.get(schema
);
1128 const cached
= schemaCache
?.[domain
];
1129 if (cached
!= null) {
1132 const { classes
} = schema
;
1133 const restrictions
= Object
.create(null);
1134 const subClassOf
= classes
[domain
]?.subClassOf
?? "Thing";
1136 const superclass
of (
1137 typeof subClassOf
== "string"
1139 : Array
.from(subClassOf
)
1140 ).filter(($) => Object($) === $)
1142 const { onProperty
, allValuesFrom
} = superclass
;
1143 restrictions
[onProperty
] = processSpace(allValuesFrom
);
1146 schemaCache
[domain
] = restrictions
;
1148 cachedClassRestrictions
.set(
1150 Object
.assign(Object
.create(null), {
1151 [domain
]: restrictions
,
1155 return restrictions
;
1159 const getPredicateRestrictions
= (schema
, predicate
) => {
1160 const schemaCache
= cachedPredicateRestrictions
.get(schema
);
1161 const cached
= schemaCache
?.[predicate
];
1162 if (cached
!= null) {
1165 const { objectProperties
} = schema
;
1166 const restrictions
= [
1167 ...predicateRestrictions(objectProperties
, predicate
),
1169 (result
, { domainIntersection
, rangeIntersection
}) => {
1170 result
.domainIntersection
.push(...domainIntersection
);
1171 result
.rangeIntersection
.push(...rangeIntersection
);
1174 Object
.assign(Object
.create(null), {
1175 domainIntersection: [],
1176 rangeIntersection: [],
1180 schemaCache
[predicate
] = restrictions
;
1182 cachedPredicateRestrictions
.set(
1184 Object
.assign(Object
.create(null), {
1185 [predicate
]: restrictions
,
1189 return restrictions
;
1193 const processSpace
= (space
) =>
1194 Object(space
) === space
1199 Object(subspace
) === subspace
1200 ? Array
.from(subspace
.unionOf
)
1203 : [Array
.from(space
.unionOf
)]
1206 const predicateRestrictions
= function* (
1209 touched
= new Set(),
1211 if (predicate
== "Property" || touched
.has(predicate
)) {
1214 const { domain
, range
, subPropertyOf
} =
1215 objectProperties
[predicate
];
1216 yield Object
.assign(Object
.create(null), {
1217 domainIntersection: processSpace(domain
?? "Thing"),
1218 rangeIntersection: processSpace(range
?? "Thing"),
1220 touched
.add(predicate
);
1222 const superproperty
of (
1223 subPropertyOf
== null
1225 : typeof subPropertyOf
== "string"
1227 : Array
.from(subPropertyOf
)
1230 yield* predicateRestrictions(
1240 isObjectPredicateOK: (
1246 const { objectProperties
} = schema
;
1247 const predicateDefinition
= objectProperties
[predicate
];
1248 const isInverse
= "inverseOf" in predicateDefinition
;
1249 const usedPredicate
= isInverse
1250 ? predicateDefinition
.inverseOf
1252 const domain
= isInverse
? objectClass : subjectClass
;
1253 const domains
= new Set(getClassAndSuperclasses(schema
, domain
));
1254 const ranges
= new Set(getClassAndSuperclasses(
1256 isInverse
? subjectClass : objectClass
,
1258 const predicateRestrictions
= getPredicateRestrictions(
1262 const { domainIntersection
} = predicateRestrictions
;
1263 const rangeIntersection
= [
1264 ...predicateRestrictions
.rangeIntersection
,
1266 for (const domain
of domains
) {
1267 const classRestrictionOnPredicate
=
1268 getClassRestrictions(schema
, domain
)[usedPredicate
];
1269 if (classRestrictionOnPredicate
!= null) {
1270 yield* classRestrictionOnPredicate
;
1277 return domainIntersection
.every((domainUnion
) =>
1278 domainUnion
.some((domain
) =>
1279 domain
== "Thing" || domains
.has(domain
)
1282 && rangeIntersection
.every((rangeUnion
) =>
1283 rangeUnion
.some((range
) =>
1284 range
== "Thing" || ranges
.has(range
)
1293 * Returns the provided value converted into a `String´ object with
1294 * `.["@value"]´ and `.["@language"]´ properties.
1296 * The same object will be returned for every call with an equivalent
1299 * TODO: Ideally this would be extracted more fully into an R·D·F
1302 * ※ This function is not exposed.
1307 * Returns the language string object corresponding to the provided
1308 * value and language.
1310 const getLangString
= (value
, language
= "") => {
1311 const valueMap
= languageMap
[language
] ??= Object
.create(null);
1312 const literal
= valueMap
[value
]?.deref();
1313 if (literal
!= null) {
1314 // There is already an object corresponding to the provided value
1318 // No object already exists corresponding to the provided value
1319 // and language; create one.
1320 const result
= Object
.preventExtensions(
1321 Object
.create(String
.prototype, {
1327 enumerable: !!language
,
1328 value: language
|| null,
1330 language: { enumerable: false, get: getLanguage
},
1331 toString: { enumerable: false, value: toString
},
1332 valueOf: { enumerable: false, value: valueOf
},
1335 const ref
= new WeakRef(result
);
1336 langStringRegistry
.register(result
, { ref
, language
, value
});
1337 valueMap
[value
] = ref
;
1342 /** Returns the `.["@language"]´ of this object. */
1343 const getLanguage
= Object
.defineProperty(
1345 return this["@language"] || null;
1348 { value: "get language" },
1352 * A `FinalizationRegistry´ for language string objects.
1354 * This simply cleans up the corresponding `WeakRef´ in the language
1357 const langStringRegistry
= new FinalizationRegistry(
1358 ({ ref
, language
, value
}) => {
1359 const valueMap
= languageMap
[language
];
1360 if (valueMap
?.[value
] === ref
) {
1361 delete valueMap
[value
];
1369 * An object whose own values are an object mapping values to
1370 * language string objects for the language specified by the key.
1372 const languageMap
= Object
.create(null);
1374 /** Returns the `.["@value"]´ of this object. */
1375 const toString = function () {
1376 return this["@value"];
1380 * Returns this object if it has a `.["@language"]´; otherwise, its
1383 const valueOf = function () {
1384 return this["@language"] ? this : this["@value"];
1394 `${$["@language"] ?? ""}`,
1396 : getLangString(`${$["@value"]}`)
1398 ? getLangString(`${$}`, `${$.language ?? ""}`)
1399 : getLangString(`${$}`)
1400 : getLangString(`${$ ?? ""}`),
1405 * Returns a normalized tag data object derived from the provided
1408 * ※ The properties of this function need to match the term names used
1409 * in the ActivityStreams serialization.
1411 * ※ This function is not exposed.
1413 const tagData
= ($) => {
1414 const data
= Object($);
1416 // prefLabel intentionally not set here
1426 let prefLabel
= langString(data
.prefLabel
);
1427 return Object
.preventExtensions(Object
.create(null, {
1430 get: () => prefLabel
,
1432 prefLabel
= langString($);
1439 ? Array
.from(altLabel
, langString
)
1447 ? Array
.from(hiddenLabel
, langString
)
1455 ? Array
.from(broader
, toIdentifier
)
1463 ? Array
.from(narrower
, toIdentifier
)
1471 ? Array
.from(inCanon
, toIdentifier
)
1479 ? Array
.from(hasInCanon
, toIdentifier
)
1487 ? Array
.from(involves
, toIdentifier
)
1495 ? Array
.from(involvedIn
, toIdentifier
)
1503 * Returns an identifier corresponding to the provided object.
1505 * This is either the value of its `.identifier´ or its string value.
1507 * ※ This function is not exposed.
1509 const toIdentifier
= ($) =>
1512 : Object($) === $ && "identifier" in $
1517 * A tag system, with storage.
1519 * The `::Tag´ constructor available on any `TagSystem´ instance can be
1520 * used to create new `Tag´s within the system.
1522 export class TagSystem
{
1523 /** The cached bound `Tag´ constructor for this `TagSystem´. */
1526 /** The domain of this `TagSystem´. */
1529 /** The date of this `TagSystem´. */
1532 /** The identifier of this `TagSystem´. */
1535 /** The schema used by this `TagSystem´. */
1538 /** The internal `Storage` of this `TagSystem´. */
1539 #storage
= new Storage();
1542 * Constructs a new `TagSystem´ with the provided domain and date.
1544 * Only actual, lowercased domain names are allowed for the domain,
1545 * and the date must be “full” (include month and day components).
1546 * This is for alignment with general best practices for Tag U·R·I¦s.
1548 * ☡ This constructor throws if provided with an invalid date.
1550 constructor(domain
, date
, identifier
= "") {
1551 const domainString
= `${domain}`;
1552 const dateString
= `${date}`;
1553 this.#identifier
= `${identifier}`;
1555 // If the identifier is a valid storage I·D, reserve it.
1556 this.#storage
.delete(this.#identifier
);
1558 // The identifier is not a valid storage I·D, so no worries.
1562 !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
1565 // ☡ The domain is invalid.
1566 throw new RangeError(`Invalid domain: ${domain}.`);
1568 !/^\d{4}-\d{2}-\d{2}$/u.test(dateString
)
1569 || dateString
!= new Date(dateString
).toISOString().split("T")[0]
1571 // ☡ The date is invalid.
1572 throw new RangeError(`Invalid date: ${date}.`);
1574 // The domain and date are 🆗.
1575 this.#domain
= domainString
;
1576 this.#date
= dateString
;
1581 * Returns a bound constructor for constructing `Tags´ in this
1585 if (this.#Tag
!= null) {
1586 // A bound constructor has already been generated; return it.
1589 // No bound constructor has been created yet.
1590 const storage
= this.#storage
;
1591 return this.#Tag
= Tag
.For(this, storage
, this.#schema
);
1596 * Applies the provided activity to this `TagSystem´ by replaying its
1597 * statement changes.
1599 * ※ This method assumes that the provided activity conforms to the
1600 * assumptions made by this module; i·e that its tags use the same
1601 * identifier format and its activities do not use statements with
1602 * inverse property predicates. It is not intended for the generic
1603 * playback of activities produced by other scripts or mechanisms,
1604 * for which a more sophisticated solution is required.
1606 * ☡ This method throws an error if the provided activity cannot be
1610 const { Tag: TagConstructor
} = this;
1614 transitiveProperties
,
1617 const { object
, states
, unstates
} = activity
;
1618 const activityTypes
= [].concat(activity
["@type"]);
1620 // ☡ The provided activity has no object.
1621 throw new TypeError(
1622 `${ÉTIQUETTE}: Cannot apply activity: Activity lacks an object.`,
1625 // The provided activity has an object.
1626 const iri
= `${object}`;
1627 const iriSpace
= `${this.iriSpace}`;
1628 const identifier
= (() => {
1629 // Extract the identifier from the object I·R·I.
1630 if (!iri
.startsWith(iriSpace
)) {
1631 // ☡ The object of the provided activity is not in the I·R·I
1632 // space of this `TagSystem´.
1633 throw new RangeError(
1634 `Cannot apply activity: Object is not in I·R·I space: ${object}`,
1637 // ☡ The object of the provided activity is in the I·R·I
1638 // space of this `TagSystem´.
1639 return iri
.substring(iriSpace
.length
);
1642 const tag
= (() => {
1643 // Either resolve the identifier to an existing tag or create
1645 if (activityTypes
.includes("Create")) {
1646 // The provided activity is a Create activity.
1647 const kind
= states
.findLast(
1648 ({ predicate
, object
}) =>
1649 predicate
== "a" && `${object}` in classes
,
1652 // ☡ There is no recognized tag class provided for the tag;
1653 // it cannot be created.
1654 throw new RangeError(
1655 `Cannot apply activity: Tag type not recognized.`,
1658 // There is a recognized tag class provided for the tag.
1659 return Tag
.new(this, identifier
, kind
);
1662 // The provided activity is not a Create activity.
1663 return TagConstructor
.fromIdentifier(identifier
);
1667 // ☡ Resolving the tag identifier failed.
1668 throw new RangeError(
1669 `${ÉTIQUETTE}: Cannot apply activity: No tag for identifier: ${identifier}.`,
1672 // Resolving the identifier succeeded; apply the changes to the
1673 // tag and then silently persist it.
1675 const [statements
, mode
] of [
1676 [unstates
?? [], "delete"],
1677 [states
?? [], "add"],
1680 // Delete unstatements, then add statements.
1681 for (const { predicate: $p
, object: $o
} of statements
) {
1682 // Iterate over the statements and apply them.
1683 const predicate
= `${$p}`;
1684 const term
= predicate
in dataProperties
1686 : predicate
in objectProperties
1687 && !(predicate
in transitiveProperties
1688 || objectProperties
[predicate
].inverseOf
!= null)
1692 // The provided predicate is not recognized; ignore it.
1694 } else if (predicate
== "prefLabel") {
1695 // Preflabels are handled specially.
1696 if (mode
== "delete") {
1697 // Unstating a preflabel has no effect unless a new one
1701 // Update the preflabel.
1702 tag
.prefLabel
= term
;
1705 // The predicate is not `"prefLabel"´.
1706 const related
= (() => {
1707 // If the predicate is an object property, attempt to
1708 // resolve the object.
1709 if (!(predicate
in objectProperties
)) {
1710 // The predicate is not an object property; return
1714 // The predicate is an object property.
1716 // Attempt to resolve the object.
1717 return TagConstructor
.fromIRI(term
);
1719 // Resolving failed; return undefined.
1724 if (related
=== undefined) {
1725 // The predicate is an object property, but its object
1726 // was not resolvable.
1728 // ☡ This is a silent error to allow for selective
1729 // replay of activities while ignoring terms which are
1733 // The predicate is not an object property or has a
1734 // resolvable object.
1736 // Apply the statement.
1739 predicate
[0].toUpperCase(),
1740 predicate
.substring(1),
1741 predicate
in objectProperties
? "Tag" : "",
1754 /** Returns the authority name (domain) for this `TagSystem´. */
1755 get authorityName() {
1756 return this.#domain
;
1759 /** Returns the date of this `TagSystem´, as a string. */
1765 * Yields the entities in this `TagSystem´.
1767 * ※ Entities can hypothetically be anything. If you specifically
1768 * want the `Tag´s, use `::Tag.all´ instead.
1771 yield* this.#storage
.values();
1775 * Returns the identifier of this `TagSystem´.
1777 * ※ Often this is just the empty string.
1780 return this.#identifier
;
1783 /** Yields the identifiers in use in this `TagSystem´. */
1785 yield* this.#storage
.keys();
1788 /** Returns the I·R·I for this `TagSystem´. */
1790 return `${this.iriSpace}${this.identifier}`;
1794 * Returns the prefix used for I·R·I¦s of `Tag´s in this `TagSystem´.
1797 return `https://${this.authorityName}/tag:${this.taggingEntity}:`;
1800 /** Returns the Tag U·R·I for this `TagSystem´. */
1802 return `tag:${this.taggingEntity}:${this.identifier}`;
1806 * Returns the tagging entity (domain and date) for this `TagSystem´.
1808 get taggingEntity() {
1809 return `${this.authorityName},${this.date}`;