Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ applies only to relation options; property and other object omissions keep
their existing severity. An option missing its name or owning property still
raises `IssueOmittedReconstruction`.

View ids remain intact even with `CompactBlockLabels` or `OmitIds`, so
`index.widgets[].view_id` still selects the same view after import.
With `CompactBlockLabels`, generated block and view ids use the target
object's collision-checked short labels. `Composer` applies that object's
view mapping to `index.widgets[].view_id`, regardless of observation order.
The primary block stays `dataview`; widgets imply it rather than storing a
separate block id. Full-ID mode and `OmitIds` retain supplied view ids.

Callers rendering an index separately can use `WidgetViewIDs` for each target
and supply `Options.ResolveWidgetViewID`. A standalone widget document with a
minted view selector requires that resolver when shortening IDs; without the
target's mapping the codec refuses to guess a possibly colliding suffix.

Heart's snapshot export collection includes both chat kinds: `chat`
(`ChatDerivedObject`) and the legacy `chat_object`. Their object metadata and
Expand Down
92 changes: 92 additions & 0 deletions bundle/compactwidgetviews_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package bundle

import (
"fmt"
"testing"

"github.com/gogo/protobuf/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anyproto/any-block/codec/anyblockjson"
"github.com/anyproto/any-block/format/v1/model"
"github.com/anyproto/any-block/internal/testfixtures"
)

