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