This repository has no description
0

Configure Feed

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

core / appview / models / label.go
18 kB 748 lines
1package models 2 3import ( 4 "cmp" 5 "context" 6 "crypto/sha1" 7 "encoding/hex" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "regexp" 12 "slices" 13 "strings" 14 "time" 15 16 "github.com/bluesky-social/indigo/api/atproto" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 "github.com/bluesky-social/indigo/xrpc" 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/idresolver" 21) 22 23type ConcreteType string 24 25const ( 26 ConcreteTypeNull ConcreteType = "null" 27 ConcreteTypeString ConcreteType = "string" 28 ConcreteTypeInt ConcreteType = "integer" 29 ConcreteTypeBool ConcreteType = "boolean" 30) 31 32type ValueTypeFormat string 33 34const ( 35 ValueTypeFormatAny ValueTypeFormat = "any" 36 ValueTypeFormatDid ValueTypeFormat = "did" 37) 38 39// ValueType represents an atproto lexicon type definition with constraints 40type ValueType struct { 41 Type ConcreteType `json:"type"` 42 Format ValueTypeFormat `json:"format,omitempty"` 43 Enum []string `json:"enum,omitempty"` 44} 45 46func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 47 return tangled.LabelDefinition_ValueType{ 48 Type: string(vt.Type), 49 Format: string(vt.Format), 50 Enum: vt.Enum, 51 } 52} 53 54func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 55 return ValueType{ 56 Type: ConcreteType(record.Type), 57 Format: ValueTypeFormat(record.Format), 58 Enum: record.Enum, 59 } 60} 61 62func (vt ValueType) IsConcreteType() bool { 63 return vt.Type == ConcreteTypeNull || 64 vt.Type == ConcreteTypeString || 65 vt.Type == ConcreteTypeInt || 66 vt.Type == ConcreteTypeBool 67} 68 69func (vt ValueType) IsNull() bool { 70 return vt.Type == ConcreteTypeNull 71} 72 73func (vt ValueType) IsString() bool { 74 return vt.Type == ConcreteTypeString 75} 76 77func (vt ValueType) IsInt() bool { 78 return vt.Type == ConcreteTypeInt 79} 80 81func (vt ValueType) IsBool() bool { 82 return vt.Type == ConcreteTypeBool 83} 84 85func (vt ValueType) IsEnum() bool { 86 return len(vt.Enum) > 0 87} 88 89func (vt ValueType) IsDidFormat() bool { 90 return vt.Format == ValueTypeFormatDid 91} 92 93func (vt ValueType) IsAnyFormat() bool { 94 return vt.Format == ValueTypeFormatAny 95} 96 97type LabelDefinition struct { 98 Id int64 99 Did string 100 Rkey string 101 102 Name string 103 ValueType ValueType 104 Scope []string 105 Color *string 106 Multiple bool 107 Created time.Time 108} 109 110func (l *LabelDefinition) AtUri() syntax.ATURI { 111 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 112} 113 114func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 115 vt := l.ValueType.AsRecord() 116 return tangled.LabelDefinition{ 117 Name: l.Name, 118 Color: l.Color, 119 CreatedAt: l.Created.Format(time.RFC3339), 120 Multiple: &l.Multiple, 121 Scope: l.Scope, 122 ValueType: &vt, 123 } 124} 125 126var ( 127 // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 128 labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 129 // Color should be a valid hex color 130 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 131 // You can only label issues and pulls presently 132 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 133) 134 135var _ Validator = new(LabelDefinition) 136 137func (l *LabelDefinition) Validate() error { 138 if l.Name == "" { 139 return fmt.Errorf("label name is empty") 140 } 141 if len(l.Name) > 40 { 142 return fmt.Errorf("label name too long (max 40 graphemes)") 143 } 144 if len(l.Name) < 1 { 145 return fmt.Errorf("label name too short (min 1 grapheme)") 146 } 147 if !labelNameRegex.MatchString(l.Name) { 148 return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 149 } 150 151 if !l.ValueType.IsConcreteType() { 152 return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) 153 } 154 155 // null type checks: cannot be enums, multiple or explicit format 156 if l.ValueType.IsNull() && l.ValueType.IsEnum() { 157 return fmt.Errorf("null type cannot be used in conjunction with enum type") 158 } 159 if l.ValueType.IsNull() && l.Multiple { 160 return fmt.Errorf("null type labels cannot be multiple") 161 } 162 if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { 163 return fmt.Errorf("format cannot be used in conjunction with null type") 164 } 165 166 // format checks: cannot be used with enum, or integers 167 if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { 168 return fmt.Errorf("enum types cannot be used in conjunction with format specification") 169 } 170 171 if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { 172 return fmt.Errorf("format specifications are only permitted on string types") 173 } 174 175 // validate scope (nsid format) 176 if l.Scope == nil { 177 return fmt.Errorf("scope is required") 178 } 179 for _, s := range l.Scope { 180 if _, err := syntax.ParseNSID(s); err != nil { 181 return fmt.Errorf("failed to parse scope: %w", err) 182 } 183 if !slices.Contains(validScopes, s) { 184 return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 185 } 186 } 187 188 // validate color if provided 189 if l.Color != nil { 190 color := strings.TrimSpace(*l.Color) 191 if color == "" { 192 // empty color is fine, set to nil 193 l.Color = nil 194 } else { 195 if !colorRegex.MatchString(color) { 196 return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 197 } 198 // expand 3-digit hex to 6-digit hex 199 if len(color) == 4 { // #ABC 200 color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 201 } 202 // convert to uppercase for consistency 203 color = strings.ToUpper(color) 204 l.Color = &color 205 } 206 } 207 208 return nil 209} 210 211// ValidateOperandValue validates the label operation operand value based on 212// label definition. 213// 214// NOTE: This can modify the [LabelOp] 215func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { 216 expectedKey := def.AtUri().String() 217 if op.OperandKey != def.AtUri().String() { 218 return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) 219 } 220 221 valueType := def.ValueType 222 223 // this is permitted, it "unsets" a label 224 if op.OperandValue == "" { 225 op.Operation = LabelOperationDel 226 return nil 227 } 228 229 switch valueType.Type { 230 case ConcreteTypeNull: 231 // For null type, value should be empty 232 if op.OperandValue != "null" { 233 return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) 234 } 235 236 case ConcreteTypeString: 237 // For string type, validate enum constraints if present 238 if valueType.IsEnum() { 239 if !slices.Contains(valueType.Enum, op.OperandValue) { 240 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 241 } 242 } 243 244 switch valueType.Format { 245 case ValueTypeFormatDid: 246 if _, err := syntax.ParseDID(op.OperandValue); err != nil { 247 return fmt.Errorf("failed to resolve did/handle: %w", err) 248 } 249 case ValueTypeFormatAny, "": 250 default: 251 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 252 } 253 254 case ConcreteTypeInt: 255 if op.OperandValue == "" { 256 return fmt.Errorf("integer type requires non-empty value") 257 } 258 if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { 259 return fmt.Errorf("value %q is not a valid integer", op.OperandValue) 260 } 261 262 if valueType.IsEnum() { 263 if !slices.Contains(valueType.Enum, op.OperandValue) { 264 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 265 } 266 } 267 268 case ConcreteTypeBool: 269 if op.OperandValue != "true" && op.OperandValue != "false" { 270 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) 271 } 272 273 // validate enum constraints if present (though uncommon for booleans) 274 if valueType.IsEnum() { 275 if !slices.Contains(valueType.Enum, op.OperandValue) { 276 return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 277 } 278 } 279 280 default: 281 return fmt.Errorf("unsupported value type: %q", valueType.Type) 282 } 283 284 return nil 285} 286 287// random color for a given seed 288func randomColor(seed string) string { 289 hash := sha1.Sum([]byte(seed)) 290 hexStr := hex.EncodeToString(hash[:]) 291 r := hexStr[0:2] 292 g := hexStr[2:4] 293 b := hexStr[4:6] 294 295 return fmt.Sprintf("#%s%s%s", r, g, b) 296} 297 298func (l LabelDefinition) GetColor() string { 299 if l.Color == nil { 300 seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 301 color := randomColor(seed) 302 return color 303 } 304 305 return *l.Color 306} 307 308func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 309 created, err := time.Parse(time.RFC3339, record.CreatedAt) 310 if err != nil { 311 created = time.Time{} 312 } 313 314 multiple := false 315 if record.Multiple != nil { 316 multiple = *record.Multiple 317 } 318 319 var vt ValueType 320 if record.ValueType != nil { 321 vt = ValueTypeFromRecord(*record.ValueType) 322 } 323 324 return &LabelDefinition{ 325 Did: did, 326 Rkey: rkey, 327 328 Name: record.Name, 329 ValueType: vt, 330 Scope: record.Scope, 331 Color: record.Color, 332 Multiple: multiple, 333 Created: created, 334 }, nil 335} 336 337type LabelOp struct { 338 Id int64 339 Did string 340 Rkey string 341 Subject syntax.ATURI 342 Operation LabelOperation 343 OperandKey string 344 OperandValue string 345 PerformedAt time.Time 346} 347 348func (l LabelOp) SortAt() time.Time { 349 // if createdat is invalid (before epoch), treat as null -> return zero time 350 if l.PerformedAt.Before(time.UnixMicro(0)) { 351 return time.Time{} 352 } 353 return l.PerformedAt 354} 355 356var _ Validator = new(LabelOp) 357 358func (l *LabelOp) Validate() error { 359 if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 360 return fmt.Errorf("invalid subject URI: %w", err) 361 } 362 if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 363 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 364 } 365 // Validate performed time is not zero/invalid 366 if l.PerformedAt.IsZero() { 367 return fmt.Errorf("performed_at timestamp is required") 368 } 369 return nil 370} 371 372type LabelOperation string 373 374const ( 375 LabelOperationAdd LabelOperation = "add" 376 LabelOperationDel LabelOperation = "del" 377) 378 379// a record can create multiple label ops 380func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 381 performed, err := time.Parse(time.RFC3339, record.PerformedAt) 382 if err != nil { 383 performed = time.Time{} 384 } 385 386 mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 387 return LabelOp{ 388 Did: did, 389 Rkey: rkey, 390 Subject: syntax.ATURI(record.Subject), 391 OperandKey: operand.Key, 392 OperandValue: operand.Value, 393 PerformedAt: performed, 394 } 395 } 396 397 var ops []LabelOp 398 // deletes first, then additions 399 for _, o := range record.Delete { 400 if o != nil { 401 op := mkOp(o) 402 op.Operation = LabelOperationDel 403 ops = append(ops, op) 404 } 405 } 406 for _, o := range record.Add { 407 if o != nil { 408 op := mkOp(o) 409 op.Operation = LabelOperationAdd 410 ops = append(ops, op) 411 } 412 } 413 414 return ops 415} 416 417func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 418 if len(ops) == 0 { 419 return tangled.LabelOp{} 420 } 421 422 // use the first operation to establish common fields 423 first := ops[0] 424 record := tangled.LabelOp{ 425 Subject: string(first.Subject), 426 PerformedAt: first.PerformedAt.Format(time.RFC3339), 427 } 428 429 var addOperands []*tangled.LabelOp_Operand 430 var deleteOperands []*tangled.LabelOp_Operand 431 432 for _, op := range ops { 433 operand := &tangled.LabelOp_Operand{ 434 Key: op.OperandKey, 435 Value: op.OperandValue, 436 } 437 438 switch op.Operation { 439 case LabelOperationAdd: 440 addOperands = append(addOperands, operand) 441 case LabelOperationDel: 442 deleteOperands = append(deleteOperands, operand) 443 default: 444 return tangled.LabelOp{} 445 } 446 } 447 448 record.Add = addOperands 449 record.Delete = deleteOperands 450 451 return record 452} 453 454type set = map[string]struct{} 455 456type LabelState struct { 457 inner map[string]set 458 names map[string]string 459} 460 461func NewLabelState() LabelState { 462 return LabelState{ 463 inner: make(map[string]set), 464 names: make(map[string]string), 465 } 466} 467 468func (s LabelState) LabelNames() []string { 469 var result []string 470 for key, valset := range s.inner { 471 if valset == nil { 472 continue 473 } 474 if name, ok := s.names[key]; ok { 475 result = append(result, name) 476 } 477 } 478 return result 479} 480 481// LabelNameValues returns composite "name:value" strings for all labels 482// that have non-empty values. 483func (s LabelState) LabelNameValues() []string { 484 var result []string 485 for key, valset := range s.inner { 486 if valset == nil { 487 continue 488 } 489 name, ok := s.names[key] 490 if !ok { 491 continue 492 } 493 for val := range valset { 494 if val != "" { 495 result = append(result, name+":"+val) 496 } 497 } 498 } 499 return result 500} 501 502func (s LabelState) Inner() map[string]set { 503 return s.inner 504} 505 506func (s LabelState) SetName(key, name string) { 507 s.names[key] = name 508} 509 510func (s LabelState) ContainsLabel(l string) bool { 511 if valset, exists := s.inner[l]; exists { 512 if valset != nil { 513 return true 514 } 515 } 516 517 return false 518} 519 520// go maps behavior in templates make this necessary, 521// indexing a map and getting `set` in return is apparently truthy 522func (s LabelState) ContainsLabelAndVal(l, v string) bool { 523 if valset, exists := s.inner[l]; exists { 524 if _, exists := valset[v]; exists { 525 return true 526 } 527 } 528 529 return false 530} 531 532func (s LabelState) GetValSet(l string) set { 533 if valset, exists := s.inner[l]; exists { 534 return valset 535 } else { 536 return make(set) 537 } 538} 539 540type LabelApplicationCtx struct { 541 Defs map[string]*LabelDefinition // labelAt -> labelDef 542} 543 544var ( 545 LabelNoOpError = errors.New("no-op") 546) 547 548func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 549 def, ok := c.Defs[op.OperandKey] 550 if !ok { 551 // this def was deleted, but an op exists, so we just skip over the op 552 return nil 553 } 554 555 if op.Subject != "" && len(def.Scope) > 0 && !slices.Contains(def.Scope, op.Subject.Collection().String()) { 556 return nil 557 } 558 559 state.names[op.OperandKey] = def.Name 560 561 switch op.Operation { 562 case LabelOperationAdd: 563 // if valueset is empty, init it 564 if state.inner[op.OperandKey] == nil { 565 state.inner[op.OperandKey] = make(set) 566 } 567 568 // if valueset is populated & this val alr exists, this labelop is a noop 569 if valueSet, exists := state.inner[op.OperandKey]; exists { 570 if _, exists = valueSet[op.OperandValue]; exists { 571 return LabelNoOpError 572 } 573 } 574 575 if def.Multiple { 576 // append to set 577 state.inner[op.OperandKey][op.OperandValue] = struct{}{} 578 } else { 579 // reset to just this value 580 state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 581 } 582 583 case LabelOperationDel: 584 // if label DNE, then deletion is a no-op 585 if valueSet, exists := state.inner[op.OperandKey]; !exists { 586 return LabelNoOpError 587 } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 588 return LabelNoOpError 589 } 590 591 if def.Multiple { 592 // remove from set 593 delete(state.inner[op.OperandKey], op.OperandValue) 594 } else { 595 // reset the entire label 596 delete(state.inner, op.OperandKey) 597 } 598 599 // if the map becomes empty, then set it to nil, this is just the inverse of add 600 if len(state.inner[op.OperandKey]) == 0 { 601 state.inner[op.OperandKey] = nil 602 } 603 604 } 605 606 return nil 607} 608 609func labelOpRank(op LabelOperation) int { 610 if op == LabelOperationDel { 611 return 0 612 } 613 return 1 614} 615 616func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 617 // sort label ops in sort order first 618 slices.SortFunc(ops, func(a, b LabelOp) int { 619 return cmp.Or( 620 a.SortAt().Compare(b.SortAt()), 621 cmp.Compare(a.Did, b.Did), 622 cmp.Compare(a.Rkey, b.Rkey), 623 cmp.Compare(labelOpRank(a.Operation), labelOpRank(b.Operation)), 624 cmp.Compare(a.OperandKey, b.OperandKey), 625 cmp.Compare(a.OperandValue, b.OperandValue), 626 ) 627 }) 628 629 // apply ops in sequence 630 for _, o := range ops { 631 _ = c.ApplyLabelOp(state, o) 632 } 633} 634 635// IsInverse checks if one label operation is the inverse of another 636// returns true if one is an add and the other is a delete with the same key and value 637func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 638 if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 639 return false 640 } 641 642 return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 643 (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 644} 645 646// removes pairs of label operations that are inverses of each other 647// from the given slice. the function preserves the order of remaining operations. 648func ReduceLabelOps(ops []LabelOp) []LabelOp { 649 if len(ops) <= 1 { 650 return ops 651 } 652 653 keep := make([]bool, len(ops)) 654 for i := range keep { 655 keep[i] = true 656 } 657 658 for i := range ops { 659 if !keep[i] { 660 continue 661 } 662 663 for j := i + 1; j < len(ops); j++ { 664 if !keep[j] { 665 continue 666 } 667 668 if ops[i].IsInverse(ops[j]) { 669 keep[i] = false 670 keep[j] = false 671 break // move to next i since this one is now eliminated 672 } 673 } 674 } 675 676 // build result slice with only kept operations 677 var result []LabelOp 678 for i, op := range ops { 679 if keep[i] { 680 result = append(result, op) 681 } 682 } 683 684 return result 685} 686 687func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 688 var labelDefs []LabelDefinition 689 ctx := context.Background() 690 691 for _, dl := range aturis { 692 atUri, err := syntax.ParseATURI(dl) 693 if err != nil { 694 return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 695 } 696 if atUri.Collection() != tangled.LabelDefinitionNSID { 697 return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 698 } 699 700 owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 701 if err != nil { 702 return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 703 } 704 705 xrpcc := xrpc.Client{ 706 Host: owner.PDSEndpoint(), 707 } 708 709 record, err := atproto.RepoGetRecord( 710 ctx, 711 &xrpcc, 712 "", 713 atUri.Collection().String(), 714 atUri.Authority().String(), 715 atUri.RecordKey().String(), 716 ) 717 if err != nil { 718 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 719 } 720 721 if record != nil { 722 bytes, err := record.Value.MarshalJSON() 723 if err != nil { 724 return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 725 } 726 727 raw := json.RawMessage(bytes) 728 labelRecord := tangled.LabelDefinition{} 729 err = json.Unmarshal(raw, &labelRecord) 730 if err != nil { 731 return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 732 } 733 734 labelDef, err := LabelDefinitionFromRecord( 735 atUri.Authority().String(), 736 atUri.RecordKey().String(), 737 labelRecord, 738 ) 739 if err != nil { 740 return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 741 } 742 743 labelDefs = append(labelDefs, *labelDef) 744 } 745 } 746 747 return labelDefs, nil 748}