Otzel (otzel v0.3.2)

View Source

Otzel is an Elixir library for Operational Transformation (OT).

Operational Transformation is a technique for maintaining consistency in collaborative editing systems. When multiple users edit a shared document simultaneously, OT ensures that all users see the same final result regardless of the order in which edits are received.

The Delta Format

Otzel implements the Delta format, representing documents and changes as lists of operations. A delta is simply a list of Otzel.Op.t/0 operations.

There are three types of operations:

Documents vs Changes

The same delta format represents both:

  • Documents: Deltas consisting only of insert operations
  • Changes: Deltas that may include retain and delete operations

Example Usage

# Create a document
doc = [Otzel.insert("Hello World")]

# Create a change that makes "World" bold
change = [Otzel.retain(6), Otzel.retain(5, %{"bold" => true})]

# Apply the change
new_doc = Otzel.compose(doc, change)

Core Operations

Configuration

The default string module can be configured:

config :otzel, :string_module, Otzel.Content.Iomemo

Available string modules:

  • Otzel.Content.Iomemo (default) - Efficient IO-list based strings with O(1) size lookups and structural sharing for split/concatenate operations
  • String - Plain Elixir strings (via the BitString protocol implementation), simpler but less efficient for large documents with frequent edits

Summary

Types

Priority determines which concurrent operation "wins" during transformation.

t()

A delta is a list of operations representing a document or change.

Functions

Performs semantic cleanup on a diff, simplifying interleaved delete/insert sequences into cleaner delete-then-insert patterns when the common text is minimal.

Compacts Delta to satisfy compactness requirement.

Composes two deltas into a single delta with the same effect as applying them sequentially.

Concatenates two transformations into one

Creates a Delete operation with the given count.

Computes the difference between two transformations, returning a new transformation that represents the changes needed to convert src into dst. Both src and dst should be strictly lists of Insert operations.

Parses a delta from JSON format.

Creates an Insert operation with the given content and attributes.

Computes an inverted transformation transformation, that has the opposite effect against a base transformation.

Creates a Retain operation with the given target and attributes.

Takes operations beyond count operations from the list of operations.

Returns the total size of the operations in the transformation. For strings, the size is the number of codepoints in the string.

Returns a slice of operations starting from start with a given count

splits a transformation into two parts at the supplied index

Takes the first count operations from the list of operations.

Transforms a delta against another concurrent delta.

Transforms a cursor/selection index against a delta.

Types

priority()

@type priority() :: :left | :right

Priority determines which concurrent operation "wins" during transformation.

  • :left - The first operation has priority
  • :right - The second operation has priority

split_fun()

@type split_fun() :: (Otzel.Op.t() -> :cont | non_neg_integer())

split_fun_ctx()

@type split_fun_ctx() :: (Otzel.Op.t(), context :: term() ->
                      :cont | {:cont, context :: term()} | non_neg_integer())

t()

@type t() :: [Otzel.Op.t()]

A delta is a list of operations representing a document or change.

Documents are deltas containing only insert operations. Changes may contain insert, retain, and delete operations.

Functions

cleanup_semantic(ops, dst_content \\ nil, dst_attrs_index \\ nil)

@spec cleanup_semantic(t(), Otzel.Content.t() | nil, list() | nil) :: t()

Performs semantic cleanup on a diff, simplifying interleaved delete/insert sequences into cleaner delete-then-insert patterns when the common text is minimal.

This makes diffs more human-readable by avoiding overly granular changes.

The optional dst_content parameter provides the destination content, which is needed to extract retained content when converting retains to inserts. The optional dst_attrs_index parameter provides the destination attributes index, which maps positions to their attributes for proper attr handling.

compact(list)

@spec compact(t()) :: t()

Compacts Delta to satisfy compactness requirement.

iex> delta = [Otzel.insert("Hel"), Otzel.insert("lo"), Otzel.insert("World", %{"bold" => true})]
iex> Otzel.compact(delta) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello"}, %{"insert" => "World", "attributes" => %{"bold" => true}}]

compose(left, right)

@spec compose(t(), t()) :: t()

Composes two deltas into a single delta with the same effect as applying them sequentially.

Given deltas A and B, compose(A, B) returns a delta C such that applying C to a document has the same effect as applying A then B.

Examples

iex> doc = [Otzel.insert("Hello")]
iex> change = [Otzel.retain(5), Otzel.insert(" World")]
iex> result = Otzel.compose(doc, change)
iex> result |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello World"}]

iex> a = [Otzel.insert("abc")]
iex> b = [Otzel.retain(1), Otzel.delete(1), Otzel.retain(1)]
iex> Otzel.compose(a, b) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "ac"}]

Properties

Compose is associative: compose(compose(a, b), c) == compose(a, compose(b, c))

concat(left, right)

@spec concat(t(), t()) :: t()

Concatenates two transformations into one

iex> Otzel.concat([Otzel.insert("Hel")], [Otzel.insert("lo")]) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello"}]

delete(count)

@spec delete(non_neg_integer()) :: Otzel.Op.Delete.t()

Creates a Delete operation with the given count.

diff(src, dst)

@spec diff(t(), t()) :: t()

Computes the difference between two transformations, returning a new transformation that represents the changes needed to convert src into dst. Both src and dst should be strictly lists of Insert operations.

