diff --git a/bundle/README.md b/bundle/README.md index 4e81942..d981476 100644 --- a/bundle/README.md +++ b/bundle/README.md @@ -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 diff --git a/bundle/compactwidgetviews_test.go b/bundle/compactwidgetviews_test.go new file mode 100644 index 0000000..84380b5 --- /dev/null +++ b/bundle/compactwidgetviews_test.go @@ -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") + }) + } + } + } +} diff --git a/bundle/compose.go b/bundle/compose.go index f070f83..a3bc139 100644 --- a/bundle/compose.go +++ b/bundle/compose.go @@ -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 @@ -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 @@ -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 diff --git a/codec/anyblockjson/compactsplit_test.go b/codec/anyblockjson/compactsplit_test.go index 7bd5195..09689ec 100644 --- a/codec/anyblockjson/compactsplit_test.go +++ b/codec/anyblockjson/compactsplit_test.go @@ -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 diff --git a/codec/anyblockjson/dataview.go b/codec/anyblockjson/dataview.go index 4f71939..2b4f5b0 100644 --- a/codec/anyblockjson/dataview.go +++ b/codec/anyblockjson/dataview.go @@ -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 diff --git a/codec/anyblockjson/documentation_contract_test.go b/codec/anyblockjson/documentation_contract_test.go index ee5dbfc..a0f7be2 100644 --- a/codec/anyblockjson/documentation_contract_test.go +++ b/codec/anyblockjson/documentation_contract_test.go @@ -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{ diff --git a/codec/anyblockjson/export.go b/codec/anyblockjson/export.go index c6b04dc..851914e 100644 --- a/codec/anyblockjson/export.go +++ b/codec/anyblockjson/export.go @@ -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 @@ -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() } @@ -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 @@ -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") @@ -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. diff --git a/codec/anyblockjson/exportreferences_test.go b/codec/anyblockjson/exportreferences_test.go index 0f7140f..4deb6af 100644 --- a/codec/anyblockjson/exportreferences_test.go +++ b/codec/anyblockjson/exportreferences_test.go @@ -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) diff --git a/codec/anyblockjson/index.go b/codec/anyblockjson/index.go index 6888d60..711daa5 100644 --- a/codec/anyblockjson/index.go +++ b/codec/anyblockjson/index.go @@ -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) diff --git a/codec/anyblockjson/widgetviewids.go b/codec/anyblockjson/widgetviewids.go new file mode 100644 index 0000000..84d233e --- /dev/null +++ b/codec/anyblockjson/widgetviewids.go @@ -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) +} diff --git a/codec/anyblockjson/widgetviewids_test.go b/codec/anyblockjson/widgetviewids_test.go new file mode 100644 index 0000000..f141950 --- /dev/null +++ b/codec/anyblockjson/widgetviewids_test.go @@ -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) +} diff --git a/format/v2/README.md b/format/v2/README.md index 8873512..baeca52 100644 --- a/format/v2/README.md +++ b/format/v2/README.md @@ -202,13 +202,15 @@ carried verbatim, no rename's business. Full object ids are ~59-character CIDs — a single mention can cost more tokens than the sentence containing it. There used to be two compactions: one that shortened object references behind a `refs` legend, and one that -relabels document-local block/row/column ids to short suffixes. The +relabels document-local block/row/column/view ids to short suffixes. The first is deleted; only the second is left. -- **`CompactBlockLabels`** relabels doc-local block/row/column ids to +- **`CompactBlockLabels`** relabels doc-local block/row/column/view ids to their last 5 characters. **Legend-less and lossy.** - `OmitIds` drops block/table/sort/filter ids and `option_ids`, for generation. - Both options preserve envelope and view ids: a widget outside the document + Both options preserve envelope ids. `OmitIds` preserves view ids; short-ID + exports shorten views and update index widget selectors using the target's + collision-checked mapping. A widget outside the document can select a view by its `view_id`. - **The envelope object id and object references are written in full, on every shape.** diff --git a/format/v2/SPEC.md b/format/v2/SPEC.md index accbc9b..a0589d4 100644 --- a/format/v2/SPEC.md +++ b/format/v2/SPEC.md @@ -5972,7 +5972,8 @@ measurements, one verdict. **The compaction that survives is the legend-less one**, and that is the rule this section has left. `CompactBlockLabels` relabels ids the document itself defines, so a short label needs no table to invert: it is a placeholder -within its containing document, never an address outside it, and a write +within its containing document, addressed with its object when referenced +from outside it, and a write endpoint resolves one against the live object by unique suffix. There is nothing to carry, nothing to keep in sync, and nothing to read back. An indirection table has all three obligations, and the object legend failed all @@ -5980,7 +5981,7 @@ three at once — which is why the half sold as "lossless, because the legend inverts it" is gone and the half documented as *lossy* stayed. With `CompactBlockLabels`, -block/row/column ids are relabeled to their last 5 characters. Only +block/row/column/view ids are relabeled to their last 5 characters. Only machine-minted opaque ids relabel: `dataview` is a documented constant, `title`/`header` are structural, and an imported document's human-readable ids carry meaning that relabeling would destroy for no benefit. Labels are @@ -5992,13 +5993,26 @@ label stays uncompacted (implementation decision — fixed-width suffixes with a full-id fallback, chosen over shortest-unique lengthening for simplicity; 5 characters over CID/hex alphabets make collisions birthday-rare). -**View ids remain full under both `CompactBlockLabels` and `OmitIds`.** -They are addressed outside their document by widget `view_id` selectors, -including those lifted into `index.widgets` (§2c). A one-document exporter -cannot know which views another document references, so it preserves every -supplied view id. Missing view ids remain valid input and are generated on -import. Preserved view ids still participate in the collision census, so -shortening a block id cannot make it alias a view or share its suffix. +**View ids shorten under `CompactBlockLabels`, using the target object's +label plan.** Block and view labels share its collision checks; ambiguous +suffixes keep their full ids. `Composer` rewrites `index.widgets[].view_id` +with the matching target-local label, so the selector still addresses the +same view. Widgets imply the primary `dataview` block, whose structural id +stays unchanged; no global uniqueness or additional index legend is needed. + +Full-ID mode retains supplied ids; compact mode can also retain full ids +when shortening would collide. Neither index nor object documents carry a +mode flag: readers match the ids actually written, scoped to the target +object, without inferring export mode from their length. + +`WidgetViewIDs` exposes the target's stored-to-exported view mapping for +callers rendering indexes separately. `Options.ResolveWidgetViewID` lets an +index or standalone widget renderer use that mapping. A standalone widget +with a minted selector refuses short-ID export without the resolver, since +it cannot infer collision fallback from the view id alone. + +`OmitIds` takes precedence over compaction and retains supplied view ids. +Missing view ids remain valid input and are generated on import. The collision rule counts BOTH id populations, and that is not an accident of implementation: the labeller's own census sees only the doc-local ids it may @@ -7281,7 +7295,8 @@ type Options struct { Legend Legend // fragment entry points only: the enclosing document's legends (§3) OmitIds bool // export only: drop block/table/sort/filter ids and option_ids; // preserve envelope and view ids and full object references (§9, §9a) - CompactBlockLabels bool // export only: relabel block/row/column ids; preserve view ids (§9a) + CompactBlockLabels bool // export only: relabel block/row/column/view ids (§9a) + ResolveWidgetViewID func(objectID, viewID string) (string, bool) // target-local selector mapping GenerateId func() string // import only: id generator for missing ids; // nil = random 24-hex (editor-shaped). The wiring // passes the editor's generator. diff --git a/format/v2/schema/index.schema.json b/format/v2/schema/index.schema.json index 45afb64..fdd2134 100644 --- a/format/v2/schema/index.schema.json +++ b/format/v2/schema/index.schema.json @@ -177,7 +177,7 @@ "view_id": { "type": "string", "minLength": 1, - "description": "Which of the target's views a `view` widget shows — a view id inside the target's dataview. Omit for the target's default view, which is also all an author can name: view ids are minted on import." + "description": "Which view in the target's primary dataview this widget shows. Must match the exported view id, including its target-local short label when compaction is enabled. Omit for the target's default view." }, "auto_added": { "type": "boolean",