]> Lady’s Gitweb - Etiquette/blob - model.js
Return undefined not null when resolving fails
[Etiquette] / model.js
1 // 📧🏷️ Étiquette ∷ model.js
2 // ====================================================================
3 //
4 // Copyright © 2023 Lady [@ Lady’s Computer].
5 //
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/>.
9
10 import { identity } from "./deps.js";
11 import { Storage } from "./memory.js";
12 import { taggingDiscoveryContext } from "./names.js";
13 import schema from "./schema.js";
14
15 /**
16 * A tag.
17 *
18 * `Tag`s are not assigned identifiers and do not have side·effects on
19 * other tags in the `TagSystem` until they are persisted with
20 * `::persist`, at which point changes to their relationships are
21 * applied.
22 *
23 * `Tag`s are also not kept up‐to‐date, but persisting an outdated
24 * `Tag` will *not* undo subsequent changes.
25 *
26 * ※ This class is not itself directly exposed, although bound
27 * versions of it are via `TagSystem::Tag`.
28 */
29 class Tag {
30 /** The `TagSystem` this `Tag` belongs to. */
31 #system;
32
33 /** The `Storage` managed by this `Tag`’s `TagSystem`. */
34 #storage;
35
36 /** The schema in use for this `Tag`. */
37 #schema;
38
39 /**
40 * The 30‐bit W·R·M·G base32 identifier with leading checksum which
41 * has been assigned to this `Tag`.
42 *
43 * Will be `null` if this `Tag` has not been persisted. Otherwise,
44 * the format is `cxx-xxxx` (`c` = checksum; `x` = digit).
45 */
46 #identifier = null;
47
48 /** The kind of this `Tag`. */
49 #kind = "Tag";
50
51 /**
52 * The data which was attached to this `Tag` the last time it was
53 * persisted or retrieved from storage.
54 *
55 * Diffing with this will reveal changes.
56 */
57 #persistedData = null;
58
59 /** The current (modified) data associated with this `Tag`. */
60 #data = tagData();
61
62 /**
63 * Adds the provided label(s) to this `Tag` as the provided
64 * predicate, then returns this `Tag`.
65 */
66 #addLabel(predicate, ...labels) {
67 const values = this.#data[predicate];
68 for (const $ of labels) {
69 // Iterate over each provided label and attempt to add it.
70 const literal = langString($);
71 values.add(literal);
72 }
73 return this;
74 }
75
76 /**
77 * Adds the provided tags to the list of tags that this `Tag` is
78 * related to by the provided predicate, then returns this `Tag`.
79 *
80 * Arguments may be string identifiers or objects with an
81 * `.identifier` property.
82 */
83 #addTag(predicate, ...tags) {
84 const storage = this.#storage;
85 const values = this.#data[predicate];
86 for (const $ of tags) {
87 // Iterate over each tag and attempt to state the predicate.
88 const identifier = toIdentifier($);
89 if (identifier == null) {
90 // ☡ The current tag has no identifier.
91 throw new TypeError(
92 `Cannot state ${predicate} of Tag: Identifier must not be nullish.`,
93 );
94 } else if (values.has(identifier)) {
95 // Short‐circuit: The identifier has already been stated with
96 // this predicate.
97 /* do nothing */
98 } else {
99 // The current tag has an identifier, but it hasn’t been stated
100 // with this predicate yet.
101 const tag = storage.get(identifier);
102 if (tag == null) {
103 // ☡ The current tag has not been persisted to this `Tag`’s
104 // storage.
105 throw new RangeError(
106 `Cannot state ${predicate} of Tag: Identifier is not persisted: ${identifier}.`,
107 );
108 } else if (!this.#isTagInStorage(tag)) {
109 // ☡ The current tag is not a tag in the correct tag system.
110 throw new TypeError(
111 `Cannot state ${predicate} of Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
112 );
113 } else if (
114 !isObjectPredicateOK(
115 this.#schema,
116 this.#kind,
117 predicate,
118 tag.#kind,
119 )
120 ) {
121 // ☡ This tag and the current tag form an invalid pair for
122 // this predicate.
123 throw new TypeError(
124 `Cannot state ${predicate} of Tag: Not valid for domain and range: ${this.#kind}, ${tag.#kind}.`,
125 );
126 } else {
127 // The current tag is a tag in the correct tag system; add
128 // its identifier.
129 values.add(identifier);
130 }
131 }
132 }
133 return this;
134 }
135
136 /**
137 * Removes the provided string label(s) from this `Tag` as the
138 * provided predicate, then returns this `Tag`.
139 */
140 #deleteLabel(predicate, ...labels) {
141 const values = this.#data[predicate];
142 for (const $ of labels) {
143 // Iterate over each provided label and attempt to remove it.
144 const literal = langString($);
145 values.delete(literal);
146 }
147 return this;
148 }
149
150 /**
151 * Removes the provided tags from the list of tags that this `Tag` is
152 * related to by the provided predicate, then returns this `Tag`.
153 *
154 * Arguments may be string identifiers or objects with an
155 * `.identifier` property.
156 */
157 #deleteTag(predicate, ...tags) {
158 const values = this.#data[predicate];
159 for (const $ of tags) {
160 // Iterate over the provided tags and delete them.
161 values.delete(toIdentifier($));
162 }
163 return this;
164 }
165
166 /**
167 * Returns whether or not the provided value is a tag which shares a
168 * storage with this tag.
169 *
170 * Sharing a storage also implies sharing a `TagSystem`.
171 */
172 #isTagInStorage($) {
173 try {
174 // Try to compare the provided value’s internal store with
175 // the provided storage.
176 return $.#storage == this.#storage;
177 } catch {
178 // The provided value was not a `Tag`.
179 return false;
180 }
181 }
182
183 /**
184 * Yields the labels of this `Tag` according to the provided
185 * predicate.
186 */
187 *#yieldLabels(predicate) {
188 yield* this.#data[predicate];
189 }
190
191 /**
192 * Yields the tags that this `Tag` is related to by the provided
193 * predicate.
194 */
195 *#yieldTags(predicate) {
196 const storage = this.#storage;
197 for (const identifier of this.#data[predicate]) {
198 // Iterate over the tags in this predicate and yield them if
199 // possible.
200 const tag = storage.get(identifier);
201 if (
202 !this.#isTagInStorage(tag) || !isObjectPredicateOK(
203 this.#schema,
204 this.#kind,
205 predicate,
206 tag.#kind,
207 )
208 ) {
209 // The tag no longer appears in storage or is not compatible;
210 // perhaps it was deleted.
211 /* do nothing */
212 } else {
213 // The tag exists and is constructable from storage.
214 yield tag;
215 }
216 }
217 }
218
219 /**
220 * Yields the tags that this `Tag` is related to by the provided
221 * predicate, figured transitively.
222 */
223 *#yieldTransitiveTags(transitivePredicate, basePredicate) {
224 const storage = this.#storage;
225 const encountered = new Set();
226 let pending = new Set(this.#data[basePredicate]);
227 while (pending.size > 0) {
228 // Loop until all tags of the predicate have been encountered.
229 const processing = pending;
230 pending = new Set();
231 for (const identifier of processing) {
232 // Iterate over the tags and yield them if possible.
233 if (!encountered.has(identifier)) {
234 // The tag has not been encountered before.
235 encountered.add(identifier);
236 const tag = storage.get(identifier);
237 if (
238 !this.#isTagInStorage(tag) || !isObjectPredicateOK(
239 this.#schema,
240 this.#kind,
241 transitivePredicate,
242 tag.#kind,
243 )
244 ) {
245 // The tag no longer appears in storage or is not
246 // compatible; perhaps it was deleted.
247 /* do nothing */
248 } else {
249 // The tag exists and is constructable from storage.
250 yield tag;
251 for (const transitive of tag.#data[basePredicate]) {
252 // Iterate over the nested tags of the current tag and
253 // add them to pending as needed.
254 if (!encountered.has(transitive)) {
255 // The nested tag has not been encountered yet.
256 pending.add(transitive);
257 } else {
258 // The nested tag has already been encountered.
259 /* do nothing */
260 }
261 }
262 }
263 } else {
264 // The tag has already been encountered.
265 /* do nothing */
266 }
267 }
268 }
269 }
270
271 /**
272 * Constructs a new `Tag` of the provided kind and with the provided
273 * preferred label.
274 *
275 * ※ The first two arguments of this constructor are bound when
276 * generating the value of `TagSystem::Tag`. It isn’t possible to
277 * access this constructor in its unbound form from outside this
278 * module.
279 *
280 * ☡ This constructor throws if the provided kind is not supported.
281 */
282 constructor(system, storage, schema, kind = "Tag", prefLabel = "") {
283 this.#system = system;
284 this.#storage = storage;
285 this.#schema = schema;
286 const kindString = `${kind}`;
287 if (!(kindString in schema.classes)) {
288 // The provided kind is not supported.
289 throw new RangeError(
290 `Cannot construct Tag: Unrecognized kind: ${kind}.`,
291 );
292 } else {
293 // The provided kind is one of the recognized tag kinds.
294 this.#kind = kindString;
295 this.#data.prefLabel = prefLabel;
296 }
297 }
298
299 /**
300 * Returns a new `Tag` constructor for the provided system, storage,
301 * schema, created with an appropriate prototype for the properties
302 * so defined.
303 *
304 * ※ This function is not exposed.
305 */
306 static For(system, storage, schema) {
307 const {
308 objectProperties,
309 transitiveProperties,
310 dataProperties,
311 } = schema;
312 const constructor = function (...$s) {
313 return Reflect.construct(
314 Tag,
315 [system, storage, schema, ...$s],
316 new.target,
317 );
318 };
319 Object.defineProperties(constructor, {
320 name: { value: "TagSystem::Tag" },
321 prototype: {
322 configurable: false,
323 enumerable: false,
324 value: Object.create(
325 Tag.prototype,
326 Object.fromEntries(Array.from(
327 function* () {
328 for (const key in objectProperties) {
329 // Iterate over each object property and yield any
330 // necessary method definitions.
331 const {
332 inverseOf,
333 subPropertyOf,
334 } = objectProperties[key];
335 if (key in transitiveProperties) {
336 // The current key indicates a transitive property.
337 //
338 // Transitive property methods are added by their
339 // nontransitive subproperties.
340 /* do nothing */
341 } else {
342 // The current key does not indicate a transitive
343 // property.
344 yield [`${key}Tags`, function* () {
345 yield* this.#yieldTags(key);
346 }];
347 if (inverseOf == null) {
348 // The current key does not indicate an inverse
349 // property, so add and delete methods are also
350 // added.
351 const cased = key[0].toUpperCase() +
352 key.substring(1);
353 yield [`add${cased}Tag`, function (...tags) {
354 return this.#addTag(key, ...tags);
355 }];
356 yield [`delete${cased}Tag`, function (...tags) {
357 return this.#deleteTag(key, ...tags);
358 }];
359 } else {
360 // The current key indicates an inverse property,
361 // so no add and delete methods are necessary.
362 /* do nothing */
363 }
364 if (
365 subPropertyOf != null &&
366 subPropertyOf in transitiveProperties
367 ) {
368 // The current key indicates a subproperty of a
369 // transitive property; its method is also added.
370 yield [`${subPropertyOf}Tags`, function* () {
371 yield* this.#yieldTransitiveTags(
372 subPropertyOf,
373 key,
374 );
375 }];
376 } else {
377 // The current key does not indicate a subproperty
378 // of a transitive property.
379 /* do nothing */
380 }
381 }
382 }
383 for (const key in dataProperties) {
384 // Iterate over each data property and yield any
385 // necessary method definitions.
386 if (key != "prefLabel") {
387 // The current key is not `"prefLabel"`.
388 const cased = key[0].toUpperCase() +
389 key.substring(1);
390 yield [`${key}s`, function* () {
391 yield* this.#yieldLabels(key);
392 }];
393 yield [`add${cased}`, function (...labels) {
394 return this.#addLabel(key, ...labels);
395 }];
396 yield [`delete${cased}`, function (...labels) {
397 return this.#deleteLabel(key, ...labels);
398 }];
399 } else {
400 // The current key is `"prefLabel"`. This is a
401 // special case which is not handled by the schema.
402 /* do nothing */
403 }
404 }
405 }(),
406 ([key, value]) => [key, {
407 configurable: true,
408 enumerable: false,
409 value: Object.defineProperty(value, "name", {
410 value: key,
411 }),
412 writable: true,
413 }],
414 )),
415 ),
416 writable: false,
417 },
418 });
419 return new TagConstructor(constructor, system, storage, schema);
420 }
421
422 /**
423 * Assigns the provided data and identifier to the provided tag.
424 *
425 * ☡ This function throws if the provided tag is not a `Tag`.
426 *
427 * ※ This function is not exposed.
428 */
429 static assignData(tag, data, identifier) {
430 tag.#identifier = `${identifier}`;
431 tag.#persistedData = tagData(data);
432 tag.#data = tagData(data);
433 return tag;
434 }
435
436 /**
437 * Returns the `TagSystem` that the provided value belongs to.
438 *
439 * ※ This function can be used to check if the provided value has
440 * private tag features.
441 *
442 * ※ This function is not exposed.
443 */
444 static getSystem($) {
445 return !(#system in Object($)) ? null : $.#system;
446 }
447
448 static {
449 // Overwrite the default `::constructor` method to instead give the
450 // actual (bound) constructor which was used to generate a given
451 // `Tag`.
452 Object.defineProperties(this.prototype, {
453 constructor: {
454 configurable: true,
455 enumerable: false,
456 get() {
457 // All `Tag`s are constructed via the `.Tag` constructor
458 // available in their `TagSystem`; return it.
459 return this.#system.Tag;
460 },
461 set: undefined,
462 },
463 });
464 }
465
466 /** Returns the authority (domain) name for this `Tag`. */
467 get authorityName() {
468 return this.#system.authorityName;
469 }
470
471 /** Returns the identifier of this `Tag`. */
472 get identifier() {
473 return this.#identifier;
474 }
475
476 /** Returns the I·R·I for this `Tag`. */
477 get iri() {
478 const { identifier, iriSpace } = this;
479 return identifier == null ? null : `${iriSpace}${identifier}`;
480 }
481
482 /** Returns the I·R·I space for this `Tag`. */
483 get iriSpace() {
484 return this.#system.iriSpace;
485 }
486
487 /** Returns the kind of this `Tag`. */
488 get kind() {
489 return this.#kind;
490 }
491
492 /**
493 * Persist this `Tag` to storage and return an ActivityStreams
494 * serialization of a Tag Activity representing any changes, or
495 * `null` if no changes were made.
496 *
497 * If the second argument is `true`, the `Tag` will be persisted but
498 * no serialization will be made. This is somewhat more efficient.
499 *
500 * ※ Persistence can imply side‐effects on other objects, which are
501 * not noted explicitly in the activity. For example, marking a tag
502 * as broader than another causes the other tag to reciprocally be
503 * marked as narrower.
504 *
505 * ※ Inverse object properties will never appear in the predicates
506 * of generated activities.
507 */
508 persist(silent = false) {
509 const system = this.#system;
510 const storage = this.#storage;
511 const {
512 objectProperties,
513 transitiveProperties,
514 dataProperties,
515 } = this.#schema;
516 const persistedData = this.#persistedData;
517 const data = this.#data;
518 const diffs = {};
519 for (const [key, value] of Object.entries(data)) {
520 // Iterate over each entry of the tag data and create a diff
521 // with the last persisted information.
522 if (
523 objectProperties[key]?.inverseOf != null ||
524 silent && key in dataProperties
525 ) {
526 // The current property is one which is skipped in diffs.
527 //
528 // In a silent persist, this includes any literal terms.
529 /* do nothing */
530 } else {
531 // The current property should be diffed.
532 const persisted = persistedData?.[key] ?? null;
533 if (persisted == null) {
534 // There is no persisted data for the current property yet.
535 diffs[key] = {
536 old: new Set(),
537 new: value instanceof Set
538 ? new Set(value)
539 : new Set([value]),
540 };
541 } else if (value instanceof Set) {
542 // The current property is set‐valued.
543 const oldValues = new Set(persisted);
544 const newValues = new Set(value);
545 for (const existing of persisted) {
546 // Iterate over each persisted property and either remove
547 // it from the list of new values or add it to the list of
548 // removed ones.
549 if (value.has(existing)) {
550 // The value is in both the old and new version of the
551 // data.
552 oldValues.delete(existing);
553 newValues.delete(existing);
554 } else {
555 // The value is not shared.
556 /* do nothing */
557 }
558 }
559 diffs[key] = { old: oldValues, new: newValues };
560 } else if (
561 `${value}` != `${persisted}` ||
562 value.language != persisted.language
563 ) {
564 // The current property is (optionally language‐tagged)
565 // string‐valued and the value changed.
566 diffs[key] = {
567 old: new Set([persisted]),
568 new: new Set([value]),
569 };
570 } else {
571 // The current property did not change.
572 diffs[key] = { old: new Set(), new: new Set() };
573 }
574 }
575 }
576 const identifier = this.#identifier;
577 if (identifier != null) {
578 // This `Tag` has already been persisted; use its existing
579 // identifier and persist.
580 storage.set(identifier, this);
581 } else {
582 // This `Tag` has not been persisted yet; save the new
583 // identifier after persisting.
584 this.#identifier = storage.add(this);
585 }
586 const persistedIdentifier = this.#identifier;
587 this.#persistedData = tagData(data); // cloning here is necessary
588 for (const inverse in objectProperties) {
589 // Iterate over each non‐transitive inverse property and update
590 // it based on its inverse on the corresponding tags if possible.
591 const term = objectProperties[inverse].inverseOf;
592 if (term == null || term in transitiveProperties) {
593 // The current property is not the inverse of an non‐transitive
594 // property.
595 /* do nothing */
596 } else {
597 // The current property is the inverse of a non‐transitive
598 // property.
599 for (const referencedIdentifier of diffs[term].old) {
600 // Iterate over the removed tags and remove this `Tag` from
601 // their inverse property.
602 const referenced = storage.get(referencedIdentifier);
603 try {
604 // Try removing this `Tag`.
605 referenced.#data[inverse].delete(persistedIdentifier);
606 storage.set(referencedIdentifier, referenced);
607 } catch {
608 // Removal failed, possibly because the other tag was
609 // deleted.
610 /* do nothing */
611 }
612 }
613 for (const referencedIdentifier of diffs[term].new) {
614 const referenced = storage.get(referencedIdentifier);
615 try {
616 // Try adding this `Tag`.
617 referenced.#data[inverse].add(persistedIdentifier);
618 storage.set(referencedIdentifier, referenced);
619 } catch {
620 // Adding failed, possibly because the other tag was deleted.
621 /* do nothing */
622 }
623 }
624 }
625 }
626 if (silent) {
627 // This is a silent persist.
628 return undefined;
629 } else {
630 // This is not a silent persist; an activity needs to be
631 // generated if a change was made.
632 const activity = {
633 "@context": taggingDiscoveryContext,
634 "@type": [
635 "TagActivity",
636 identifier == null ? "Create" : "Update",
637 ],
638 context: `${system.iri}`,
639 object: `${this.iri}`,
640 endTime: new Date().toISOString(),
641 ...(() => {
642 const statements = {
643 unstates: [],
644 states: [],
645 };
646 const { unstates, states } = statements;
647 if (identifier == null) {
648 // This is a Create activity.
649 states.push({ predicate: "a", object: `${this.kind}` });
650 } else {
651 // This is an Update activity.
652 /* do nothing */
653 }
654 for (
655 const [term, {
656 old: oldValues,
657 new: newValues,
658 }] of Object.entries(diffs)
659 ) {
660 // Iterate over the diffs of each term and state/unstate
661 // things as needed.
662 for (const oldValue of oldValues) {
663 // Iterate over removals and unstate them.
664 if (term in dataProperties) {
665 // This is a literal term; push it.
666 unstates.push({
667 predicate: term,
668 object: { ...oldValue },
669 });
670 } else {
671 // This is a named term; attempt to get its I·R·I and
672 // push it.
673 try {
674 // Attempt to resolve the value and push the change.
675 const tag = storage.get(oldValue);
676 if (!this.#isTagInStorage(tag)) {
677 // The value did not resolve to a tag in storage.
678 /* do nothing */
679 } else {
680 // The value resolved; push its I·R·I.
681 unstates.push({
682 predicate: term,
683 object: tag.iri,
684 });
685 }
686 } catch {
687 // Value resolution failed for some reason; perhaps the
688 // tag was deleted.
689 /* do nothing */
690 }
691 }
692 }
693 for (const newValue of newValues) {
694 // Iterate over additions and state them.
695 if (term in dataProperties) {
696 // This is a literal term; push it.
697 states.push({
698 predicate: term,
699 object: { ...newValue },
700 });
701 } else {
702 // This is a named term; attempt to get its I·R·I and
703 // push it.
704 try {
705 // Attempt to resolve the value and push the change.
706 const tag = storage.get(newValue);
707 if (!this.#isTagInStorage(tag)) {
708 // The value did not resolve to a tag in storage.
709 /* do nothing */
710 } else {
711 // The value resolved; push its I·R·I.
712 states.push({
713 predicate: term,
714 object: tag.iri,
715 });
716 }
717 } catch {
718 // Value resolution failed for some reason; perhaps the
719 // tag was deleted.
720 /* do nothing */
721 }
722 }
723 }
724 }
725 if (unstates.length == 0) {
726 // Nothing was unstated.
727 delete statements.unstates;
728 } else {
729 // Things were stated.
730 /* do nothing */
731 }
732 if (states.length == 0) {
733 // Nothing was stated.
734 delete statements.states;
735 } else {
736 // Things were stated.
737 /* do nothing */
738 }
739 return statements;
740 })(),
741 };
742 if (
743 !Object.hasOwn(activity, "states") &&
744 !Object.hasOwn(activity, "unstates")
745 ) {
746 // No meaningful changes were actually persisted.
747 return null;
748 } else {
749 // There were meaningful changes persisted regarding this `Tag`.
750 return activity;
751 }
752 }
753 }
754
755 /** Returns the preferred label for this `Tag`. */
756 get prefLabel() {
757 return this.#data.prefLabel;
758 }
759
760 /** Sets the preferred label of this `Tag` to the provided label. */
761 set prefLabel($) {
762 this.#data.prefLabel = langString($);
763 }
764
765 /** Returns the Tag U·R·I for this `Tag`. */
766 get tagURI() {
767 const { identifier } = this;
768 return identifier == null
769 ? null
770 : `tag:${this.taggingEntity}:${identifier}`;
771 }
772
773 /** Returns the tagging entity (domain and date) for this `Tag`. */
774 get taggingEntity() {
775 return this.#system.taggingEntity;
776 }
777
778 /** Returns the string form of the preferred label of this `Tag`. */
779 toString() {
780 return `${this.#data.prefLabel}`;
781 }
782
783 /**
784 * Returns a new object whose enumerable own properties contain the
785 * data from this object needed for storage.
786 *
787 * ※ This method is not really intended for public usage.
788 */
789 [Storage.toObject]() {
790 const data = this.#data;
791 return Object.assign(Object.create(null), {
792 ...data,
793 kind: this.#kind,
794 });
795 }
796 }
797
798 const {
799 /**
800 * A `Tag` constructor function.
801 *
802 * This class extends the identity function, meaning that the object
803 * provided as the constructor is used verbatim (with new private
804 * fields added).
805 *
806 * ※ The instance methods of this class are provided as static
807 * methods on the superclass which all `Tag` constructors inherit
808 * from.
809 *
810 * ※ This class is not exposed.
811 */
812 TagConstructor,
813
814 /**
815 * The exposed constructor function from which all `Tag` constructors
816 * inherit.
817 *
818 * ☡ This constructor always throws.
819 */
820 TagSuper,
821 } = (() => {
822 const tagConstructorBehaviours = Object.create(null);
823 return {
824 TagConstructor: class extends identity {
825 /**
826 * The `TagSystem` used for `Tag`s constructed by this
827 * constructor.
828 */
829 #system;
830
831 /** The `Storage` managed by this constructor’s `TagSystem`. */
832 #storage;
833
834 /** The schema in use for this constructor. */
835 #schema;
836
837 /**
838 * Constructs a new `Tag` constructor by adding the appropriate
839 * private fields to the provided constructor, setting its
840 * prototype, and then returning it.
841 *
842 * ※ This constructor does not modify the `name` or `prototype`
843 * properties of the provided constructor.
844 *
845 * ※ See `Tag.For`, where this constructor is used.
846 */
847 constructor(constructor, system, storage, schema) {
848 super(constructor);
849 Object.setPrototypeOf(this, TagSuper);
850 this.#system = system;
851 this.#storage = storage;
852 this.#schema = schema;
853 }
854
855 static {
856 // Define the superclass constructor which all `Tag`
857 // constructors will inherit from.
858 const superclass = tagConstructorBehaviours.TagSuper =
859 function Tag() {
860 throw new TypeError("Tags must belong to a System.");
861 };
862 const { prototype: methods } = this;
863 delete methods.constructor;
864 Object.defineProperty(superclass, "prototype", {
865 configurable: false,
866 enumerable: false,
867 value: Tag.prototype,
868 writable: false,
869 });
870 Object.defineProperties(
871 superclass,
872 Object.getOwnPropertyDescriptors(methods),
873 );
874 }
875
876 /**
877 * Yields the tags in the `TagSystem` associated with this
878 * constructor.
879 */
880 *all() {
881 const system = this.#system;
882 const storage = this.#storage;
883 for (const instance of storage.values()) {
884 // Iterate over the entries and yield the ones which are
885 // `Tag`s in this `TagSystem`.
886 if (Tag.getSystem(instance) == system) {
887 // The current instance is a `Tag` in this `TagSystem`.
888 yield instance;
889 } else {
890 // The current instance is not a `Tag` in this
891 // `TagSystem`.
892 /* do nothing */
893 }
894 }
895 }
896
897 /**
898 * Returns a new `Tag` resolved from the provided I·R·I.
899 *
900 * ☡ This function throws if the I·R·I is not in the `.iriSpace`
901 * of the `TagSystem` associated with this constructor.
902 *
903 * ※ If the I·R·I is not recognized, this function returns
904 * `undefined`.
905 */
906 fromIRI(iri) {
907 const system = this.#system;
908 const storage = this.#storage;
909 const name = `${iri}`;
910 const prefix = `${system.iriSpace}`;
911 if (!name.startsWith(prefix)) {
912 // The I·R·I does not begin with the expected prefix.
913 throw new RangeError(
914 `I·R·I did not begin with the expected prefix: ${iri}`,
915 );
916 } else {
917 // The I·R·I begins with the expected prefix.
918 const identifier = name.substring(prefix.length);
919 try {
920 // Attempt to resolve the identifier.
921 const instance = storage.get(identifier);
922 return Tag.getSystem(instance) == system
923 ? instance
924 : undefined;
925 } catch {
926 // Do not throw for bad identifiers.
927 return undefined;
928 }
929 }
930 }
931
932 /**
933 * Returns a new `Tag` resolved from the provided identifier.
934 *
935 * ☡ This function throws if the identifier is invalid.
936 *
937 * ※ If the identifier is valid but not recognized, this
938 * function returns `undefined`.
939 */
940 fromIdentifier(identifier) {
941 const system = this.#system;
942 const storage = this.#storage;
943 const instance = storage.get(identifier);
944 return Tag.getSystem(instance) == system
945 ? instance
946 : undefined;
947 }
948
949 /**
950 * Returns a new `Tag` resolved from the provided Tag U·R·I.
951 *
952 * ☡ This function throws if the provided Tag U·R·I does not
953 * match the tagging entity of this constructor’s `TagSystem`.
954 *
955 * ※ If the specific component of the Tag U·R·I is not
956 * recognized, this function returns `undefined`.
957 */
958 fromTagURI(tagURI) {
959 const system = this.#system;
960 const storage = this.#storage;
961 const tagName = `${tagURI}`;
962 const tagPrefix = `tag:${system.taggingEntity}:`;
963 if (!tagName.startsWith(tagPrefix)) {
964 // The Tag U·R·I does not begin with the expected prefix.
965 throw new RangeError(
966 `Tag U·R·I did not begin with the expected prefix: ${tagURI}`,
967 );
968 } else {
969 // The I·R·I begins with the expected prefix.
970 const identifier = tagName.substring(tagPrefix.length);
971 try {
972 // Attempt to resolve the identifier.
973 const instance = storage.get(identifier);
974 return Tag.getSystem(instance) == system
975 ? instance
976 : undefined;
977 } catch {
978 // Do not throw for bad identifiers.
979 return undefined;
980 }
981 }
982 }
983
984 /**
985 * Yields the tag identifiers in the `TagSystem` associated with
986 * this constructor.
987 */
988 *identifiers() {
989 const system = this.#system;
990 const storage = this.#storage;
991 for (const [identifier, instance] of storage.entries()) {
992 // Iterate over the entries and yield the ones which are
993 // `Tag`s in this `TagSystem`.
994 if (Tag.getSystem(instance) == system) {
995 // The current instance is a `Tag` in this `TagSystem`.
996 yield identifier;
997 } else {
998 // The current instance is not a `Tag` in this `TagSystem`.
999 /* do nothing */
1000 }
1001 }
1002 }
1003
1004 /**
1005 * Returns a new `Tag` constructed from the provided data and
1006 * with the provided identifier.
1007 *
1008 * ※ This function is not really intended for public usage.
1009 */
1010 [Storage.toInstance](data, identifier) {
1011 const tag = new this(data.kind);
1012 return Tag.assignData(tag, data, identifier);
1013 }
1014 },
1015 TagSuper: tagConstructorBehaviours.TagSuper,
1016 };
1017 })();
1018
1019 const {
1020 /**
1021 * Returns whether the provided schema, subject class, object
1022 * property, and object class are consistent.
1023 *
1024 * This is hardly a full reasoner; it is tuned to the abilites and
1025 * needs of this module.
1026 */
1027 isObjectPredicateOK,
1028 } = (() => {
1029 const cachedClassAndSuperclasses = new WeakMap();
1030 const cachedClassRestrictions = new WeakMap();
1031 const cachedPredicateRestrictions = new WeakMap();
1032
1033 const classAndSuperclasses = function* (
1034 classes,
1035 baseClass,
1036 touched = new Set(),
1037 ) {
1038 if (baseClass == "Thing" || touched.has(baseClass)) {
1039 /* do nothing */
1040 } else {
1041 yield baseClass;
1042 touched.add(baseClass);
1043 const subClassOf = classes[baseClass]?.subClassOf ?? "Thing";
1044 for (
1045 const superclass of (
1046 typeof subClassOf == "string"
1047 ? [subClassOf]
1048 : Array.from(subClassOf)
1049 ).filter(($) => typeof $ == "string")
1050 ) {
1051 yield* classAndSuperclasses(classes, superclass, touched);
1052 }
1053 }
1054 };
1055
1056 const getClassAndSuperclasses = (schema, baseClass) => {
1057 const schemaCache = cachedClassAndSuperclasses.get(schema);
1058 const cached = schemaCache?.[baseClass];
1059 if (cached != null) {
1060 return cached;
1061 } else {
1062 const { classes } = schema;
1063 const result = [...classAndSuperclasses(classes, baseClass)];
1064 if (schemaCache) {
1065 schemaCache[baseClass] = result;
1066 } else {
1067 cachedClassRestrictions.set(
1068 schema,
1069 Object.assign(Object.create(null), { [baseClass]: result }),
1070 );
1071 }
1072 return result;
1073 }
1074 };
1075
1076 const getClassRestrictions = (schema, domain) => {
1077 const schemaCache = cachedClassRestrictions.get(schema);
1078 const cached = schemaCache?.[domain];
1079 if (cached != null) {
1080 return cached;
1081 } else {
1082 const { classes } = schema;
1083 const restrictions = Object.create(null);
1084 const subClassOf = classes[domain]?.subClassOf ?? "Thing";
1085 for (
1086 const superclass of (
1087 typeof subClassOf == "string"
1088 ? [subClassOf]
1089 : Array.from(subClassOf)
1090 ).filter(($) => Object($) === $)
1091 ) {
1092 const { onProperty, allValuesFrom } = superclass;
1093 restrictions[onProperty] = processSpace(allValuesFrom);
1094 }
1095 if (schemaCache) {
1096 schemaCache[domain] = restrictions;
1097 } else {
1098 cachedClassRestrictions.set(
1099 schema,
1100 Object.assign(Object.create(null), {
1101 [domain]: restrictions,
1102 }),
1103 );
1104 }
1105 return restrictions;
1106 }
1107 };
1108
1109 const getPredicateRestrictions = (schema, predicate) => {
1110 const schemaCache = cachedPredicateRestrictions.get(schema);
1111 const cached = schemaCache?.[predicate];
1112 if (cached != null) {
1113 return cached;
1114 } else {
1115 const { objectProperties } = schema;
1116 const restrictions = [
1117 ...predicateRestrictions(objectProperties, predicate),
1118 ].reduce(
1119 (result, { domainIntersection, rangeIntersection }) => {
1120 result.domainIntersection.push(...domainIntersection);
1121 result.rangeIntersection.push(...rangeIntersection);
1122 return result;
1123 },
1124 Object.assign(Object.create(null), {
1125 domainIntersection: [],
1126 rangeIntersection: [],
1127 }),
1128 );
1129 if (schemaCache) {
1130 schemaCache[predicate] = restrictions;
1131 } else {
1132 cachedPredicateRestrictions.set(
1133 schema,
1134 Object.assign(Object.create(null), {
1135 [predicate]: restrictions,
1136 }),
1137 );
1138 }
1139 return restrictions;
1140 }
1141 };
1142
1143 const processSpace = (space) =>
1144 Object(space) === space
1145 ? "length" in space
1146 ? Array.from(
1147 space,
1148 (subspace) =>
1149 Object(subspace) === subspace
1150 ? Array.from(subspace.unionOf)
1151 : [subspace],
1152 )
1153 : [Array.from(space.unionOf)]
1154 : [[space]];
1155
1156 const predicateRestrictions = function* (
1157 objectProperties,
1158 predicate,
1159 touched = new Set(),
1160 ) {
1161 if (predicate == "Property" || touched.has(predicate)) {
1162 /* do nothing */
1163 } else {
1164 const { domain, range, subPropertyOf } =
1165 objectProperties[predicate];
1166 yield Object.assign(Object.create(null), {
1167 domainIntersection: processSpace(domain ?? "Thing"),
1168 rangeIntersection: processSpace(range ?? "Thing"),
1169 });
1170 touched.add(predicate);
1171 for (
1172 const superproperty of (
1173 subPropertyOf == null
1174 ? ["Property"]
1175 : typeof subPropertyOf == "string"
1176 ? [subPropertyOf]
1177 : Array.from(subPropertyOf)
1178 )
1179 ) {
1180 yield* predicateRestrictions(
1181 objectProperties,
1182 superproperty,
1183 touched,
1184 );
1185 }
1186 }
1187 };
1188
1189 return {
1190 isObjectPredicateOK: (
1191 schema,
1192 subjectClass,
1193 predicate,
1194 objectClass,
1195 ) => {
1196 const { objectProperties } = schema;
1197 const predicateDefinition = objectProperties[predicate];
1198 const isInverse = "inverseOf" in predicateDefinition;
1199 const usedPredicate = isInverse
1200 ? predicateDefinition.inverseOf
1201 : predicate;
1202 const domain = isInverse ? objectClass : subjectClass;
1203 const domains = new Set(getClassAndSuperclasses(schema, domain));
1204 const ranges = new Set(getClassAndSuperclasses(
1205 schema,
1206 isInverse ? subjectClass : objectClass,
1207 ));
1208 const predicateRestrictions = getPredicateRestrictions(
1209 schema,
1210 usedPredicate,
1211 );
1212 const { domainIntersection } = predicateRestrictions;
1213 const rangeIntersection = [
1214 ...predicateRestrictions.rangeIntersection,
1215 ...function* () {
1216 for (const domain of domains) {
1217 const classRestrictionOnPredicate =
1218 getClassRestrictions(schema, domain)[usedPredicate];
1219 if (classRestrictionOnPredicate != null) {
1220 yield* classRestrictionOnPredicate;
1221 } else {
1222 /* do nothing */
1223 }
1224 }
1225 }(),
1226 ];
1227 return domainIntersection.every((domainUnion) =>
1228 domainUnion.some((domain) =>
1229 domain == "Thing" || domains.has(domain)
1230 )
1231 ) &&
1232 rangeIntersection.every((rangeUnion) =>
1233 rangeUnion.some((range) =>
1234 range == "Thing" || ranges.has(range)
1235 )
1236 );
1237 },
1238 };
1239 })();
1240
1241 const {
1242 /**
1243 * Returns the provided value converted into a `String` object with
1244 * `.["@value"]` and `.["@language"]` properties.
1245 *
1246 * The same object will be returned for every call with an equivalent
1247 * value.
1248 *
1249 * TODO: Ideally this would be extracted more fully into an R·D·F
1250 * library.
1251 *
1252 * ※ This function is not exposed.
1253 */
1254 langString,
1255 } = (() => {
1256 /**
1257 * Returns the language string object corresponding to the provided
1258 * value and language.
1259 */
1260 const getLangString = (value, language = "") => {
1261 const valueMap = languageMap[language] ??= Object.create(null);
1262 const literal = valueMap[value]?.deref();
1263 if (literal != null) {
1264 // There is already an object corresponding to the provided value
1265 // and language.
1266 return literal;
1267 } else {
1268 // No object already exists corresponding to the provided value
1269 // and language; create one.
1270 const result = Object.preventExtensions(
1271 Object.create(String.prototype, {
1272 "@value": {
1273 enumerable: true,
1274 value,
1275 },
1276 "@language": {
1277 enumerable: !!language,
1278 value: language || null,
1279 },
1280 language: { enumerable: false, get: getLanguage },
1281 toString: { enumerable: false, value: toString },
1282 valueOf: { enumerable: false, value: valueOf },
1283 }),
1284 );
1285 const ref = new WeakRef(result);
1286 langStringRegistry.register(result, { ref, language, value });
1287 valueMap[value] = ref;
1288 return result;
1289 }
1290 };
1291
1292 /** Returns the `.["@language"]` of this object. */
1293 const getLanguage = Object.defineProperty(
1294 function () {
1295 return this["@language"] || null;
1296 },
1297 "name",
1298 { value: "get language" },
1299 );
1300
1301 /**
1302 * A `FinalizationRegistry` for language string objects.
1303 *
1304 * This simply cleans up the corresponding `WeakRef` in the language
1305 * map.
1306 */
1307 const langStringRegistry = new FinalizationRegistry(
1308 ({ ref, language, value }) => {
1309 const valueMap = languageMap[language];
1310 if (valueMap?.[value] === ref) {
1311 delete valueMap[value];
1312 } else {
1313 /* do nothing */
1314 }
1315 },
1316 );
1317
1318 /**
1319 * An object whose own values are an object mapping values to
1320 * language string objects for the language specified by the key.
1321 */
1322 const languageMap = Object.create(null);
1323
1324 /** Returns the `.["@value"]` of this object. */
1325 const toString = function () {
1326 return this["@value"];
1327 };
1328
1329 /**
1330 * Returns this object if it has a `.["@language"]`; otherwise, its
1331 * `.["@value"]`.
1332 */
1333 const valueOf = function () {
1334 return this["@language"] ? this : this["@value"];
1335 };
1336
1337 return {
1338 langString: ($) =>
1339 Object($) === $
1340 ? "@value" in $
1341 ? "@language" in $
1342 ? getLangString(
1343 `${$["@value"]}`,
1344 `${$["@language"] ?? ""}`,
1345 )
1346 : getLangString(`${$["@value"]}`)
1347 : "language" in $
1348 ? getLangString(`${$}`, `${$.language ?? ""}`)
1349 : getLangString(`${$}`)
1350 : getLangString(`${$ ?? ""}`),
1351 };
1352 })();
1353
1354 /**
1355 * Returns a normalized tag data object derived from the provided
1356 * object.
1357 *
1358 * ※ The properties of this function need to match the term names used
1359 * in the ActivityStreams serialization.
1360 *
1361 * ※ This function is not exposed.
1362 */
1363 const tagData = ($) => {
1364 const data = Object($);
1365 const {
1366 // prefLabel intentionally not set here
1367 altLabel,
1368 hiddenLabel,
1369 broader,
1370 narrower,
1371 inCanon,
1372 hasInCanon,
1373 involves,
1374 involvedIn,
1375 } = data;
1376 let prefLabel = langString(data.prefLabel);
1377 return Object.preventExtensions(Object.create(null, {
1378 prefLabel: {
1379 enumerable: true,
1380 get: () => prefLabel,
1381 set: ($) => {
1382 prefLabel = langString($);
1383 },
1384 },
1385 altLabel: {
1386 enumerable: true,
1387 value: new Set(
1388 altLabel != null
1389 ? Array.from(altLabel, langString)
1390 : undefined,
1391 ),
1392 },
1393 hiddenLabel: {
1394 enumerable: true,
1395 value: new Set(
1396 hiddenLabel != null
1397 ? Array.from(hiddenLabel, langString)
1398 : undefined,
1399 ),
1400 },
1401 broader: {
1402 enumerable: true,
1403 value: new Set(
1404 broader != null
1405 ? Array.from(broader, toIdentifier)
1406 : undefined,
1407 ),
1408 },
1409 narrower: {
1410 enumerable: true,
1411 value: new Set(
1412 narrower != null
1413 ? Array.from(narrower, toIdentifier)
1414 : undefined,
1415 ),
1416 },
1417 inCanon: {
1418 enumerable: true,
1419 value: new Set(
1420 inCanon != null
1421 ? Array.from(inCanon, toIdentifier)
1422 : undefined,
1423 ),
1424 },
1425 hasInCanon: {
1426 enumerable: true,
1427 value: new Set(
1428 hasInCanon != null
1429 ? Array.from(hasInCanon, toIdentifier)
1430 : undefined,
1431 ),
1432 },
1433 involves: {
1434 enumerable: true,
1435 value: new Set(
1436 involves != null
1437 ? Array.from(involves, toIdentifier)
1438 : undefined,
1439 ),
1440 },
1441 involvedIn: {
1442 enumerable: true,
1443 value: new Set(
1444 involvedIn != null
1445 ? Array.from(involvedIn, toIdentifier)
1446 : undefined,
1447 ),
1448 },
1449 }));
1450 };
1451
1452 /**
1453 * Returns an identifier corresponding to the provided object.
1454 *
1455 * This is either the value of its `.identifier` or its string value.
1456 *
1457 * ※ This function is not exposed.
1458 */
1459 const toIdentifier = ($) =>
1460 $ == null
1461 ? null
1462 : Object($) === $ && "identifier" in $
1463 ? $.identifier
1464 : `${$}`;
1465
1466 /**
1467 * A tag system, with storage.
1468 *
1469 * The `::Tag` constructor available on any `TagSystem` instance can be
1470 * used to create new `Tag`s within the system.
1471 */
1472 export class TagSystem {
1473 /** The cached bound `Tag` constructor for this `TagSystem`. */
1474 #Tag = null;
1475
1476 /** The domain of this `TagSystem`. */
1477 #domain;
1478
1479 /** The date of this `TagSystem`. */
1480 #date;
1481
1482 /** The identifier of this `TagSystem`. */
1483 #identifier;
1484
1485 /** The internal `Storage` of this `TagSystem`. */
1486 #storage = new Storage();
1487
1488 /**
1489 * Constructs a new `TagSystem` with the provided domain and date.
1490 *
1491 * Only actual, lowercased domain names are allowed for the domain,
1492 * and the date must be “full” (include month and day components).
1493 * This is for alignment with general best practices for Tag URI’s.
1494 *
1495 * ☡ This constructor throws if provided with an invalid date.
1496 */
1497 constructor(domain, date, identifier = "") {
1498 const domainString = `${domain}`;
1499 const dateString = `${date}`;
1500 this.#identifier = `${identifier}`;
1501 try {
1502 // If the identifier is a valid storage I·D, reserve it.
1503 this.#storage.delete(this.#identifier);
1504 } catch {
1505 // The identifier is not a valid storage I·D, so no worries.
1506 /* do nothing */
1507 }
1508 if (
1509 !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
1510 .test(domainString)
1511 ) {
1512 // ☡ The domain is invalid.
1513 throw new RangeError(`Invalid domain: ${domain}.`);
1514 } else if (
1515 !/^\d{4}-\d{2}-\d{2}$/u.test(dateString) ||
1516 dateString != new Date(dateString).toISOString().split("T")[0]
1517 ) {
1518 // ☡ The date is invalid.
1519 throw new RangeError(`Invalid date: ${date}.`);
1520 } else {
1521 // The domain and date are 🆗.
1522 this.#domain = domainString;
1523 this.#date = dateString;
1524 }
1525 }
1526
1527 /**
1528 * Returns a bound constructor for constructing `Tags` in this
1529 * `TagSystem`.
1530 */
1531 get Tag() {
1532 if (this.#Tag != null) {
1533 // A bound constructor has already been generated; return it.
1534 return this.#Tag;
1535 } else {
1536 // No bound constructor has been created yet.
1537 const storage = this.#storage;
1538 return this.#Tag = Tag.For(this, storage, schema);
1539 }
1540 }
1541
1542 /** Returns the authority name (domain) for this `TagSystem`. */
1543 get authorityName() {
1544 return this.#domain;
1545 }
1546
1547 /** Returns the date of this `TagSystem`, as a string. */
1548 get date() {
1549 return this.#date;
1550 }
1551
1552 /**
1553 * Yields the entities in this `TagSystem`.
1554 *
1555 * ※ Entities can hypothetically be anything. If you specifically
1556 * want the `Tag`s, use `::Tag.all` instead.
1557 */
1558 *entities() {
1559 yield* this.#storage.values();
1560 }
1561
1562 /**
1563 * Returns the identifier of this `TagSystem`.
1564 *
1565 * ※ Often this is just the empty string.
1566 */
1567 get identifier() {
1568 return this.#identifier;
1569 }
1570
1571 /** Yields the identifiers in use in this `TagSystem`. */
1572 *identifiers() {
1573 yield* this.#storage.keys();
1574 }
1575
1576 /** Returns the I·R·I for this `TagSystem`. */
1577 get iri() {
1578 return `${this.iriSpace}${this.identifier}`;
1579 }
1580
1581 /**
1582 * Returns the prefix used for I·R·I’s of `Tag`s in this `TagSystem`.
1583 */
1584 get iriSpace() {
1585 return `https://${this.authorityName}/tag:${this.taggingEntity}:`;
1586 }
1587
1588 /** Returns the Tag U·R·I for this `TagSystem`. */
1589 get tagURI() {
1590 return `tag:${this.taggingEntity}:${this.identifier}`;
1591 }
1592
1593 /**
1594 * Returns the tagging entity (domain and date) for this `TagSystem`.
1595 */
1596 get taggingEntity() {
1597 return `${this.authorityName},${this.date}`;
1598 }
1599 }
This page took 0.400106 seconds and 5 git commands to generate.