Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: require matching roaster name match for bean linking

+95 -5
+38
internal/suggestions/suggestions.go
··· 241 241 return nil, err 242 242 } 243 243 244 + // For beans, build a roaster URI -> name map so we can include the 245 + // roaster name in suggestion fields. This lets clients verify the 246 + // roaster matches before setting source_ref. 247 + var roasterNames map[string]string 248 + if collection == atproto.NSIDBean { 249 + roasterNames = buildRoasterNameMap(ctx, source) 250 + } 251 + 244 252 // dedupKey -> aggregated suggestion 245 253 type candidate struct { 246 254 suggestion EntitySuggestion ··· 265 273 for _, f := range config.allFields { 266 274 if v, ok := recordData[f].(string); ok && v != "" { 267 275 fields[f] = v 276 + } 277 + } 278 + 279 + // For beans, resolve roasterRef to a roaster name 280 + if roasterNames != nil { 281 + if ref, ok := recordData["roasterRef"].(string); ok && ref != "" { 282 + if rn, ok := roasterNames[ref]; ok { 283 + fields["roasterName"] = rn 284 + } 268 285 } 269 286 } 270 287 ··· 359 376 } 360 377 361 378 return score 379 + } 380 + 381 + // buildRoasterNameMap loads all indexed roaster records and returns a map 382 + // from AT-URI to roaster name. Used to resolve roaster references in bean 383 + // suggestions so the client can verify roaster match before setting source_ref. 384 + func buildRoasterNameMap(ctx context.Context, source RecordSource) map[string]string { 385 + records, err := source.ListRecordsByCollectionOldest(ctx, atproto.NSIDRoaster) 386 + if err != nil { 387 + return nil 388 + } 389 + m := make(map[string]string, len(records)) 390 + for _, r := range records { 391 + var data map[string]any 392 + if err := json.Unmarshal(r.Record, &data); err != nil { 393 + continue 394 + } 395 + if name, ok := data["name"].(string); ok && name != "" { 396 + m[r.URI] = name 397 + } 398 + } 399 + return m 362 400 } 363 401 364 402 func countNonEmpty(fields map[string]string) int {
+37
internal/suggestions/suggestions_test.go
··· 426 426 assert.Equal(t, "Light", results[0].Fields["roastLevel"]) 427 427 } 428 428 429 + func TestSearch_BeanRoasterNameResolved(t *testing.T) { 430 + idx := newTestFeedIndex(t) 431 + 432 + // Insert a roaster record 433 + roasterURI := "at://did:plc:alice/social.arabica.alpha.roaster/r1" 434 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDRoaster, "r1", map[string]any{ 435 + "name": "Counter Culture", 436 + }) 437 + 438 + // Insert a bean that references that roaster 439 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDBean, "b1", map[string]any{ 440 + "name": "Hologram", 441 + "origin": "Blend", 442 + "roasterRef": roasterURI, 443 + }) 444 + 445 + results, err := Search(context.Background(), idx, atproto.NSIDBean, "hologram", 10) 446 + assert.NoError(t, err) 447 + assert.Len(t, results, 1) 448 + assert.Equal(t, "Counter Culture", results[0].Fields["roasterName"]) 449 + } 450 + 451 + func TestSearch_BeanNoRoasterRef(t *testing.T) { 452 + idx := newTestFeedIndex(t) 453 + 454 + // Bean without a roasterRef should not have roasterName in fields 455 + insertRecord(t, idx, "did:plc:alice", atproto.NSIDBean, "b1", map[string]any{ 456 + "name": "Mystery Bean", 457 + "origin": "Unknown", 458 + }) 459 + 460 + results, err := Search(context.Background(), idx, atproto.NSIDBean, "mystery", 10) 461 + assert.NoError(t, err) 462 + assert.Len(t, results, 1) 463 + assert.Empty(t, results[0].Fields["roasterName"]) 464 + } 465 + 429 466 func TestSearch_BrewerFields(t *testing.T) { 430 467 idx := newTestFeedIndex(t) 431 468
+1 -1
internal/web/components/combo_select.templ
··· 24 24 switch entityType { 25 25 case "bean": 26 26 formatLabel = `(e) => { const n = e.name || e.Name || ''; const o = e.origin || e.Origin || ''; const r = e.roast_level || e.RoastLevel || ''; if (o && r) return n + ' (' + o + ' - ' + r + ')'; if (o) return n + ' (' + o + ')'; return n; }` 27 - formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.roastLevel) d.roast_level = s.fields.roastLevel; if (s.fields.process) d.process = s.fields.process; } return d; }` 27 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.roastLevel) d.roast_level = s.fields.roastLevel; if (s.fields.process) d.process = s.fields.process; if (s.fields.roasterName) d._source_roaster_name = s.fields.roasterName; } return d; }` 28 28 extraFields = `[{name:'origin',label:'Origin',type:'text',placeholder:'e.g. Ethiopia, Colombia'},{name:'roast_level',label:'Roast Level',type:'select',options:['Ultra-Light','Light','Medium-Light','Medium','Medium-Dark','Dark']},{name:'process',label:'Process',type:'text',placeholder:'e.g. Washed, Natural, Honey'}]` 29 29 case "brewer": 30 30 formatLabel = `(e) => e.name || e.Name || ''`
+15
static/js/combo-select.js
··· 306 306 } 307 307 } 308 308 309 + // For beans: clear source_ref if the roaster doesn't match the source 310 + if (this.entityType === "bean" && data.source_ref && data._source_roaster_name) { 311 + const selected = (this.selectedRoasterLabel || this.newRoasterName || "").toLowerCase().trim(); 312 + const source = data._source_roaster_name.toLowerCase().trim(); 313 + if (selected !== source) { 314 + delete data.source_ref; 315 + } 316 + } 317 + delete data._source_roaster_name; 318 + 309 319 await this._doCreate(data); 310 320 this.showCreateForm = false; 311 321 this.createFormData = {}; ··· 317 327 const data = { name: this.createFormData.name }; 318 328 if (this.createFormData.source_ref) { 319 329 data.source_ref = this.createFormData.source_ref; 330 + } 331 + // For beans: skip details means no roaster selected, so clear source_ref 332 + // if the source had a roaster 333 + if (this.entityType === "bean" && data.source_ref && this.createFormData._source_roaster_name) { 334 + delete data.source_ref; 320 335 } 321 336 this.showCreateForm = false; 322 337 this.createFormData = {};
+4 -4
tests/integration/go.mod
··· 1 - module arabica/tests/integration 1 + module tangled.org/arabica.social/arabica/tests/integration 2 2 3 3 go 1.26.1 4 4 5 5 replace ( 6 - arabica => ../.. 7 6 github.com/haileyok/cocoon => github.com/ptdewey/cocoon v0.0.0-20260406233545-539d73959ca6 7 + tangled.org/arabica.social/arabica => ../.. 8 8 ) 9 9 10 10 require ( 11 - arabica v0.0.0-00010101000000-000000000000 12 11 github.com/bluesky-social/indigo v0.0.0-20260318212431-cbaa83aee9dd 13 12 github.com/haileyok/cocoon v0.9.0 13 + github.com/ptdewey/shutter v0.2.1 14 14 github.com/rs/zerolog v1.34.0 15 15 github.com/stretchr/testify v1.11.1 16 16 gorm.io/gorm v1.31.1 17 + tangled.org/arabica.social/arabica v0.0.0-00010101000000-000000000000 17 18 tangled.org/pdewey.com/atp v0.0.0-20260407015143-f53954e5e783 18 19 ) 19 20 ··· 123 124 github.com/prometheus/client_model v0.6.2 // indirect 124 125 github.com/prometheus/common v0.67.5 // indirect 125 126 github.com/prometheus/procfs v0.20.1 // indirect 126 - github.com/ptdewey/shutter v0.2.1 // indirect 127 127 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 128 128 github.com/samber/lo v1.53.0 // indirect 129 129 github.com/samber/slog-echo v1.21.0 // indirect