diff --git a/Sources/Automerge/Automerge.docc/Curation/Document.md b/Sources/Automerge/Automerge.docc/Curation/Document.md index 7b41af2b..6bb6923b 100644 --- a/Sources/Automerge/Automerge.docc/Curation/Document.md +++ b/Sources/Automerge/Automerge.docc/Curation/Document.md @@ -75,6 +75,9 @@ - ``heads()`` - ``getHistory()`` - ``change(hash:)`` +- ``difference(from:to:)`` +- ``difference(since:)`` +- ``difference(to:)`` ### Reading historical map values diff --git a/Sources/Automerge/Document.swift b/Sources/Automerge/Document.swift index ac2b7ff3..2c196fe3 100644 --- a/Sources/Automerge/Document.swift +++ b/Sources/Automerge/Document.swift @@ -946,6 +946,66 @@ public final class Document: @unchecked Sendable { } } + /// Generates patches between two points in the document history. + /// + /// Use: + /// ``` + /// let doc = Document() + /// let textId = try! doc.putObject(obj: ObjId.ROOT, key: "text", ty: .Text) + /// let before = doc.heads() + /// + /// try doc.spliceText(obj: textId, start: 0, delete: 0, value: "Hello") + /// let after = doc.heads() + /// + /// let patches = doc.difference(from: before, to: after) + /// ``` + /// + /// - Parameters: + /// - from: The set of heads at beginning point in the documents history. + /// - to: The set of heads at ending point in the documents history. + /// - Note: `from` and `to` do not have to be chronological. Document state can move backward. + /// - Returns: The difference needed to produce a document at `to` when it is set at `from` in history. + public func difference(from before: Set, to after: Set) -> [Patch] { + sync { + let patches = self.doc.wrapErrors { doc in + doc.difference(before: before.map(\.bytes), after: after.map(\.bytes)) + } + return patches.map { Patch($0) } + } + } + + /// Generates patches **since** a given point in the document history. + /// + /// Use: + /// ``` + /// let doc = Document() + /// doc.difference(since: doc.heads()) + /// ``` + /// + /// - Parameters: + /// - since: The set of heads at the point in the documents history to compare to. + /// - Returns: The difference needed to produce current document given an arbitrary + /// point in the history. + public func difference(since lhs: Set) -> [Patch] { + difference(from: lhs, to: heads()) + } + + /// Generates patches **to** a given point in the document history. + /// + /// Use: + /// ``` + /// let doc = Document() + /// doc.difference(to: doc.heads()) + /// ``` + /// + /// - Parameters: + /// - to: The set of heads at ending point in the documents history. + /// - Returns: The difference needed to move current document to a previous point + /// in the history. + public func difference(to rhs: Set) -> [Patch] { + difference(from: heads(), to: rhs) + } + /// Get the path to an object within the document. /// /// - Parameter obj: The identifier of an array, dictionary or text object. diff --git a/Tests/AutomergeTests/TestChanges.swift b/Tests/AutomergeTests/TestChanges.swift index 177d890d..c5acccea 100644 --- a/Tests/AutomergeTests/TestChanges.swift +++ b/Tests/AutomergeTests/TestChanges.swift @@ -31,4 +31,57 @@ class ChangeSetTests: XCTestCase { XCTAssertEqual(bytes, data) // print(data.hexEncodedString()) } + + func testDifferenceToPreviousCommit() throws { + let doc = Document() + let textId = try! doc.putObject(obj: ObjId.ROOT, key: "text", ty: .Text) + try doc.spliceText(obj: textId, start: 0, delete: 0, value: "Hello") + + let before = doc.heads() + try doc.spliceText(obj: textId, start: 5, delete: 0, value: " World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + + let patches = doc.difference(to: before) + let length = UInt64(" World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.count) + XCTAssertEqual(patches.count, 1) + XCTAssertEqual(patches.first?.action, .DeleteSeq(DeleteSeq(obj: textId, index: 5, length: length))) + } + + func testDifferenceSincePreviousCommit() throws { + let doc = Document() + let textId = try! doc.putObject(obj: ObjId.ROOT, key: "text", ty: .Text) + try doc.spliceText(obj: textId, start: 0, delete: 0, value: "Hello") + + let before = doc.heads() + try doc.spliceText(obj: textId, start: 5, delete: 0, value: " World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + + let patches = doc.difference(since: before) + XCTAssertEqual(patches.count, 1) + XCTAssertEqual(patches.first?.action, .SpliceText(obj: textId, index: 5, value: " World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", marks: [:])) + } + + func testDifferenceBetweenTwoCommitsInHistory() throws { + let doc = Document() + let textId = try! doc.putObject(obj: ObjId.ROOT, key: "text", ty: .Text) + let before = doc.heads() + try doc.spliceText(obj: textId, start: 0, delete: 0, value: "Hello") + try doc.spliceText(obj: textId, start: 5, delete: 0, value: " World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + let after = doc.heads() + + let patches = doc.difference(from: before, to: after) + XCTAssertEqual(patches.count, 1) + XCTAssertEqual(patches.first?.action, .SpliceText(obj: textId, index: 0, value: "Hello World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", marks: [:])) + } + + func testDifferenceProperty_DifferenceBetweenCommitAndCurrent_DifferenceSinceCommit_ResultsEquals() throws { + let doc = Document() + let textId = try! doc.putObject(obj: ObjId.ROOT, key: "text", ty: .Text) + let before = doc.heads() + try doc.spliceText(obj: textId, start: 0, delete: 0, value: "Hello") + try doc.spliceText(obj: textId, start: 5, delete: 0, value: " World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + + let patches1 = doc.difference(from: before, to: doc.heads()) + let patches2 = doc.difference(since: before) + XCTAssertEqual(patches1.count, 1) + XCTAssertEqual(patches1, patches2) + } } diff --git a/rust/src/automerge.udl b/rust/src/automerge.udl index 91cc92c9..faa9f98f 100644 --- a/rust/src/automerge.udl +++ b/rust/src/automerge.udl @@ -233,6 +233,8 @@ interface Doc { Change? change_by_hash(ChangeHash hash); + sequence difference(sequence before, sequence after); + void commit_with(string? msg, i64 time); sequence save(); diff --git a/rust/src/doc.rs b/rust/src/doc.rs index 90c363cd..e8dbd3e8 100644 --- a/rust/src/doc.rs +++ b/rust/src/doc.rs @@ -585,6 +585,20 @@ impl Doc { changes.into_iter().map(|h| h.hash().into()).collect() } + pub fn difference(&self, before: Vec, after: Vec) -> Vec { + let lhs = before + .into_iter() + .map(am::ChangeHash::from) + .collect::>(); + let rhs = after + .into_iter() + .map(am::ChangeHash::from) + .collect::>(); + let mut doc = self.0.write().unwrap(); + let patches = doc.diff(&lhs, &rhs); + patches.into_iter().map(Patch::from).collect() + } + pub fn change_by_hash(&self, hash: ChangeHash) -> Option { let doc = self.0.write().unwrap(); doc.get_change_by_hash(&am::ChangeHash::from(hash))