Note that the following invariant holds for compact transformation a: a == Otzel.compose(b, Otzel.diff(a, b))

iex> Otzel.diff([Otzel.insert("Hello")], [Otzel.insert("Hello!")])
[Otzel.retain(5), Otzel.insert("!")]

from_json(json, opts \\ [])

Parses a delta from JSON format.

Accepts a list of operation maps in the Quill Delta JSON format.

Options

  • :embed_encoder - A {module, function} tuple that will be called with the raw content to convert embedded content back to structs. The function should return the appropriate struct or pass through the content unchanged.

Examples

iex> json = [%{"insert" => "Hello"}, %{"insert" => " World", "attributes" => %{"bold" => true}}]
iex> delta = Otzel.from_json(json)
iex> length(delta)
2

insert(content, attrs \\ nil)

@spec insert(String.t() | Otzel.Content.t(), attrs :: nil | map()) ::
  Otzel.Op.Insert.t()

Creates an Insert operation with the given content and attributes.

insert(content, attrs, module)

invert(change, base)

@spec invert(t(), t()) :: t()

Computes an inverted transformation transformation, that has the opposite effect against a base transformation.

Note that the following invariant holds for base a list of inserts and change a transformation with retains and deletes fewer than the size of base:

base == compose(compose(base, change), compose(invert(change, base)))

iex> alias Otzel.Op.{Insert, Retain, Delete}
iex> Otzel.invert([%Retain{target: 6, attrs: %{"bold" => true}}, %Delete{count: 5}, %Insert{content: "!"}], [%Insert{content: "Hello\nWorld"}])
[
  %Retain{target: 6, attrs: %{"bold" => nil}},
  %Insert{content: "World"},
  %Delete{count: 1}
]

retain(target, attrs \\ nil)

@spec retain(non_neg_integer() | Otzel.Content.t(), attrs :: nil | map()) ::
  Otzel.Op.Retain.t()

Creates a Retain operation with the given target and attributes.

Note: if a the target is an embedded Otzel.Content.t/0 then the "retain" operation signifies that the target should be treated as a delta for the embedded content.

seek(ops, start)

@spec seek(t(), non_neg_integer()) :: t()

Takes operations beyond count operations from the list of operations.

iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.seek([%Insert{content: "Hello"}, %Retain{target: 3}], 3)
[%Insert{content: "lo"}, %Retain{target: 3}]

size(ops)

@spec size(t()) :: non_neg_integer()

Returns the total size of the operations in the transformation. For strings, the size is the number of codepoints in the string.

iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.size([%Insert{content: "Hello"}])
5
iex> Otzel.size([%Insert{content: "Howdy🤠!"}])
7
iex> Otzel.size([%Insert{content: "Hello"}, %Retain{target: 3}])
8

slice(ops, start, count)

@spec slice(t(), start :: non_neg_integer(), count :: non_neg_integer()) :: t()

Returns a slice of operations starting from start with a given count

iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.slice([%Insert{content: "Hello"}, %Retain{target: 3}], 2, 5)
[%Insert{content: "llo"}, %Retain{target: 2}]

split(ops, fun)

@spec split(
  t(),
  non_neg_integer() | split_fun() | {split_fun_ctx(), context :: term()}
) :: {t(), t()}

splits a transformation into two parts at the supplied index

iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.split([%Insert{content: "Hello"}, %Retain{target: 3}], 2)
{[%Insert{content: "He"}], [%Insert{content: "llo"}, %Retain{target: 3}]}

split_rev(ops, start)

take(ops, count, so_far \\ [])

Takes the first count operations from the list of operations.

iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.take([%Insert{content: "Hello"}, %Retain{target: 3}], 6)
[%Insert{content: "Hello"}, %Retain{target: 1}]

transform(left, right, priority \\ :right)

@spec transform(t(), t(), priority()) :: t()

Transforms a delta against another concurrent delta.

When two users make edits concurrently, their deltas need to be transformed against each other to maintain consistency. Given deltas A and B that were created from the same base document:

  • transform(A, B, :right) returns B' that can be applied after A
  • transform(B, A, :left) returns A' that can be applied after B

The result satisfies: compose(A, B') == compose(B, A')

Parameters

  • from - The delta that was applied first
  • into - The delta to transform
  • priority - Which delta wins when both insert at the same position

Examples

iex> a = [Otzel.insert("A")]
iex> b = [Otzel.insert("B")]
iex> Otzel.transform(a, b, :right) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "B"}]

Priority

The priority parameter determines what happens when both deltas insert at the same position:

  • :right - The into delta's insert comes after
  • :left - The into delta's insert comes before

transform_index(left, right, priority \\ :right)

@spec transform_index(non_neg_integer(), t(), priority()) :: non_neg_integer()

Transforms a cursor/selection index against a delta.

When a delta is applied to a document, cursor positions need to be adjusted. This function computes where an index should move to after the delta is applied.

Parameters

  • index - The original cursor position
  • delta - The delta being applied
  • priority - Whether the cursor should be pushed by inserts at the same position

Examples

iex> Otzel.transform_index(5, [Otzel.insert("abc")], :right)
8

iex> Otzel.transform_index(5, [Otzel.retain(3), Otzel.delete(2)], :right)
3