]> Lady’s Gitweb - Etiquette/commitdiff
Enable application of activities onto tag systems current
authorLady <redacted>
Tue, 20 Jun 2023 01:19:22 +0000 (18:19 -0700)
committerLady <redacted>
Tue, 20 Jun 2023 01:32:24 +0000 (18:32 -0700)
model.js
model.test.js

index 7e0fefc9e5836bcd5b3080708111dd10894b45b4..3c8287ea5b27433f854114cc8ea3ccc0037beeea 100644 (file)
--- a/model.js
+++ b/model.js
@@ -433,6 +433,32 @@ class Tag {
     return tag;
   }
 
+  /**
+   * Returns a new `Tag` with the provided identifier, kind, and
+   * prefLabel.
+   *
+   * ※ This function exists to enable `TagSystem`s to replay Create
+   * activities, maintaining the identifier of the original.
+   *
+   * ☡ This function throws if the provided identifier is already in
+   * use.
+   *
+   * ※ This function is not exposed.
+   */
+  static new(system, identifier, kind = "Tag", prefLabel = "") {
+    const storage = (new system.Tag()).#storage;
+    if (storage.has(identifier)) {
+      throw new RangeError(
+        `Cannot create Tag: Identifier already in use: ${identifier}.`,
+      );
+    } else {
+      const createdTag = new system.Tag(kind, prefLabel);
+      createdTag.#identifier = identifier;
+      createdTag.persist(true);
+      return createdTag;
+    }
+  }
+
   /**
    * Returns the `TagSystem` that the provided value belongs to.
    *
@@ -1499,6 +1525,9 @@ export class TagSystem {
   /** The identifier of this `TagSystem`. */
   #identifier;
 
+  /** The schema used by this `TagSystem. */
+  #schema = schema;
+
   /** The internal `Storage` of this `TagSystem`. */
   #storage = new Storage();
 
