From: Lady Date: Tue, 20 Jun 2023 01:19:22 +0000 (-0700) Subject: Enable application of activities onto tag systems X-Git-Url: https://git.ladys.computer/Etiquette/commitdiff_plain/HEAD?ds=sidebyside;hp=83cf1728db4305d5b40bdbccef92a9e01877489f Enable application of activities onto tag systems --- diff --git a/model.js b/model.js index 7e0fefc..3c8287e 100644 --- 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; + } } } diff --git a/model.test.js b/model.test.js index 4396d37..254dd4c 100644 --- a/model.test.js +++ b/model.test.js @@ -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");