]> Lady’s Gitweb - Etiquette/blob - model.js
7e0fefc9e5836bcd5b3080708111dd10894b45b4
[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 * ※ `Tag::system` is an overridable, publicly‐accessible means of
443 * accessing the system.
444 *
445 * ※ This function is not exposed.
446 */
447 static getSystem($) {
448 return !(#system in Object($)) ? null : $.#system;
449 }
450
451 static {
452 // Overwrite the default `::constructor` method to instead give the
453 // actual (bound) constructor which was used to generate a given
454 // `Tag`.
455 Object.defineProperties(this.prototype, {
456 constructor: {
457 configurable: true,
458 enumerable: false,
459 get() {
460 // All `Tag`s are constructed via the `.Tag` constructor
461 // available in their `TagSystem`; return it.
462 return this.#system.Tag;
463 },
464 set: undefined,
465 },
466 });
467 }
468
469 /** Returns the authority (domain) name for this `Tag`. */
470 get authorityName() {
471 return this.#system.authorityName;
472 }
473
474 /** Returns the identifier of this `Tag`. */
475 get identifier() {
476 return this.#identifier;
477 }
478
479 /** Returns the I·R·I for this `Tag`. */
480 get iri() {
481 const { identifier, iriSpace } = this;
482 return identifier == null ? null : `${iriSpace}${identifier}`;
483 }
484
485 /** Returns the I·R·I space for this `Tag`. */
486 get iriSpace() {
487 return this.#system.iriSpace;
488 }
489
490 /** Returns the kind of this `Tag`. */
491 get kind() {
492 return this.#kind;
493 }
494
495 /**
496 * Returns the `TagSystem` for this `Tag`.
497 *
498 * ※ Internally, `Tag.getSystem` is preferred.
499 */
500 get system() {
501 return this.#system;
502 }
503
504 /**
505 * Persist this `Tag` to storage and return an ActivityStreams
506 * serialization of a Tag Activity representing any changes, or
507 * `null` if no changes were made.
508 *
509 * If the second argument is `true`, the `Tag` will be persisted but
510 * no serialization will be made. This is somewhat more efficient.
511 *
512 * ※ Persistence can imply side‐effects on other objects, which are
513 * not noted explicitly in the activity. For example, marking a tag
514 * as broader than another causes the other tag to reciprocally be
515 * marked as narrower.
516 *
517 * ※ Inverse object properties will never appear in the predicates
518 * of generated activities.
519 */
520 persist(silent = false) {
521 const system = this.#system;
522 const storage = this.#storage;
523 const {
524 objectProperties,
525 transitiveProperties,
526 dataProperties,
527 } = this.#schema;
528 const persistedData = this.#persistedData;
529 const data = this.#data;
530 const diffs = {};
531 for (const [key, value] of Object.entries(data)) {
532 // Iterate over each entry of the tag data and create a diff
533 // with the last persisted information.
534 if (
535 objectProperties[key]?.inverseOf != null ||
536 silent && key in dataProperties
537 ) {
538 // The current property is one which is skipped in diffs.
539 //
540 // In a silent persist, this includes any literal terms.
541 /* do nothing */
542 } else {
543 // The current property should be diffed.
544 const persisted = persistedData?.[key] ?? null;
545 if (persisted == null) {
546 // There is no persisted data for the current property yet.
547 diffs[key] = {
548 old: new Set(),
549 new: value instanceof Set
550 ? new Set(value)
551 : new Set([value]),
552 };
553 } else if (value instanceof Set) {
554 // The current property is set‐valued.
555 const oldValues = new Set(persisted);
556 const newValues = new Set(value);
557 for (const existing of persisted) {
558 // Iterate over each persisted property and either remove
559 // it from the list of new values or add it to the list of
560 // removed ones.
561 if (value.has(existing)) {
562 // The value is in both the old and new version of the
563 // data.
564 oldValues.delete(existing);
565 newValues.delete(existing);
566 } else {
567 // The value is not shared.
568 /* do nothing */
569 }
570 }
571 diffs[key] = { old: oldValues, new: newValues };
572 } else if (
573 `${value}` != `${persisted}` ||
574 value.language != persisted.language
575 ) {
576 // The current property is (optionally language‐tagged)
577 // string‐valued and the value changed.
578 diffs[key] = {
579 old: new Set([persisted]),
580 new: new Set([value]),
581 };
582 } else {
583 // The current property did not change.
584 diffs[key] = { old: new Set(), new: new Set() };
585 }
586 }
587 }
588 const identifier = this.#identifier;
589 if (identifier != null) {
590 // This `Tag` has already been persisted; use its existing
591 // identifier and persist.
592 storage.set(identifier, this);
593 } else {
594 // This `Tag` has not been persisted yet; save the new
595 // identifier after persisting.
596 this.#identifier = storage.add(this);
597 }
598 const persistedIdentifier = this.#identifier;
599 this.#persistedData = tagData(data); // cloning here is necessary
600 for (const inverse in objectProperties) {
601 // Iterate over each non‐transitive inverse property and update
602 // it based on its inverse on the corresponding tags if possible.
603 const term = objectProperties[inverse].inverseOf;
604 if (term == null || term in transitiveProperties) {
605 // The current property is not the inverse of an non‐transitive
606 // property.
607 /* do nothing */
608 } else {
609 // The current property is the inverse of a non‐transitive
610 // property.
611 for (const referencedIdentifier of diffs[term].old) {
612 // Iterate over the removed tags and remove this `Tag` from
613 // their inverse property.
614 const referenced = storage.get(referencedIdentifier);
615 try {
616 // Try removing this `Tag`.
617 referenced.#data[inverse].delete(persistedIdentifier);
618 storage.set(referencedIdentifier, referenced);
619 } catch {
620 // Removal failed, possibly because the other tag was
621 // deleted.
622 /* do nothing */
623 }
624 }
625 for (const referencedIdentifier of diffs[term].new) {
626 const referenced = storage.get(referencedIdentifier);
627 try {
628 // Try adding this `Tag`.
629 referenced.#data[inverse].add(persistedIdentifier);
630 storage.set(referencedIdentifier, referenced);
631 } catch {
632 // Adding failed, possibly because the other tag was deleted.
633 /* do nothing */
634 }
635 }
636 }
637 }
638 if (silent) {
639 // This is a silent persist.
640 return undefined;
641 } else {
642 // This is not a silent persist; an activity needs to be
643 // generated if a change was made.
644 const activity = {
645 "@context": taggingDiscoveryContext,
646 "@type": [
647 "TagActivity",
648 identifier == null ? "Create" : "Update",
649 ],
650 context: `${system.iri}`,
651 object: `${this.iri}`,
652 endTime: new Date().toISOString(),
653 ...(() => {
654 const statements = {
655 unstates: [],
656 states: [],
657 };
658 const { unstates, states } = statements;
659 if (identifier == null) {
660 // This is a Create activity.
661 states.push({ predicate: "a", object: `${this.kind}` });
662 } else {
663 // This is an Update activity.
664 /* do nothing */
665 }
666 for (
667 const [term, {
668 old: oldValues,
669 new: newValues,
670 }] of Object.entries(diffs)
671 ) {
672 // Iterate over the diffs of each term and state/unstate
673 // things as needed.
674 for (const oldValue of oldValues) {
675 // Iterate over removals and unstate them.
676 if (term in dataProperties) {
677 // This is a literal term; push it.
678 unstates.push({
679 predicate: term,
680 object: { ...oldValue },
681 });
682 } else {
683 // This is a named term; attempt to get its I·R·I and
684 // push it.
685 try {
686 // Attempt to resolve the value and push the change.
687 const tag = storage.get(oldValue);
688 if (!this.#isTagInStorage(tag)) {
689 // The value did not resolve to a tag in storage.
690 /* do nothing */
691 } else {
692 // The value resolved; push its I·R·I.
693 unstates.push({
694 predicate: term,
695 object: tag.iri,
696 });
697 }
698 } catch {
699 // Value resolution failed for some reason; perhaps the
700 // tag was deleted.
701 /* do nothing */
702 }
703 }
704 }
705 for (const newValue of newValues) {
706 // Iterate over additions and state them.
707 if (term in dataProperties) {
708 // This is a literal term; push it.
709 states.push({
710 predicate: term,
711 object: { ...newValue },
712 });
713 } else {
714 // This is a named term; attempt to get its I·R·I and
715 // push it.
716 try {
717 // Attempt to resolve the value and push the change.
718 const tag = storage.get(newValue);
719 if (!this.#isTagInStorage(tag)) {
720 // The value did not resolve to a tag in storage.
721 /* do nothing */
722 } else {
723 // The value resolved; push its I·R·I.
724 states.push({
725 predicate: term,
726 object: tag.iri,
727 });
728 }
729 } catch {
730 // Value resolution failed for some reason; perhaps the
731 // tag was deleted.
732 /* do nothing */
733 }
734 }
735 }
736 }
737 if (unstates.length == 0) {
738 // Nothing was unstated.
739 delete statements.unstates;
740 } else {
741 // Things were stated.
742 /* do nothing */
743 }
744 if (states.length == 0) {
745 // Nothing was stated.
746 delete statements.states;
747 } else {
748 // Things were stated.
749 /* do nothing */
750 }
751 return statements;
752 })(),
753 };
754 if (
755 !Object.hasOwn(activity, "states") &&
756 !Object.hasOwn(activity, "unstates")
757 ) {
758 // No meaningful changes were actually persisted.
759 return null;
760 } else {
761 // There were meaningful changes persisted regarding this `Tag`.
762 return activity;
763 }
764 }
765 }
766
767 /** Returns the preferred label for this `Tag`. */
768 get prefLabel() {
769 return this.#data.prefLabel;
770 }
771
772 /** Sets the preferred label of this `Tag` to the provided label. */
773 set prefLabel($) {
774 this.#data.prefLabel = langString($);
775 }
776
777 /** Returns the Tag U·R·I for this `Tag`. */
778 get tagURI() {
779 const { identifier } = this;
780 return identifier == null
781 ? null
782 : `tag:${this.taggingEntity}:${identifier}`;
783 }
784
785 /** Returns the tagging entity (domain and date) for this `Tag`. */
786 get taggingEntity() {
787 return this.#system.taggingEntity;
788 }
789
790 /** Returns the string form of the preferred label of this `Tag`. */
791 toString() {
792 return `${this.#data.prefLabel}`;
793 }
794
795 /**
796 * Returns a new object whose enumerable own properties contain the
797 * data from this object needed for storage.
798 *
799 * ※ This method is not really intended for public usage.
800 */
801 [Storage.toObject]() {
802 const data = this.#data;
803 return Object.assign(Object.create(null), {
804 ...data,
805 kind: this.#kind,
806 });
807 }
808 }
809
810 const {
811 /**
812 * A `Tag` constructor function.
813 *
814 * This class extends the identity function, meaning that the object
815 * provided as the constructor is used verbatim (with new private
816 * fields added).
817 *
818 * ※ The instance methods of this class are provided as static
819 * methods on the superclass which all `Tag` constructors inherit
820 * from.
821 *
822 * ※ This class is not exposed.
823 */
824 TagConstructor,
825
826 /**
827 * The exposed constructor function from which all `Tag` constructors
828 * inherit.
829 *
830 * ☡ This constructor always throws.
831 */
832 TagSuper,
833 } = (() => {
834 const tagConstructorBehaviours = Object.create(null);
835 return {
836 TagConstructor: class extends identity {
837 /**
838 * The `TagSystem` used for `Tag`s constructed by this
839 * constructor.
840 */
841 #system;
842
843 /** The `Storage` managed by this constructor’s `TagSystem`. */
844 #storage;
845
846 /** The schema in use for this constructor. */
847 #schema;
848
849 /**
850 * Constructs a new `Tag` constructor by adding the appropriate
851 * private fields to the provided constructor, setting its
852 * prototype, and then returning it.
853 *
854 * ※ This constructor does not modify the `name` or `prototype`
855 * properties of the provided constructor.
856 *
857 * ※ See `Tag.For`, where this constructor is used.
858 */
859 constructor(constructor, system, storage, schema) {
860 super(constructor);
861 Object.setPrototypeOf(this, TagSuper);
862 this.#system = system;
863 this.#storage = storage;
864 this.#schema = schema;
865 }
866
867 static {
868 // Define the superclass constructor which all `Tag`
869 // constructors will inherit from.
870 const superclass = tagConstructorBehaviours.TagSuper =
871 function Tag() {
872 throw new TypeError("Tags must belong to a System.");
873 };
874 const { prototype: staticFeatures } = this;
875 delete staticFeatures.constructor;
876 Object.defineProperty(superclass, "prototype", {
877 configurable: false,
878 enumerable: false,
879 value: Tag.prototype,
880 writable: false,
881 });
882 Object.defineProperties(
883 superclass,
884 Object.getOwnPropertyDescriptors(staticFeatures),
885 );
886 }
887
888 /**
889 * Yields the tags in the `TagSystem` associated with this
890 * constructor.
891 */
892 *all() {
893 const system = this.#system;
894 const storage = this.#storage;
895 for (const instance of storage.values()) {
896 // Iterate over the entries and yield the ones which are
897 // `Tag`s in this `TagSystem`.
898 if (Tag.getSystem(instance) == system) {
899 // The current instance is a `Tag` in this `TagSystem`.
900 yield instance;
901 } else {
902 // The current instance is not a `Tag` in this
903 // `TagSystem`.
904 /* do nothing */
905 }
906 }
907 }
908
909 /**
910 * Returns a new `Tag` resolved from the provided I·R·I.
911 *
912 * ☡ This function throws if the I·R·I is not in the `.iriSpace`
913 * of the `TagSystem` associated with this constructor.
914 *
915 * ※ If the I·R·I is not recognized, this function returns
916 * `undefined`.
917 */
918 fromIRI(iri) {
919 const system = this.#system;
920 const storage = this.#storage;
921 const name = `${iri}`;
922 const prefix = `${system.iriSpace}`;
923 if (!name.startsWith(prefix)) {
924 // The I·R·I does not begin with the expected prefix.
925 throw new RangeError(
926 `I·R·I did not begin with the expected prefix: ${iri}`,
927 );
928 } else {
929 // The I·R·I begins with the expected prefix.
930 const identifier = name.substring(prefix.length);
931 try {
932 // Attempt to resolve the identifier.
933 const instance = storage.get(identifier);
934 return Tag.getSystem(instance) == system
935 ? instance
936 : undefined;
937 } catch {
938 // Do not throw for bad identifiers.
939 return undefined;
940 }
941 }
942 }
943
944 /**
945 * Returns a new `Tag` resolved from the provided identifier.
946 *
947 * ☡ This function throws if the identifier is invalid.
948 *
949 * ※ If the identifier is valid but not recognized, this
950 * function returns `undefined`.
951 */
952 fromIdentifier(identifier) {
953 const system = this.#system;
954 const storage = this.#storage;
955 const instance = storage.get(identifier);
956 return Tag.getSystem(instance) == system
957 ? instance
958 : undefined;
959 }
960
961 /**
962 * Returns a new `Tag` resolved from the provided Tag U·R·I.
963 *
964 * ☡ This function throws if the provided Tag U·R·I does not
965 * match the tagging entity of this constructor’s `TagSystem`.
966 *
967 * ※ If the specific component of the Tag U·R·I is not
968 * recognized, this function returns `undefined`.
969 */
970 fromTagURI(tagURI) {
971 const system = this.#system;
972 const storage = this.#storage;
973 const tagName = `${tagURI}`;
974 const tagPrefix = `tag:${system.taggingEntity}:`;
975 if (!tagName.startsWith(tagPrefix)) {
976 // The Tag U·R·I does not begin with the expected prefix.
977 throw new RangeError(
978 `Tag U·R·I did not begin with the expected prefix: ${tagURI}`,
979 );
980 } else {
981 // The I·R·I begins with the expected prefix.
982 const identifier = tagName.substring(tagPrefix.length);
983 try {
984 // Attempt to resolve the identifier.
985 const instance = storage.get(identifier);
986 return Tag.getSystem(instance) == system
987 ? instance
988 : undefined;
989 } catch {
990 // Do not throw for bad identifiers.
991 return undefined;
992 }
993 }
994 }
995
996 /**
997 * Yields the tag identifiers in the `TagSystem` associated with
998 * this constructor.
999 */
1000 *identifiers() {
1001 const system = this.#system;
1002 const storage = this.#storage;
1003 for (const [identifier, instance] of storage.entries()) {
1004 // Iterate over the entries and yield the ones which are
1005 // `Tag`s in this `TagSystem`.
1006 if (Tag.getSystem(instance) == system) {
1007 // The current instance is a `Tag` in this `TagSystem`.
1008 yield identifier;
1009 } else {
1010 // The current instance is not a `Tag` in this `TagSystem`.
1011 /* do nothing */
1012 }
1013 }
1014 }
1015
1016 /** Returns the `TagSystem` for this `Tag` constructor. */
1017 get system() {
1018 return this.#system;
1019 }
1020
1021 /**
1022 * Returns a new `Tag` constructed from the provided data and
1023 * with the provided identifier.
1024 *
1025 * ※ This function is not really intended for public usage.
1026 */
1027 [Storage.toInstance](data, identifier) {
1028 const tag = new this(data.kind);
1029 return Tag.assignData(tag, data, identifier);
1030 }
1031 },
1032 TagSuper: tagConstructorBehaviours.TagSuper,
1033 };
1034 })();
1035
1036 const {
1037 /**
1038 * Returns whether the provided schema, subject class, object
1039 * property, and object class are consistent.
1040 *
1041 * This is hardly a full reasoner; it is tuned to the abilites and
1042 * needs of this module.
1043 */
1044 isObjectPredicateOK,
1045 } = (() => {
1046 const cachedClassAndSuperclasses = new WeakMap();
1047 const cachedClassRestrictions = new WeakMap();
1048 const cachedPredicateRestrictions = new WeakMap();
1049
1050 const classAndSuperclasses = function* (
1051 classes,
1052 baseClass,
1053 touched = new Set(),
1054 ) {
1055 if (baseClass == "Thing" || touched.has(baseClass)) {
1056 /* do nothing */
1057 } else {
1058 yield baseClass;
1059 touched.add(baseClass);
1060 const subClassOf = classes[baseClass]?.subClassOf ?? "Thing";
1061 for (
1062 const superclass of (
1063 typeof subClassOf == "string"
1064 ? [subClassOf]
1065 : Array.from(subClassOf)
1066 ).filter(($) => typeof $ == "string")
1067 ) {
1068 yield* classAndSuperclasses(classes, superclass, touched);
1069 }
1070 }
1071 };
1072
1073 const getClassAndSuperclasses = (schema, baseClass) => {
1074 const schemaCache = cachedClassAndSuperclasses.get(schema);
1075 const cached = schemaCache?.[baseClass];
1076 if (cached != null) {
1077 return cached;
1078 } else {
1079 const { classes } = schema;
1080 const result = [...classAndSuperclasses(classes, baseClass)];
1081 if (schemaCache) {
1082 schemaCache[baseClass] = result;
1083 } else {
1084 cachedClassRestrictions.set(
1085 schema,
1086 Object.assign(Object.create(null), { [baseClass]: result }),
1087 );
1088 }
1089 return result;
1090 }
1091 };
1092
1093 const getClassRestrictions = (schema, domain) => {
1094 const schemaCache = cachedClassRestrictions.get(schema);
1095 const cached = schemaCache?.[domain];
1096 if (cached != null) {
1097 return cached;
1098 } else {
1099 const { classes } = schema;
1100 const restrictions = Object.create(null);
1101 const subClassOf = classes[domain]?.subClassOf ?? "Thing";
1102 for (
1103 const superclass of (
1104 typeof subClassOf == "string"
1105 ? [subClassOf]
1106 : Array.from(subClassOf)
1107 ).filter(($) => Object($) === $)
1108 ) {
1109 const { onProperty, allValuesFrom } = superclass;
1110 restrictions[onProperty] = processSpace(allValuesFrom);
1111 }
1112 if (schemaCache) {
1113 schemaCache[domain] = restrictions;
1114 } else {
1115 cachedClassRestrictions.set(
1116 schema,
1117 Object.assign(Object.create(null), {
1118 [domain]: restrictions,
1119 }),
1120 );
1121 }
1122 return restrictions;
1123 }
1124 };
1125
1126 const getPredicateRestrictions = (schema, predicate) => {
1127 const schemaCache = cachedPredicateRestrictions.get(schema);
1128 const cached = schemaCache?.[predicate];
1129 if (cached != null) {
1130 return cached;
1131 } else {
1132 const { objectProperties } = schema;
1133 const restrictions = [
1134 ...predicateRestrictions(objectProperties, predicate),
1135 ].reduce(
1136 (result, { domainIntersection, rangeIntersection }) => {
1137 result.domainIntersection.push(...domainIntersection);
1138 result.rangeIntersection.push(...rangeIntersection);
1139 return result;
1140 },
1141 Object.assign(Object.create(null), {
1142 domainIntersection: [],
1143 rangeIntersection: [],
1144 }),
1145 );
1146 if (schemaCache) {
1147 schemaCache[predicate] = restrictions;
1148 } else {
1149 cachedPredicateRestrictions.set(
1150 schema,
1151 Object.assign(Object.create(null), {
1152 [predicate]: restrictions,
1153 }),
1154 );
1155 }
1156 return restrictions;
1157 }
1158 };
1159
1160 const processSpace = (space) =>
1161 Object(space) === space
1162 ? "length" in space
1163 ? Array.from(
1164 space,
1165 (subspace) =>
1166 Object(subspace) === subspace
1167 ? Array.from(subspace.unionOf)
1168 : [subspace],
1169 )
1170 : [Array.from(space.unionOf)]
1171 : [[space]];
1172
1173 const predicateRestrictions = function* (
1174 objectProperties,
1175 predicate,
1176 touched = new Set(),
1177 ) {
1178 if (predicate == "Property" || touched.has(predicate)) {
1179 /* do nothing */
1180 } else {
1181 const { domain, range, subPropertyOf } =
1182 objectProperties[predicate];
1183 yield Object.assign(Object.create(null), {
1184 domainIntersection: processSpace(domain ?? "Thing"),
1185 rangeIntersection: processSpace(range ?? "Thing"),
1186 });
1187 touched.add(predicate);
1188 for (
1189 const superproperty of (
1190 subPropertyOf == null
1191 ? ["Property"]
1192 : typeof subPropertyOf == "string"
1193 ? [subPropertyOf]
1194 : Array.from(subPropertyOf)
1195 )
1196 ) {
1197 yield* predicateRestrictions(
1198 objectProperties,
1199 superproperty,
1200 touched,
1201 );
1202 }
1203 }
1204 };
1205
1206 return {
1207 isObjectPredicateOK: (
1208 schema,
1209 subjectClass,
1210 predicate,
1211 objectClass,
1212 ) => {
1213 const { objectProperties } = schema;
1214 const predicateDefinition = objectProperties[predicate];
1215 const isInverse = "inverseOf" in predicateDefinition;
1216 const usedPredicate = isInverse
1217 ? predicateDefinition.inverseOf
1218 : predicate;
1219 const domain = isInverse ? objectClass : subjectClass;
1220 const domains = new Set(getClassAndSuperclasses(schema, domain));
1221 const ranges = new Set(getClassAndSuperclasses(
1222 schema,
1223 isInverse ? subjectClass : objectClass,
1224 ));
1225 const predicateRestrictions = getPredicateRestrictions(
1226 schema,
1227 usedPredicate,
1228 );
1229 const { domainIntersection } = predicateRestrictions;
1230 const rangeIntersection = [
1231 ...predicateRestrictions.rangeIntersection,
1232 ...function* () {
1233 for (const domain of domains) {
1234 const classRestrictionOnPredicate =
1235 getClassRestrictions(schema, domain)[usedPredicate];
1236 if (classRestrictionOnPredicate != null) {
1237 yield* classRestrictionOnPredicate;
1238 } else {
1239 /* do nothing */
1240 }
1241 }
1242 }(),
1243 ];
1244 return domainIntersection.every((domainUnion) =>
1245 domainUnion.some((domain) =>
1246 domain == "Thing" || domains.has(domain)
1247 )
1248 ) &&
1249 rangeIntersection.every((rangeUnion) =>
1250 rangeUnion.some((range) =>
1251 range == "Thing" || ranges.has(range)
1252 )
1253 );
1254 },
1255 };
1256 })();
1257
1258 const {
1259 /**
1260 * Returns the provided value converted into a `String` object with
1261 * `.["@value"]` and `.["@language"]` properties.
1262 *
1263 * The same object will be returned for every call with an equivalent
1264 * value.
1265 *
1266 * TODO: Ideally this would be extracted more fully into an R·D·F
1267 * library.
1268 *
1269 * ※ This function is not exposed.
1270 */
1271 langString,
1272 } = (() => {
1273 /**
1274 * Returns the language string object corresponding to the provided
1275 * value and language.
1276 */
1277 const getLangString = (value, language = "") => {
1278 const valueMap = languageMap[language] ??= Object.create(null);
1279 const literal = valueMap[value]?.deref();
1280 if (literal != null) {
1281 // There is already an object corresponding to the provided value
1282 // and language.
1283 return literal;
1284 } else {
1285 // No object already exists corresponding to the provided value
1286 // and language; create one.
1287 const result = Object.preventExtensions(
1288 Object.create(String.prototype, {
1289 "@value": {
1290 enumerable: true,
1291 value,
1292 },
1293 "@language": {
1294 enumerable: !!language,
1295 value: language || null,
1296 },
1297 language: { enumerable: false, get: getLanguage },
1298 toString: { enumerable: false, value: toString },
1299 valueOf: { enumerable: false, value: valueOf },
1300 }),
1301 );
1302 const ref = new WeakRef(result);
1303 langStringRegistry.register(result, { ref, language, value });
1304 valueMap[value] = ref;
1305 return result;
1306 }
1307 };
1308
1309 /** Returns the `.["@language"]` of this object. */
1310 const getLanguage = Object.defineProperty(
1311 function () {
1312 return this["@language"] || null;
1313 },
1314 "name",
1315 { value: "get language" },
1316 );
1317
1318 /**
1319 * A `FinalizationRegistry` for language string objects.
1320 *
1321 * This simply cleans up the corresponding `WeakRef` in the language
1322 * map.
1323 */
1324 const langStringRegistry = new FinalizationRegistry(
1325 ({ ref, language, value }) => {
1326 const valueMap = languageMap[language];
1327 if (valueMap?.[value] === ref) {
1328 delete valueMap[value];
1329 } else {
1330 /* do nothing */
1331 }
1332 },
1333 );
1334
1335 /**
1336 * An object whose own values are an object mapping values to
1337 * language string objects for the language specified by the key.
1338 */
1339 const languageMap = Object.create(null);
1340
1341 /** Returns the `.["@value"]` of this object. */
1342 const toString = function () {
1343 return this["@value"];
1344 };
1345
1346 /**
1347 * Returns this object if it has a `.["@language"]`; otherwise, its
1348 * `.["@value"]`.
1349 */
1350 const valueOf = function () {
1351 return this["@language"] ? this : this["@value"];
1352 };
1353
1354 return {
1355 langString: ($) =>
1356 Object($) === $
1357 ? "@value" in $
1358 ? "@language" in $
1359 ? getLangString(
1360 `${$["@value"]}`,
1361 `${$["@language"] ?? ""}`,
1362 )
1363 : getLangString(`${$["@value"]}`)
1364 : "language" in $
1365 ? getLangString(`${$}`, `${$.language ?? ""}`)
1366 : getLangString(`${$}`)
1367 : getLangString(`${$ ?? ""}`),
1368 };
1369 })();
1370
1371 /**
1372 * Returns a normalized tag data object derived from the provided
1373 * object.
1374 *
1375 * ※ The properties of this function need to match the term names used
1376 * in the ActivityStreams serialization.
1377 *
1378 * ※ This function is not exposed.
1379 */
1380 const tagData = ($) => {
1381 const data = Object($);
1382 const {
1383 // prefLabel intentionally not set here
1384 altLabel,
1385 hiddenLabel,
1386 broader,
1387 narrower,
1388 inCanon,
1389 hasInCanon,
1390 involves,
1391 involvedIn,
1392 } = data;
1393 let prefLabel = langString(data.prefLabel);
1394 return Object.preventExtensions(Object.create(null, {
1395 prefLabel: {
1396 enumerable: true,
1397 get: () => prefLabel,
1398 set: ($) => {
1399 prefLabel = langString($);
1400 },
1401 },
1402 altLabel: {
1403 enumerable: true,
1404 value: new Set(
1405 altLabel != null
1406 ? Array.from(altLabel, langString)
1407 : undefined,
1408 ),
1409 },
1410 hiddenLabel: {
1411 enumerable: true,
1412 value: new Set(
1413 hiddenLabel != null
1414 ? Array.from(hiddenLabel, langString)
1415 : undefined,
1416 ),
1417 },
1418 broader: {
1419 enumerable: true,
1420 value: new Set(
1421 broader != null
1422 ? Array.from(broader, toIdentifier)
1423 : undefined,
1424 ),
1425 },
1426 narrower: {
1427 enumerable: true,
1428 value: new Set(
1429 narrower != null
1430 ? Array.from(narrower, toIdentifier)
1431 : undefined,
1432 ),
1433 },
1434 inCanon: {
1435 enumerable: true,
1436 value: new Set(
1437 inCanon != null
1438 ? Array.from(inCanon, toIdentifier)
1439 : undefined,
1440 ),
1441 },
1442 hasInCanon: {
1443 enumerable: true,
1444 value: new Set(
1445 hasInCanon != null
1446 ? Array.from(hasInCanon, toIdentifier)
1447 : undefined,
1448 ),
1449 },
1450 involves: {
1451 enumerable: true,
1452 value: new Set(
1453 involves != null
1454 ? Array.from(involves, toIdentifier)
1455 : undefined,
1456 ),
1457 },
1458 involvedIn: {
1459 enumerable: true,
1460 value: new Set(
1461 involvedIn != null
1462 ? Array.from(involvedIn, toIdentifier)
1463 : undefined,
1464 ),
1465 },
1466 }));
1467 };
1468
1469 /**
1470 * Returns an identifier corresponding to the provided object.
1471 *
1472 * This is either the value of its `.identifier` or its string value.
1473 *
1474 * ※ This function is not exposed.
1475 */
1476 const toIdentifier = ($) =>
1477 $ == null
1478 ? null
1479 : Object($) === $ && "identifier" in $
1480 ? $.identifier
1481 : `${$}`;
1482
1483 /**
1484 * A tag system, with storage.
1485 *
1486 * The `::Tag` constructor available on any `TagSystem` instance can be
1487 * used to create new `Tag`s within the system.
1488 */
1489 export class TagSystem {
1490 /** The cached bound `Tag` constructor for this `TagSystem`. */
1491 #Tag = null;
1492
1493 /** The domain of this `TagSystem`. */
1494 #domain;
1495
1496 /** The date of this `TagSystem`. */
1497 #date;
1498
1499 /** The identifier of this `TagSystem`. */
1500 #identifier;
1501
1502 /** The internal `Storage` of this `TagSystem`. */
1503 #storage = new Storage();
1504
1505 /**
1506 * Constructs a new `TagSystem` with the provided domain and date.
1507 *
1508 * Only actual, lowercased domain names are allowed for the domain,
1509 * and the date must be “full” (include month and day components).
1510 * This is for alignment with general best practices for Tag URI’s.
1511 *
1512 * ☡ This constructor throws if provided with an invalid date.
1513 */
1514 constructor(domain, date, identifier = "") {
1515 const domainString = `${domain}`;
1516 const dateString = `${date}`;
1517 this.#identifier = `${identifier}`;
1518 try {
1519 // If the identifier is a valid storage I·D, reserve it.
1520 this.#storage.delete(this.#identifier);
1521 } catch {
1522 // The identifier is not a valid storage I·D, so no worries.
1523 /* do nothing */
1524 }
1525 if (
1526 !/^[a-z](?:[\da-z-]*[\da-z])?(?:\.[a-z](?:[\da-z-]*[\da-z])?)*$/u
1527 .test(domainString)
1528 ) {
1529 // ☡ The domain is invalid.
1530 throw new RangeError(`Invalid domain: ${domain}.`);
1531 } else if (
1532 !/^\d{4}-\d{2}-\d{2}$/u.test(dateString) ||
1533 dateString != new Date(dateString).toISOString().split("T")[0]
1534 ) {
1535 // ☡ The date is invalid.
1536 throw new RangeError(`Invalid date: ${date}.`);
1537 } else {
1538 // The domain and date are 🆗.
1539 this.#domain = domainString;
1540 this.#date = dateString;
1541 }
1542 }
1543
1544 /**
1545 * Returns a bound constructor for constructing `Tags` in this
1546 * `TagSystem`.
1547 */
1548 get Tag() {
1549 if (this.#Tag != null) {
1550 // A bound constructor has already been generated; return it.
1551 return this.#Tag;
1552 } else {
1553 // No bound constructor has been created yet.
1554 const storage = this.#storage;
1555 return this.#Tag = Tag.For(this, storage, schema);
1556 }
1557 }
1558
1559 /** Returns the authority name (domain) for this `TagSystem`. */
1560 get authorityName() {
1561 return this.#domain;
1562 }
1563
1564 /** Returns the date of this `TagSystem`, as a string. */
1565 get date() {
1566 return this.#date;
1567 }
1568
1569 /**
1570 * Yields the entities in this `TagSystem`.
1571 *
1572 * ※ Entities can hypothetically be anything. If you specifically
1573 * want the `Tag`s, use `::Tag.all` instead.
1574 */
1575 *entities() {
1576 yield* this.#storage.values();
1577 }
1578
1579 /**
1580 * Returns the identifier of this `TagSystem`.
1581 *
1582 * ※ Often this is just the empty string.
1583 */
1584 get identifier() {
1585 return this.#identifier;
1586 }
1587
1588 /** Yields the identifiers in use in this `TagSystem`. */
1589 *identifiers() {
1590 yield* this.#storage.keys();
1591 }
1592
1593 /** Returns the I·R·I for this `TagSystem`. */
1594 get iri() {
1595 return `${this.iriSpace}${this.identifier}`;
1596 }
1597
1598 /**
1599 * Returns the prefix used for I·R·I’s of `Tag`s in this `TagSystem`.
1600 */
1601 get iriSpace() {
1602 return `https://${this.authorityName}/tag:${this.taggingEntity}:`;
1603 }
1604
1605 /** Returns the Tag U·R·I for this `TagSystem`. */
1606 get tagURI() {
1607 return `tag:${this.taggingEntity}:${this.identifier}`;
1608 }
1609
1610 /**
1611 * Returns the tagging entity (domain and date) for this `TagSystem`.
1612 */
1613 get taggingEntity() {
1614 return `${this.authorityName},${this.date}`;
1615 }
1616 }
This page took 0.234011 seconds and 3 git commands to generate.