func TestCompactWidgetViewsUseTargetObjectLabels(t *testing.T) {
const selected = "32726bf3-cd8b-4099-aafb-688e9525ed67"
const collision = "12726bf3-cd8b-4099-aafb-688e9525ed67"
const paragraph = "abcdef0123456789abc12345"
for _, compact := range []bool{false, true} {
for _, widgetFirst := range []bool{false, true} {
for _, collides := range []bool{false, true} {
t.Run(fmt.Sprintf("compact=%t/widget-first=%t/collision=%t", compact, widgetFirst, collides), func(t *testing.T) {
opts := anyblockjson.Options{CompactBlockLabels: compact}
c := newComposer(t, opts, "Synthetic")
widgets, err := anyblockjson.WidgetsSnapshot(&anyblockjson.Index{Widgets: []anyblockjson.Widget{
{Target: testfixtures.ObjectID, Layout: "view", ViewId: selected},
{Target: testfixtures.ObjectIDAlt, Layout: "view", ViewId: selected},
}})
require.NoError(t, err)
observeWidgets := func() {
omitted, issues := c.Observe(model.SmartBlockType_Widget, widgets)
require.True(t, omitted)
require.Empty(t, issues)
}
if widgetFirst {
observeWidgets()
}
var targets []*model.SmartBlockSnapshotBase
for n, id := range []string{testfixtures.ObjectID, testfixtures.ObjectIDAlt} {
views := []*model.BlockContentDataviewView{{Id: selected, Name: "Chosen"}}
if n == 0 && collides {
views = append(views, &model.BlockContentDataviewView{Id: collision, Name: "Other"})
}
snap := &model.SmartBlockSnapshotBase{
ObjectTypes: []string{"ot-set"},
Details: detFields(map[string]*types.Value{"id": strVal(id), "setOf": {Kind: &types.Value_ListValue{ListValue: &types.ListValue{}}}}),
Blocks: []*model.Block{
{Id: id, ChildrenIds: []string{paragraph, "dataview"}, Content: &model.BlockContentOfSmartblock{Smartblock: &model.BlockContentSmartblock{}}},
{Id: paragraph, Content: &model.BlockContentOfText{Text: &model.BlockContentText{Text: "Note"}}},
{Id: "dataview", Content: &model.BlockContentOfDataview{Dataview: &model.BlockContentDataview{Views: views}}},
},
}
data, err := anyblockjson.Marshal(model.SmartBlockType_Page, snap, opts)
require.NoError(t, err)
require.NoError(t, c.ObserveWritten(model.SmartBlockType_Page, snap, data))
_, imported, err := anyblockjson.Unmarshal(data, opts)
require.NoError(t, err)
targets = append(targets, imported)
}
if !widgetFirst {
observeWidgets()
}
indexData, _, _, err := c.Finish()
require.NoError(t, err)
index, err := anyblockjson.UnmarshalIndex(indexData, opts)
require.NoError(t, err)
for n, widget := range index.Widgets {
want := selected
if compact && !(n == 0 && collides) {
want = "5ed67"
}
assert.Equal(t, want, widget.ViewId)
var chosen string
for _, b := range targets[n].Blocks {
if b.Id != "dataview" {
continue
}
for _, v := range b.GetDataview().GetViews() {
if v.Id == widget.ViewId {
chosen = v.Name
}
}
}
assert.Equal(t, "Chosen", chosen)
}
assert.Equal(t, selected, widgets.Blocks[1].GetWidget().ViewId, "caller snapshot stays unchanged")
})
}
}
}
}
17 changes: 17 additions & 0 deletions bundle/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ type Composer struct {
// no document can: whether an id this index names is carried here.
documentIds map[string]bool
indexReferenceSources map[anyblockjson.ObjectReference]struct{}
widgetViewLabels map[string]map[string]string

written int
omitted int
Expand Down Expand Up @@ -525,6 +526,16 @@ func (c *Composer) ObserveWritten(sbType model.SmartBlockType, base *model.Smart
base.GetDetails().GetFields()["id"].GetStringValue(), base.GetKey()); err != nil {
return fmt.Errorf("observe written document: %w", err)
}
if c.opts.CompactBlockLabels && !c.opts.OmitIds {
if labels := anyblockjson.WidgetViewIDs(sbType, base, c.opts); len(labels) > 0 {
if c.widgetViewLabels == nil {
c.widgetViewLabels = map[string]map[string]string{}
}
id := base.GetDetails().GetFields()["id"].GetStringValue()
c.widgetViewLabels[id] = labels
c.widgetViewLabels[anyblockjson.FoldDocumentId(c.opts, sbType, id, base.GetKey())] = labels
}
}
c.written++
// the id the document was WRITTEN under, which for a type or a
// participant is the derived id and not the store id (§9). Taken from
Expand Down Expand Up @@ -1041,6 +1052,12 @@ func (c *Composer) Finish() (index, properties []byte, stats Stats, err error) {
// IndexFromSpaceSettings result, keeping extraction in the codec while the
// explicit candidate sets keep Composer's conflict policy visible.
idx := c.index
idx.Widgets = append([]anyblockjson.Widget(nil), c.index.Widgets...)
for n, widget := range idx.Widgets {
if label, ok := c.widgetViewLabels[widget.Target][widget.ViewId]; ok {
idx.Widgets[n].ViewId = label
}
}
idx.NetworkId = c.opts.NetworkId
idx.Name = spaceSettings.Name
idx.Description = spaceSettings.Description
Expand Down
2 changes: 1 addition & 1 deletion codec/anyblockjson/compactsplit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func TestExport_MintedShapeRelabeling(t *testing.T) {
assert.Contains(t, served, shortHex, "a short id serves as itself")
assert.Contains(t, served, aliasMint,
"a minted id whose suffix spells another block's id must stay full, not alias it")
assert.Equal(t, []string{viewUuid}, viewIds, "view ids remain stable for external widget selectors")
assert.Equal(t, []string{"5ed67"}, viewIds, "view ids follow the same short-ID rule as blocks")

// the invariant behind the relabeling rules above, pinned independently
// of the rule that produces it: no two blocks/views ever share a served id
Expand Down
7 changes: 3 additions & 4 deletions codec/anyblockjson/dataview.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,9 @@ func (e *exporter) dataviewSourceForExport(dv *model.BlockContentDataview) (targ
func (e *exporter) viewToJSON(v *model.BlockContentDataviewView, dv *model.BlockContentDataview) (*omap, error) {
vm := &omap{}
e.recordEmitted(v.Id)
// A widget in another document or index.json can select this view by id.
// Neither compaction nor OmitIds may disconnect that selector. Keep views
// in the local-id census as reservations against block-label collisions.
vm.setNonEmpty("id", v.Id)
// Views share the target object's collision-checked label plan. The bundle
// composer applies the same labels to selectors in index.widgets.
vm.setNonEmpty("id", e.localId(v.Id))
if v.Type != model.BlockContentDataviewView_Table {
// an out-of-range view type is omitted rather than emitted as an
// empty string, which the schema would reject; it therefore reads
Expand Down
2 changes: 1 addition & 1 deletion codec/anyblockjson/documentation_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func TestDocumentationContract_OmitIdsKeepsEnvelopeIdentity(t *testing.T) {
docs := readFormatDocumentation(t)
assert.Contains(t, docs["SPEC.md"], "It **retains the envelope object `id`, view ids**")
assert.Contains(t, docs["PRINCIPLES.md"], "a provided envelope object id is preserved")
assert.Contains(t, docs["README.md"], "Both options preserve envelope and view ids")
assert.Contains(t, docs["README.md"], "Both options preserve envelope ids")

all := strings.ToLower(docs["SPEC.md"] + docs["PRINCIPLES.md"] + docs["README.md"])
for _, stale := range []string{
Expand Down
31 changes: 19 additions & 12 deletions codec/anyblockjson/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,15 @@ type Options struct {
// OmitIds drops document-local block/table/sort/filter ids and option_ids.
// It preserves envelope and view ids: widgets can address views from
// outside this document. Object references also remain full (§9, §9a).
OmitIds bool
CompactBlockLabels bool // export only: relabel doc-local block/row/column ids to short suffixes; preserve view ids (§9a)
GenerateId func() string // import only: id generator for missing ids; nil = random 24-hex
NormalizeIndent bool // import only: clamp over-deep indents instead of rejecting (§4)
OnWarning func(Issue) // optional sink for warning-grade issues, both directions (indent clamps, unrepresentable dates, …)
OmitIds bool
// ResolveWidgetViewID supplies a target object's exported view label when
// rendering a standalone widget document. Bundle index widgets are remapped
// automatically by Composer. The callback must use the target's label plan.
ResolveWidgetViewID func(objectID, viewID string) (string, bool)
CompactBlockLabels bool // export only: relabel doc-local block/row/column/view ids to short suffixes (§9a)
GenerateId func() string // import only: id generator for missing ids; nil = random 24-hex
NormalizeIndent bool // import only: clamp over-deep indents instead of rejecting (§4)
OnWarning func(Issue) // optional sink for warning-grade issues, both directions (indent clamps, unrepresentable dates, …)
}

// Legend carries the two legends of the document a fragment was cut out
Expand Down Expand Up @@ -324,9 +328,8 @@ func Marshal(sbType model.SmartBlockType, snapshot *model.SmartBlockSnapshotBase
if err := e.checkTableGridBounds(); err != nil {
return nil, err
}
// OmitIds retains only envelope and view ids, neither of which relabels, so
// a label plan has nothing to label. Running the census probe with both
// flags would add a second block emit without changing the output.
// OmitIds takes precedence over compaction; preserved view selectors remain
// full in that mode, while ordinary short-ID exports relabel views too.
if opts.compactBlockLabels() && !opts.OmitIds {
e.buildLabelPlan()
}
Expand Down Expand Up @@ -378,7 +381,7 @@ type exporter struct {
querySourceValue querySource
querySourceBuilt bool

localIds map[string]string // local id -> short label; views keep their stored ids (§9a)
localIds map[string]string // local id -> short label, including views (§9a)

// optionRefs is the second `refs` population: the option id behind every
// name export wrote for a select value (optionrefs.go). Recorded against
Expand Down Expand Up @@ -2566,7 +2569,11 @@ func (e *exporter) blockToJSON(b *model.Block, depth int) (*omap, bool, error) {
m.setNonEmpty("layout", widgetLayoutNames.name(w.Layout))
}
m.setNonEmpty("limit", w.Limit)
m.setNonEmpty("view_id", w.ViewId)
viewID, err := e.widgetViewID(b, w.ViewId)
if err != nil {
return nil, false, err
}
m.setNonEmpty("view_id", viewID)
m.setNonEmpty("auto_added", w.AutoAdded)
case *model.BlockContentOfChat:
m.set("type", "chat")
Expand Down Expand Up @@ -2788,9 +2795,9 @@ func (e *exporter) emittedLocalIds() map[string]bool {
return probe.emitted
}

// buildLabelPlan works out which doc-local block/row/column ids may be
// buildLabelPlan works out which doc-local block/row/column/view ids may be
// relabeled to a short suffix (§9a). It walks BOTH id populations to do it:
// the doc-local ids (including preserved view ids), and every OBJECT id
// the doc-local ids (including view ids), and every OBJECT id
// the document references — not because an object id is ever compacted (none
// is, §9a), but because every one of them is spelled verbatim in the output,
// so a label equal to one would make two different things answer to one name.
Expand Down
13 changes: 13 additions & 0 deletions codec/anyblockjson/exportreferences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,20 @@ func TestExportPreservesWidgetViewSelectors(t *testing.T) {
data, err := Marshal(host.sbType, snapshot, opts)
require.NoError(t, err)
require.NoError(t, Validate(data, Options{}))
labels := WidgetViewIDs(host.sbType, snapshot, opts)
opts.ResolveWidgetViewID = func(objectID, viewID string) (string, bool) {
if objectID != host.id {
return "", false
}
label, ok := labels[viewID]
return label, ok
}
indexData, err := MarshalIndex(&Index{Widgets: []Widget{{Target: host.id, Layout: "view", ViewId: selected}}}, opts)
if opts.CompactBlockLabels && !opts.OmitIds {
assert.Equal(t, "5ed67", labels[selected])
} else {
assert.Equal(t, selected, labels[selected])
}
require.NoError(t, err)
for _, readOpts := range []Options{{}, {ResolveProperties: opts.ResolveProperties}} {
index, err := UnmarshalIndex(indexData, readOpts)
Expand Down
8 changes: 7 additions & 1 deletion codec/anyblockjson/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,13 @@ func MarshalIndex(idx *Index, opts Options) ([]byte, error) {
wm.set("layout", w.Layout)
}
wm.setNonEmpty("limit", w.Limit)
wm.setNonEmpty("view_id", w.ViewId)
viewID := w.ViewId
if opts.compactBlockLabels() && !opts.OmitIds && opts.ResolveWidgetViewID != nil {
if label, ok := opts.ResolveWidgetViewID(w.Target, viewID); ok && label != "" {
viewID = label
}
}
wm.setNonEmpty("view_id", viewID)
wm.setNonEmpty("auto_added", w.AutoAdded)
if w.CardStyle != "" && w.CardStyle != "text" {
wm.set("card_style", w.CardStyle)
Expand Down
66 changes: 66 additions & 0 deletions codec/anyblockjson/widgetviewids.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package anyblockjson

import (
"fmt"

"github.com/anyproto/any-block/format/v1/model"
)

// WidgetViewIDs returns the primary dataview's stored-to-exported view IDs.
// It uses the target object's own label plan, including collision fallback.
// Call with the same snapshot and options used by Marshal. Index widgets imply
// the fixed primary block "dataview"; they do not address arbitrary blocks.
func WidgetViewIDs(sbType model.SmartBlockType, snapshot *model.SmartBlockSnapshotBase, opts Options) map[string]string {
if snapshot == nil {
return nil
}
var primary *model.Block
for _, b := range snapshot.Blocks {
if b != nil && b.Id == dataviewBlockId {
primary = b
}
}
if primary == nil || len(primary.GetDataview().GetViews()) == 0 {
return nil
}
// Preserve warning-enabled behavior without duplicating diagnostics.
if opts.OnWarning != nil {
opts.OnWarning = func(Issue) {}
}
e := &exporter{opts: opts, snapshot: snapshot, sbType: sbType, blocks: map[string]*model.Block{}, visited: map[string]bool{}}
e.indexBlocks()
if !e.canonicalEmissionPlan().emitted[dataviewBlockId] {
return nil
}
if opts.compactBlockLabels() && !opts.OmitIds {
e.buildLabelPlan()
}
labels := map[string]string{}
for _, view := range primary.GetDataview().GetViews() {
if view != nil && view.Id != "" {
labels[view.Id] = e.localId(view.Id)
}
}
return labels
}

// A kept widget document is rendered before the composer can rewrite index
// selectors. Require its target's mapping instead of guessing a suffix.
func (e *exporter) widgetViewID(block *model.Block, viewID string) (string, error) {
if !e.opts.compactBlockLabels() || e.opts.OmitIds || !isMintedLocalId(viewID) {
return viewID, nil
}
var target string
for _, child := range block.ChildrenIds {
if link := e.blocks[child].GetLink(); link != nil {
target = link.TargetBlockId
break
}
}
if e.opts.ResolveWidgetViewID != nil {
if label, ok := e.opts.ResolveWidgetViewID(target, viewID); ok && label != "" {
return label, nil
}
}
return "", fmt.Errorf("widget block %q: shortening view %q in target %q requires ResolveWidgetViewID; use the target object's WidgetViewIDs mapping or export this widget document with full IDs", block.Id, viewID, target)
}
38 changes: 38 additions & 0 deletions codec/anyblockjson/widgetviewids_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package anyblockjson

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anyproto/any-block/format/v1/model"
)

func TestStandaloneWidgetUsesTargetViewMapping(t *testing.T) {
const viewID = "32726bf3-cd8b-4099-aafb-688e9525ed67"
wrapper := &model.Block{Id: "widget", ChildrenIds: []string{"link"}, Content: &model.BlockContentOfWidget{Widget: &model.BlockContentWidget{Layout: model.BlockContentWidget_View, ViewId: viewID}}}
snapshot := blockSnapshot(wrapper)
snapshot.Blocks = append(snapshot.Blocks, &model.Block{Id: "link", Content: &model.BlockContentOfLink{Link: &model.BlockContentLink{TargetBlockId: liveCid}}})

_, err := Marshal(model.SmartBlockType_Page, snapshot, Options{CompactBlockLabels: true})
require.ErrorContains(t, err, "requires ResolveWidgetViewID")

for _, mapped := range []string{"5ed67", viewID} {
opts := Options{CompactBlockLabels: true, ResolveWidgetViewID: func(objectID, storedViewID string) (string, bool) {
assert.Equal(t, liveCid, objectID)
assert.Equal(t, viewID, storedViewID)
return mapped, true
}}
data, err := Marshal(model.SmartBlockType_Page, snapshot, opts)
require.NoError(t, err)
require.NoError(t, Validate(data, Options{}))
assert.Contains(t, string(data), `"view_id": "`+mapped+`"`)
}
for _, opts := range []Options{{}, {OmitIds: true, CompactBlockLabels: true}} {
data, err := Marshal(model.SmartBlockType_Page, snapshot, opts)
require.NoError(t, err)
assert.Contains(t, string(data), `"view_id": "`+viewID+`"`)
}
assert.Equal(t, viewID, wrapper.GetWidget().ViewId)
}
Loading
Loading