@@ -1552,7 +1581,166 @@ export class TagSystem {
     } else {
       // No bound constructor has been created yet.
       const storage = this.#storage;
-      return this.#Tag = Tag.For(this, storage, schema);
+      return this.#Tag = Tag.For(this, storage, this.#schema);
+    }
+  }
+
+  /**
+   * Applies the provided activity to this `TagSystem` by replaying its
+   * statement changes.
+   *
+   * ※ This method assumes that the provided activity conforms to the
+   * assumptions made by this module; i·e that its tags use the same
+   * identifier format and its activities do not use statements with
+   * inverse property predicates. It is not intended for the generic
+   * playback of activities produced by other scripts or mechanisms,
+   * for which a more sophisticated solution is required.
+   *
+   * ☡ This method throws an error if the provided activity cannot be
+   * processed.
+   */
+  apply(activity) {
+    const { Tag: TagConstructor } = this;
+    const {
+      classes,
+      objectProperties,
+      transitiveProperties,
+      dataProperties,
+    } = this.#schema;
+    const { object, states, unstates } = activity;
+    const activityTypes = [].concat(activity["@type"]);
+    if (!object) {
+      // ☡ The provided activity has no object.
+      throw new TypeError(
+        "Cannot apply activity: Activity lacks an object.",
+      );
+    } else {
+      // The provided activity has an object.
+      const iri = `${object}`;
+      const iriSpace = `${this.iriSpace}`;
+      const identifier = (() => {
+        // Extract the identifier from the object I·R·I.
+        if (!iri.startsWith(iriSpace)) {
+          // ☡ The object of the provided activity is not in the I·R·I
+          // space of this `TagSystem`.
+          throw new RangeError(
+            `Cannot apply activity: Object is not in I·R·I space: ${object}`,
+          );
+        } else {
+          // ☡ The object of the provided activity is in the I·R·I
+          // space of this `TagSystem`.
+          return iri.substring(iriSpace.length);
+        }
+      })();
+      const tag = (() => {
+        // Either resolve the identifier to an existing tag or create
+        // a new one.
+        if (activityTypes.includes("Create")) {
+          // The provided activity is a Create activity.
+          const kind = states.findLast(
+            ({ predicate, object }) =>
+              predicate == "a" && `${object}` in classes,
+          )?.object;
+          if (kind == null) {
+            // ☡ There is no recognized tag class provided for the tag;
+            // it cannot be created.
+            throw new RangeError(
+              `Cannot apply activity: Tag type not recognized.`,
+            );
+          } else {
+            // There is a recognized tag class provided for the tag.
+            return Tag.new(this, identifier, kind);
+          }
+        } else {
+          // The provided activity is not a Create activity.
+          return TagConstructor.fromIdentifier(identifier);
+        }
+      })();
+      if (!tag) {
+        // ☡ Resolving the tag identifier failed.
+        throw new RangeError(
+          `Cannot apply activity: No tag for identifier: ${identifier}.`,
+        );
+      } else {
+        // Resolving the identifier succeeded; apply the changes to the
+        // tag and then silently persist it.
+        for (
+          const [statements, mode] of [
+            [unstates ?? [], "delete"],
+            [states ?? [], "add"],
+          ]
+        ) {
+          // Delete unstatements, then add statements.
+          for (const { predicate: $p, object: $o } of statements) {
+            // Iterate over the statements and apply them.
+            const predicate = `${$p}`;
+            const term = predicate in dataProperties
+              ? langString($o)
+              : predicate in objectProperties &&
+                  !(predicate in transitiveProperties ||
+                    objectProperties[predicate].inverseOf != null)
+              ? `${$o}`
+              : null;
+            if (term == null) {
+              // The provided predicate is not recognized; ignore it.
+              /* do nothing */
+            } else if (predicate == "prefLabel") {
+              // Preflabels are handled specially.
+              if (mode == "delete") {
+                // Unstating a preflabel has no effect unless a new one
+                // is also stated.
+                /* do nothing */
+              } else {
+                // Update the preflabel.
+                tag.prefLabel = term;
+              }
+            } else {
+              // The predicate is not `"prefLabel"`.
+              const related = (() => {
+                // If the predicate is an object property, attempt to
+                // resolve the object.
+                if (!(predicate in objectProperties)) {
+                  // The predicate is not an object property; return
+                  // null.
+                  return null;
+                } else {
+                  // The predicate is an object property.
+                  try {
+                    // Attempt to resolve the object.
+                    return TagConstructor.fromIRI(term);
+                  } catch {
+                    // Resolving failed; return undefined.
+                    return undefined;
+                  }
+                }
+              })();
+              if (related === undefined) {
+                // The predicate is an object property, but its object
+                // was not resolvable.
+                //
+                // ☡ This is a silent error to allow for selective
+                // replay of activities while ignoring terms which are
+                // not covered.
+                /* do nothing */
+              } else {
+                // The predicate is not an object property or has a
+                // resolvable object.
+                //
+                // Apply the statement.
+                tag[
+                  mode.concat(
+                    predicate[0].toUpperCase(),
+                    predicate.substring(1),
+                    predicate in objectProperties ? "Tag" : "",
+                  )
+                ](related ?? term);
+              }
+            }
+          }
+        }
+        tag.persist(true);
+        return tag;
+      }
     }
   }
 
index 4396d37def8892db01a6f32076ca066b31773a21..254dd4cc7474c67ccc43abc2f53db61cb102dd10 100644 (file)
@@ -973,6 +973,208 @@ describe("TagSystem", () => {
     // `::[Storage.toObject]` is tested by `::persist`.
   });
 
