forked from tangled.org/core
Monorepo for Tangled

appview/models: move db.Label* into models

- db.{LabelOp,LabelDefinition,LabelState,LabelApplicationCtx} have been
moved
- auxilliary helpers used to calculate label state have been moved

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 6390396d 451ebd4a

verified
Changed files
+550 -537
appview
db
issues
labels
models
pages
repo
validator
+2 -1
appview/db/issues.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/models" 14 15 "tangled.org/core/appview/pagination" 15 16 ) 16 17 ··· 30 31 // optionally, populate this when querying for reverse mappings 31 32 // like comment counts, parent repo etc. 32 33 Comments []IssueComment 33 - Labels LabelState 34 + Labels models.LabelState 34 35 Repo *Repo 35 36 } 36 37
+33 -496
appview/db/label.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/sha1" 5 4 "database/sql" 6 - "encoding/hex" 7 - "errors" 8 5 "fmt" 9 6 "maps" 10 7 "slices" ··· 12 9 "time" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/syntax" 15 - "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/consts" 17 - ) 18 - 19 - type ConcreteType string 20 - 21 - const ( 22 - ConcreteTypeNull ConcreteType = "null" 23 - ConcreteTypeString ConcreteType = "string" 24 - ConcreteTypeInt ConcreteType = "integer" 25 - ConcreteTypeBool ConcreteType = "boolean" 26 - ) 27 - 28 - type ValueTypeFormat string 29 - 30 - const ( 31 - ValueTypeFormatAny ValueTypeFormat = "any" 32 - ValueTypeFormatDid ValueTypeFormat = "did" 12 + "tangled.org/core/appview/models" 33 13 ) 34 14 35 - // ValueType represents an atproto lexicon type definition with constraints 36 - type ValueType struct { 37 - Type ConcreteType `json:"type"` 38 - Format ValueTypeFormat `json:"format,omitempty"` 39 - Enum []string `json:"enum,omitempty"` 40 - } 41 - 42 - func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 43 - return tangled.LabelDefinition_ValueType{ 44 - Type: string(vt.Type), 45 - Format: string(vt.Format), 46 - Enum: vt.Enum, 47 - } 48 - } 49 - 50 - func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 51 - return ValueType{ 52 - Type: ConcreteType(record.Type), 53 - Format: ValueTypeFormat(record.Format), 54 - Enum: record.Enum, 55 - } 56 - } 57 - 58 - func (vt ValueType) IsConcreteType() bool { 59 - return vt.Type == ConcreteTypeNull || 60 - vt.Type == ConcreteTypeString || 61 - vt.Type == ConcreteTypeInt || 62 - vt.Type == ConcreteTypeBool 63 - } 64 - 65 - func (vt ValueType) IsNull() bool { 66 - return vt.Type == ConcreteTypeNull 67 - } 68 - 69 - func (vt ValueType) IsString() bool { 70 - return vt.Type == ConcreteTypeString 71 - } 72 - 73 - func (vt ValueType) IsInt() bool { 74 - return vt.Type == ConcreteTypeInt 75 - } 76 - 77 - func (vt ValueType) IsBool() bool { 78 - return vt.Type == ConcreteTypeBool 79 - } 80 - 81 - func (vt ValueType) IsEnum() bool { 82 - return len(vt.Enum) > 0 83 - } 84 - 85 - func (vt ValueType) IsDidFormat() bool { 86 - return vt.Format == ValueTypeFormatDid 87 - } 88 - 89 - func (vt ValueType) IsAnyFormat() bool { 90 - return vt.Format == ValueTypeFormatAny 91 - } 92 - 93 - type LabelDefinition struct { 94 - Id int64 95 - Did string 96 - Rkey string 97 - 98 - Name string 99 - ValueType ValueType 100 - Scope []string 101 - Color *string 102 - Multiple bool 103 - Created time.Time 104 - } 105 - 106 - func (l *LabelDefinition) AtUri() syntax.ATURI { 107 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 108 - } 109 - 110 - func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 111 - vt := l.ValueType.AsRecord() 112 - return tangled.LabelDefinition{ 113 - Name: l.Name, 114 - Color: l.Color, 115 - CreatedAt: l.Created.Format(time.RFC3339), 116 - Multiple: &l.Multiple, 117 - Scope: l.Scope, 118 - ValueType: &vt, 119 - } 120 - } 121 - 122 - // random color for a given seed 123 - func randomColor(seed string) string { 124 - hash := sha1.Sum([]byte(seed)) 125 - hexStr := hex.EncodeToString(hash[:]) 126 - r := hexStr[0:2] 127 - g := hexStr[2:4] 128 - b := hexStr[4:6] 129 - 130 - return fmt.Sprintf("#%s%s%s", r, g, b) 131 - } 132 - 133 - func (ld LabelDefinition) GetColor() string { 134 - if ld.Color == nil { 135 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 136 - color := randomColor(seed) 137 - return color 138 - } 139 - 140 - return *ld.Color 141 - } 142 - 143 - func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 144 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 145 - if err != nil { 146 - created = time.Now() 147 - } 148 - 149 - multiple := false 150 - if record.Multiple != nil { 151 - multiple = *record.Multiple 152 - } 153 - 154 - var vt ValueType 155 - if record.ValueType != nil { 156 - vt = ValueTypeFromRecord(*record.ValueType) 157 - } 158 - 159 - return &LabelDefinition{ 160 - Did: did, 161 - Rkey: rkey, 162 - 163 - Name: record.Name, 164 - ValueType: vt, 165 - Scope: record.Scope, 166 - Color: record.Color, 167 - Multiple: multiple, 168 - Created: created, 169 - }, nil 170 - } 171 - 172 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 173 - var conditions []string 174 - var args []any 175 - for _, filter := range filters { 176 - conditions = append(conditions, filter.Condition()) 177 - args = append(args, filter.Arg()...) 178 - } 179 - whereClause := "" 180 - if conditions != nil { 181 - whereClause = " where " + strings.Join(conditions, " and ") 182 - } 183 - query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 184 - _, err := e.Exec(query, args...) 185 - return err 186 - } 187 - 188 15 // no updating type for now 189 - func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { 16 + func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { 190 17 result, err := e.Exec( 191 18 `insert into label_definitions ( 192 19 did, ··· 232 59 return id, nil 233 60 } 234 61 235 - func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { 236 - var labelDefinitions []LabelDefinition 62 + func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 + var conditions []string 64 + var args []any 65 + for _, filter := range filters { 66 + conditions = append(conditions, filter.Condition()) 67 + args = append(args, filter.Arg()...) 68 + } 69 + whereClause := "" 70 + if conditions != nil { 71 + whereClause = " where " + strings.Join(conditions, " and ") 72 + } 73 + query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 74 + _, err := e.Exec(query, args...) 75 + return err 76 + } 77 + 78 + func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 + var labelDefinitions []models.LabelDefinition 237 80 var conditions []string 238 81 var args []any 239 82 ··· 275 118 defer rows.Close() 276 119 277 120 for rows.Next() { 278 - var labelDefinition LabelDefinition 121 + var labelDefinition models.LabelDefinition 279 122 var createdAt, enumVariants, scopes string 280 123 var color sql.Null[string] 281 124 var multiple int ··· 324 167 } 325 168 326 169 // helper to get exactly one label def 327 - func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { 170 + func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 328 171 labels, err := GetLabelDefinitions(e, filters...) 329 172 if err != nil { 330 173 return nil, err ··· 341 184 return &labels[0], nil 342 185 } 343 186 344 - type LabelOp struct { 345 - Id int64 346 - Did string 347 - Rkey string 348 - Subject syntax.ATURI 349 - Operation LabelOperation 350 - OperandKey string 351 - OperandValue string 352 - PerformedAt time.Time 353 - IndexedAt time.Time 354 - } 355 - 356 - func (l LabelOp) SortAt() time.Time { 357 - createdAt := l.PerformedAt 358 - indexedAt := l.IndexedAt 359 - 360 - // if we don't have an indexedat, fall back to now 361 - if indexedAt.IsZero() { 362 - indexedAt = time.Now() 363 - } 364 - 365 - // if createdat is invalid (before epoch), treat as null -> return zero time 366 - if createdAt.Before(time.UnixMicro(0)) { 367 - return time.Time{} 368 - } 369 - 370 - // if createdat is <= indexedat, use createdat 371 - if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 372 - return createdAt 373 - } 374 - 375 - // otherwise, createdat is in the future relative to indexedat -> use indexedat 376 - return indexedAt 377 - } 378 - 379 - type LabelOperation string 380 - 381 - const ( 382 - LabelOperationAdd LabelOperation = "add" 383 - LabelOperationDel LabelOperation = "del" 384 - ) 385 - 386 - // a record can create multiple label ops 387 - func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 388 - performed, err := time.Parse(time.RFC3339, record.PerformedAt) 389 - if err != nil { 390 - performed = time.Now() 391 - } 392 - 393 - mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 394 - return LabelOp{ 395 - Did: did, 396 - Rkey: rkey, 397 - Subject: syntax.ATURI(record.Subject), 398 - OperandKey: operand.Key, 399 - OperandValue: operand.Value, 400 - PerformedAt: performed, 401 - } 402 - } 403 - 404 - var ops []LabelOp 405 - for _, o := range record.Add { 406 - if o != nil { 407 - op := mkOp(o) 408 - op.Operation = LabelOperationAdd 409 - ops = append(ops, op) 410 - } 411 - } 412 - for _, o := range record.Delete { 413 - if o != nil { 414 - op := mkOp(o) 415 - op.Operation = LabelOperationDel 416 - ops = append(ops, op) 417 - } 418 - } 419 - 420 - return ops 421 - } 422 - 423 - func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 424 - if len(ops) == 0 { 425 - return tangled.LabelOp{} 426 - } 427 - 428 - // use the first operation to establish common fields 429 - first := ops[0] 430 - record := tangled.LabelOp{ 431 - Subject: string(first.Subject), 432 - PerformedAt: first.PerformedAt.Format(time.RFC3339), 433 - } 434 - 435 - var addOperands []*tangled.LabelOp_Operand 436 - var deleteOperands []*tangled.LabelOp_Operand 437 - 438 - for _, op := range ops { 439 - operand := &tangled.LabelOp_Operand{ 440 - Key: op.OperandKey, 441 - Value: op.OperandValue, 442 - } 443 - 444 - switch op.Operation { 445 - case LabelOperationAdd: 446 - addOperands = append(addOperands, operand) 447 - case LabelOperationDel: 448 - deleteOperands = append(deleteOperands, operand) 449 - default: 450 - return tangled.LabelOp{} 451 - } 452 - } 453 - 454 - record.Add = addOperands 455 - record.Delete = deleteOperands 456 - 457 - return record 458 - } 459 - 460 - func AddLabelOp(e Execer, l *LabelOp) (int64, error) { 187 + func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { 461 188 now := time.Now() 462 189 result, err := e.Exec( 463 190 `insert into label_ops ( ··· 500 227 return id, nil 501 228 } 502 229 503 - func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { 504 - var labelOps []LabelOp 230 + func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + var labelOps []models.LabelOp 505 232 var conditions []string 506 233 var args []any 507 234 ··· 541 268 defer rows.Close() 542 269 543 270 for rows.Next() { 544 - var labelOp LabelOp 271 + var labelOp models.LabelOp 545 272 var performedAt, indexedAt string 546 273 547 274 if err := rows.Scan( ··· 575 302 } 576 303 577 304 // get labels for a given list of subject URIs 578 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { 305 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 579 306 ops, err := GetLabelOps(e, filters...) 580 307 if err != nil { 581 308 return nil, err 582 309 } 583 310 584 311 // group ops by subject 585 - opsBySubject := make(map[syntax.ATURI][]LabelOp) 312 + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) 586 313 for _, op := range ops { 587 314 subject := syntax.ATURI(op.Subject) 588 315 opsBySubject[subject] = append(opsBySubject[subject], op) ··· 601 328 } 602 329 603 330 // apply label ops for each subject and collect results 604 - results := make(map[syntax.ATURI]LabelState) 331 + results := make(map[syntax.ATURI]models.LabelState) 605 332 for subject, subjectOps := range opsBySubject { 606 - state := NewLabelState() 333 + state := models.NewLabelState() 607 334 actx.ApplyLabelOps(state, subjectOps) 608 335 results[subject] = state 609 336 } ··· 611 338 return results, nil 612 339 } 613 340 614 - type set = map[string]struct{} 615 - 616 - type LabelState struct { 617 - inner map[string]set 618 - } 619 - 620 - func NewLabelState() LabelState { 621 - return LabelState{ 622 - inner: make(map[string]set), 623 - } 624 - } 625 - 626 - func (s LabelState) Inner() map[string]set { 627 - return s.inner 628 - } 629 - 630 - func (s LabelState) ContainsLabel(l string) bool { 631 - if valset, exists := s.inner[l]; exists { 632 - if valset != nil { 633 - return true 634 - } 635 - } 636 - 637 - return false 638 - } 639 - 640 - // go maps behavior in templates make this necessary, 641 - // indexing a map and getting `set` in return is apparently truthy 642 - func (s LabelState) ContainsLabelAndVal(l, v string) bool { 643 - if valset, exists := s.inner[l]; exists { 644 - if _, exists := valset[v]; exists { 645 - return true 646 - } 647 - } 648 - 649 - return false 650 - } 651 - 652 - func (s LabelState) GetValSet(l string) set { 653 - if valset, exists := s.inner[l]; exists { 654 - return valset 655 - } else { 656 - return make(set) 657 - } 658 - } 659 - 660 - type LabelApplicationCtx struct { 661 - Defs map[string]*LabelDefinition // labelAt -> labelDef 662 - } 663 - 664 - var ( 665 - LabelNoOpError = errors.New("no-op") 666 - ) 667 - 668 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) { 341 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 669 342 labels, err := GetLabelDefinitions(e, filters...) 670 343 if err != nil { 671 344 return nil, err 672 345 } 673 346 674 - defs := make(map[string]*LabelDefinition) 347 + defs := make(map[string]*models.LabelDefinition) 675 348 for _, l := range labels { 676 349 defs[l.AtUri().String()] = &l 677 350 } 678 351 679 - return &LabelApplicationCtx{defs}, nil 680 - } 681 - 682 - func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 683 - def, ok := c.Defs[op.OperandKey] 684 - if !ok { 685 - // this def was deleted, but an op exists, so we just skip over the op 686 - return nil 687 - } 688 - 689 - switch op.Operation { 690 - case LabelOperationAdd: 691 - // if valueset is empty, init it 692 - if state.inner[op.OperandKey] == nil { 693 - state.inner[op.OperandKey] = make(set) 694 - } 695 - 696 - // if valueset is populated & this val alr exists, this labelop is a noop 697 - if valueSet, exists := state.inner[op.OperandKey]; exists { 698 - if _, exists = valueSet[op.OperandValue]; exists { 699 - return LabelNoOpError 700 - } 701 - } 702 - 703 - if def.Multiple { 704 - // append to set 705 - state.inner[op.OperandKey][op.OperandValue] = struct{}{} 706 - } else { 707 - // reset to just this value 708 - state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 709 - } 710 - 711 - case LabelOperationDel: 712 - // if label DNE, then deletion is a no-op 713 - if valueSet, exists := state.inner[op.OperandKey]; !exists { 714 - return LabelNoOpError 715 - } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 716 - return LabelNoOpError 717 - } 718 - 719 - if def.Multiple { 720 - // remove from set 721 - delete(state.inner[op.OperandKey], op.OperandValue) 722 - } else { 723 - // reset the entire label 724 - delete(state.inner, op.OperandKey) 725 - } 726 - 727 - // if the map becomes empty, then set it to nil, this is just the inverse of add 728 - if len(state.inner[op.OperandKey]) == 0 { 729 - state.inner[op.OperandKey] = nil 730 - } 731 - 732 - } 733 - 734 - return nil 735 - } 736 - 737 - func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 738 - // sort label ops in sort order first 739 - slices.SortFunc(ops, func(a, b LabelOp) int { 740 - return a.SortAt().Compare(b.SortAt()) 741 - }) 742 - 743 - // apply ops in sequence 744 - for _, o := range ops { 745 - _ = c.ApplyLabelOp(state, o) 746 - } 747 - } 748 - 749 - // IsInverse checks if one label operation is the inverse of another 750 - // returns true if one is an add and the other is a delete with the same key and value 751 - func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 752 - if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 753 - return false 754 - } 755 - 756 - return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 757 - (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 758 - } 759 - 760 - // removes pairs of label operations that are inverses of each other 761 - // from the given slice. the function preserves the order of remaining operations. 762 - func ReduceLabelOps(ops []LabelOp) []LabelOp { 763 - if len(ops) <= 1 { 764 - return ops 765 - } 766 - 767 - keep := make([]bool, len(ops)) 768 - for i := range keep { 769 - keep[i] = true 770 - } 771 - 772 - for i := range ops { 773 - if !keep[i] { 774 - continue 775 - } 776 - 777 - for j := i + 1; j < len(ops); j++ { 778 - if !keep[j] { 779 - continue 780 - } 781 - 782 - if ops[i].IsInverse(ops[j]) { 783 - keep[i] = false 784 - keep[j] = false 785 - break // move to next i since this one is now eliminated 786 - } 787 - } 788 - } 789 - 790 - // build result slice with only kept operations 791 - var result []LabelOp 792 - for i, op := range ops { 793 - if keep[i] { 794 - result = append(result, op) 795 - } 796 - } 797 - 798 - return result 799 - } 800 - 801 - func DefaultLabelDefs() []string { 802 - rkeys := []string{ 803 - "wontfix", 804 - "duplicate", 805 - "assignee", 806 - "good-first-issue", 807 - "documentation", 808 - } 809 - 810 - defs := make([]string, len(rkeys)) 811 - for i, r := range rkeys { 812 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 813 - } 814 - 815 - return defs 352 + return &models.LabelApplicationCtx{defs}, nil 816 353 }
+1 -1
appview/ingester.go
··· 923 923 return fmt.Errorf("invalid record: %w", err) 924 924 } 925 925 926 - def, err := db.LabelDefinitionFromRecord(did, rkey, record) 926 + def, err := models.LabelDefinitionFromRecord(did, rkey, record) 927 927 if err != nil { 928 928 return fmt.Errorf("failed to parse labeldef from record: %w", err) 929 929 }
+3 -2
appview/issues/issues.go
··· 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/config" 21 21 "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 22 23 "tangled.org/core/appview/notify" 23 24 "tangled.org/core/appview/oauth" 24 25 "tangled.org/core/appview/pages" ··· 103 104 return 104 105 } 105 106 106 - defs := make(map[string]*db.LabelDefinition) 107 + defs := make(map[string]*models.LabelDefinition) 107 108 for _, l := range labelDefs { 108 109 defs[l.AtUri().String()] = &l 109 110 } ··· 796 797 return 797 798 } 798 799 799 - defs := make(map[string]*db.LabelDefinition) 800 + defs := make(map[string]*models.LabelDefinition) 800 801 for _, l := range labelDefs { 801 802 defs[l.AtUri().String()] = &l 802 803 }
+10 -9
appview/labels/labels.go
··· 17 17 "tangled.sh/tangled.sh/core/api/tangled" 18 18 "tangled.sh/tangled.sh/core/appview/db" 19 19 "tangled.sh/tangled.sh/core/appview/middleware" 20 + "tangled.sh/tangled.sh/core/appview/models" 20 21 "tangled.sh/tangled.sh/core/appview/oauth" 21 22 "tangled.sh/tangled.sh/core/appview/pages" 22 23 "tangled.sh/tangled.sh/core/appview/validator" ··· 113 114 return 114 115 } 115 116 116 - labelState := db.NewLabelState() 117 + labelState := models.NewLabelState() 117 118 actx.ApplyLabelOps(labelState, existingOps) 118 119 119 - var labelOps []db.LabelOp 120 + var labelOps []models.LabelOp 120 121 121 122 // first delete all existing state 122 123 for key, vals := range labelState.Inner() { 123 124 for val := range vals { 124 - labelOps = append(labelOps, db.LabelOp{ 125 + labelOps = append(labelOps, models.LabelOp{ 125 126 Did: did, 126 127 Rkey: rkey, 127 128 Subject: syntax.ATURI(subjectUri), 128 - Operation: db.LabelOperationDel, 129 + Operation: models.LabelOperationDel, 129 130 OperandKey: key, 130 131 OperandValue: val, 131 132 PerformedAt: performedAt, ··· 141 142 } 142 143 143 144 for _, val := range vals { 144 - labelOps = append(labelOps, db.LabelOp{ 145 + labelOps = append(labelOps, models.LabelOp{ 145 146 Did: did, 146 147 Rkey: rkey, 147 148 Subject: syntax.ATURI(subjectUri), 148 - Operation: db.LabelOperationAdd, 149 + Operation: models.LabelOperationAdd, 149 150 OperandKey: key, 150 151 OperandValue: val, 151 152 PerformedAt: performedAt, ··· 155 156 } 156 157 157 158 // reduce the opset 158 - labelOps = db.ReduceLabelOps(labelOps) 159 + labelOps = models.ReduceLabelOps(labelOps) 159 160 160 161 for i := range labelOps { 161 162 def := actx.Defs[labelOps[i].OperandKey] ··· 168 169 // next, apply all ops introduced in this request and filter out ones that are no-ops 169 170 validLabelOps := labelOps[:0] 170 171 for _, op := range labelOps { 171 - if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError { 172 + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { 172 173 validLabelOps = append(validLabelOps, op) 173 174 } 174 175 } ··· 180 181 } 181 182 182 183 // create an atproto record of valid ops 183 - record := db.LabelOpsAsRecord(validLabelOps) 184 + record := models.LabelOpsAsRecord(validLabelOps) 184 185 185 186 client, err := l.oauth.AuthorizedClient(r) 186 187 if err != nil {
+473
appview/models/label.go
··· 1 + package models 2 + 3 + import ( 4 + "crypto/sha1" 5 + "encoding/hex" 6 + "errors" 7 + "fmt" 8 + "slices" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/consts" 14 + ) 15 + 16 + type ConcreteType string 17 + 18 + const ( 19 + ConcreteTypeNull ConcreteType = "null" 20 + ConcreteTypeString ConcreteType = "string" 21 + ConcreteTypeInt ConcreteType = "integer" 22 + ConcreteTypeBool ConcreteType = "boolean" 23 + ) 24 + 25 + type ValueTypeFormat string 26 + 27 + const ( 28 + ValueTypeFormatAny ValueTypeFormat = "any" 29 + ValueTypeFormatDid ValueTypeFormat = "did" 30 + ) 31 + 32 + // ValueType represents an atproto lexicon type definition with constraints 33 + type ValueType struct { 34 + Type ConcreteType `json:"type"` 35 + Format ValueTypeFormat `json:"format,omitempty"` 36 + Enum []string `json:"enum,omitempty"` 37 + } 38 + 39 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 40 + return tangled.LabelDefinition_ValueType{ 41 + Type: string(vt.Type), 42 + Format: string(vt.Format), 43 + Enum: vt.Enum, 44 + } 45 + } 46 + 47 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 48 + return ValueType{ 49 + Type: ConcreteType(record.Type), 50 + Format: ValueTypeFormat(record.Format), 51 + Enum: record.Enum, 52 + } 53 + } 54 + 55 + func (vt ValueType) IsConcreteType() bool { 56 + return vt.Type == ConcreteTypeNull || 57 + vt.Type == ConcreteTypeString || 58 + vt.Type == ConcreteTypeInt || 59 + vt.Type == ConcreteTypeBool 60 + } 61 + 62 + func (vt ValueType) IsNull() bool { 63 + return vt.Type == ConcreteTypeNull 64 + } 65 + 66 + func (vt ValueType) IsString() bool { 67 + return vt.Type == ConcreteTypeString 68 + } 69 + 70 + func (vt ValueType) IsInt() bool { 71 + return vt.Type == ConcreteTypeInt 72 + } 73 + 74 + func (vt ValueType) IsBool() bool { 75 + return vt.Type == ConcreteTypeBool 76 + } 77 + 78 + func (vt ValueType) IsEnum() bool { 79 + return len(vt.Enum) > 0 80 + } 81 + 82 + func (vt ValueType) IsDidFormat() bool { 83 + return vt.Format == ValueTypeFormatDid 84 + } 85 + 86 + func (vt ValueType) IsAnyFormat() bool { 87 + return vt.Format == ValueTypeFormatAny 88 + } 89 + 90 + type LabelDefinition struct { 91 + Id int64 92 + Did string 93 + Rkey string 94 + 95 + Name string 96 + ValueType ValueType 97 + Scope []string 98 + Color *string 99 + Multiple bool 100 + Created time.Time 101 + } 102 + 103 + func (l *LabelDefinition) AtUri() syntax.ATURI { 104 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 105 + } 106 + 107 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 108 + vt := l.ValueType.AsRecord() 109 + return tangled.LabelDefinition{ 110 + Name: l.Name, 111 + Color: l.Color, 112 + CreatedAt: l.Created.Format(time.RFC3339), 113 + Multiple: &l.Multiple, 114 + Scope: l.Scope, 115 + ValueType: &vt, 116 + } 117 + } 118 + 119 + // random color for a given seed 120 + func randomColor(seed string) string { 121 + hash := sha1.Sum([]byte(seed)) 122 + hexStr := hex.EncodeToString(hash[:]) 123 + r := hexStr[0:2] 124 + g := hexStr[2:4] 125 + b := hexStr[4:6] 126 + 127 + return fmt.Sprintf("#%s%s%s", r, g, b) 128 + } 129 + 130 + func (ld LabelDefinition) GetColor() string { 131 + if ld.Color == nil { 132 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 133 + color := randomColor(seed) 134 + return color 135 + } 136 + 137 + return *ld.Color 138 + } 139 + 140 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 141 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 142 + if err != nil { 143 + created = time.Now() 144 + } 145 + 146 + multiple := false 147 + if record.Multiple != nil { 148 + multiple = *record.Multiple 149 + } 150 + 151 + var vt ValueType 152 + if record.ValueType != nil { 153 + vt = ValueTypeFromRecord(*record.ValueType) 154 + } 155 + 156 + return &LabelDefinition{ 157 + Did: did, 158 + Rkey: rkey, 159 + 160 + Name: record.Name, 161 + ValueType: vt, 162 + Scope: record.Scope, 163 + Color: record.Color, 164 + Multiple: multiple, 165 + Created: created, 166 + }, nil 167 + } 168 + 169 + type LabelOp struct { 170 + Id int64 171 + Did string 172 + Rkey string 173 + Subject syntax.ATURI 174 + Operation LabelOperation 175 + OperandKey string 176 + OperandValue string 177 + PerformedAt time.Time 178 + IndexedAt time.Time 179 + } 180 + 181 + func (l LabelOp) SortAt() time.Time { 182 + createdAt := l.PerformedAt 183 + indexedAt := l.IndexedAt 184 + 185 + // if we don't have an indexedat, fall back to now 186 + if indexedAt.IsZero() { 187 + indexedAt = time.Now() 188 + } 189 + 190 + // if createdat is invalid (before epoch), treat as null -> return zero time 191 + if createdAt.Before(time.UnixMicro(0)) { 192 + return time.Time{} 193 + } 194 + 195 + // if createdat is <= indexedat, use createdat 196 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 197 + return createdAt 198 + } 199 + 200 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 201 + return indexedAt 202 + } 203 + 204 + type LabelOperation string 205 + 206 + const ( 207 + LabelOperationAdd LabelOperation = "add" 208 + LabelOperationDel LabelOperation = "del" 209 + ) 210 + 211 + // a record can create multiple label ops 212 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 213 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 214 + if err != nil { 215 + performed = time.Now() 216 + } 217 + 218 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 219 + return LabelOp{ 220 + Did: did, 221 + Rkey: rkey, 222 + Subject: syntax.ATURI(record.Subject), 223 + OperandKey: operand.Key, 224 + OperandValue: operand.Value, 225 + PerformedAt: performed, 226 + } 227 + } 228 + 229 + var ops []LabelOp 230 + for _, o := range record.Add { 231 + if o != nil { 232 + op := mkOp(o) 233 + op.Operation = LabelOperationAdd 234 + ops = append(ops, op) 235 + } 236 + } 237 + for _, o := range record.Delete { 238 + if o != nil { 239 + op := mkOp(o) 240 + op.Operation = LabelOperationDel 241 + ops = append(ops, op) 242 + } 243 + } 244 + 245 + return ops 246 + } 247 + 248 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 249 + if len(ops) == 0 { 250 + return tangled.LabelOp{} 251 + } 252 + 253 + // use the first operation to establish common fields 254 + first := ops[0] 255 + record := tangled.LabelOp{ 256 + Subject: string(first.Subject), 257 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 258 + } 259 + 260 + var addOperands []*tangled.LabelOp_Operand 261 + var deleteOperands []*tangled.LabelOp_Operand 262 + 263 + for _, op := range ops { 264 + operand := &tangled.LabelOp_Operand{ 265 + Key: op.OperandKey, 266 + Value: op.OperandValue, 267 + } 268 + 269 + switch op.Operation { 270 + case LabelOperationAdd: 271 + addOperands = append(addOperands, operand) 272 + case LabelOperationDel: 273 + deleteOperands = append(deleteOperands, operand) 274 + default: 275 + return tangled.LabelOp{} 276 + } 277 + } 278 + 279 + record.Add = addOperands 280 + record.Delete = deleteOperands 281 + 282 + return record 283 + } 284 + 285 + type set = map[string]struct{} 286 + 287 + type LabelState struct { 288 + inner map[string]set 289 + } 290 + 291 + func NewLabelState() LabelState { 292 + return LabelState{ 293 + inner: make(map[string]set), 294 + } 295 + } 296 + 297 + func (s LabelState) Inner() map[string]set { 298 + return s.inner 299 + } 300 + 301 + func (s LabelState) ContainsLabel(l string) bool { 302 + if valset, exists := s.inner[l]; exists { 303 + if valset != nil { 304 + return true 305 + } 306 + } 307 + 308 + return false 309 + } 310 + 311 + // go maps behavior in templates make this necessary, 312 + // indexing a map and getting `set` in return is apparently truthy 313 + func (s LabelState) ContainsLabelAndVal(l, v string) bool { 314 + if valset, exists := s.inner[l]; exists { 315 + if _, exists := valset[v]; exists { 316 + return true 317 + } 318 + } 319 + 320 + return false 321 + } 322 + 323 + func (s LabelState) GetValSet(l string) set { 324 + if valset, exists := s.inner[l]; exists { 325 + return valset 326 + } else { 327 + return make(set) 328 + } 329 + } 330 + 331 + type LabelApplicationCtx struct { 332 + Defs map[string]*LabelDefinition // labelAt -> labelDef 333 + } 334 + 335 + var ( 336 + LabelNoOpError = errors.New("no-op") 337 + ) 338 + 339 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 340 + def, ok := c.Defs[op.OperandKey] 341 + if !ok { 342 + // this def was deleted, but an op exists, so we just skip over the op 343 + return nil 344 + } 345 + 346 + switch op.Operation { 347 + case LabelOperationAdd: 348 + // if valueset is empty, init it 349 + if state.inner[op.OperandKey] == nil { 350 + state.inner[op.OperandKey] = make(set) 351 + } 352 + 353 + // if valueset is populated & this val alr exists, this labelop is a noop 354 + if valueSet, exists := state.inner[op.OperandKey]; exists { 355 + if _, exists = valueSet[op.OperandValue]; exists { 356 + return LabelNoOpError 357 + } 358 + } 359 + 360 + if def.Multiple { 361 + // append to set 362 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 363 + } else { 364 + // reset to just this value 365 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 366 + } 367 + 368 + case LabelOperationDel: 369 + // if label DNE, then deletion is a no-op 370 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 371 + return LabelNoOpError 372 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 373 + return LabelNoOpError 374 + } 375 + 376 + if def.Multiple { 377 + // remove from set 378 + delete(state.inner[op.OperandKey], op.OperandValue) 379 + } else { 380 + // reset the entire label 381 + delete(state.inner, op.OperandKey) 382 + } 383 + 384 + // if the map becomes empty, then set it to nil, this is just the inverse of add 385 + if len(state.inner[op.OperandKey]) == 0 { 386 + state.inner[op.OperandKey] = nil 387 + } 388 + 389 + } 390 + 391 + return nil 392 + } 393 + 394 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 395 + // sort label ops in sort order first 396 + slices.SortFunc(ops, func(a, b LabelOp) int { 397 + return a.SortAt().Compare(b.SortAt()) 398 + }) 399 + 400 + // apply ops in sequence 401 + for _, o := range ops { 402 + _ = c.ApplyLabelOp(state, o) 403 + } 404 + } 405 + 406 + // IsInverse checks if one label operation is the inverse of another 407 + // returns true if one is an add and the other is a delete with the same key and value 408 + func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 409 + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 410 + return false 411 + } 412 + 413 + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 414 + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 415 + } 416 + 417 + // removes pairs of label operations that are inverses of each other 418 + // from the given slice. the function preserves the order of remaining operations. 419 + func ReduceLabelOps(ops []LabelOp) []LabelOp { 420 + if len(ops) <= 1 { 421 + return ops 422 + } 423 + 424 + keep := make([]bool, len(ops)) 425 + for i := range keep { 426 + keep[i] = true 427 + } 428 + 429 + for i := range ops { 430 + if !keep[i] { 431 + continue 432 + } 433 + 434 + for j := i + 1; j < len(ops); j++ { 435 + if !keep[j] { 436 + continue 437 + } 438 + 439 + if ops[i].IsInverse(ops[j]) { 440 + keep[i] = false 441 + keep[j] = false 442 + break // move to next i since this one is now eliminated 443 + } 444 + } 445 + } 446 + 447 + // build result slice with only kept operations 448 + var result []LabelOp 449 + for i, op := range ops { 450 + if keep[i] { 451 + result = append(result, op) 452 + } 453 + } 454 + 455 + return result 456 + } 457 + 458 + func DefaultLabelDefs() []string { 459 + rkeys := []string{ 460 + "wontfix", 461 + "duplicate", 462 + "assignee", 463 + "good-first-issue", 464 + "documentation", 465 + } 466 + 467 + defs := make([]string, len(rkeys)) 468 + for i, r := range rkeys { 469 + defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 470 + } 471 + 472 + return defs 473 + }
+8 -8
appview/pages/pages.go
··· 841 841 type RepoGeneralSettingsParams struct { 842 842 LoggedInUser *oauth.User 843 843 RepoInfo repoinfo.RepoInfo 844 - Labels []db.LabelDefinition 845 - DefaultLabels []db.LabelDefinition 844 + Labels []models.LabelDefinition 845 + DefaultLabels []models.LabelDefinition 846 846 SubscribedLabels map[string]struct{} 847 847 Active string 848 848 Tabs []map[string]any ··· 890 890 RepoInfo repoinfo.RepoInfo 891 891 Active string 892 892 Issues []db.Issue 893 - LabelDefs map[string]*db.LabelDefinition 893 + LabelDefs map[string]*models.LabelDefinition 894 894 Page pagination.Page 895 895 FilteringByOpen bool 896 896 } ··· 906 906 Active string 907 907 Issue *db.Issue 908 908 CommentList []db.CommentListItem 909 - LabelDefs map[string]*db.LabelDefinition 909 + LabelDefs map[string]*models.LabelDefinition 910 910 911 911 OrderedReactionKinds []db.ReactionKind 912 912 Reactions map[db.ReactionKind]int ··· 1236 1236 type LabelPanelParams struct { 1237 1237 LoggedInUser *oauth.User 1238 1238 RepoInfo repoinfo.RepoInfo 1239 - Defs map[string]*db.LabelDefinition 1239 + Defs map[string]*models.LabelDefinition 1240 1240 Subject string 1241 - State db.LabelState 1241 + State models.LabelState 1242 1242 } 1243 1243 1244 1244 func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { ··· 1248 1248 type EditLabelPanelParams struct { 1249 1249 LoggedInUser *oauth.User 1250 1250 RepoInfo repoinfo.RepoInfo 1251 - Defs map[string]*db.LabelDefinition 1251 + Defs map[string]*models.LabelDefinition 1252 1252 Subject string 1253 - State db.LabelState 1253 + State models.LabelState 1254 1254 } 1255 1255 1256 1256 func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
+8 -8
appview/repo/repo.go
··· 1005 1005 concreteType = "null" 1006 1006 } 1007 1007 1008 - format := db.ValueTypeFormatAny 1008 + format := models.ValueTypeFormatAny 1009 1009 if valueFormat == "did" { 1010 - format = db.ValueTypeFormatDid 1010 + format = models.ValueTypeFormatDid 1011 1011 } 1012 1012 1013 - valueType := db.ValueType{ 1014 - Type: db.ConcreteType(concreteType), 1013 + valueType := models.ValueType{ 1014 + Type: models.ConcreteType(concreteType), 1015 1015 Format: format, 1016 1016 Enum: variants, 1017 1017 } 1018 1018 1019 - label := db.LabelDefinition{ 1019 + label := models.LabelDefinition{ 1020 1020 Did: user.Did, 1021 1021 Rkey: tid.TID(), 1022 1022 Name: name, ··· 1396 1396 return 1397 1397 } 1398 1398 1399 - defs := make(map[string]*db.LabelDefinition) 1399 + defs := make(map[string]*models.LabelDefinition) 1400 1400 for _, l := range labelDefs { 1401 1401 defs[l.AtUri().String()] = &l 1402 1402 } ··· 1444 1444 return 1445 1445 } 1446 1446 1447 - defs := make(map[string]*db.LabelDefinition) 1447 + defs := make(map[string]*models.LabelDefinition) 1448 1448 for _, l := range labelDefs { 1449 1449 defs[l.AtUri().String()] = &l 1450 1450 } ··· 1895 1895 return 1896 1896 } 1897 1897 1898 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) 1898 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1899 1899 if err != nil { 1900 1900 log.Println("failed to fetch labels", err) 1901 1901 rp.pages.Error503(w)
+12 -12
appview/validator/label.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "golang.org/x/exp/slices" 11 11 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/models" 13 13 ) 14 14 15 15 var ( ··· 21 21 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 22 ) 23 23 24 - func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { 24 + func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 25 if label.Name == "" { 26 26 return fmt.Errorf("label name is empty") 27 27 } ··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } ··· 108 108 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 109 109 } 110 110 111 - if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel { 111 + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 112 112 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 113 113 } 114 114 ··· 131 131 return nil 132 132 } 133 133 134 - func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 134 + func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 135 135 valueType := labelDef.ValueType 136 136 137 137 // this is permitted, it "unsets" a label 138 138 if labelOp.OperandValue == "" { 139 - labelOp.Operation = db.LabelOperationDel 139 + labelOp.Operation = models.LabelOperationDel 140 140 return nil 141 141 } 142 142 143 143 switch valueType.Type { 144 - case db.ConcreteTypeNull: 144 + case models.ConcreteTypeNull: 145 145 // For null type, value should be empty 146 146 if labelOp.OperandValue != "null" { 147 147 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 148 148 } 149 149 150 - case db.ConcreteTypeString: 150 + case models.ConcreteTypeString: 151 151 // For string type, validate enum constraints if present 152 152 if valueType.IsEnum() { 153 153 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { ··· 156 156 } 157 157 158 158 switch valueType.Format { 159 - case db.ValueTypeFormatDid: 159 + case models.ValueTypeFormatDid: 160 160 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 161 161 if err != nil { 162 162 return fmt.Errorf("failed to resolve did/handle: %w", err) ··· 164 164 165 165 labelOp.OperandValue = id.DID.String() 166 166 167 - case db.ValueTypeFormatAny, "": 167 + case models.ValueTypeFormatAny, "": 168 168 default: 169 169 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 170 170 } 171 171 172 - case db.ConcreteTypeInt: 172 + case models.ConcreteTypeInt: 173 173 if labelOp.OperandValue == "" { 174 174 return fmt.Errorf("integer type requires non-empty value") 175 175 } ··· 183 183 } 184 184 } 185 185 186 - case db.ConcreteTypeBool: 186 + case models.ConcreteTypeBool: 187 187 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 188 188 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 189 189 }