Recursive AI

Save edits as JSON and replay them

All onehtml recipes

Store edits in the versioned onehtml-edits JSON format and apply them to another copy of the page later. Replay finds each target again or reports why it can’t, and never edits the wrong element.

An Edit in memory points at a node of one snapshot. To keep edits across processes or page versions, EditJson.Create turns them into an envelope whose targets are durable. Each target has a locator (a CSS selector unique in the page), a fingerprint (tag, key attributes and hashes of the text and path), and an anchor (the byte range and a hash of its bytes).

C#
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Editing;
 
string html = File.ReadAllText("events.html");
Document original = Document.Parse(html);
Node img = original.Root.QuerySelector("main img:not([alt])");
Node partner = original.Root.QuerySelector("a[target=_blank]");
 
// Save edits as versioned JSON, with durable targets instead of in-memory handles
EditEnvelope envelope = EditJson.Create(original, new[]
{
    Edit.SetAttribute(img, "alt", "Children listening to a story")
        .WithMeta(new Dictionary<string, object> { ["ticket"] = "WEB-212" }),
    Edit.SetAttribute(partner, "rel", "noopener"),
});
File.WriteAllText("events.edits.json", EditJson.ToJson(envelope, indented: true));
Console.WriteLine($"First target: {envelope.Edits[0].Target!.Locator}");
 
void Replay(string label, string page)
{
    EditEnvelope saved = EditJson.Parse(File.ReadAllText("events.edits.json"));
    ReplayResult replay = EditReplay.Replay(saved, Document.Parse(page));
    Console.WriteLine($"{label}: succeeded {replay.Succeeded}");
    foreach (ReplayOutcome outcome in replay.Outcomes)
    {
        Console.WriteLine($"  edit {outcome.Index}: {outcome.Status} {outcome.Reason} {outcome.MetaJson}");
    }
}
 
// 1. Someone changed the text above the image: every byte offset after it moved
Replay("Text changed", html.Replace("Saturdays at 10:30", "Saturdays at 11:00, starting in May"));
 
// 2. A new card was inserted first: article:nth-of-type(1) is now a different element
Replay("Card inserted", html.Replace(
    "<!-- events are edited by hand",
    "<article class=\"card\"><h2>Book sale</h2><img src=\"/img/sale.jpg\" alt=\"\"></article>\n    <!-- events are edited by hand"));
 
// 3. The image is gone
Replay("Image removed", html.Replace("<img src=\"/img/story-time.jpg\" width=640 height=360>", ""));
Text
First target: #content > article:nth-of-type(1) > img
Text changed: succeeded True
  edit 0: Applied  {"ticket":"WEB-212"}
  edit 1: Applied  
Card inserted: succeeded False
  edit 0: Stale the element the locator finds has a different tag or key attributes than the target had {"ticket":"WEB-212"}
  edit 1: Stale the element the locator finds has a different tag or key attributes than the target had 
Image removed: succeeded False
  edit 0: Stale the locator matches nothing: #content > article:nth-of-type(1) > img {"ticket":"WEB-212"}
  edit 1: NotAttempted  

The saved first edit looks like this:

JSON
{
  "op": "setAttribute",
  "target": {
    "locator": "#content > article:nth-of-type(1) > img",
    "fingerprint": {
      "tag": "img",
      "attrs": {
        "src": "/img/story-time.jpg"
      },
      "text": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
      "path": "sha256:e65c3a7e9beab0adae31be014068e912aae457e050b4d3adea04e09597f884de"
    },
    "anchor": {
      "start": 1035,
      "end": 1087,
      "bytes": "sha256:1a78698d2b635aade3ba3ba8c10bcae8d4452e3c395ca1826e08b66ebf3a34ec"
    }
  },
  "name": "alt",
  "value": "Children listening to a story",
  "meta": {"ticket":"WEB-212"}
}

How replay decided:

  1. Text changed. Every byte offset after the edit moved, so the anchor no longer held. The locator still matched exactly one element, and its fingerprint matched, so both edits applied.
  2. Card inserted. article:nth-of-type(1) now selects the new card. Its image has a different src, so the fingerprint didn’t match and the edit is Stale. A positional locator selects whatever sits at that position now, so a mismatch means stop, not guess. The second edit’s target moved the same way.
  3. Image removed. The locator matches nothing, so the edit is Stale. The second edit resolved but is NotAttempted, because a transaction applies all of its edits or none.

Other outcomes are Ambiguous (several candidates), Invalid and Failed. meta is yours: the library stores it and hands it back as MetaJson, and never reads it. In memory it must be JSON-shaped: strings, numbers, booleans, JsonElement, string-keyed dictionaries or sequences of those. An anonymous object throws ArgumentException.

Writing is deterministic: the same edits give the same bytes on every platform. The full format and replay rules are in Edit format, and how locators are chosen is in Locators.