+  describe("::apply", () => {
+    let system;
+    let Tag;
+
+    beforeEach(() => {
+      system = new TagSystem("example", "1972-12-31");
+      Tag = system.Tag;
+    });
+
+    it("[[Call]] throws if no activity is provided", () => {
+      assertThrows(() => {
+        system.apply();
+      });
+    });
+
+    it("[[Call]] throws with an invalid activity", () => {
+      assertThrows(() => {
+        system.apply({});
+      });
+    });
+
+    it("[[Call]] throws when specifying an invalid object", () => {
+      assertThrows(() => {
+        system.apply({
+          object: "",
+        });
+      });
+      assertThrows(() => {
+        system.apply({
+          object: `${system.iriSpace}000-0000`,
+        });
+      });
+    });
+
+    it("[[Call]] returns the tag being modified", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const applied = system.apply({ object: tag.iri });
+      assertStrictEquals(
+        Object.getPrototypeOf(applied),
+        system.Tag.prototype,
+      );
+      assertStrictEquals(tag.identifier, applied.identifier);
+    });
+
+    it("[[Call]] applies the changes", () => {
+      const broaderTag = new Tag();
+      const broaderActivity = broaderTag.persist();
+      const otherBroaderTag = new Tag();
+      const otherBroaderActivity = otherBroaderTag.persist();
+      const tag = new Tag("EntityTag", "my pref label");
+      tag.addHiddenLabel("label");
+      tag.addBroaderTag(broaderTag);
+      const createActivity = tag.persist();
+      tag.prefLabel = "new pref label";
+      tag.addAltLabel("alternative label");
+      tag.deleteHiddenLabel("label");
+      tag.addBroaderTag(otherBroaderTag);
+      tag.deleteBroaderTag(broaderTag);
+      const updateActivity = tag.persist();
+      const otherSystem = new TagSystem(
+        system.authorityName,
+        system.date,
+      );
+      otherSystem.apply(broaderActivity);
+      otherSystem.apply(otherBroaderActivity);
+      const appliedCreate = otherSystem.apply(createActivity);
+      assertStrictEquals(appliedCreate.kind, "EntityTag");
+      assertStrictEquals(appliedCreate.identifier, tag.identifier);
+      assertEquals(
+        { ...appliedCreate.prefLabel },
+        { "@value": "my pref label" },
+      );
+      assertEquals(
+        [...appliedCreate.hiddenLabels()].map(($) => ({ ...$ })),
+        [{ "@value": "label" }],
+      );
+      assertEquals(
+        [...appliedCreate.broaderTags()].map(($) => $.identifier),
+        [broaderTag.identifier],
+      );
+      const appliedUpdate = otherSystem.apply(updateActivity);
+      assertEquals(
+        { ...appliedUpdate.prefLabel },
+        { "@value": "new pref label" },
+      );
+      assertEquals(
+        [...appliedUpdate.altLabels()].map(($) => ({ ...$ })),
+        [{ "@value": "alternative label" }],
+      );
+      assertEquals([...appliedUpdate.hiddenLabels()], []);
+      assertEquals(
+        [...appliedUpdate.broaderTags()].map(($) => $.identifier),
+        [otherBroaderTag.identifier],
+      );
+    });
+
+    it("[[Call]] silently fails deleting preflabels", () => {
+      const tag = new system.Tag("Tag", "my pref label");
+      tag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        unstates: [{
+          predicate: "prefLabel",
+          object: "my pref label",
+        }],
+      });
+      assertEquals(
+        { ...applied.prefLabel },
+        { "@value": "my pref label" },
+      );
+    });
+
+    it("[[Call]] silently fails deleting unrecognized statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const otherTag = new Tag();
+      otherTag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        unstates: [{
+          predicate: "bad_statement",
+          object: otherTag.iri,
+        }],
+      });
+      assert(applied);
+    });
+
+    it("[[Call]] silently fails deleting immutable statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        unstates: [{ predicate: "a", object: "Tag" }],
+      });
+      assertStrictEquals(applied.kind, "Tag");
+    });
+
+    it("[[Call]] silently fails deleting inverse statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const otherTag = new Tag();
+      otherTag.addBroaderTag(tag);
+      otherTag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        unstates: [{ predicate: "narrower", object: otherTag.iri }],
+      });
+      assertStrictEquals(
+        [...applied.narrowerTags()][0].identifier,
+        otherTag.identifier,
+      );
+    });
+
+    it("[[Call]] sets preflabels", () => {
+      const tag = new Tag("Tag", "my pref label");
+      tag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        states: [{ predicate: "prefLabel", object: "new pref label" }],
+      });
+      assertEquals(
+        { ...applied.prefLabel },
+        { "@value": "new pref label" },
+      );
+    });
+
+    it("[[Call]] silently fails setting unrecognized statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const otherTag = new Tag();
+      otherTag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        states: [{ predicate: "bad_statement", object: otherTag.iri }],
+      });
+      assert(applied);
+    });
+
+    it("[[Call]] silently fails setting immutable statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        states: [{ predicate: "a", object: "RelationshipTag" }],
+      });
+      assertStrictEquals(applied.kind, "Tag");
+    });
+
+    it("[[Call]] silently fails setting inverse statements", () => {
+      const tag = new Tag();
+      tag.persist(true);
+      const otherTag = new Tag();
+      otherTag.persist(true);
+      const applied = system.apply({
+        object: tag.iri,
+        unstates: [{ predicate: "narrower", object: otherTag.iri }],
+      });
+      assertEquals([...applied.narrowerTags()], []);
+    });
+  });
+
   describe("::authorityName", () => {
     it("[[Get]] returns the authority name", () => {
       const system = new TagSystem("etaoin.example", "1972-12-31");
This page took 0.029216 seconds and 4 git commands to generate.