+1
-1
.air/knotserver.toml
+1
-1
.air/knotserver.toml
···
1
1
[build]
2
-
cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
2
+
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
3
3
bin = ".bin/knot server"
4
4
root = "."
5
5
+6
.tangled/workflows/test.yml
+6
.tangled/workflows/test.yml
+1272
-170
api/tangled/cbor_gen.go
+1272
-170
api/tangled/cbor_gen.go
···
1499
1499
1500
1500
return nil
1501
1501
}
1502
-
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1502
+
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1503
1503
if t == nil {
1504
1504
_, err := w.Write(cbg.CborNull)
1505
1505
return err
1506
1506
}
1507
1507
1508
1508
cw := cbg.NewCborWriter(w)
1509
-
fieldCount := 1
1510
1509
1511
-
if t.Inputs == nil {
1512
-
fieldCount--
1510
+
if _, err := cw.Write([]byte{162}); err != nil {
1511
+
return err
1513
1512
}
1514
1513
1515
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1514
+
// t.Lang (string) (string)
1515
+
if len("lang") > 1000000 {
1516
+
return xerrors.Errorf("Value in field \"lang\" was too long")
1517
+
}
1518
+
1519
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1520
+
return err
1521
+
}
1522
+
if _, err := cw.WriteString(string("lang")); err != nil {
1516
1523
return err
1517
1524
}
1518
1525
1519
-
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1520
-
if t.Inputs != nil {
1526
+
if len(t.Lang) > 1000000 {
1527
+
return xerrors.Errorf("Value in field t.Lang was too long")
1528
+
}
1521
1529
1522
-
if len("inputs") > 1000000 {
1523
-
return xerrors.Errorf("Value in field \"inputs\" was too long")
1524
-
}
1530
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1531
+
return err
1532
+
}
1533
+
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1534
+
return err
1535
+
}
1525
1536
1526
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1527
-
return err
1528
-
}
1529
-
if _, err := cw.WriteString(string("inputs")); err != nil {
1530
-
return err
1531
-
}
1537
+
// t.Size (int64) (int64)
1538
+
if len("size") > 1000000 {
1539
+
return xerrors.Errorf("Value in field \"size\" was too long")
1540
+
}
1532
1541
1533
-
if len(t.Inputs) > 8192 {
1534
-
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1535
-
}
1542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1543
+
return err
1544
+
}
1545
+
if _, err := cw.WriteString(string("size")); err != nil {
1546
+
return err
1547
+
}
1536
1548
1537
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1549
+
if t.Size >= 0 {
1550
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1538
1551
return err
1539
1552
}
1540
-
for _, v := range t.Inputs {
1541
-
if err := v.MarshalCBOR(cw); err != nil {
1542
-
return err
1543
-
}
1544
-
1553
+
} else {
1554
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1555
+
return err
1545
1556
}
1546
1557
}
1558
+
1547
1559
return nil
1548
1560
}
1549
1561
1550
-
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1551
-
*t = GitRefUpdate_LangBreakdown{}
1562
+
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1563
+
*t = GitRefUpdate_IndividualLanguageSize{}
1552
1564
1553
1565
cr := cbg.NewCborReader(r)
1554
1566
···
1567
1579
}
1568
1580
1569
1581
if extra > cbg.MaxLength {
1570
-
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1582
+
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1571
1583
}
1572
1584
1573
1585
n := extra
1574
1586
1575
-
nameBuf := make([]byte, 6)
1587
+
nameBuf := make([]byte, 4)
1576
1588
for i := uint64(0); i < n; i++ {
1577
1589
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1578
1590
if err != nil {
···
1588
1600
}
1589
1601
1590
1602
switch string(nameBuf[:nameLen]) {
1591
-
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1592
-
case "inputs":
1593
-
1594
-
maj, extra, err = cr.ReadHeader()
1595
-
if err != nil {
1596
-
return err
1597
-
}
1598
-
1599
-
if extra > 8192 {
1600
-
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1601
-
}
1603
+
// t.Lang (string) (string)
1604
+
case "lang":
1602
1605
1603
-
if maj != cbg.MajArray {
1604
-
return fmt.Errorf("expected cbor array")
1605
-
}
1606
+
{
1607
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1608
+
if err != nil {
1609
+
return err
1610
+
}
1606
1611
1607
-
if extra > 0 {
1608
-
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1612
+
t.Lang = string(sval)
1609
1613
}
1610
-
1611
-
for i := 0; i < int(extra); i++ {
1612
-
{
1613
-
var maj byte
1614
-
var extra uint64
1615
-
var err error
1616
-
_ = maj
1617
-
_ = extra
1618
-
_ = err
1619
-
1620
-
{
1621
-
1622
-
b, err := cr.ReadByte()
1623
-
if err != nil {
1624
-
return err
1625
-
}
1626
-
if b != cbg.CborNull[0] {
1627
-
if err := cr.UnreadByte(); err != nil {
1628
-
return err
1629
-
}
1630
-
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1631
-
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1632
-
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1633
-
}
1634
-
}
1635
-
1614
+
// t.Size (int64) (int64)
1615
+
case "size":
1616
+
{
1617
+
maj, extra, err := cr.ReadHeader()
1618
+
if err != nil {
1619
+
return err
1620
+
}
1621
+
var extraI int64
1622
+
switch maj {
1623
+
case cbg.MajUnsignedInt:
1624
+
extraI = int64(extra)
1625
+
if extraI < 0 {
1626
+
return fmt.Errorf("int64 positive overflow")
1636
1627
}
1637
-
1628
+
case cbg.MajNegativeInt:
1629
+
extraI = int64(extra)
1630
+
if extraI < 0 {
1631
+
return fmt.Errorf("int64 negative overflow")
1632
+
}
1633
+
extraI = -1 - extraI
1634
+
default:
1635
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
1638
1636
}
1637
+
1638
+
t.Size = int64(extraI)
1639
1639
}
1640
1640
1641
1641
default:
···
1648
1648
1649
1649
return nil
1650
1650
}
1651
-
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1651
+
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1652
1652
if t == nil {
1653
1653
_, err := w.Write(cbg.CborNull)
1654
1654
return err
1655
1655
}
1656
1656
1657
1657
cw := cbg.NewCborWriter(w)
1658
+
fieldCount := 1
1658
1659
1659
-
if _, err := cw.Write([]byte{162}); err != nil {
1660
-
return err
1661
-
}
1662
-
1663
-
// t.Lang (string) (string)
1664
-
if len("lang") > 1000000 {
1665
-
return xerrors.Errorf("Value in field \"lang\" was too long")
1660
+
if t.Inputs == nil {
1661
+
fieldCount--
1666
1662
}
1667
1663
1668
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1669
-
return err
1670
-
}
1671
-
if _, err := cw.WriteString(string("lang")); err != nil {
1664
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1672
1665
return err
1673
1666
}
1674
1667
1675
-
if len(t.Lang) > 1000000 {
1676
-
return xerrors.Errorf("Value in field t.Lang was too long")
1677
-
}
1668
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1669
+
if t.Inputs != nil {
1678
1670
1679
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1680
-
return err
1681
-
}
1682
-
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1683
-
return err
1684
-
}
1671
+
if len("inputs") > 1000000 {
1672
+
return xerrors.Errorf("Value in field \"inputs\" was too long")
1673
+
}
1685
1674
1686
-
// t.Size (int64) (int64)
1687
-
if len("size") > 1000000 {
1688
-
return xerrors.Errorf("Value in field \"size\" was too long")
1689
-
}
1675
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1676
+
return err
1677
+
}
1678
+
if _, err := cw.WriteString(string("inputs")); err != nil {
1679
+
return err
1680
+
}
1690
1681
1691
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1692
-
return err
1693
-
}
1694
-
if _, err := cw.WriteString(string("size")); err != nil {
1695
-
return err
1696
-
}
1682
+
if len(t.Inputs) > 8192 {
1683
+
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1684
+
}
1697
1685
1698
-
if t.Size >= 0 {
1699
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1686
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1700
1687
return err
1701
1688
}
1702
-
} else {
1703
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1704
-
return err
1689
+
for _, v := range t.Inputs {
1690
+
if err := v.MarshalCBOR(cw); err != nil {
1691
+
return err
1692
+
}
1693
+
1705
1694
}
1706
1695
}
1707
-
1708
1696
return nil
1709
1697
}
1710
1698
1711
-
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1712
-
*t = GitRefUpdate_IndividualLanguageSize{}
1699
+
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1700
+
*t = GitRefUpdate_LangBreakdown{}
1713
1701
1714
1702
cr := cbg.NewCborReader(r)
1715
1703
···
1728
1716
}
1729
1717
1730
1718
if extra > cbg.MaxLength {
1731
-
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1719
+
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1732
1720
}
1733
1721
1734
1722
n := extra
1735
1723
1736
-
nameBuf := make([]byte, 4)
1724
+
nameBuf := make([]byte, 6)
1737
1725
for i := uint64(0); i < n; i++ {
1738
1726
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1739
1727
if err != nil {
···
1749
1737
}
1750
1738
1751
1739
switch string(nameBuf[:nameLen]) {
1752
-
// t.Lang (string) (string)
1753
-
case "lang":
1740
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1741
+
case "inputs":
1754
1742
1755
-
{
1756
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1757
-
if err != nil {
1758
-
return err
1759
-
}
1743
+
maj, extra, err = cr.ReadHeader()
1744
+
if err != nil {
1745
+
return err
1746
+
}
1760
1747
1761
-
t.Lang = string(sval)
1748
+
if extra > 8192 {
1749
+
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1762
1750
}
1763
-
// t.Size (int64) (int64)
1764
-
case "size":
1765
-
{
1766
-
maj, extra, err := cr.ReadHeader()
1767
-
if err != nil {
1768
-
return err
1769
-
}
1770
-
var extraI int64
1771
-
switch maj {
1772
-
case cbg.MajUnsignedInt:
1773
-
extraI = int64(extra)
1774
-
if extraI < 0 {
1775
-
return fmt.Errorf("int64 positive overflow")
1776
-
}
1777
-
case cbg.MajNegativeInt:
1778
-
extraI = int64(extra)
1779
-
if extraI < 0 {
1780
-
return fmt.Errorf("int64 negative overflow")
1751
+
1752
+
if maj != cbg.MajArray {
1753
+
return fmt.Errorf("expected cbor array")
1754
+
}
1755
+
1756
+
if extra > 0 {
1757
+
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1758
+
}
1759
+
1760
+
for i := 0; i < int(extra); i++ {
1761
+
{
1762
+
var maj byte
1763
+
var extra uint64
1764
+
var err error
1765
+
_ = maj
1766
+
_ = extra
1767
+
_ = err
1768
+
1769
+
{
1770
+
1771
+
b, err := cr.ReadByte()
1772
+
if err != nil {
1773
+
return err
1774
+
}
1775
+
if b != cbg.CborNull[0] {
1776
+
if err := cr.UnreadByte(); err != nil {
1777
+
return err
1778
+
}
1779
+
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1780
+
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1781
+
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1782
+
}
1783
+
}
1784
+
1781
1785
}
1782
-
extraI = -1 - extraI
1783
-
default:
1784
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
1786
+
1785
1787
}
1786
-
1787
-
t.Size = int64(extraI)
1788
1788
}
1789
1789
1790
1790
default:
···
2469
2469
2470
2470
return nil
2471
2471
}
2472
+
func (t *LabelDefinition) MarshalCBOR(w io.Writer) error {
2473
+
if t == nil {
2474
+
_, err := w.Write(cbg.CborNull)
2475
+
return err
2476
+
}
2477
+
2478
+
cw := cbg.NewCborWriter(w)
2479
+
fieldCount := 7
2480
+
2481
+
if t.Color == nil {
2482
+
fieldCount--
2483
+
}
2484
+
2485
+
if t.Multiple == nil {
2486
+
fieldCount--
2487
+
}
2488
+
2489
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2490
+
return err
2491
+
}
2492
+
2493
+
// t.Name (string) (string)
2494
+
if len("name") > 1000000 {
2495
+
return xerrors.Errorf("Value in field \"name\" was too long")
2496
+
}
2497
+
2498
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
2499
+
return err
2500
+
}
2501
+
if _, err := cw.WriteString(string("name")); err != nil {
2502
+
return err
2503
+
}
2504
+
2505
+
if len(t.Name) > 1000000 {
2506
+
return xerrors.Errorf("Value in field t.Name was too long")
2507
+
}
2508
+
2509
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
2510
+
return err
2511
+
}
2512
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
2513
+
return err
2514
+
}
2515
+
2516
+
// t.LexiconTypeID (string) (string)
2517
+
if len("$type") > 1000000 {
2518
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2519
+
}
2520
+
2521
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2522
+
return err
2523
+
}
2524
+
if _, err := cw.WriteString(string("$type")); err != nil {
2525
+
return err
2526
+
}
2527
+
2528
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.definition"))); err != nil {
2529
+
return err
2530
+
}
2531
+
if _, err := cw.WriteString(string("sh.tangled.label.definition")); err != nil {
2532
+
return err
2533
+
}
2534
+
2535
+
// t.Color (string) (string)
2536
+
if t.Color != nil {
2537
+
2538
+
if len("color") > 1000000 {
2539
+
return xerrors.Errorf("Value in field \"color\" was too long")
2540
+
}
2541
+
2542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("color"))); err != nil {
2543
+
return err
2544
+
}
2545
+
if _, err := cw.WriteString(string("color")); err != nil {
2546
+
return err
2547
+
}
2548
+
2549
+
if t.Color == nil {
2550
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2551
+
return err
2552
+
}
2553
+
} else {
2554
+
if len(*t.Color) > 1000000 {
2555
+
return xerrors.Errorf("Value in field t.Color was too long")
2556
+
}
2557
+
2558
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Color))); err != nil {
2559
+
return err
2560
+
}
2561
+
if _, err := cw.WriteString(string(*t.Color)); err != nil {
2562
+
return err
2563
+
}
2564
+
}
2565
+
}
2566
+
2567
+
// t.Scope ([]string) (slice)
2568
+
if len("scope") > 1000000 {
2569
+
return xerrors.Errorf("Value in field \"scope\" was too long")
2570
+
}
2571
+
2572
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scope"))); err != nil {
2573
+
return err
2574
+
}
2575
+
if _, err := cw.WriteString(string("scope")); err != nil {
2576
+
return err
2577
+
}
2578
+
2579
+
if len(t.Scope) > 8192 {
2580
+
return xerrors.Errorf("Slice value in field t.Scope was too long")
2581
+
}
2582
+
2583
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Scope))); err != nil {
2584
+
return err
2585
+
}
2586
+
for _, v := range t.Scope {
2587
+
if len(v) > 1000000 {
2588
+
return xerrors.Errorf("Value in field v was too long")
2589
+
}
2590
+
2591
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2592
+
return err
2593
+
}
2594
+
if _, err := cw.WriteString(string(v)); err != nil {
2595
+
return err
2596
+
}
2597
+
2598
+
}
2599
+
2600
+
// t.Multiple (bool) (bool)
2601
+
if t.Multiple != nil {
2602
+
2603
+
if len("multiple") > 1000000 {
2604
+
return xerrors.Errorf("Value in field \"multiple\" was too long")
2605
+
}
2606
+
2607
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("multiple"))); err != nil {
2608
+
return err
2609
+
}
2610
+
if _, err := cw.WriteString(string("multiple")); err != nil {
2611
+
return err
2612
+
}
2613
+
2614
+
if t.Multiple == nil {
2615
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2616
+
return err
2617
+
}
2618
+
} else {
2619
+
if err := cbg.WriteBool(w, *t.Multiple); err != nil {
2620
+
return err
2621
+
}
2622
+
}
2623
+
}
2624
+
2625
+
// t.CreatedAt (string) (string)
2626
+
if len("createdAt") > 1000000 {
2627
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2628
+
}
2629
+
2630
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2631
+
return err
2632
+
}
2633
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2634
+
return err
2635
+
}
2636
+
2637
+
if len(t.CreatedAt) > 1000000 {
2638
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2639
+
}
2640
+
2641
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2642
+
return err
2643
+
}
2644
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2645
+
return err
2646
+
}
2647
+
2648
+
// t.ValueType (tangled.LabelDefinition_ValueType) (struct)
2649
+
if len("valueType") > 1000000 {
2650
+
return xerrors.Errorf("Value in field \"valueType\" was too long")
2651
+
}
2652
+
2653
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("valueType"))); err != nil {
2654
+
return err
2655
+
}
2656
+
if _, err := cw.WriteString(string("valueType")); err != nil {
2657
+
return err
2658
+
}
2659
+
2660
+
if err := t.ValueType.MarshalCBOR(cw); err != nil {
2661
+
return err
2662
+
}
2663
+
return nil
2664
+
}
2665
+
2666
+
func (t *LabelDefinition) UnmarshalCBOR(r io.Reader) (err error) {
2667
+
*t = LabelDefinition{}
2668
+
2669
+
cr := cbg.NewCborReader(r)
2670
+
2671
+
maj, extra, err := cr.ReadHeader()
2672
+
if err != nil {
2673
+
return err
2674
+
}
2675
+
defer func() {
2676
+
if err == io.EOF {
2677
+
err = io.ErrUnexpectedEOF
2678
+
}
2679
+
}()
2680
+
2681
+
if maj != cbg.MajMap {
2682
+
return fmt.Errorf("cbor input should be of type map")
2683
+
}
2684
+
2685
+
if extra > cbg.MaxLength {
2686
+
return fmt.Errorf("LabelDefinition: map struct too large (%d)", extra)
2687
+
}
2688
+
2689
+
n := extra
2690
+
2691
+
nameBuf := make([]byte, 9)
2692
+
for i := uint64(0); i < n; i++ {
2693
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2694
+
if err != nil {
2695
+
return err
2696
+
}
2697
+
2698
+
if !ok {
2699
+
// Field doesn't exist on this type, so ignore it
2700
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2701
+
return err
2702
+
}
2703
+
continue
2704
+
}
2705
+
2706
+
switch string(nameBuf[:nameLen]) {
2707
+
// t.Name (string) (string)
2708
+
case "name":
2709
+
2710
+
{
2711
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2712
+
if err != nil {
2713
+
return err
2714
+
}
2715
+
2716
+
t.Name = string(sval)
2717
+
}
2718
+
// t.LexiconTypeID (string) (string)
2719
+
case "$type":
2720
+
2721
+
{
2722
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2723
+
if err != nil {
2724
+
return err
2725
+
}
2726
+
2727
+
t.LexiconTypeID = string(sval)
2728
+
}
2729
+
// t.Color (string) (string)
2730
+
case "color":
2731
+
2732
+
{
2733
+
b, err := cr.ReadByte()
2734
+
if err != nil {
2735
+
return err
2736
+
}
2737
+
if b != cbg.CborNull[0] {
2738
+
if err := cr.UnreadByte(); err != nil {
2739
+
return err
2740
+
}
2741
+
2742
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2743
+
if err != nil {
2744
+
return err
2745
+
}
2746
+
2747
+
t.Color = (*string)(&sval)
2748
+
}
2749
+
}
2750
+
// t.Scope ([]string) (slice)
2751
+
case "scope":
2752
+
2753
+
maj, extra, err = cr.ReadHeader()
2754
+
if err != nil {
2755
+
return err
2756
+
}
2757
+
2758
+
if extra > 8192 {
2759
+
return fmt.Errorf("t.Scope: array too large (%d)", extra)
2760
+
}
2761
+
2762
+
if maj != cbg.MajArray {
2763
+
return fmt.Errorf("expected cbor array")
2764
+
}
2765
+
2766
+
if extra > 0 {
2767
+
t.Scope = make([]string, extra)
2768
+
}
2769
+
2770
+
for i := 0; i < int(extra); i++ {
2771
+
{
2772
+
var maj byte
2773
+
var extra uint64
2774
+
var err error
2775
+
_ = maj
2776
+
_ = extra
2777
+
_ = err
2778
+
2779
+
{
2780
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2781
+
if err != nil {
2782
+
return err
2783
+
}
2784
+
2785
+
t.Scope[i] = string(sval)
2786
+
}
2787
+
2788
+
}
2789
+
}
2790
+
// t.Multiple (bool) (bool)
2791
+
case "multiple":
2792
+
2793
+
{
2794
+
b, err := cr.ReadByte()
2795
+
if err != nil {
2796
+
return err
2797
+
}
2798
+
if b != cbg.CborNull[0] {
2799
+
if err := cr.UnreadByte(); err != nil {
2800
+
return err
2801
+
}
2802
+
2803
+
maj, extra, err = cr.ReadHeader()
2804
+
if err != nil {
2805
+
return err
2806
+
}
2807
+
if maj != cbg.MajOther {
2808
+
return fmt.Errorf("booleans must be major type 7")
2809
+
}
2810
+
2811
+
var val bool
2812
+
switch extra {
2813
+
case 20:
2814
+
val = false
2815
+
case 21:
2816
+
val = true
2817
+
default:
2818
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
2819
+
}
2820
+
t.Multiple = &val
2821
+
}
2822
+
}
2823
+
// t.CreatedAt (string) (string)
2824
+
case "createdAt":
2825
+
2826
+
{
2827
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2828
+
if err != nil {
2829
+
return err
2830
+
}
2831
+
2832
+
t.CreatedAt = string(sval)
2833
+
}
2834
+
// t.ValueType (tangled.LabelDefinition_ValueType) (struct)
2835
+
case "valueType":
2836
+
2837
+
{
2838
+
2839
+
b, err := cr.ReadByte()
2840
+
if err != nil {
2841
+
return err
2842
+
}
2843
+
if b != cbg.CborNull[0] {
2844
+
if err := cr.UnreadByte(); err != nil {
2845
+
return err
2846
+
}
2847
+
t.ValueType = new(LabelDefinition_ValueType)
2848
+
if err := t.ValueType.UnmarshalCBOR(cr); err != nil {
2849
+
return xerrors.Errorf("unmarshaling t.ValueType pointer: %w", err)
2850
+
}
2851
+
}
2852
+
2853
+
}
2854
+
2855
+
default:
2856
+
// Field doesn't exist on this type, so ignore it
2857
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2858
+
return err
2859
+
}
2860
+
}
2861
+
}
2862
+
2863
+
return nil
2864
+
}
2865
+
func (t *LabelDefinition_ValueType) MarshalCBOR(w io.Writer) error {
2866
+
if t == nil {
2867
+
_, err := w.Write(cbg.CborNull)
2868
+
return err
2869
+
}
2870
+
2871
+
cw := cbg.NewCborWriter(w)
2872
+
fieldCount := 3
2873
+
2874
+
if t.Enum == nil {
2875
+
fieldCount--
2876
+
}
2877
+
2878
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2879
+
return err
2880
+
}
2881
+
2882
+
// t.Enum ([]string) (slice)
2883
+
if t.Enum != nil {
2884
+
2885
+
if len("enum") > 1000000 {
2886
+
return xerrors.Errorf("Value in field \"enum\" was too long")
2887
+
}
2888
+
2889
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enum"))); err != nil {
2890
+
return err
2891
+
}
2892
+
if _, err := cw.WriteString(string("enum")); err != nil {
2893
+
return err
2894
+
}
2895
+
2896
+
if len(t.Enum) > 8192 {
2897
+
return xerrors.Errorf("Slice value in field t.Enum was too long")
2898
+
}
2899
+
2900
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Enum))); err != nil {
2901
+
return err
2902
+
}
2903
+
for _, v := range t.Enum {
2904
+
if len(v) > 1000000 {
2905
+
return xerrors.Errorf("Value in field v was too long")
2906
+
}
2907
+
2908
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2909
+
return err
2910
+
}
2911
+
if _, err := cw.WriteString(string(v)); err != nil {
2912
+
return err
2913
+
}
2914
+
2915
+
}
2916
+
}
2917
+
2918
+
// t.Type (string) (string)
2919
+
if len("type") > 1000000 {
2920
+
return xerrors.Errorf("Value in field \"type\" was too long")
2921
+
}
2922
+
2923
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("type"))); err != nil {
2924
+
return err
2925
+
}
2926
+
if _, err := cw.WriteString(string("type")); err != nil {
2927
+
return err
2928
+
}
2929
+
2930
+
if len(t.Type) > 1000000 {
2931
+
return xerrors.Errorf("Value in field t.Type was too long")
2932
+
}
2933
+
2934
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
2935
+
return err
2936
+
}
2937
+
if _, err := cw.WriteString(string(t.Type)); err != nil {
2938
+
return err
2939
+
}
2940
+
2941
+
// t.Format (string) (string)
2942
+
if len("format") > 1000000 {
2943
+
return xerrors.Errorf("Value in field \"format\" was too long")
2944
+
}
2945
+
2946
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("format"))); err != nil {
2947
+
return err
2948
+
}
2949
+
if _, err := cw.WriteString(string("format")); err != nil {
2950
+
return err
2951
+
}
2952
+
2953
+
if len(t.Format) > 1000000 {
2954
+
return xerrors.Errorf("Value in field t.Format was too long")
2955
+
}
2956
+
2957
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Format))); err != nil {
2958
+
return err
2959
+
}
2960
+
if _, err := cw.WriteString(string(t.Format)); err != nil {
2961
+
return err
2962
+
}
2963
+
return nil
2964
+
}
2965
+
2966
+
func (t *LabelDefinition_ValueType) UnmarshalCBOR(r io.Reader) (err error) {
2967
+
*t = LabelDefinition_ValueType{}
2968
+
2969
+
cr := cbg.NewCborReader(r)
2970
+
2971
+
maj, extra, err := cr.ReadHeader()
2972
+
if err != nil {
2973
+
return err
2974
+
}
2975
+
defer func() {
2976
+
if err == io.EOF {
2977
+
err = io.ErrUnexpectedEOF
2978
+
}
2979
+
}()
2980
+
2981
+
if maj != cbg.MajMap {
2982
+
return fmt.Errorf("cbor input should be of type map")
2983
+
}
2984
+
2985
+
if extra > cbg.MaxLength {
2986
+
return fmt.Errorf("LabelDefinition_ValueType: map struct too large (%d)", extra)
2987
+
}
2988
+
2989
+
n := extra
2990
+
2991
+
nameBuf := make([]byte, 6)
2992
+
for i := uint64(0); i < n; i++ {
2993
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2994
+
if err != nil {
2995
+
return err
2996
+
}
2997
+
2998
+
if !ok {
2999
+
// Field doesn't exist on this type, so ignore it
3000
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3001
+
return err
3002
+
}
3003
+
continue
3004
+
}
3005
+
3006
+
switch string(nameBuf[:nameLen]) {
3007
+
// t.Enum ([]string) (slice)
3008
+
case "enum":
3009
+
3010
+
maj, extra, err = cr.ReadHeader()
3011
+
if err != nil {
3012
+
return err
3013
+
}
3014
+
3015
+
if extra > 8192 {
3016
+
return fmt.Errorf("t.Enum: array too large (%d)", extra)
3017
+
}
3018
+
3019
+
if maj != cbg.MajArray {
3020
+
return fmt.Errorf("expected cbor array")
3021
+
}
3022
+
3023
+
if extra > 0 {
3024
+
t.Enum = make([]string, extra)
3025
+
}
3026
+
3027
+
for i := 0; i < int(extra); i++ {
3028
+
{
3029
+
var maj byte
3030
+
var extra uint64
3031
+
var err error
3032
+
_ = maj
3033
+
_ = extra
3034
+
_ = err
3035
+
3036
+
{
3037
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3038
+
if err != nil {
3039
+
return err
3040
+
}
3041
+
3042
+
t.Enum[i] = string(sval)
3043
+
}
3044
+
3045
+
}
3046
+
}
3047
+
// t.Type (string) (string)
3048
+
case "type":
3049
+
3050
+
{
3051
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3052
+
if err != nil {
3053
+
return err
3054
+
}
3055
+
3056
+
t.Type = string(sval)
3057
+
}
3058
+
// t.Format (string) (string)
3059
+
case "format":
3060
+
3061
+
{
3062
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3063
+
if err != nil {
3064
+
return err
3065
+
}
3066
+
3067
+
t.Format = string(sval)
3068
+
}
3069
+
3070
+
default:
3071
+
// Field doesn't exist on this type, so ignore it
3072
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3073
+
return err
3074
+
}
3075
+
}
3076
+
}
3077
+
3078
+
return nil
3079
+
}
3080
+
func (t *LabelOp) MarshalCBOR(w io.Writer) error {
3081
+
if t == nil {
3082
+
_, err := w.Write(cbg.CborNull)
3083
+
return err
3084
+
}
3085
+
3086
+
cw := cbg.NewCborWriter(w)
3087
+
3088
+
if _, err := cw.Write([]byte{165}); err != nil {
3089
+
return err
3090
+
}
3091
+
3092
+
// t.Add ([]*tangled.LabelOp_Operand) (slice)
3093
+
if len("add") > 1000000 {
3094
+
return xerrors.Errorf("Value in field \"add\" was too long")
3095
+
}
3096
+
3097
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("add"))); err != nil {
3098
+
return err
3099
+
}
3100
+
if _, err := cw.WriteString(string("add")); err != nil {
3101
+
return err
3102
+
}
3103
+
3104
+
if len(t.Add) > 8192 {
3105
+
return xerrors.Errorf("Slice value in field t.Add was too long")
3106
+
}
3107
+
3108
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Add))); err != nil {
3109
+
return err
3110
+
}
3111
+
for _, v := range t.Add {
3112
+
if err := v.MarshalCBOR(cw); err != nil {
3113
+
return err
3114
+
}
3115
+
3116
+
}
3117
+
3118
+
// t.LexiconTypeID (string) (string)
3119
+
if len("$type") > 1000000 {
3120
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3121
+
}
3122
+
3123
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3124
+
return err
3125
+
}
3126
+
if _, err := cw.WriteString(string("$type")); err != nil {
3127
+
return err
3128
+
}
3129
+
3130
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.op"))); err != nil {
3131
+
return err
3132
+
}
3133
+
if _, err := cw.WriteString(string("sh.tangled.label.op")); err != nil {
3134
+
return err
3135
+
}
3136
+
3137
+
// t.Delete ([]*tangled.LabelOp_Operand) (slice)
3138
+
if len("delete") > 1000000 {
3139
+
return xerrors.Errorf("Value in field \"delete\" was too long")
3140
+
}
3141
+
3142
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("delete"))); err != nil {
3143
+
return err
3144
+
}
3145
+
if _, err := cw.WriteString(string("delete")); err != nil {
3146
+
return err
3147
+
}
3148
+
3149
+
if len(t.Delete) > 8192 {
3150
+
return xerrors.Errorf("Slice value in field t.Delete was too long")
3151
+
}
3152
+
3153
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Delete))); err != nil {
3154
+
return err
3155
+
}
3156
+
for _, v := range t.Delete {
3157
+
if err := v.MarshalCBOR(cw); err != nil {
3158
+
return err
3159
+
}
3160
+
3161
+
}
3162
+
3163
+
// t.Subject (string) (string)
3164
+
if len("subject") > 1000000 {
3165
+
return xerrors.Errorf("Value in field \"subject\" was too long")
3166
+
}
3167
+
3168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
3169
+
return err
3170
+
}
3171
+
if _, err := cw.WriteString(string("subject")); err != nil {
3172
+
return err
3173
+
}
3174
+
3175
+
if len(t.Subject) > 1000000 {
3176
+
return xerrors.Errorf("Value in field t.Subject was too long")
3177
+
}
3178
+
3179
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
3180
+
return err
3181
+
}
3182
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
3183
+
return err
3184
+
}
3185
+
3186
+
// t.PerformedAt (string) (string)
3187
+
if len("performedAt") > 1000000 {
3188
+
return xerrors.Errorf("Value in field \"performedAt\" was too long")
3189
+
}
3190
+
3191
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("performedAt"))); err != nil {
3192
+
return err
3193
+
}
3194
+
if _, err := cw.WriteString(string("performedAt")); err != nil {
3195
+
return err
3196
+
}
3197
+
3198
+
if len(t.PerformedAt) > 1000000 {
3199
+
return xerrors.Errorf("Value in field t.PerformedAt was too long")
3200
+
}
3201
+
3202
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.PerformedAt))); err != nil {
3203
+
return err
3204
+
}
3205
+
if _, err := cw.WriteString(string(t.PerformedAt)); err != nil {
3206
+
return err
3207
+
}
3208
+
return nil
3209
+
}
3210
+
3211
+
func (t *LabelOp) UnmarshalCBOR(r io.Reader) (err error) {
3212
+
*t = LabelOp{}
3213
+
3214
+
cr := cbg.NewCborReader(r)
3215
+
3216
+
maj, extra, err := cr.ReadHeader()
3217
+
if err != nil {
3218
+
return err
3219
+
}
3220
+
defer func() {
3221
+
if err == io.EOF {
3222
+
err = io.ErrUnexpectedEOF
3223
+
}
3224
+
}()
3225
+
3226
+
if maj != cbg.MajMap {
3227
+
return fmt.Errorf("cbor input should be of type map")
3228
+
}
3229
+
3230
+
if extra > cbg.MaxLength {
3231
+
return fmt.Errorf("LabelOp: map struct too large (%d)", extra)
3232
+
}
3233
+
3234
+
n := extra
3235
+
3236
+
nameBuf := make([]byte, 11)
3237
+
for i := uint64(0); i < n; i++ {
3238
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3239
+
if err != nil {
3240
+
return err
3241
+
}
3242
+
3243
+
if !ok {
3244
+
// Field doesn't exist on this type, so ignore it
3245
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3246
+
return err
3247
+
}
3248
+
continue
3249
+
}
3250
+
3251
+
switch string(nameBuf[:nameLen]) {
3252
+
// t.Add ([]*tangled.LabelOp_Operand) (slice)
3253
+
case "add":
3254
+
3255
+
maj, extra, err = cr.ReadHeader()
3256
+
if err != nil {
3257
+
return err
3258
+
}
3259
+
3260
+
if extra > 8192 {
3261
+
return fmt.Errorf("t.Add: array too large (%d)", extra)
3262
+
}
3263
+
3264
+
if maj != cbg.MajArray {
3265
+
return fmt.Errorf("expected cbor array")
3266
+
}
3267
+
3268
+
if extra > 0 {
3269
+
t.Add = make([]*LabelOp_Operand, extra)
3270
+
}
3271
+
3272
+
for i := 0; i < int(extra); i++ {
3273
+
{
3274
+
var maj byte
3275
+
var extra uint64
3276
+
var err error
3277
+
_ = maj
3278
+
_ = extra
3279
+
_ = err
3280
+
3281
+
{
3282
+
3283
+
b, err := cr.ReadByte()
3284
+
if err != nil {
3285
+
return err
3286
+
}
3287
+
if b != cbg.CborNull[0] {
3288
+
if err := cr.UnreadByte(); err != nil {
3289
+
return err
3290
+
}
3291
+
t.Add[i] = new(LabelOp_Operand)
3292
+
if err := t.Add[i].UnmarshalCBOR(cr); err != nil {
3293
+
return xerrors.Errorf("unmarshaling t.Add[i] pointer: %w", err)
3294
+
}
3295
+
}
3296
+
3297
+
}
3298
+
3299
+
}
3300
+
}
3301
+
// t.LexiconTypeID (string) (string)
3302
+
case "$type":
3303
+
3304
+
{
3305
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3306
+
if err != nil {
3307
+
return err
3308
+
}
3309
+
3310
+
t.LexiconTypeID = string(sval)
3311
+
}
3312
+
// t.Delete ([]*tangled.LabelOp_Operand) (slice)
3313
+
case "delete":
3314
+
3315
+
maj, extra, err = cr.ReadHeader()
3316
+
if err != nil {
3317
+
return err
3318
+
}
3319
+
3320
+
if extra > 8192 {
3321
+
return fmt.Errorf("t.Delete: array too large (%d)", extra)
3322
+
}
3323
+
3324
+
if maj != cbg.MajArray {
3325
+
return fmt.Errorf("expected cbor array")
3326
+
}
3327
+
3328
+
if extra > 0 {
3329
+
t.Delete = make([]*LabelOp_Operand, extra)
3330
+
}
3331
+
3332
+
for i := 0; i < int(extra); i++ {
3333
+
{
3334
+
var maj byte
3335
+
var extra uint64
3336
+
var err error
3337
+
_ = maj
3338
+
_ = extra
3339
+
_ = err
3340
+
3341
+
{
3342
+
3343
+
b, err := cr.ReadByte()
3344
+
if err != nil {
3345
+
return err
3346
+
}
3347
+
if b != cbg.CborNull[0] {
3348
+
if err := cr.UnreadByte(); err != nil {
3349
+
return err
3350
+
}
3351
+
t.Delete[i] = new(LabelOp_Operand)
3352
+
if err := t.Delete[i].UnmarshalCBOR(cr); err != nil {
3353
+
return xerrors.Errorf("unmarshaling t.Delete[i] pointer: %w", err)
3354
+
}
3355
+
}
3356
+
3357
+
}
3358
+
3359
+
}
3360
+
}
3361
+
// t.Subject (string) (string)
3362
+
case "subject":
3363
+
3364
+
{
3365
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3366
+
if err != nil {
3367
+
return err
3368
+
}
3369
+
3370
+
t.Subject = string(sval)
3371
+
}
3372
+
// t.PerformedAt (string) (string)
3373
+
case "performedAt":
3374
+
3375
+
{
3376
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3377
+
if err != nil {
3378
+
return err
3379
+
}
3380
+
3381
+
t.PerformedAt = string(sval)
3382
+
}
3383
+
3384
+
default:
3385
+
// Field doesn't exist on this type, so ignore it
3386
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3387
+
return err
3388
+
}
3389
+
}
3390
+
}
3391
+
3392
+
return nil
3393
+
}
3394
+
func (t *LabelOp_Operand) MarshalCBOR(w io.Writer) error {
3395
+
if t == nil {
3396
+
_, err := w.Write(cbg.CborNull)
3397
+
return err
3398
+
}
3399
+
3400
+
cw := cbg.NewCborWriter(w)
3401
+
3402
+
if _, err := cw.Write([]byte{162}); err != nil {
3403
+
return err
3404
+
}
3405
+
3406
+
// t.Key (string) (string)
3407
+
if len("key") > 1000000 {
3408
+
return xerrors.Errorf("Value in field \"key\" was too long")
3409
+
}
3410
+
3411
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil {
3412
+
return err
3413
+
}
3414
+
if _, err := cw.WriteString(string("key")); err != nil {
3415
+
return err
3416
+
}
3417
+
3418
+
if len(t.Key) > 1000000 {
3419
+
return xerrors.Errorf("Value in field t.Key was too long")
3420
+
}
3421
+
3422
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil {
3423
+
return err
3424
+
}
3425
+
if _, err := cw.WriteString(string(t.Key)); err != nil {
3426
+
return err
3427
+
}
3428
+
3429
+
// t.Value (string) (string)
3430
+
if len("value") > 1000000 {
3431
+
return xerrors.Errorf("Value in field \"value\" was too long")
3432
+
}
3433
+
3434
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil {
3435
+
return err
3436
+
}
3437
+
if _, err := cw.WriteString(string("value")); err != nil {
3438
+
return err
3439
+
}
3440
+
3441
+
if len(t.Value) > 1000000 {
3442
+
return xerrors.Errorf("Value in field t.Value was too long")
3443
+
}
3444
+
3445
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil {
3446
+
return err
3447
+
}
3448
+
if _, err := cw.WriteString(string(t.Value)); err != nil {
3449
+
return err
3450
+
}
3451
+
return nil
3452
+
}
3453
+
3454
+
func (t *LabelOp_Operand) UnmarshalCBOR(r io.Reader) (err error) {
3455
+
*t = LabelOp_Operand{}
3456
+
3457
+
cr := cbg.NewCborReader(r)
3458
+
3459
+
maj, extra, err := cr.ReadHeader()
3460
+
if err != nil {
3461
+
return err
3462
+
}
3463
+
defer func() {
3464
+
if err == io.EOF {
3465
+
err = io.ErrUnexpectedEOF
3466
+
}
3467
+
}()
3468
+
3469
+
if maj != cbg.MajMap {
3470
+
return fmt.Errorf("cbor input should be of type map")
3471
+
}
3472
+
3473
+
if extra > cbg.MaxLength {
3474
+
return fmt.Errorf("LabelOp_Operand: map struct too large (%d)", extra)
3475
+
}
3476
+
3477
+
n := extra
3478
+
3479
+
nameBuf := make([]byte, 5)
3480
+
for i := uint64(0); i < n; i++ {
3481
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3482
+
if err != nil {
3483
+
return err
3484
+
}
3485
+
3486
+
if !ok {
3487
+
// Field doesn't exist on this type, so ignore it
3488
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3489
+
return err
3490
+
}
3491
+
continue
3492
+
}
3493
+
3494
+
switch string(nameBuf[:nameLen]) {
3495
+
// t.Key (string) (string)
3496
+
case "key":
3497
+
3498
+
{
3499
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3500
+
if err != nil {
3501
+
return err
3502
+
}
3503
+
3504
+
t.Key = string(sval)
3505
+
}
3506
+
// t.Value (string) (string)
3507
+
case "value":
3508
+
3509
+
{
3510
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3511
+
if err != nil {
3512
+
return err
3513
+
}
3514
+
3515
+
t.Value = string(sval)
3516
+
}
3517
+
3518
+
default:
3519
+
// Field doesn't exist on this type, so ignore it
3520
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3521
+
return err
3522
+
}
3523
+
}
3524
+
}
3525
+
3526
+
return nil
3527
+
}
2472
3528
func (t *Pipeline) MarshalCBOR(w io.Writer) error {
2473
3529
if t == nil {
2474
3530
_, err := w.Write(cbg.CborNull)
···
4756
5812
fieldCount--
4757
5813
}
4758
5814
5815
+
if t.Labels == nil {
5816
+
fieldCount--
5817
+
}
5818
+
4759
5819
if t.Source == nil {
4760
5820
fieldCount--
4761
5821
}
···
4833
5893
return err
4834
5894
}
4835
5895
4836
-
// t.Owner (string) (string)
4837
-
if len("owner") > 1000000 {
4838
-
return xerrors.Errorf("Value in field \"owner\" was too long")
4839
-
}
5896
+
// t.Labels ([]string) (slice)
5897
+
if t.Labels != nil {
5898
+
5899
+
if len("labels") > 1000000 {
5900
+
return xerrors.Errorf("Value in field \"labels\" was too long")
5901
+
}
5902
+
5903
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("labels"))); err != nil {
5904
+
return err
5905
+
}
5906
+
if _, err := cw.WriteString(string("labels")); err != nil {
5907
+
return err
5908
+
}
4840
5909
4841
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
4842
-
return err
4843
-
}
4844
-
if _, err := cw.WriteString(string("owner")); err != nil {
4845
-
return err
4846
-
}
5910
+
if len(t.Labels) > 8192 {
5911
+
return xerrors.Errorf("Slice value in field t.Labels was too long")
5912
+
}
5913
+
5914
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Labels))); err != nil {
5915
+
return err
5916
+
}
5917
+
for _, v := range t.Labels {
5918
+
if len(v) > 1000000 {
5919
+
return xerrors.Errorf("Value in field v was too long")
5920
+
}
4847
5921
4848
-
if len(t.Owner) > 1000000 {
4849
-
return xerrors.Errorf("Value in field t.Owner was too long")
4850
-
}
5922
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
5923
+
return err
5924
+
}
5925
+
if _, err := cw.WriteString(string(v)); err != nil {
5926
+
return err
5927
+
}
4851
5928
4852
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
4853
-
return err
4854
-
}
4855
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
4856
-
return err
5929
+
}
4857
5930
}
4858
5931
4859
5932
// t.Source (string) (string)
···
5051
6124
5052
6125
t.LexiconTypeID = string(sval)
5053
6126
}
5054
-
// t.Owner (string) (string)
5055
-
case "owner":
6127
+
// t.Labels ([]string) (slice)
6128
+
case "labels":
6129
+
6130
+
maj, extra, err = cr.ReadHeader()
6131
+
if err != nil {
6132
+
return err
6133
+
}
6134
+
6135
+
if extra > 8192 {
6136
+
return fmt.Errorf("t.Labels: array too large (%d)", extra)
6137
+
}
6138
+
6139
+
if maj != cbg.MajArray {
6140
+
return fmt.Errorf("expected cbor array")
6141
+
}
6142
+
6143
+
if extra > 0 {
6144
+
t.Labels = make([]string, extra)
6145
+
}
6146
+
6147
+
for i := 0; i < int(extra); i++ {
6148
+
{
6149
+
var maj byte
6150
+
var extra uint64
6151
+
var err error
6152
+
_ = maj
6153
+
_ = extra
6154
+
_ = err
5056
6155
5057
-
{
5058
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5059
-
if err != nil {
5060
-
return err
6156
+
{
6157
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6158
+
if err != nil {
6159
+
return err
6160
+
}
6161
+
6162
+
t.Labels[i] = string(sval)
6163
+
}
6164
+
5061
6165
}
5062
-
5063
-
t.Owner = string(sval)
5064
6166
}
5065
6167
// t.Source (string) (string)
5066
6168
case "source":
+42
api/tangled/labeldefinition.go
+42
api/tangled/labeldefinition.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.definition
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelDefinitionNSID = "sh.tangled.label.definition"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.definition", &LabelDefinition{})
17
+
} //
18
+
// RECORDTYPE: LabelDefinition
19
+
type LabelDefinition struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"`
21
+
// color: The hex value for the background color for the label. Appviews may choose to respect this.
22
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
23
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
+
// multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]
25
+
Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"`
26
+
// name: The display name of this label.
27
+
Name string `json:"name" cborgen:"name"`
28
+
// scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.
29
+
Scope []string `json:"scope" cborgen:"scope"`
30
+
// valueType: The type definition of this label. Appviews may allow sorting for certain types.
31
+
ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"`
32
+
}
33
+
34
+
// LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema.
35
+
type LabelDefinition_ValueType struct {
36
+
// enum: Closed set of values that this label can take.
37
+
Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"`
38
+
// format: An optional constraint that can be applied on string concrete types.
39
+
Format string `json:"format" cborgen:"format"`
40
+
// type: The concrete type of this label's value.
41
+
Type string `json:"type" cborgen:"type"`
42
+
}
+34
api/tangled/labelop.go
+34
api/tangled/labelop.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.op
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelOpNSID = "sh.tangled.label.op"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.op", &LabelOp{})
17
+
} //
18
+
// RECORDTYPE: LabelOp
19
+
type LabelOp struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"`
21
+
Add []*LabelOp_Operand `json:"add" cborgen:"add"`
22
+
Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
23
+
PerformedAt string `json:"performedAt" cborgen:"performedAt"`
24
+
// subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
25
+
Subject string `json:"subject" cborgen:"subject"`
26
+
}
27
+
28
+
// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
29
+
type LabelOp_Operand struct {
30
+
// key: ATURI to the label definition
31
+
Key string `json:"key" cborgen:"key"`
32
+
// value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value.
33
+
Value string `json:"value" cborgen:"value"`
34
+
}
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
32
// parent: The parent path in the tree
33
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
34
36
// ref: The git reference used
35
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
36
46
}
37
47
38
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+3
-2
api/tangled/tangledrepo.go
+3
-2
api/tangled/tangledrepo.go
···
22
22
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
23
// knot: knot where the repo was created
24
24
Knot string `json:"knot" cborgen:"knot"`
25
+
// labels: List of labels that this repo subscribes to
26
+
Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"`
25
27
// name: name of the repo
26
-
Name string `json:"name" cborgen:"name"`
27
-
Owner string `json:"owner" cborgen:"owner"`
28
+
Name string `json:"name" cborgen:"name"`
28
29
// source: source of the repo
29
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
30
31
// spindle: CI runner to send jobs to and receive results from
+1
-1
appview/cache/session/store.go
+1
-1
appview/cache/session/store.go
+5
-4
appview/commitverify/verify.go
+5
-4
appview/commitverify/verify.go
···
4
4
"log"
5
5
6
6
"github.com/go-git/go-git/v5/plumbing/object"
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/crypto"
9
-
"tangled.sh/tangled.sh/core/types"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/crypto"
10
+
"tangled.org/core/types"
10
11
)
11
12
12
13
type verifiedCommit struct {
···
45
46
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
46
47
vcs := VerifiedCommits{}
47
48
48
-
didPubkeyCache := make(map[string][]db.PublicKey)
49
+
didPubkeyCache := make(map[string][]models.PublicKey)
49
50
50
51
for _, commit := range ndCommits {
51
52
c := commit.Commit
+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
···
72
72
}
73
73
74
74
type Cloudflare struct {
75
-
ApiToken string `env:"API_TOKEN"`
76
-
ZoneId string `env:"ZONE_ID"`
75
+
ApiToken string `env:"API_TOKEN"`
76
+
ZoneId string `env:"ZONE_ID"`
77
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
78
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
77
79
}
78
80
79
81
func (cfg RedisConfig) ToURL() string {
+5
-25
appview/db/artifact.go
+5
-25
appview/db/artifact.go
···
5
5
"strings"
6
6
"time"
7
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
8
"github.com/go-git/go-git/v5/plumbing"
10
9
"github.com/ipfs/go-cid"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.org/core/appview/models"
12
11
)
13
12
14
-
type Artifact struct {
15
-
Id uint64
16
-
Did string
17
-
Rkey string
18
-
19
-
RepoAt syntax.ATURI
20
-
Tag plumbing.Hash
21
-
CreatedAt time.Time
22
-
23
-
BlobCid cid.Cid
24
-
Name string
25
-
Size uint64
26
-
MimeType string
27
-
}
28
-
29
-
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
31
-
}
32
-
33
-
func AddArtifact(e Execer, artifact Artifact) error {
13
+
func AddArtifact(e Execer, artifact models.Artifact) error {
34
14
_, err := e.Exec(
35
15
`insert or ignore into artifacts (
36
16
did,
···
57
37
return err
58
38
}
59
39
60
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
61
-
var artifacts []Artifact
40
+
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
var artifacts []models.Artifact
62
42
63
43
var conditions []string
64
44
var args []any
···
94
74
defer rows.Close()
95
75
96
76
for rows.Next() {
97
-
var artifact Artifact
77
+
var artifact models.Artifact
98
78
var createdAt string
99
79
var tag []byte
100
80
var blobCid string
+3
-18
appview/db/collaborators.go
+3
-18
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
-
"time"
7
6
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
9
8
)
10
9
11
-
type Collaborator struct {
12
-
// identifiers for the record
13
-
Id int64
14
-
Did syntax.DID
15
-
Rkey string
16
-
17
-
// content
18
-
SubjectDid syntax.DID
19
-
RepoAt syntax.ATURI
20
-
21
-
// meta
22
-
Created time.Time
23
-
}
24
-
25
-
func AddCollaborator(e Execer, c Collaborator) error {
10
+
func AddCollaborator(e Execer, c models.Collaborator) error {
26
11
_, err := e.Exec(
27
12
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
13
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
49
34
return err
50
35
}
51
36
52
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
37
+
func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) {
53
38
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
39
if err != nil {
55
40
return nil, err
+247
-16
appview/db/db.go
+247
-16
appview/db/db.go
···
466
466
primary key (did, rkey)
467
467
);
468
468
469
+
create table if not exists label_definitions (
470
+
-- identifiers
471
+
id integer primary key autoincrement,
472
+
did text not null,
473
+
rkey text not null,
474
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.definition' || '/' || rkey) stored,
475
+
476
+
-- content
477
+
name text not null,
478
+
value_type text not null check (value_type in (
479
+
"null",
480
+
"boolean",
481
+
"integer",
482
+
"string"
483
+
)),
484
+
value_format text not null default "any",
485
+
value_enum text, -- comma separated list
486
+
scope text not null, -- comma separated list of nsid
487
+
color text,
488
+
multiple integer not null default 0,
489
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
490
+
491
+
-- constraints
492
+
unique (did, rkey)
493
+
unique (at_uri)
494
+
);
495
+
496
+
-- ops are flattened, a record may contain several additions and deletions, but the table will include one row per add/del
497
+
create table if not exists label_ops (
498
+
-- identifiers
499
+
id integer primary key autoincrement,
500
+
did text not null,
501
+
rkey text not null,
502
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.op' || '/' || rkey) stored,
503
+
504
+
-- content
505
+
subject text not null,
506
+
operation text not null check (operation in ("add", "del")),
507
+
operand_key text not null,
508
+
operand_value text not null,
509
+
-- we need two time values: performed is declared by the user, indexed is calculated by the av
510
+
performed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
511
+
indexed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
512
+
513
+
-- constraints
514
+
-- traditionally (did, rkey) pair should be unique, but not in this case
515
+
-- operand_key should reference a label definition
516
+
foreign key (operand_key) references label_definitions (at_uri) on delete cascade,
517
+
unique (did, rkey, subject, operand_key, operand_value)
518
+
);
519
+
520
+
create table if not exists repo_labels (
521
+
-- identifiers
522
+
id integer primary key autoincrement,
523
+
524
+
-- repo identifiers
525
+
repo_at text not null,
526
+
527
+
-- label to subscribe to
528
+
label_at text not null,
529
+
530
+
unique (repo_at, label_at)
531
+
);
532
+
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
559
+
);
560
+
469
561
create table if not exists migrations (
470
562
id integer primary key autoincrement,
471
563
name text unique
472
564
);
473
565
474
-
-- indexes for better star query performance
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
475
569
create index if not exists idx_stars_created on stars(created);
476
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
477
571
`)
···
604
698
})
605
699
conn.ExecContext(ctx, "pragma foreign_keys = on;")
606
700
607
-
// run migrations
608
701
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
609
702
tx.Exec(`
610
703
alter table repos add column spindle text;
···
724
817
_, err := tx.Exec(`
725
818
alter table spindles add column needs_upgrade integer not null default 0;
726
819
`)
727
-
if err != nil {
728
-
return err
729
-
}
730
-
731
-
_, err = tx.Exec(`
732
-
update spindles set needs_upgrade = 1;
733
-
`)
734
820
return err
735
821
})
736
822
···
868
954
return err
869
955
})
870
956
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
+
871
1097
return &DB{db}, nil
872
1098
}
873
1099
···
932
1158
}
933
1159
}
934
1160
935
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
936
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
937
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
938
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
939
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
940
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
941
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1161
+
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1162
+
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1163
+
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1164
+
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1165
+
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1166
+
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1167
+
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1168
+
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1169
+
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1170
+
func FilterContains(key string, arg any) filter {
1171
+
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1172
+
}
942
1173
943
1174
func (f filter) Condition() string {
944
1175
rv := reflect.ValueOf(f.arg)
+29
-34
appview/db/email.go
+29
-34
appview/db/email.go
···
3
3
import (
4
4
"strings"
5
5
"time"
6
-
)
7
6
8
-
type Email struct {
9
-
ID int64
10
-
Did string
11
-
Address string
12
-
Verified bool
13
-
Primary bool
14
-
VerificationCode string
15
-
LastSent *time.Time
16
-
CreatedAt time.Time
17
-
}
7
+
"tangled.org/core/appview/models"
8
+
)
18
9
19
-
func GetPrimaryEmail(e Execer, did string) (Email, error) {
10
+
func GetPrimaryEmail(e Execer, did string) (models.Email, error) {
20
11
query := `
21
12
select id, did, email, verified, is_primary, verification_code, last_sent, created
22
13
from emails
23
14
where did = ? and is_primary = true
24
15
`
25
-
var email Email
16
+
var email models.Email
26
17
var createdStr string
27
18
var lastSent string
28
19
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
29
20
if err != nil {
30
-
return Email{}, err
21
+
return models.Email{}, err
31
22
}
32
23
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
33
24
if err != nil {
34
-
return Email{}, err
25
+
return models.Email{}, err
35
26
}
36
27
parsedTime, err := time.Parse(time.RFC3339, lastSent)
37
28
if err != nil {
38
-
return Email{}, err
29
+
return models.Email{}, err
39
30
}
40
31
email.LastSent = &parsedTime
41
32
return email, nil
42
33
}
43
34
44
-
func GetEmail(e Execer, did string, em string) (Email, error) {
35
+
func GetEmail(e Execer, did string, em string) (models.Email, error) {
45
36
query := `
46
37
select id, did, email, verified, is_primary, verification_code, last_sent, created
47
38
from emails
48
39
where did = ? and email = ?
49
40
`
50
-
var email Email
41
+
var email models.Email
51
42
var createdStr string
52
43
var lastSent string
53
44
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
54
45
if err != nil {
55
-
return Email{}, err
46
+
return models.Email{}, err
56
47
}
57
48
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
58
49
if err != nil {
59
-
return Email{}, err
50
+
return models.Email{}, err
60
51
}
61
52
parsedTime, err := time.Parse(time.RFC3339, lastSent)
62
53
if err != nil {
63
-
return Email{}, err
54
+
return models.Email{}, err
64
55
}
65
56
email.LastSent = &parsedTime
66
57
return email, nil
···
80
71
return did, nil
81
72
}
82
73
83
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
84
-
if len(ems) == 0 {
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
85
76
return make(map[string]string), nil
86
77
}
87
78
···
90
81
verifiedFilter = 1
91
82
}
92
83
84
+
assoc := make(map[string]string)
85
+
93
86
// Create placeholders for the IN clause
94
-
placeholders := make([]string, len(ems))
95
-
args := make([]any, len(ems)+1)
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
96
89
97
90
args[0] = verifiedFilter
98
-
for i, em := range ems {
99
-
placeholders[i] = "?"
100
-
args[i+1] = em
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
101
98
}
102
99
103
100
query := `
···
113
110
return nil, err
114
111
}
115
112
defer rows.Close()
116
-
117
-
assoc := make(map[string]string)
118
113
119
114
for rows.Next() {
120
115
var email, did string
···
187
182
return count > 0, nil
188
183
}
189
184
190
-
func AddEmail(e Execer, email Email) error {
185
+
func AddEmail(e Execer, email models.Email) error {
191
186
// Check if this is the first email for this DID
192
187
countQuery := `
193
188
select count(*)
···
254
249
return err
255
250
}
256
251
257
-
func GetAllEmails(e Execer, did string) ([]Email, error) {
252
+
func GetAllEmails(e Execer, did string) ([]models.Email, error) {
258
253
query := `
259
254
select did, email, verified, is_primary, verification_code, last_sent, created
260
255
from emails
···
266
261
}
267
262
defer rows.Close()
268
263
269
-
var emails []Email
264
+
var emails []models.Email
270
265
for rows.Next() {
271
-
var email Email
266
+
var email models.Email
272
267
var createdStr string
273
268
var lastSent string
274
269
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+79
-50
appview/db/follow.go
+79
-50
appview/db/follow.go
···
5
5
"log"
6
6
"strings"
7
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type Follow struct {
11
-
UserDid string
12
-
SubjectDid string
13
-
FollowedAt time.Time
14
-
Rkey string
15
-
}
16
-
17
-
func AddFollow(e Execer, follow *Follow) error {
12
+
func AddFollow(e Execer, follow *models.Follow) error {
18
13
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
19
14
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
20
15
return err
21
16
}
22
17
23
18
// Get a follow record
24
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
19
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
25
20
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
26
21
row := e.QueryRow(query, userDid, subjectDid)
27
22
28
-
var follow Follow
23
+
var follow models.Follow
29
24
var followedAt string
30
25
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
31
26
if err != nil {
···
55
50
return err
56
51
}
57
52
58
-
type FollowStats struct {
59
-
Followers int64
60
-
Following int64
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
53
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
64
54
var followers, following int64
65
55
err := e.QueryRow(
66
56
`SELECT
···
68
58
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
59
FROM follows;`, did, did).Scan(&followers, &following)
70
60
if err != nil {
71
-
return FollowStats{}, err
61
+
return models.FollowStats{}, err
72
62
}
73
-
return FollowStats{
63
+
return models.FollowStats{
74
64
Followers: followers,
75
65
Following: following,
76
66
}, nil
77
67
}
78
68
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
69
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
80
70
if len(dids) == 0 {
81
71
return nil, nil
82
72
}
···
112
102
) g on f.did = g.did`,
113
103
placeholderStr, placeholderStr)
114
104
115
-
result := make(map[string]FollowStats)
105
+
result := make(map[string]models.FollowStats)
116
106
117
107
rows, err := e.Query(query, args...)
118
108
if err != nil {
···
126
116
if err := rows.Scan(&did, &followers, &following); err != nil {
127
117
return nil, err
128
118
}
129
-
result[did] = FollowStats{
119
+
result[did] = models.FollowStats{
130
120
Followers: followers,
131
121
Following: following,
132
122
}
···
134
124
135
125
for _, did := range dids {
136
126
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
127
+
result[did] = models.FollowStats{
138
128
Followers: 0,
139
129
Following: 0,
140
130
}
···
144
134
return result, nil
145
135
}
146
136
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
148
-
var follows []Follow
137
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
var follows []models.Follow
149
139
150
140
var conditions []string
151
141
var args []any
···
177
167
return nil, err
178
168
}
179
169
for rows.Next() {
180
-
var follow Follow
170
+
var follow models.Follow
181
171
var followedAt string
182
172
err := rows.Scan(
183
173
&follow.UserDid,
···
200
190
return follows, nil
201
191
}
202
192
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
193
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
204
194
return GetFollows(e, 0, FilterEq("subject_did", did))
205
195
}
206
196
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
197
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
208
198
return GetFollows(e, 0, FilterEq("user_did", did))
209
199
}
210
200
211
-
type FollowStatus int
201
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
202
+
if len(subjectDids) == 0 || userDid == "" {
203
+
return make(map[string]models.FollowStatus), nil
204
+
}
205
+
206
+
result := make(map[string]models.FollowStatus)
207
+
208
+
for _, subjectDid := range subjectDids {
209
+
if userDid == subjectDid {
210
+
result[subjectDid] = models.IsSelf
211
+
} else {
212
+
result[subjectDid] = models.IsNotFollowing
213
+
}
214
+
}
215
+
216
+
var querySubjects []string
217
+
for _, subjectDid := range subjectDids {
218
+
if userDid != subjectDid {
219
+
querySubjects = append(querySubjects, subjectDid)
220
+
}
221
+
}
212
222
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
223
+
if len(querySubjects) == 0 {
224
+
return result, nil
225
+
}
218
226
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
227
+
placeholders := make([]string, len(querySubjects))
228
+
args := make([]any, len(querySubjects)+1)
229
+
args[0] = userDid
230
+
231
+
for i, subjectDid := range querySubjects {
232
+
placeholders[i] = "?"
233
+
args[i+1] = subjectDid
229
234
}
235
+
236
+
query := fmt.Sprintf(`
237
+
SELECT subject_did
238
+
FROM follows
239
+
WHERE user_did = ? AND subject_did IN (%s)
240
+
`, strings.Join(placeholders, ","))
241
+
242
+
rows, err := e.Query(query, args...)
243
+
if err != nil {
244
+
return nil, err
245
+
}
246
+
defer rows.Close()
247
+
248
+
for rows.Next() {
249
+
var subjectDid string
250
+
if err := rows.Scan(&subjectDid); err != nil {
251
+
return nil, err
252
+
}
253
+
result[subjectDid] = models.IsFollowing
254
+
}
255
+
256
+
return result, nil
230
257
}
231
258
232
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
233
-
if userDid == subjectDid {
234
-
return IsSelf
235
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
236
-
return IsNotFollowing
237
-
} else {
238
-
return IsFollowing
259
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
260
+
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
261
+
if err != nil {
262
+
return models.IsNotFollowing
239
263
}
264
+
return statuses[subjectDid]
265
+
}
266
+
267
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
268
+
return getFollowStatuses(e, userDid, subjectDids)
240
269
}
+35
-192
appview/db/issues.go
+35
-192
appview/db/issues.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pagination"
15
15
)
16
16
17
-
type Issue struct {
18
-
Id int64
19
-
Did string
20
-
Rkey string
21
-
RepoAt syntax.ATURI
22
-
IssueId int
23
-
Created time.Time
24
-
Edited *time.Time
25
-
Deleted *time.Time
26
-
Title string
27
-
Body string
28
-
Open bool
29
-
30
-
// optionally, populate this when querying for reverse mappings
31
-
// like comment counts, parent repo etc.
32
-
Comments []IssueComment
33
-
Repo *Repo
34
-
}
35
-
36
-
func (i *Issue) AtUri() syntax.ATURI {
37
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
38
-
}
39
-
40
-
func (i *Issue) AsRecord() tangled.RepoIssue {
41
-
return tangled.RepoIssue{
42
-
Repo: i.RepoAt.String(),
43
-
Title: i.Title,
44
-
Body: &i.Body,
45
-
CreatedAt: i.Created.Format(time.RFC3339),
46
-
}
47
-
}
48
-
49
-
func (i *Issue) State() string {
50
-
if i.Open {
51
-
return "open"
52
-
}
53
-
return "closed"
54
-
}
55
-
56
-
type CommentListItem struct {
57
-
Self *IssueComment
58
-
Replies []*IssueComment
59
-
}
60
-
61
-
func (i *Issue) CommentList() []CommentListItem {
62
-
// Create a map to quickly find comments by their aturi
63
-
toplevel := make(map[string]*CommentListItem)
64
-
var replies []*IssueComment
65
-
66
-
// collect top level comments into the map
67
-
for _, comment := range i.Comments {
68
-
if comment.IsTopLevel() {
69
-
toplevel[comment.AtUri().String()] = &CommentListItem{
70
-
Self: &comment,
71
-
}
72
-
} else {
73
-
replies = append(replies, &comment)
74
-
}
75
-
}
76
-
77
-
for _, r := range replies {
78
-
parentAt := *r.ReplyTo
79
-
if parent, exists := toplevel[parentAt]; exists {
80
-
parent.Replies = append(parent.Replies, r)
81
-
}
82
-
}
83
-
84
-
var listing []CommentListItem
85
-
for _, v := range toplevel {
86
-
listing = append(listing, *v)
87
-
}
88
-
89
-
// sort everything
90
-
sortFunc := func(a, b *IssueComment) bool {
91
-
return a.Created.Before(b.Created)
92
-
}
93
-
sort.Slice(listing, func(i, j int) bool {
94
-
return sortFunc(listing[i].Self, listing[j].Self)
95
-
})
96
-
for _, r := range listing {
97
-
sort.Slice(r.Replies, func(i, j int) bool {
98
-
return sortFunc(r.Replies[i], r.Replies[j])
99
-
})
100
-
}
101
-
102
-
return listing
103
-
}
104
-
105
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
106
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
107
-
if err != nil {
108
-
created = time.Now()
109
-
}
110
-
111
-
body := ""
112
-
if record.Body != nil {
113
-
body = *record.Body
114
-
}
115
-
116
-
return Issue{
117
-
RepoAt: syntax.ATURI(record.Repo),
118
-
Did: did,
119
-
Rkey: rkey,
120
-
Created: created,
121
-
Title: record.Title,
122
-
Body: body,
123
-
Open: true, // new issues are open by default
124
-
}
125
-
}
126
-
127
-
type IssueComment struct {
128
-
Id int64
129
-
Did string
130
-
Rkey string
131
-
IssueAt string
132
-
ReplyTo *string
133
-
Body string
134
-
Created time.Time
135
-
Edited *time.Time
136
-
Deleted *time.Time
137
-
}
138
-
139
-
func (i *IssueComment) AtUri() syntax.ATURI {
140
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
141
-
}
142
-
143
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
144
-
return tangled.RepoIssueComment{
145
-
Body: i.Body,
146
-
Issue: i.IssueAt,
147
-
CreatedAt: i.Created.Format(time.RFC3339),
148
-
ReplyTo: i.ReplyTo,
149
-
}
150
-
}
151
-
152
-
func (i *IssueComment) IsTopLevel() bool {
153
-
return i.ReplyTo == nil
154
-
}
155
-
156
-
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
157
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
158
-
if err != nil {
159
-
created = time.Now()
160
-
}
161
-
162
-
ownerDid := did
163
-
164
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
165
-
return nil, err
166
-
}
167
-
168
-
comment := IssueComment{
169
-
Did: ownerDid,
170
-
Rkey: rkey,
171
-
Body: record.Body,
172
-
IssueAt: record.Issue,
173
-
ReplyTo: record.ReplyTo,
174
-
Created: created,
175
-
}
176
-
177
-
return &comment, nil
178
-
}
179
-
180
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
17
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
181
18
// ensure sequence exists
182
19
_, err := tx.Exec(`
183
20
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
212
49
}
213
50
}
214
51
215
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
52
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
216
53
// get next issue_id
217
54
var newIssueId int
218
55
err := tx.QueryRow(`
219
-
update repo_issue_seqs
220
-
set next_issue_id = next_issue_id + 1
221
-
where repo_at = ?
56
+
update repo_issue_seqs
57
+
set next_issue_id = next_issue_id + 1
58
+
where repo_at = ?
222
59
returning next_issue_id - 1
223
60
`, issue.RepoAt).Scan(&newIssueId)
224
61
if err != nil {
···
235
72
return row.Scan(&issue.Id, &issue.IssueId)
236
73
}
237
74
238
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
75
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
239
76
// update existing issue
240
77
_, err := tx.Exec(`
241
78
update issues
···
245
82
return err
246
83
}
247
84
248
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
249
-
issueMap := make(map[string]*Issue) // at-uri -> issue
85
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
250
87
251
88
var conditions []string
252
89
var args []any
···
301
138
defer rows.Close()
302
139
303
140
for rows.Next() {
304
-
var issue Issue
141
+
var issue models.Issue
305
142
var createdAt string
306
143
var editedAt, deletedAt sql.Null[string]
307
144
var rowNum int64
···
354
191
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
355
192
}
356
193
357
-
repoMap := make(map[string]*Repo)
194
+
repoMap := make(map[string]*models.Repo)
358
195
for i := range repos {
359
196
repoMap[string(repos[i].RepoAt())] = &repos[i]
360
197
}
···
371
208
372
209
// collect comments
373
210
issueAts := slices.Collect(maps.Keys(issueMap))
211
+
374
212
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
375
213
if err != nil {
376
214
return nil, fmt.Errorf("failed to query comments: %w", err)
377
215
}
378
-
379
216
for i := range comments {
380
217
issueAt := comments[i].IssueAt
381
218
if issue, ok := issueMap[issueAt]; ok {
···
383
220
}
384
221
}
385
222
386
-
var issues []Issue
223
+
// collect allLabels for each issue
224
+
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
225
+
if err != nil {
226
+
return nil, fmt.Errorf("failed to query labels: %w", err)
227
+
}
228
+
for issueAt, labels := range allLabels {
229
+
if issue, ok := issueMap[issueAt.String()]; ok {
230
+
issue.Labels = labels
231
+
}
232
+
}
233
+
234
+
var issues []models.Issue
387
235
for _, i := range issueMap {
388
236
issues = append(issues, *i)
389
237
}
···
395
243
return issues, nil
396
244
}
397
245
398
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
246
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
399
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
400
248
}
401
249
402
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
403
251
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
404
252
row := e.QueryRow(query, repoAt, issueId)
405
253
406
-
var issue Issue
254
+
var issue models.Issue
407
255
var createdAt string
408
256
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
409
257
if err != nil {
···
419
267
return &issue, nil
420
268
}
421
269
422
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
270
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
423
271
result, err := e.Exec(
424
272
`insert into issue_comments (
425
273
did,
···
481
329
return err
482
330
}
483
331
484
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
485
-
var comments []IssueComment
332
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
333
+
var comments []models.IssueComment
486
334
487
335
var conditions []string
488
336
var args []any
···
518
366
}
519
367
520
368
for rows.Next() {
521
-
var comment IssueComment
369
+
var comment models.IssueComment
522
370
var created string
523
371
var rkey, edited, deleted, replyTo sql.Null[string]
524
372
err := rows.Scan(
···
625
473
return err
626
474
}
627
475
628
-
type IssueCount struct {
629
-
Open int
630
-
Closed int
631
-
}
632
-
633
-
func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
476
+
func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
634
477
row := e.QueryRow(`
635
478
select
636
479
count(case when open = 1 then 1 end) as open_count,
···
640
483
repoAt,
641
484
)
642
485
643
-
var count IssueCount
486
+
var count models.IssueCount
644
487
if err := row.Scan(&count.Open, &count.Closed); err != nil {
645
-
return IssueCount{0, 0}, err
488
+
return models.IssueCount{}, err
646
489
}
647
490
648
491
return count, nil
+353
appview/db/label.go
+353
appview/db/label.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"maps"
7
+
"slices"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/appview/models"
13
+
)
14
+
15
+
// no updating type for now
16
+
func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) {
17
+
result, err := e.Exec(
18
+
`insert into label_definitions (
19
+
did,
20
+
rkey,
21
+
name,
22
+
value_type,
23
+
value_format,
24
+
value_enum,
25
+
scope,
26
+
color,
27
+
multiple,
28
+
created
29
+
)
30
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
31
+
on conflict(did, rkey) do update set
32
+
name = excluded.name,
33
+
scope = excluded.scope,
34
+
color = excluded.color,
35
+
multiple = excluded.multiple`,
36
+
l.Did,
37
+
l.Rkey,
38
+
l.Name,
39
+
l.ValueType.Type,
40
+
l.ValueType.Format,
41
+
strings.Join(l.ValueType.Enum, ","),
42
+
strings.Join(l.Scope, ","),
43
+
l.Color,
44
+
l.Multiple,
45
+
l.Created.Format(time.RFC3339),
46
+
time.Now().Format(time.RFC3339),
47
+
)
48
+
if err != nil {
49
+
return 0, err
50
+
}
51
+
52
+
id, err := result.LastInsertId()
53
+
if err != nil {
54
+
return 0, err
55
+
}
56
+
57
+
l.Id = id
58
+
59
+
return id, nil
60
+
}
61
+
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
80
+
var conditions []string
81
+
var args []any
82
+
83
+
for _, filter := range filters {
84
+
conditions = append(conditions, filter.Condition())
85
+
args = append(args, filter.Arg()...)
86
+
}
87
+
88
+
whereClause := ""
89
+
if conditions != nil {
90
+
whereClause = " where " + strings.Join(conditions, " and ")
91
+
}
92
+
93
+
query := fmt.Sprintf(
94
+
`
95
+
select
96
+
id,
97
+
did,
98
+
rkey,
99
+
name,
100
+
value_type,
101
+
value_format,
102
+
value_enum,
103
+
scope,
104
+
color,
105
+
multiple,
106
+
created
107
+
from label_definitions
108
+
%s
109
+
order by created
110
+
`,
111
+
whereClause,
112
+
)
113
+
114
+
rows, err := e.Query(query, args...)
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
defer rows.Close()
119
+
120
+
for rows.Next() {
121
+
var labelDefinition models.LabelDefinition
122
+
var createdAt, enumVariants, scopes string
123
+
var color sql.Null[string]
124
+
var multiple int
125
+
126
+
if err := rows.Scan(
127
+
&labelDefinition.Id,
128
+
&labelDefinition.Did,
129
+
&labelDefinition.Rkey,
130
+
&labelDefinition.Name,
131
+
&labelDefinition.ValueType.Type,
132
+
&labelDefinition.ValueType.Format,
133
+
&enumVariants,
134
+
&scopes,
135
+
&color,
136
+
&multiple,
137
+
&createdAt,
138
+
); err != nil {
139
+
return nil, err
140
+
}
141
+
142
+
labelDefinition.Created, err = time.Parse(time.RFC3339, createdAt)
143
+
if err != nil {
144
+
labelDefinition.Created = time.Now()
145
+
}
146
+
147
+
if color.Valid {
148
+
labelDefinition.Color = &color.V
149
+
}
150
+
151
+
if multiple != 0 {
152
+
labelDefinition.Multiple = true
153
+
}
154
+
155
+
if enumVariants != "" {
156
+
labelDefinition.ValueType.Enum = strings.Split(enumVariants, ",")
157
+
}
158
+
159
+
for s := range strings.SplitSeq(scopes, ",") {
160
+
labelDefinition.Scope = append(labelDefinition.Scope, s)
161
+
}
162
+
163
+
labelDefinitions = append(labelDefinitions, labelDefinition)
164
+
}
165
+
166
+
return labelDefinitions, nil
167
+
}
168
+
169
+
// helper to get exactly one label def
170
+
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
+
labels, err := GetLabelDefinitions(e, filters...)
172
+
if err != nil {
173
+
return nil, err
174
+
}
175
+
176
+
if labels == nil {
177
+
return nil, sql.ErrNoRows
178
+
}
179
+
180
+
if len(labels) != 1 {
181
+
return nil, fmt.Errorf("too many rows returned")
182
+
}
183
+
184
+
return &labels[0], nil
185
+
}
186
+
187
+
func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) {
188
+
now := time.Now()
189
+
result, err := e.Exec(
190
+
`insert into label_ops (
191
+
did,
192
+
rkey,
193
+
subject,
194
+
operation,
195
+
operand_key,
196
+
operand_value,
197
+
performed,
198
+
indexed
199
+
)
200
+
values (?, ?, ?, ?, ?, ?, ?, ?)
201
+
on conflict(did, rkey, subject, operand_key, operand_value) do update set
202
+
operation = excluded.operation,
203
+
operand_value = excluded.operand_value,
204
+
performed = excluded.performed,
205
+
indexed = excluded.indexed`,
206
+
l.Did,
207
+
l.Rkey,
208
+
l.Subject.String(),
209
+
string(l.Operation),
210
+
l.OperandKey,
211
+
l.OperandValue,
212
+
l.PerformedAt.Format(time.RFC3339),
213
+
now.Format(time.RFC3339),
214
+
)
215
+
if err != nil {
216
+
return 0, err
217
+
}
218
+
219
+
id, err := result.LastInsertId()
220
+
if err != nil {
221
+
return 0, err
222
+
}
223
+
224
+
l.Id = id
225
+
l.IndexedAt = now
226
+
227
+
return id, nil
228
+
}
229
+
230
+
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
var labelOps []models.LabelOp
232
+
var conditions []string
233
+
var args []any
234
+
235
+
for _, filter := range filters {
236
+
conditions = append(conditions, filter.Condition())
237
+
args = append(args, filter.Arg()...)
238
+
}
239
+
240
+
whereClause := ""
241
+
if conditions != nil {
242
+
whereClause = " where " + strings.Join(conditions, " and ")
243
+
}
244
+
245
+
query := fmt.Sprintf(
246
+
`
247
+
select
248
+
id,
249
+
did,
250
+
rkey,
251
+
subject,
252
+
operation,
253
+
operand_key,
254
+
operand_value,
255
+
performed,
256
+
indexed
257
+
from label_ops
258
+
%s
259
+
order by indexed
260
+
`,
261
+
whereClause,
262
+
)
263
+
264
+
rows, err := e.Query(query, args...)
265
+
if err != nil {
266
+
return nil, err
267
+
}
268
+
defer rows.Close()
269
+
270
+
for rows.Next() {
271
+
var labelOp models.LabelOp
272
+
var performedAt, indexedAt string
273
+
274
+
if err := rows.Scan(
275
+
&labelOp.Id,
276
+
&labelOp.Did,
277
+
&labelOp.Rkey,
278
+
&labelOp.Subject,
279
+
&labelOp.Operation,
280
+
&labelOp.OperandKey,
281
+
&labelOp.OperandValue,
282
+
&performedAt,
283
+
&indexedAt,
284
+
); err != nil {
285
+
return nil, err
286
+
}
287
+
288
+
labelOp.PerformedAt, err = time.Parse(time.RFC3339, performedAt)
289
+
if err != nil {
290
+
labelOp.PerformedAt = time.Now()
291
+
}
292
+
293
+
labelOp.IndexedAt, err = time.Parse(time.RFC3339, indexedAt)
294
+
if err != nil {
295
+
labelOp.IndexedAt = time.Now()
296
+
}
297
+
298
+
labelOps = append(labelOps, labelOp)
299
+
}
300
+
301
+
return labelOps, nil
302
+
}
303
+
304
+
// get labels for a given list of subject URIs
305
+
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
+
ops, err := GetLabelOps(e, filters...)
307
+
if err != nil {
308
+
return nil, err
309
+
}
310
+
311
+
// group ops by subject
312
+
opsBySubject := make(map[syntax.ATURI][]models.LabelOp)
313
+
for _, op := range ops {
314
+
subject := syntax.ATURI(op.Subject)
315
+
opsBySubject[subject] = append(opsBySubject[subject], op)
316
+
}
317
+
318
+
// get all unique labelats for creating the context
319
+
labelAtSet := make(map[string]bool)
320
+
for _, op := range ops {
321
+
labelAtSet[op.OperandKey] = true
322
+
}
323
+
labelAts := slices.Collect(maps.Keys(labelAtSet))
324
+
325
+
actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts))
326
+
if err != nil {
327
+
return nil, err
328
+
}
329
+
330
+
// apply label ops for each subject and collect results
331
+
results := make(map[syntax.ATURI]models.LabelState)
332
+
for subject, subjectOps := range opsBySubject {
333
+
state := models.NewLabelState()
334
+
actx.ApplyLabelOps(state, subjectOps)
335
+
results[subject] = state
336
+
}
337
+
338
+
return results, nil
339
+
}
340
+
341
+
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
+
labels, err := GetLabelDefinitions(e, filters...)
343
+
if err != nil {
344
+
return nil, err
345
+
}
346
+
347
+
defs := make(map[string]*models.LabelDefinition)
348
+
for _, l := range labels {
349
+
defs[l.AtUri().String()] = &l
350
+
}
351
+
352
+
return &models.LabelApplicationCtx{Defs: defs}, nil
353
+
}
+38
-13
appview/db/language.go
+38
-13
appview/db/language.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
5
6
"strings"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type RepoLanguage struct {
11
-
Id int64
12
-
RepoAt syntax.ATURI
13
-
Ref string
14
-
IsDefaultRef bool
15
-
Language string
16
-
Bytes int64
17
-
}
18
-
19
-
func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) {
12
+
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
20
13
var conditions []string
21
14
var args []any
22
15
for _, filter := range filters {
···
39
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
40
33
}
41
34
42
-
var langs []RepoLanguage
35
+
var langs []models.RepoLanguage
43
36
for rows.Next() {
44
-
var rl RepoLanguage
37
+
var rl models.RepoLanguage
45
38
var isDefaultRef int
46
39
47
40
err := rows.Scan(
···
69
62
return langs, nil
70
63
}
71
64
72
-
func InsertRepoLanguages(e Execer, langs []RepoLanguage) error {
65
+
func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error {
73
66
stmt, err := e.Prepare(
74
67
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
75
68
)
···
91
84
92
85
return nil
93
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+450
appview/db/notifications.go
+450
appview/db/notifications.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/appview/models"
12
+
"tangled.org/core/appview/pagination"
13
+
)
14
+
15
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
query := `
17
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
+
`
20
+
21
+
result, err := d.DB.ExecContext(ctx, query,
22
+
notification.RecipientDid,
23
+
notification.ActorDid,
24
+
string(notification.Type),
25
+
notification.EntityType,
26
+
notification.EntityId,
27
+
notification.Read,
28
+
notification.RepoId,
29
+
notification.IssueId,
30
+
notification.PullId,
31
+
)
32
+
if err != nil {
33
+
return fmt.Errorf("failed to create notification: %w", err)
34
+
}
35
+
36
+
id, err := result.LastInsertId()
37
+
if err != nil {
38
+
return fmt.Errorf("failed to get notification ID: %w", err)
39
+
}
40
+
41
+
notification.ID = id
42
+
return nil
43
+
}
44
+
45
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
46
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
47
+
var conditions []string
48
+
var args []any
49
+
50
+
for _, filter := range filters {
51
+
conditions = append(conditions, filter.Condition())
52
+
args = append(args, filter.Arg()...)
53
+
}
54
+
55
+
whereClause := ""
56
+
if len(conditions) > 0 {
57
+
whereClause = "WHERE " + conditions[0]
58
+
for _, condition := range conditions[1:] {
59
+
whereClause += " AND " + condition
60
+
}
61
+
}
62
+
63
+
query := fmt.Sprintf(`
64
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
65
+
from notifications
66
+
%s
67
+
order by created desc
68
+
limit ? offset ?
69
+
`, whereClause)
70
+
71
+
args = append(args, page.Limit, page.Offset)
72
+
73
+
rows, err := e.QueryContext(context.Background(), query, args...)
74
+
if err != nil {
75
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
76
+
}
77
+
defer rows.Close()
78
+
79
+
var notifications []*models.Notification
80
+
for rows.Next() {
81
+
var n models.Notification
82
+
var typeStr string
83
+
var createdStr string
84
+
err := rows.Scan(
85
+
&n.ID,
86
+
&n.RecipientDid,
87
+
&n.ActorDid,
88
+
&typeStr,
89
+
&n.EntityType,
90
+
&n.EntityId,
91
+
&n.Read,
92
+
&createdStr,
93
+
&n.RepoId,
94
+
&n.IssueId,
95
+
&n.PullId,
96
+
)
97
+
if err != nil {
98
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
99
+
}
100
+
n.Type = models.NotificationType(typeStr)
101
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
104
+
}
105
+
notifications = append(notifications, &n)
106
+
}
107
+
108
+
return notifications, nil
109
+
}
110
+
111
+
// GetNotificationsWithEntities retrieves notifications with their related entities
112
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
113
+
var conditions []string
114
+
var args []any
115
+
116
+
for _, filter := range filters {
117
+
conditions = append(conditions, filter.Condition())
118
+
args = append(args, filter.Arg()...)
119
+
}
120
+
121
+
whereClause := ""
122
+
if len(conditions) > 0 {
123
+
whereClause = "WHERE " + conditions[0]
124
+
for _, condition := range conditions[1:] {
125
+
whereClause += " AND " + condition
126
+
}
127
+
}
128
+
129
+
query := fmt.Sprintf(`
130
+
select
131
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
132
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
133
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
134
+
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
135
+
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
136
+
from notifications n
137
+
left join repos r on n.repo_id = r.id
138
+
left join issues i on n.issue_id = i.id
139
+
left join pulls p on n.pull_id = p.id
140
+
%s
141
+
order by n.created desc
142
+
limit ? offset ?
143
+
`, whereClause)
144
+
145
+
args = append(args, page.Limit, page.Offset)
146
+
147
+
rows, err := e.QueryContext(context.Background(), query, args...)
148
+
if err != nil {
149
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
150
+
}
151
+
defer rows.Close()
152
+
153
+
var notifications []*models.NotificationWithEntity
154
+
for rows.Next() {
155
+
var n models.Notification
156
+
var typeStr string
157
+
var createdStr string
158
+
var repo models.Repo
159
+
var issue models.Issue
160
+
var pull models.Pull
161
+
var rId, iId, pId sql.NullInt64
162
+
var rDid, rName, rDescription sql.NullString
163
+
var iDid sql.NullString
164
+
var iIssueId sql.NullInt64
165
+
var iTitle sql.NullString
166
+
var iOpen sql.NullBool
167
+
var pOwnerDid sql.NullString
168
+
var pPullId sql.NullInt64
169
+
var pTitle sql.NullString
170
+
var pState sql.NullInt64
171
+
172
+
err := rows.Scan(
173
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
174
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
175
+
&rId, &rDid, &rName, &rDescription,
176
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
177
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
178
+
)
179
+
if err != nil {
180
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
181
+
}
182
+
183
+
n.Type = models.NotificationType(typeStr)
184
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
185
+
if err != nil {
186
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
187
+
}
188
+
189
+
nwe := &models.NotificationWithEntity{Notification: &n}
190
+
191
+
// populate repo if present
192
+
if rId.Valid {
193
+
repo.Id = rId.Int64
194
+
if rDid.Valid {
195
+
repo.Did = rDid.String
196
+
}
197
+
if rName.Valid {
198
+
repo.Name = rName.String
199
+
}
200
+
if rDescription.Valid {
201
+
repo.Description = rDescription.String
202
+
}
203
+
nwe.Repo = &repo
204
+
}
205
+
206
+
// populate issue if present
207
+
if iId.Valid {
208
+
issue.Id = iId.Int64
209
+
if iDid.Valid {
210
+
issue.Did = iDid.String
211
+
}
212
+
if iIssueId.Valid {
213
+
issue.IssueId = int(iIssueId.Int64)
214
+
}
215
+
if iTitle.Valid {
216
+
issue.Title = iTitle.String
217
+
}
218
+
if iOpen.Valid {
219
+
issue.Open = iOpen.Bool
220
+
}
221
+
nwe.Issue = &issue
222
+
}
223
+
224
+
// populate pull if present
225
+
if pId.Valid {
226
+
pull.ID = int(pId.Int64)
227
+
if pOwnerDid.Valid {
228
+
pull.OwnerDid = pOwnerDid.String
229
+
}
230
+
if pPullId.Valid {
231
+
pull.PullId = int(pPullId.Int64)
232
+
}
233
+
if pTitle.Valid {
234
+
pull.Title = pTitle.String
235
+
}
236
+
if pState.Valid {
237
+
pull.State = models.PullState(pState.Int64)
238
+
}
239
+
nwe.Pull = &pull
240
+
}
241
+
242
+
notifications = append(notifications, nwe)
243
+
}
244
+
245
+
return notifications, nil
246
+
}
247
+
248
+
// GetNotifications retrieves notifications with filters
249
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
250
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
251
+
}
252
+
253
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
254
+
var conditions []string
255
+
var args []any
256
+
for _, filter := range filters {
257
+
conditions = append(conditions, filter.Condition())
258
+
args = append(args, filter.Arg()...)
259
+
}
260
+
261
+
whereClause := ""
262
+
if conditions != nil {
263
+
whereClause = " where " + strings.Join(conditions, " and ")
264
+
}
265
+
266
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
267
+
var count int64
268
+
err := e.QueryRow(query, args...).Scan(&count)
269
+
270
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
271
+
return 0, err
272
+
}
273
+
274
+
return count, nil
275
+
}
276
+
277
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
278
+
idFilter := FilterEq("id", notificationID)
279
+
recipientFilter := FilterEq("recipient_did", userDID)
280
+
281
+
query := fmt.Sprintf(`
282
+
UPDATE notifications
283
+
SET read = 1
284
+
WHERE %s AND %s
285
+
`, idFilter.Condition(), recipientFilter.Condition())
286
+
287
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
+
289
+
result, err := d.DB.ExecContext(ctx, query, args...)
290
+
if err != nil {
291
+
return fmt.Errorf("failed to mark notification as read: %w", err)
292
+
}
293
+
294
+
rowsAffected, err := result.RowsAffected()
295
+
if err != nil {
296
+
return fmt.Errorf("failed to get rows affected: %w", err)
297
+
}
298
+
299
+
if rowsAffected == 0 {
300
+
return fmt.Errorf("notification not found or access denied")
301
+
}
302
+
303
+
return nil
304
+
}
305
+
306
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
307
+
recipientFilter := FilterEq("recipient_did", userDID)
308
+
readFilter := FilterEq("read", 0)
309
+
310
+
query := fmt.Sprintf(`
311
+
UPDATE notifications
312
+
SET read = 1
313
+
WHERE %s AND %s
314
+
`, recipientFilter.Condition(), readFilter.Condition())
315
+
316
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
+
318
+
_, err := d.DB.ExecContext(ctx, query, args...)
319
+
if err != nil {
320
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
+
}
322
+
323
+
return nil
324
+
}
325
+
326
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
327
+
idFilter := FilterEq("id", notificationID)
328
+
recipientFilter := FilterEq("recipient_did", userDID)
329
+
330
+
query := fmt.Sprintf(`
331
+
DELETE FROM notifications
332
+
WHERE %s AND %s
333
+
`, idFilter.Condition(), recipientFilter.Condition())
334
+
335
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
+
337
+
result, err := d.DB.ExecContext(ctx, query, args...)
338
+
if err != nil {
339
+
return fmt.Errorf("failed to delete notification: %w", err)
340
+
}
341
+
342
+
rowsAffected, err := result.RowsAffected()
343
+
if err != nil {
344
+
return fmt.Errorf("failed to get rows affected: %w", err)
345
+
}
346
+
347
+
if rowsAffected == 0 {
348
+
return fmt.Errorf("notification not found or access denied")
349
+
}
350
+
351
+
return nil
352
+
}
353
+
354
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
+
userFilter := FilterEq("user_did", userDID)
356
+
357
+
query := fmt.Sprintf(`
358
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
+
FROM notification_preferences
361
+
WHERE %s
362
+
`, userFilter.Condition())
363
+
364
+
var prefs models.NotificationPreferences
365
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
+
&prefs.ID,
367
+
&prefs.UserDid,
368
+
&prefs.RepoStarred,
369
+
&prefs.IssueCreated,
370
+
&prefs.IssueCommented,
371
+
&prefs.PullCreated,
372
+
&prefs.PullCommented,
373
+
&prefs.Followed,
374
+
&prefs.PullMerged,
375
+
&prefs.IssueClosed,
376
+
&prefs.EmailNotifications,
377
+
)
378
+
379
+
if err != nil {
380
+
if err == sql.ErrNoRows {
381
+
return &models.NotificationPreferences{
382
+
UserDid: userDID,
383
+
RepoStarred: true,
384
+
IssueCreated: true,
385
+
IssueCommented: true,
386
+
PullCreated: true,
387
+
PullCommented: true,
388
+
Followed: true,
389
+
PullMerged: true,
390
+
IssueClosed: true,
391
+
EmailNotifications: false,
392
+
}, nil
393
+
}
394
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
395
+
}
396
+
397
+
return &prefs, nil
398
+
}
399
+
400
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
401
+
query := `
402
+
INSERT OR REPLACE INTO notification_preferences
403
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
404
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
405
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
+
`
407
+
408
+
result, err := d.DB.ExecContext(ctx, query,
409
+
prefs.UserDid,
410
+
prefs.RepoStarred,
411
+
prefs.IssueCreated,
412
+
prefs.IssueCommented,
413
+
prefs.PullCreated,
414
+
prefs.PullCommented,
415
+
prefs.Followed,
416
+
prefs.PullMerged,
417
+
prefs.IssueClosed,
418
+
prefs.EmailNotifications,
419
+
)
420
+
if err != nil {
421
+
return fmt.Errorf("failed to update notification preferences: %w", err)
422
+
}
423
+
424
+
if prefs.ID == 0 {
425
+
id, err := result.LastInsertId()
426
+
if err != nil {
427
+
return fmt.Errorf("failed to get preferences ID: %w", err)
428
+
}
429
+
prefs.ID = id
430
+
}
431
+
432
+
return nil
433
+
}
434
+
435
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
436
+
cutoff := time.Now().Add(-olderThan)
437
+
createdFilter := FilterLte("created", cutoff)
438
+
439
+
query := fmt.Sprintf(`
440
+
DELETE FROM notifications
441
+
WHERE %s
442
+
`, createdFilter.Condition())
443
+
444
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
445
+
if err != nil {
446
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
447
+
}
448
+
449
+
return nil
450
+
}
-173
appview/db/oauth.go
-173
appview/db/oauth.go
···
1
-
package db
2
-
3
-
type OAuthRequest struct {
4
-
ID uint
5
-
AuthserverIss string
6
-
Handle string
7
-
State string
8
-
Did string
9
-
PdsUrl string
10
-
PkceVerifier string
11
-
DpopAuthserverNonce string
12
-
DpopPrivateJwk string
13
-
}
14
-
15
-
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
16
-
_, err := e.Exec(`
17
-
insert into oauth_requests (
18
-
auth_server_iss,
19
-
state,
20
-
handle,
21
-
did,
22
-
pds_url,
23
-
pkce_verifier,
24
-
dpop_auth_server_nonce,
25
-
dpop_private_jwk
26
-
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
27
-
oauthRequest.AuthserverIss,
28
-
oauthRequest.State,
29
-
oauthRequest.Handle,
30
-
oauthRequest.Did,
31
-
oauthRequest.PdsUrl,
32
-
oauthRequest.PkceVerifier,
33
-
oauthRequest.DpopAuthserverNonce,
34
-
oauthRequest.DpopPrivateJwk,
35
-
)
36
-
return err
37
-
}
38
-
39
-
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
40
-
var req OAuthRequest
41
-
err := e.QueryRow(`
42
-
select
43
-
id,
44
-
auth_server_iss,
45
-
handle,
46
-
state,
47
-
did,
48
-
pds_url,
49
-
pkce_verifier,
50
-
dpop_auth_server_nonce,
51
-
dpop_private_jwk
52
-
from oauth_requests
53
-
where state = ?`, state).Scan(
54
-
&req.ID,
55
-
&req.AuthserverIss,
56
-
&req.Handle,
57
-
&req.State,
58
-
&req.Did,
59
-
&req.PdsUrl,
60
-
&req.PkceVerifier,
61
-
&req.DpopAuthserverNonce,
62
-
&req.DpopPrivateJwk,
63
-
)
64
-
return req, err
65
-
}
66
-
67
-
func DeleteOAuthRequestByState(e Execer, state string) error {
68
-
_, err := e.Exec(`
69
-
delete from oauth_requests
70
-
where state = ?`, state)
71
-
return err
72
-
}
73
-
74
-
type OAuthSession struct {
75
-
ID uint
76
-
Handle string
77
-
Did string
78
-
PdsUrl string
79
-
AccessJwt string
80
-
RefreshJwt string
81
-
AuthServerIss string
82
-
DpopPdsNonce string
83
-
DpopAuthserverNonce string
84
-
DpopPrivateJwk string
85
-
Expiry string
86
-
}
87
-
88
-
func SaveOAuthSession(e Execer, session OAuthSession) error {
89
-
_, err := e.Exec(`
90
-
insert into oauth_sessions (
91
-
did,
92
-
handle,
93
-
pds_url,
94
-
access_jwt,
95
-
refresh_jwt,
96
-
auth_server_iss,
97
-
dpop_auth_server_nonce,
98
-
dpop_private_jwk,
99
-
expiry
100
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
-
session.Did,
102
-
session.Handle,
103
-
session.PdsUrl,
104
-
session.AccessJwt,
105
-
session.RefreshJwt,
106
-
session.AuthServerIss,
107
-
session.DpopAuthserverNonce,
108
-
session.DpopPrivateJwk,
109
-
session.Expiry,
110
-
)
111
-
return err
112
-
}
113
-
114
-
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
115
-
_, err := e.Exec(`
116
-
update oauth_sessions
117
-
set access_jwt = ?, refresh_jwt = ?, expiry = ?
118
-
where did = ?`,
119
-
accessJwt,
120
-
refreshJwt,
121
-
expiry,
122
-
did,
123
-
)
124
-
return err
125
-
}
126
-
127
-
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
128
-
var session OAuthSession
129
-
err := e.QueryRow(`
130
-
select
131
-
id,
132
-
did,
133
-
handle,
134
-
pds_url,
135
-
access_jwt,
136
-
refresh_jwt,
137
-
auth_server_iss,
138
-
dpop_auth_server_nonce,
139
-
dpop_private_jwk,
140
-
expiry
141
-
from oauth_sessions
142
-
where did = ?`, did).Scan(
143
-
&session.ID,
144
-
&session.Did,
145
-
&session.Handle,
146
-
&session.PdsUrl,
147
-
&session.AccessJwt,
148
-
&session.RefreshJwt,
149
-
&session.AuthServerIss,
150
-
&session.DpopAuthserverNonce,
151
-
&session.DpopPrivateJwk,
152
-
&session.Expiry,
153
-
)
154
-
return &session, err
155
-
}
156
-
157
-
func DeleteOAuthSessionByDid(e Execer, did string) error {
158
-
_, err := e.Exec(`
159
-
delete from oauth_sessions
160
-
where did = ?`, did)
161
-
return err
162
-
}
163
-
164
-
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
-
_, err := e.Exec(`
166
-
update oauth_sessions
167
-
set dpop_pds_nonce = ?
168
-
where did = ?`,
169
-
dpopPdsNonce,
170
-
did,
171
-
)
172
-
return err
173
-
}
+17
-139
appview/db/pipeline.go
+17
-139
appview/db/pipeline.go
···
6
6
"strings"
7
7
"time"
8
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/go-git/go-git/v5/plumbing"
11
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
-
"tangled.sh/tangled.sh/core/workflow"
9
+
"tangled.org/core/appview/models"
13
10
)
14
11
15
-
type Pipeline struct {
16
-
Id int
17
-
Rkey string
18
-
Knot string
19
-
RepoOwner syntax.DID
20
-
RepoName string
21
-
TriggerId int
22
-
Sha string
23
-
Created time.Time
24
-
25
-
// populate when querying for reverse mappings
26
-
Trigger *Trigger
27
-
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
type WorkflowStatus struct {
31
-
Data []PipelineStatus
32
-
}
33
-
34
-
func (w WorkflowStatus) Latest() PipelineStatus {
35
-
return w.Data[len(w.Data)-1]
36
-
}
37
-
38
-
// time taken by this workflow to reach an "end state"
39
-
func (w WorkflowStatus) TimeTaken() time.Duration {
40
-
var start, end *time.Time
41
-
for _, s := range w.Data {
42
-
if s.Status.IsStart() {
43
-
start = &s.Created
44
-
}
45
-
if s.Status.IsFinish() {
46
-
end = &s.Created
47
-
}
48
-
}
49
-
50
-
if start != nil && end != nil && end.After(*start) {
51
-
return end.Sub(*start)
52
-
}
53
-
54
-
return 0
55
-
}
56
-
57
-
func (p Pipeline) Counts() map[string]int {
58
-
m := make(map[string]int)
59
-
for _, w := range p.Statuses {
60
-
m[w.Latest().Status.String()] += 1
61
-
}
62
-
return m
63
-
}
64
-
65
-
func (p Pipeline) TimeTaken() time.Duration {
66
-
var s time.Duration
67
-
for _, w := range p.Statuses {
68
-
s += w.TimeTaken()
69
-
}
70
-
return s
71
-
}
72
-
73
-
func (p Pipeline) Workflows() []string {
74
-
var ws []string
75
-
for v := range p.Statuses {
76
-
ws = append(ws, v)
77
-
}
78
-
slices.Sort(ws)
79
-
return ws
80
-
}
81
-
82
-
// if we know that a spindle has picked up this pipeline, then it is Responding
83
-
func (p Pipeline) IsResponding() bool {
84
-
return len(p.Statuses) != 0
85
-
}
86
-
87
-
type Trigger struct {
88
-
Id int
89
-
Kind workflow.TriggerKind
90
-
91
-
// push trigger fields
92
-
PushRef *string
93
-
PushNewSha *string
94
-
PushOldSha *string
95
-
96
-
// pull request trigger fields
97
-
PRSourceBranch *string
98
-
PRTargetBranch *string
99
-
PRSourceSha *string
100
-
PRAction *string
101
-
}
102
-
103
-
func (t *Trigger) IsPush() bool {
104
-
return t != nil && t.Kind == workflow.TriggerKindPush
105
-
}
106
-
107
-
func (t *Trigger) IsPullRequest() bool {
108
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
-
}
110
-
111
-
func (t *Trigger) TargetRef() string {
112
-
if t.IsPush() {
113
-
return plumbing.ReferenceName(*t.PushRef).Short()
114
-
} else if t.IsPullRequest() {
115
-
return *t.PRTargetBranch
116
-
}
117
-
118
-
return ""
119
-
}
120
-
121
-
type PipelineStatus struct {
122
-
ID int
123
-
Spindle string
124
-
Rkey string
125
-
PipelineKnot string
126
-
PipelineRkey string
127
-
Created time.Time
128
-
Workflow string
129
-
Status spindle.StatusKind
130
-
Error *string
131
-
ExitCode int
132
-
}
133
-
134
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
-
var pipelines []Pipeline
12
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
var pipelines []models.Pipeline
136
14
137
15
var conditions []string
138
16
var args []any
···
156
34
defer rows.Close()
157
35
158
36
for rows.Next() {
159
-
var pipeline Pipeline
37
+
var pipeline models.Pipeline
160
38
var createdAt string
161
39
err = rows.Scan(
162
40
&pipeline.Id,
···
185
63
return pipelines, nil
186
64
}
187
65
188
-
func AddPipeline(e Execer, pipeline Pipeline) error {
66
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
189
67
args := []any{
190
68
pipeline.Rkey,
191
69
pipeline.Knot,
···
216
94
return err
217
95
}
218
96
219
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
97
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
220
98
args := []any{
221
99
trigger.Kind,
222
100
trigger.PushRef,
···
252
130
return res.LastInsertId()
253
131
}
254
132
255
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
133
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
256
134
args := []any{
257
135
status.Spindle,
258
136
status.Rkey,
···
290
168
291
169
// this is a mega query, but the most useful one:
292
170
// get N pipelines, for each one get the latest status of its N workflows
293
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
171
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
294
172
var conditions []string
295
173
var args []any
296
174
for _, filter := range filters {
···
335
213
}
336
214
defer rows.Close()
337
215
338
-
pipelines := make(map[string]Pipeline)
216
+
pipelines := make(map[string]models.Pipeline)
339
217
for rows.Next() {
340
-
var p Pipeline
341
-
var t Trigger
218
+
var p models.Pipeline
219
+
var t models.Trigger
342
220
var created string
343
221
344
222
err := rows.Scan(
···
370
248
371
249
t.Id = p.TriggerId
372
250
p.Trigger = &t
373
-
p.Statuses = make(map[string]WorkflowStatus)
251
+
p.Statuses = make(map[string]models.WorkflowStatus)
374
252
375
253
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
254
pipelines[k] = p
···
409
287
defer rows.Close()
410
288
411
289
for rows.Next() {
412
-
var ps PipelineStatus
290
+
var ps models.PipelineStatus
413
291
var created string
414
292
415
293
err := rows.Scan(
···
442
320
}
443
321
statuses, _ := pipeline.Statuses[ps.Workflow]
444
322
if !ok {
445
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
323
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
446
324
}
447
325
448
326
// append
···
453
331
pipelines[key] = pipeline
454
332
}
455
333
456
-
var all []Pipeline
334
+
var all []models.Pipeline
457
335
for _, p := range pipelines {
458
336
for _, s := range p.Statuses {
459
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
337
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
460
338
if a.Created.After(b.Created) {
461
339
return 1
462
340
}
···
476
354
}
477
355
478
356
// sort pipelines by date
479
-
slices.SortFunc(all, func(a, b Pipeline) int {
357
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
480
358
if a.Created.After(b.Created) {
481
359
return -1
482
360
}
+25
-194
appview/db/profile.go
+25
-194
appview/db/profile.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.org/core/appview/models"
14
14
)
15
15
16
-
type RepoEvent struct {
17
-
Repo *Repo
18
-
Source *Repo
19
-
}
20
-
21
-
type ProfileTimeline struct {
22
-
ByMonth []ByMonth
23
-
}
24
-
25
-
func (p *ProfileTimeline) IsEmpty() bool {
26
-
if p == nil {
27
-
return true
28
-
}
29
-
30
-
for _, m := range p.ByMonth {
31
-
if !m.IsEmpty() {
32
-
return false
33
-
}
34
-
}
35
-
36
-
return true
37
-
}
38
-
39
-
type ByMonth struct {
40
-
RepoEvents []RepoEvent
41
-
IssueEvents IssueEvents
42
-
PullEvents PullEvents
43
-
}
44
-
45
-
func (b ByMonth) IsEmpty() bool {
46
-
return len(b.RepoEvents) == 0 &&
47
-
len(b.IssueEvents.Items) == 0 &&
48
-
len(b.PullEvents.Items) == 0
49
-
}
50
-
51
-
type IssueEvents struct {
52
-
Items []*Issue
53
-
}
54
-
55
-
type IssueEventStats struct {
56
-
Open int
57
-
Closed int
58
-
}
59
-
60
-
func (i IssueEvents) Stats() IssueEventStats {
61
-
var open, closed int
62
-
for _, issue := range i.Items {
63
-
if issue.Open {
64
-
open += 1
65
-
} else {
66
-
closed += 1
67
-
}
68
-
}
69
-
70
-
return IssueEventStats{
71
-
Open: open,
72
-
Closed: closed,
73
-
}
74
-
}
75
-
76
-
type PullEvents struct {
77
-
Items []*Pull
78
-
}
79
-
80
-
func (p PullEvents) Stats() PullEventStats {
81
-
var open, merged, closed int
82
-
for _, pull := range p.Items {
83
-
switch pull.State {
84
-
case PullOpen:
85
-
open += 1
86
-
case PullMerged:
87
-
merged += 1
88
-
case PullClosed:
89
-
closed += 1
90
-
}
91
-
}
92
-
93
-
return PullEventStats{
94
-
Open: open,
95
-
Merged: merged,
96
-
Closed: closed,
97
-
}
98
-
}
99
-
100
-
type PullEventStats struct {
101
-
Closed int
102
-
Open int
103
-
Merged int
104
-
}
105
-
106
16
const TimeframeMonths = 7
107
17
108
-
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
109
-
timeline := ProfileTimeline{
110
-
ByMonth: make([]ByMonth, TimeframeMonths),
18
+
func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) {
19
+
timeline := models.ProfileTimeline{
20
+
ByMonth: make([]models.ByMonth, TimeframeMonths),
111
21
}
112
22
currentMonth := time.Now().Month()
113
23
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
162
72
163
73
for _, repo := range repos {
164
74
// TODO: get this in the original query; requires COALESCE because nullable
165
-
var sourceRepo *Repo
75
+
var sourceRepo *models.Repo
166
76
if repo.Source != "" {
167
77
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
168
78
if err != nil {
···
180
90
idx := currentMonth - repoMonth
181
91
182
92
items := &timeline.ByMonth[idx].RepoEvents
183
-
*items = append(*items, RepoEvent{
93
+
*items = append(*items, models.RepoEvent{
184
94
Repo: &repo,
185
95
Source: sourceRepo,
186
96
})
···
189
99
return &timeline, nil
190
100
}
191
101
192
-
type Profile struct {
193
-
// ids
194
-
ID int
195
-
Did string
196
-
197
-
// data
198
-
Description string
199
-
IncludeBluesky bool
200
-
Location string
201
-
Links [5]string
202
-
Stats [2]VanityStat
203
-
PinnedRepos [6]syntax.ATURI
204
-
}
205
-
206
-
func (p Profile) IsLinksEmpty() bool {
207
-
for _, l := range p.Links {
208
-
if l != "" {
209
-
return false
210
-
}
211
-
}
212
-
return true
213
-
}
214
-
215
-
func (p Profile) IsStatsEmpty() bool {
216
-
for _, s := range p.Stats {
217
-
if s.Kind != "" {
218
-
return false
219
-
}
220
-
}
221
-
return true
222
-
}
223
-
224
-
func (p Profile) IsPinnedReposEmpty() bool {
225
-
for _, r := range p.PinnedRepos {
226
-
if r != "" {
227
-
return false
228
-
}
229
-
}
230
-
return true
231
-
}
232
-
233
-
type VanityStatKind string
234
-
235
-
const (
236
-
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
237
-
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
238
-
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
239
-
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
240
-
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
241
-
VanityStatRepositoryCount VanityStatKind = "repository-count"
242
-
)
243
-
244
-
func (v VanityStatKind) String() string {
245
-
switch v {
246
-
case VanityStatMergedPRCount:
247
-
return "Merged PRs"
248
-
case VanityStatClosedPRCount:
249
-
return "Closed PRs"
250
-
case VanityStatOpenPRCount:
251
-
return "Open PRs"
252
-
case VanityStatOpenIssueCount:
253
-
return "Open Issues"
254
-
case VanityStatClosedIssueCount:
255
-
return "Closed Issues"
256
-
case VanityStatRepositoryCount:
257
-
return "Repositories"
258
-
}
259
-
return ""
260
-
}
261
-
262
-
type VanityStat struct {
263
-
Kind VanityStatKind
264
-
Value uint64
265
-
}
266
-
267
-
func (p *Profile) ProfileAt() syntax.ATURI {
268
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
269
-
}
270
-
271
-
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
102
+
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
272
103
defer tx.Rollback()
273
104
274
105
// update links
···
366
197
return tx.Commit()
367
198
}
368
199
369
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
200
+
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
370
201
var conditions []string
371
202
var args []any
372
203
for _, filter := range filters {
···
396
227
return nil, err
397
228
}
398
229
399
-
profileMap := make(map[string]*Profile)
230
+
profileMap := make(map[string]*models.Profile)
400
231
for rows.Next() {
401
-
var profile Profile
232
+
var profile models.Profile
402
233
var includeBluesky int
403
234
404
235
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
469
300
return profileMap, nil
470
301
}
471
302
472
-
func GetProfile(e Execer, did string) (*Profile, error) {
473
-
var profile Profile
303
+
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
+
var profile models.Profile
474
305
profile.Did = did
475
306
476
307
includeBluesky := 0
···
479
310
did,
480
311
).Scan(&profile.Description, &includeBluesky, &profile.Location)
481
312
if err == sql.ErrNoRows {
482
-
profile := Profile{}
313
+
profile := models.Profile{}
483
314
profile.Did = did
484
315
return &profile, nil
485
316
}
···
539
370
return &profile, nil
540
371
}
541
372
542
-
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
373
+
func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
543
374
query := ""
544
375
var args []any
545
376
switch stat {
546
-
case VanityStatMergedPRCount:
377
+
case models.VanityStatMergedPRCount:
547
378
query = `select count(id) from pulls where owner_did = ? and state = ?`
548
-
args = append(args, did, PullMerged)
549
-
case VanityStatClosedPRCount:
379
+
args = append(args, did, models.PullMerged)
380
+
case models.VanityStatClosedPRCount:
550
381
query = `select count(id) from pulls where owner_did = ? and state = ?`
551
-
args = append(args, did, PullClosed)
552
-
case VanityStatOpenPRCount:
382
+
args = append(args, did, models.PullClosed)
383
+
case models.VanityStatOpenPRCount:
553
384
query = `select count(id) from pulls where owner_did = ? and state = ?`
554
-
args = append(args, did, PullOpen)
555
-
case VanityStatOpenIssueCount:
385
+
args = append(args, did, models.PullOpen)
386
+
case models.VanityStatOpenIssueCount:
556
387
query = `select count(id) from issues where did = ? and open = 1`
557
388
args = append(args, did)
558
-
case VanityStatClosedIssueCount:
389
+
case models.VanityStatClosedIssueCount:
559
390
query = `select count(id) from issues where did = ? and open = 0`
560
391
args = append(args, did)
561
-
case VanityStatRepositoryCount:
392
+
case models.VanityStatRepositoryCount:
562
393
query = `select count(id) from repos where did = ?`
563
394
args = append(args, did)
564
395
}
···
572
403
return result, nil
573
404
}
574
405
575
-
func ValidateProfile(e Execer, profile *Profile) error {
406
+
func ValidateProfile(e Execer, profile *models.Profile) error {
576
407
// ensure description is not too long
577
408
if len(profile.Description) > 256 {
578
409
return fmt.Errorf("Entered bio is too long.")
···
620
451
return nil
621
452
}
622
453
623
-
func validateLinks(profile *Profile) error {
454
+
func validateLinks(profile *models.Profile) error {
624
455
for i, link := range profile.Links {
625
456
if link == "" {
626
457
continue
+7
-26
appview/db/pubkeys.go
+7
-26
appview/db/pubkeys.go
···
1
1
package db
2
2
3
3
import (
4
-
"encoding/json"
4
+
"tangled.org/core/appview/models"
5
5
"time"
6
6
)
7
7
···
29
29
return err
30
30
}
31
31
32
-
type PublicKey struct {
33
-
Did string `json:"did"`
34
-
Key string `json:"key"`
35
-
Name string `json:"name"`
36
-
Rkey string `json:"rkey"`
37
-
Created *time.Time
38
-
}
39
-
40
-
func (p PublicKey) MarshalJSON() ([]byte, error) {
41
-
type Alias PublicKey
42
-
return json.Marshal(&struct {
43
-
Created string `json:"created"`
44
-
*Alias
45
-
}{
46
-
Created: p.Created.Format(time.RFC3339),
47
-
Alias: (*Alias)(&p),
48
-
})
49
-
}
50
-
51
-
func GetAllPublicKeys(e Execer) ([]PublicKey, error) {
52
-
var keys []PublicKey
32
+
func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) {
33
+
var keys []models.PublicKey
53
34
54
35
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
55
36
if err != nil {
···
58
39
defer rows.Close()
59
40
60
41
for rows.Next() {
61
-
var publicKey PublicKey
42
+
var publicKey models.PublicKey
62
43
var createdAt string
63
44
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
64
45
return nil, err
···
75
56
return keys, nil
76
57
}
77
58
78
-
func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) {
79
-
var keys []PublicKey
59
+
func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) {
60
+
var keys []models.PublicKey
80
61
81
62
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
82
63
if err != nil {
···
85
66
defer rows.Close()
86
67
87
68
for rows.Next() {
88
-
var publicKey PublicKey
69
+
var publicKey models.PublicKey
89
70
var createdAt string
90
71
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
91
72
return nil, err
+193
-572
appview/db/pulls.go
+193
-572
appview/db/pulls.go
···
1
1
package db
2
2
3
3
import (
4
+
"cmp"
4
5
"database/sql"
6
+
"errors"
5
7
"fmt"
6
-
"log"
8
+
"maps"
7
9
"slices"
8
10
"sort"
9
11
"strings"
10
12
"time"
11
13
12
14
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/patchutil"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
)
17
-
18
-
type PullState int
19
-
20
-
const (
21
-
PullClosed PullState = iota
22
-
PullOpen
23
-
PullMerged
24
-
PullDeleted
15
+
"tangled.org/core/appview/models"
25
16
)
26
17
27
-
func (p PullState) String() string {
28
-
switch p {
29
-
case PullOpen:
30
-
return "open"
31
-
case PullMerged:
32
-
return "merged"
33
-
case PullClosed:
34
-
return "closed"
35
-
case PullDeleted:
36
-
return "deleted"
37
-
default:
38
-
return "closed"
39
-
}
40
-
}
41
-
42
-
func (p PullState) IsOpen() bool {
43
-
return p == PullOpen
44
-
}
45
-
func (p PullState) IsMerged() bool {
46
-
return p == PullMerged
47
-
}
48
-
func (p PullState) IsClosed() bool {
49
-
return p == PullClosed
50
-
}
51
-
func (p PullState) IsDeleted() bool {
52
-
return p == PullDeleted
53
-
}
54
-
55
-
type Pull struct {
56
-
// ids
57
-
ID int
58
-
PullId int
59
-
60
-
// at ids
61
-
RepoAt syntax.ATURI
62
-
OwnerDid string
63
-
Rkey string
64
-
65
-
// content
66
-
Title string
67
-
Body string
68
-
TargetBranch string
69
-
State PullState
70
-
Submissions []*PullSubmission
71
-
72
-
// stacking
73
-
StackId string // nullable string
74
-
ChangeId string // nullable string
75
-
ParentChangeId string // nullable string
76
-
77
-
// meta
78
-
Created time.Time
79
-
PullSource *PullSource
80
-
81
-
// optionally, populate this when querying for reverse mappings
82
-
Repo *Repo
83
-
}
84
-
85
-
func (p Pull) AsRecord() tangled.RepoPull {
86
-
var source *tangled.RepoPull_Source
87
-
if p.PullSource != nil {
88
-
s := p.PullSource.AsRecord()
89
-
source = &s
90
-
source.Sha = p.LatestSha()
91
-
}
92
-
93
-
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
Target: &tangled.RepoPull_Target{
98
-
Repo: p.RepoAt.String(),
99
-
Branch: p.TargetBranch,
100
-
},
101
-
Patch: p.LatestPatch(),
102
-
Source: source,
103
-
}
104
-
return record
105
-
}
106
-
107
-
type PullSource struct {
108
-
Branch string
109
-
RepoAt *syntax.ATURI
110
-
111
-
// optionally populate this for reverse mappings
112
-
Repo *Repo
113
-
}
114
-
115
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
116
-
var repoAt *string
117
-
if p.RepoAt != nil {
118
-
s := p.RepoAt.String()
119
-
repoAt = &s
120
-
}
121
-
record := tangled.RepoPull_Source{
122
-
Branch: p.Branch,
123
-
Repo: repoAt,
124
-
}
125
-
return record
126
-
}
127
-
128
-
type PullSubmission struct {
129
-
// ids
130
-
ID int
131
-
PullId int
132
-
133
-
// at ids
134
-
RepoAt syntax.ATURI
135
-
136
-
// content
137
-
RoundNumber int
138
-
Patch string
139
-
Comments []PullComment
140
-
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
141
-
142
-
// meta
143
-
Created time.Time
144
-
}
145
-
146
-
type PullComment struct {
147
-
// ids
148
-
ID int
149
-
PullId int
150
-
SubmissionId int
151
-
152
-
// at ids
153
-
RepoAt string
154
-
OwnerDid string
155
-
CommentAt string
156
-
157
-
// content
158
-
Body string
159
-
160
-
// meta
161
-
Created time.Time
162
-
}
163
-
164
-
func (p *Pull) LatestPatch() string {
165
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
166
-
return latestSubmission.Patch
167
-
}
168
-
169
-
func (p *Pull) LatestSha() string {
170
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
171
-
return latestSubmission.SourceRev
172
-
}
173
-
174
-
func (p *Pull) PullAt() syntax.ATURI {
175
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
176
-
}
177
-
178
-
func (p *Pull) LastRoundNumber() int {
179
-
return len(p.Submissions) - 1
180
-
}
181
-
182
-
func (p *Pull) IsPatchBased() bool {
183
-
return p.PullSource == nil
184
-
}
185
-
186
-
func (p *Pull) IsBranchBased() bool {
187
-
if p.PullSource != nil {
188
-
if p.PullSource.RepoAt != nil {
189
-
return p.PullSource.RepoAt == &p.RepoAt
190
-
} else {
191
-
// no repo specified
192
-
return true
193
-
}
194
-
}
195
-
return false
196
-
}
197
-
198
-
func (p *Pull) IsForkBased() bool {
199
-
if p.PullSource != nil {
200
-
if p.PullSource.RepoAt != nil {
201
-
// make sure repos are different
202
-
return p.PullSource.RepoAt != &p.RepoAt
203
-
}
204
-
}
205
-
return false
206
-
}
207
-
208
-
func (p *Pull) IsStacked() bool {
209
-
return p.StackId != ""
210
-
}
211
-
212
-
func (s PullSubmission) IsFormatPatch() bool {
213
-
return patchutil.IsFormatPatch(s.Patch)
214
-
}
215
-
216
-
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
217
-
patches, err := patchutil.ExtractPatches(s.Patch)
218
-
if err != nil {
219
-
log.Println("error extracting patches from submission:", err)
220
-
return []types.FormatPatch{}
221
-
}
222
-
223
-
return patches
224
-
}
225
-
226
-
func NewPull(tx *sql.Tx, pull *Pull) error {
18
+
func NewPull(tx *sql.Tx, pull *models.Pull) error {
227
19
_, err := tx.Exec(`
228
20
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
229
21
values (?, 1)
···
244
36
}
245
37
246
38
pull.PullId = nextId
247
-
pull.State = PullOpen
39
+
pull.State = models.PullOpen
248
40
249
41
var sourceBranch, sourceRepoAt *string
250
42
if pull.PullSource != nil {
···
266
58
parentChangeId = &pull.ParentChangeId
267
59
}
268
60
269
-
_, err = tx.Exec(
61
+
result, err := tx.Exec(
270
62
`
271
63
insert into pulls (
272
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
290
82
return err
291
83
}
292
84
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
91
+
293
92
_, err = tx.Exec(`
294
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
295
-
values (?, ?, ?, ?, ?)
296
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
297
96
return err
298
97
}
299
98
···
311
110
return pullId - 1, err
312
111
}
313
112
314
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
315
-
pulls := make(map[int]*Pull)
113
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
316
115
317
116
var conditions []string
318
117
var args []any
···
332
131
333
132
query := fmt.Sprintf(`
334
133
select
134
+
id,
335
135
owner_did,
336
136
repo_at,
337
137
pull_id,
···
361
161
defer rows.Close()
362
162
363
163
for rows.Next() {
364
-
var pull Pull
164
+
var pull models.Pull
365
165
var createdAt string
366
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
367
167
err := rows.Scan(
168
+
&pull.ID,
368
169
&pull.OwnerDid,
369
170
&pull.RepoAt,
370
171
&pull.PullId,
···
391
192
pull.Created = createdTime
392
193
393
194
if sourceBranch.Valid {
394
-
pull.PullSource = &PullSource{
195
+
pull.PullSource = &models.PullSource{
395
196
Branch: sourceBranch.String,
396
197
}
397
198
if sourceRepoAt.Valid {
···
413
214
pull.ParentChangeId = parentChangeId.String
414
215
}
415
216
416
-
pulls[pull.PullId] = &pull
217
+
pulls[pull.PullAt()] = &pull
417
218
}
418
219
419
-
// get latest round no. for each pull
420
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
421
-
submissionsQuery := fmt.Sprintf(`
422
-
select
423
-
id, pull_id, round_number, patch, created, source_rev
424
-
from
425
-
pull_submissions
426
-
where
427
-
repo_at in (%s) and pull_id in (%s)
428
-
`, inClause, inClause)
429
-
430
-
args = make([]any, len(pulls)*2)
431
-
idx := 0
432
-
for _, p := range pulls {
433
-
args[idx] = p.RepoAt
434
-
idx += 1
435
-
}
220
+
var pullAts []syntax.ATURI
436
221
for _, p := range pulls {
437
-
args[idx] = p.PullId
438
-
idx += 1
222
+
pullAts = append(pullAts, p.PullAt())
439
223
}
440
-
submissionsRows, err := e.Query(submissionsQuery, args...)
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
441
225
if err != nil {
442
-
return nil, err
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
443
227
}
444
-
defer submissionsRows.Close()
445
228
446
-
for submissionsRows.Next() {
447
-
var s PullSubmission
448
-
var sourceRev sql.NullString
449
-
var createdAt string
450
-
err := submissionsRows.Scan(
451
-
&s.ID,
452
-
&s.PullId,
453
-
&s.RoundNumber,
454
-
&s.Patch,
455
-
&createdAt,
456
-
&sourceRev,
457
-
)
458
-
if err != nil {
459
-
return nil, err
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
460
232
}
461
-
462
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
463
-
if err != nil {
464
-
return nil, err
465
-
}
466
-
s.Created = createdTime
233
+
}
467
234
468
-
if sourceRev.Valid {
469
-
s.SourceRev = sourceRev.String
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
470
243
}
244
+
}
471
245
472
-
if p, ok := pulls[s.PullId]; ok {
473
-
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
474
-
p.Submissions[s.RoundNumber] = &s
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
475
251
}
476
252
}
477
-
if err := rows.Err(); err != nil {
478
-
return nil, err
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
479
256
}
480
-
481
-
// get comment count on latest submission on each pull
482
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
483
-
commentsQuery := fmt.Sprintf(`
484
-
select
485
-
count(id), pull_id
486
-
from
487
-
pull_comments
488
-
where
489
-
submission_id in (%s)
490
-
group by
491
-
submission_id
492
-
`, inClause)
493
-
494
-
args = []any{}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
+
}
495
261
for _, p := range pulls {
496
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
497
-
}
498
-
commentsRows, err := e.Query(commentsQuery, args...)
499
-
if err != nil {
500
-
return nil, err
501
-
}
502
-
defer commentsRows.Close()
503
-
504
-
for commentsRows.Next() {
505
-
var commentCount, pullId int
506
-
err := commentsRows.Scan(
507
-
&commentCount,
508
-
&pullId,
509
-
)
510
-
if err != nil {
511
-
return nil, err
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
512
266
}
513
-
if p, ok := pulls[pullId]; ok {
514
-
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
515
-
}
516
-
}
517
-
if err := rows.Err(); err != nil {
518
-
return nil, err
519
267
}
520
268
521
-
orderedByPullId := []*Pull{}
269
+
orderedByPullId := []*models.Pull{}
522
270
for _, p := range pulls {
523
271
orderedByPullId = append(orderedByPullId, p)
524
272
}
···
529
277
return orderedByPullId, nil
530
278
}
531
279
532
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
280
+
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
533
281
return GetPullsWithLimit(e, 0, filters...)
534
282
}
535
283
536
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
537
-
query := `
538
-
select
539
-
owner_did,
540
-
pull_id,
541
-
created,
542
-
title,
543
-
state,
544
-
target_branch,
545
-
repo_at,
546
-
body,
547
-
rkey,
548
-
source_branch,
549
-
source_repo_at,
550
-
stack_id,
551
-
change_id,
552
-
parent_change_id
553
-
from
554
-
pulls
555
-
where
556
-
repo_at = ? and pull_id = ?
557
-
`
558
-
row := e.QueryRow(query, repoAt, pullId)
559
-
560
-
var pull Pull
561
-
var createdAt string
562
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
563
-
err := row.Scan(
564
-
&pull.OwnerDid,
565
-
&pull.PullId,
566
-
&createdAt,
567
-
&pull.Title,
568
-
&pull.State,
569
-
&pull.TargetBranch,
570
-
&pull.RepoAt,
571
-
&pull.Body,
572
-
&pull.Rkey,
573
-
&sourceBranch,
574
-
&sourceRepoAt,
575
-
&stackId,
576
-
&changeId,
577
-
&parentChangeId,
578
-
)
284
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
579
286
if err != nil {
580
287
return nil, err
581
288
}
582
-
583
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
584
-
if err != nil {
585
-
return nil, err
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
586
291
}
587
-
pull.Created = createdTime
588
292
589
-
// populate source
590
-
if sourceBranch.Valid {
591
-
pull.PullSource = &PullSource{
592
-
Branch: sourceBranch.String,
593
-
}
594
-
if sourceRepoAt.Valid {
595
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
596
-
if err != nil {
597
-
return nil, err
598
-
}
599
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
600
-
}
601
-
}
293
+
return pulls[0], nil
294
+
}
602
295
603
-
if stackId.Valid {
604
-
pull.StackId = stackId.String
605
-
}
606
-
if changeId.Valid {
607
-
pull.ChangeId = changeId.String
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
608
303
}
609
-
if parentChangeId.Valid {
610
-
pull.ParentChangeId = parentChangeId.String
304
+
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
611
308
}
612
309
613
-
submissionsQuery := `
310
+
query := fmt.Sprintf(`
614
311
select
615
-
id, pull_id, repo_at, round_number, patch, created, source_rev
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
616
318
from
617
319
pull_submissions
618
-
where
619
-
repo_at = ? and pull_id = ?
620
-
`
621
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
622
326
if err != nil {
623
327
return nil, err
624
328
}
625
-
defer submissionsRows.Close()
329
+
defer rows.Close()
626
330
627
-
submissionsMap := make(map[int]*PullSubmission)
331
+
submissionMap := make(map[int]*models.PullSubmission)
628
332
629
-
for submissionsRows.Next() {
630
-
var submission PullSubmission
631
-
var submissionCreatedStr string
632
-
var submissionSourceRev sql.NullString
633
-
err := submissionsRows.Scan(
333
+
for rows.Next() {
334
+
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
634
338
&submission.ID,
635
-
&submission.PullId,
636
-
&submission.RepoAt,
339
+
&submission.PullAt,
637
340
&submission.RoundNumber,
638
341
&submission.Patch,
639
-
&submissionCreatedStr,
640
-
&submissionSourceRev,
342
+
&createdAt,
343
+
&sourceRev,
641
344
)
642
345
if err != nil {
643
346
return nil, err
644
347
}
645
348
646
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
647
350
if err != nil {
648
351
return nil, err
649
352
}
650
-
submission.Created = submissionCreatedTime
353
+
submission.Created = createdTime
651
354
652
-
if submissionSourceRev.Valid {
653
-
submission.SourceRev = submissionSourceRev.String
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
654
357
}
655
358
656
-
submissionsMap[submission.ID] = &submission
359
+
submissionMap[submission.ID] = &submission
657
360
}
658
-
if err = submissionsRows.Close(); err != nil {
361
+
362
+
if err := rows.Err(); err != nil {
363
+
return nil, err
364
+
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
659
370
return nil, err
660
371
}
661
-
if len(submissionsMap) == 0 {
662
-
return &pull, nil
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
+
}
383
+
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
663
389
}
664
390
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
665
396
var args []any
666
-
for k := range submissionsMap {
667
-
args = append(args, k)
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
668
400
}
669
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
670
-
commentsQuery := fmt.Sprintf(`
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
+
}
406
+
407
+
query := fmt.Sprintf(`
671
408
select
672
409
id,
673
410
pull_id,
···
679
416
created
680
417
from
681
418
pull_comments
682
-
where
683
-
submission_id IN (%s)
419
+
%s
684
420
order by
685
421
created asc
686
-
`, inClause)
687
-
commentsRows, err := e.Query(commentsQuery, args...)
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
688
425
if err != nil {
689
426
return nil, err
690
427
}
691
-
defer commentsRows.Close()
428
+
defer rows.Close()
692
429
693
-
for commentsRows.Next() {
694
-
var comment PullComment
695
-
var commentCreatedStr string
696
-
err := commentsRows.Scan(
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
+
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
697
435
&comment.ID,
698
436
&comment.PullId,
699
437
&comment.SubmissionId,
···
701
439
&comment.OwnerDid,
702
440
&comment.CommentAt,
703
441
&comment.Body,
704
-
&commentCreatedStr,
442
+
&createdAt,
705
443
)
706
444
if err != nil {
707
445
return nil, err
708
446
}
709
447
710
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
711
-
if err != nil {
712
-
return nil, err
713
-
}
714
-
comment.Created = commentCreatedTime
715
-
716
-
// Add the comment to its submission
717
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
718
-
submission.Comments = append(submission.Comments, comment)
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
719
450
}
720
451
721
-
}
722
-
if err = commentsRows.Err(); err != nil {
723
-
return nil, err
724
-
}
725
-
726
-
var pullSourceRepo *Repo
727
-
if pull.PullSource != nil {
728
-
if pull.PullSource.RepoAt != nil {
729
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
730
-
if err != nil {
731
-
log.Printf("failed to get repo by at uri: %v", err)
732
-
} else {
733
-
pull.PullSource.Repo = pullSourceRepo
734
-
}
735
-
}
452
+
comments = append(comments, comment)
736
453
}
737
454
738
-
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
739
-
for _, submission := range submissionsMap {
740
-
pull.Submissions[submission.RoundNumber] = submission
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
741
457
}
742
458
743
-
return &pull, nil
459
+
return comments, nil
744
460
}
745
461
746
462
// timeframe here is directly passed into the sql query filter, and any
747
463
// timeframe in the past should be negative; e.g.: "-3 months"
748
-
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
749
-
var pulls []Pull
464
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
465
+
var pulls []models.Pull
750
466
751
467
rows, err := e.Query(`
752
468
select
···
775
491
defer rows.Close()
776
492
777
493
for rows.Next() {
778
-
var pull Pull
779
-
var repo Repo
494
+
var pull models.Pull
495
+
var repo models.Repo
780
496
var pullCreatedAt, repoCreatedAt string
781
497
err := rows.Scan(
782
498
&pull.OwnerDid,
···
819
535
return pulls, nil
820
536
}
821
537
822
-
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
538
+
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
823
539
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
824
540
res, err := e.Exec(
825
541
query,
···
842
558
return i, nil
843
559
}
844
560
845
-
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
561
+
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
846
562
_, err := e.Exec(
847
563
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
848
564
pullState,
849
565
repoAt,
850
566
pullId,
851
-
PullDeleted, // only update state of non-deleted pulls
852
-
PullMerged, // only update state of non-merged pulls
567
+
models.PullDeleted, // only update state of non-deleted pulls
568
+
models.PullMerged, // only update state of non-merged pulls
853
569
)
854
570
return err
855
571
}
856
572
857
573
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
858
-
err := SetPullState(e, repoAt, pullId, PullClosed)
574
+
err := SetPullState(e, repoAt, pullId, models.PullClosed)
859
575
return err
860
576
}
861
577
862
578
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
863
-
err := SetPullState(e, repoAt, pullId, PullOpen)
579
+
err := SetPullState(e, repoAt, pullId, models.PullOpen)
864
580
return err
865
581
}
866
582
867
583
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
868
-
err := SetPullState(e, repoAt, pullId, PullMerged)
584
+
err := SetPullState(e, repoAt, pullId, models.PullMerged)
869
585
return err
870
586
}
871
587
872
588
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
873
-
err := SetPullState(e, repoAt, pullId, PullDeleted)
589
+
err := SetPullState(e, repoAt, pullId, models.PullDeleted)
874
590
return err
875
591
}
876
592
877
-
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
593
+
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
878
594
newRoundNumber := len(pull.Submissions)
879
595
_, err := e.Exec(`
880
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
881
-
values (?, ?, ?, ?, ?)
882
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
883
599
884
600
return err
885
601
}
···
931
647
return err
932
648
}
933
649
934
-
type PullCount struct {
935
-
Open int
936
-
Merged int
937
-
Closed int
938
-
Deleted int
939
-
}
940
-
941
-
func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
650
+
func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) {
942
651
row := e.QueryRow(`
943
652
select
944
653
count(case when state = ? then 1 end) as open_count,
···
947
656
count(case when state = ? then 1 end) as deleted_count
948
657
from pulls
949
658
where repo_at = ?`,
950
-
PullOpen,
951
-
PullMerged,
952
-
PullClosed,
953
-
PullDeleted,
659
+
models.PullOpen,
660
+
models.PullMerged,
661
+
models.PullClosed,
662
+
models.PullDeleted,
954
663
repoAt,
955
664
)
956
665
957
-
var count PullCount
666
+
var count models.PullCount
958
667
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
959
-
return PullCount{0, 0, 0, 0}, err
668
+
return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err
960
669
}
961
670
962
671
return count, nil
963
672
}
964
-
965
-
type Stack []*Pull
966
673
967
674
// change-id parent-change-id
968
675
//
···
972
679
// 1 x <------' nil (BOT)
973
680
//
974
681
// `w` is parent of none, so it is the top of the stack
975
-
func GetStack(e Execer, stackId string) (Stack, error) {
682
+
func GetStack(e Execer, stackId string) (models.Stack, error) {
976
683
unorderedPulls, err := GetPulls(
977
684
e,
978
685
FilterEq("stack_id", stackId),
979
-
FilterNotEq("state", PullDeleted),
686
+
FilterNotEq("state", models.PullDeleted),
980
687
)
981
688
if err != nil {
982
689
return nil, err
983
690
}
984
691
// map of parent-change-id to pull
985
-
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
986
-
parentMap := make(map[string]*Pull, len(unorderedPulls))
692
+
changeIdMap := make(map[string]*models.Pull, len(unorderedPulls))
693
+
parentMap := make(map[string]*models.Pull, len(unorderedPulls))
987
694
for _, p := range unorderedPulls {
988
695
changeIdMap[p.ChangeId] = p
989
696
if p.ParentChangeId != "" {
···
992
699
}
993
700
994
701
// the top of the stack is the pull that is not a parent of any pull
995
-
var topPull *Pull
702
+
var topPull *models.Pull
996
703
for _, maybeTop := range unorderedPulls {
997
704
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
998
705
topPull = maybeTop
···
1000
707
}
1001
708
}
1002
709
1003
-
pulls := []*Pull{}
710
+
pulls := []*models.Pull{}
1004
711
for {
1005
712
pulls = append(pulls, topPull)
1006
713
if topPull.ParentChangeId != "" {
···
1017
724
return pulls, nil
1018
725
}
1019
726
1020
-
func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) {
727
+
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
1021
728
pulls, err := GetPulls(
1022
729
e,
1023
730
FilterEq("stack_id", stackId),
1024
-
FilterEq("state", PullDeleted),
731
+
FilterEq("state", models.PullDeleted),
1025
732
)
1026
733
if err != nil {
1027
734
return nil, err
···
1029
736
1030
737
return pulls, nil
1031
738
}
1032
-
1033
-
// position of this pull in the stack
1034
-
func (stack Stack) Position(pull *Pull) int {
1035
-
return slices.IndexFunc(stack, func(p *Pull) bool {
1036
-
return p.ChangeId == pull.ChangeId
1037
-
})
1038
-
}
1039
-
1040
-
// all pulls below this pull (including self) in this stack
1041
-
//
1042
-
// nil if this pull does not belong to this stack
1043
-
func (stack Stack) Below(pull *Pull) Stack {
1044
-
position := stack.Position(pull)
1045
-
1046
-
if position < 0 {
1047
-
return nil
1048
-
}
1049
-
1050
-
return stack[position:]
1051
-
}
1052
-
1053
-
// all pulls below this pull (excluding self) in this stack
1054
-
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
1055
-
below := stack.Below(pull)
1056
-
1057
-
if len(below) > 0 {
1058
-
return below[1:]
1059
-
}
1060
-
1061
-
return nil
1062
-
}
1063
-
1064
-
// all pulls above this pull (including self) in this stack
1065
-
func (stack Stack) Above(pull *Pull) Stack {
1066
-
position := stack.Position(pull)
1067
-
1068
-
if position < 0 {
1069
-
return nil
1070
-
}
1071
-
1072
-
return stack[:position+1]
1073
-
}
1074
-
1075
-
// all pulls below this pull (excluding self) in this stack
1076
-
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
1077
-
above := stack.Above(pull)
1078
-
1079
-
if len(above) > 0 {
1080
-
return above[:len(above)-1]
1081
-
}
1082
-
1083
-
return nil
1084
-
}
1085
-
1086
-
// the combined format-patches of all the newest submissions in this stack
1087
-
func (stack Stack) CombinedPatch() string {
1088
-
// go in reverse order because the bottom of the stack is the last element in the slice
1089
-
var combined strings.Builder
1090
-
for idx := range stack {
1091
-
pull := stack[len(stack)-1-idx]
1092
-
combined.WriteString(pull.LatestPatch())
1093
-
combined.WriteString("\n")
1094
-
}
1095
-
return combined.String()
1096
-
}
1097
-
1098
-
// filter out PRs that are "active"
1099
-
//
1100
-
// PRs that are still open are active
1101
-
func (stack Stack) Mergeable() Stack {
1102
-
var mergeable Stack
1103
-
1104
-
for _, p := range stack {
1105
-
// stop at the first merged PR
1106
-
if p.State == PullMerged || p.State == PullClosed {
1107
-
break
1108
-
}
1109
-
1110
-
// skip over deleted PRs
1111
-
if p.State != PullDeleted {
1112
-
mergeable = append(mergeable, p)
1113
-
}
1114
-
}
1115
-
1116
-
return mergeable
1117
-
}
+7
-16
appview/db/punchcard.go
+7
-16
appview/db/punchcard.go
···
5
5
"fmt"
6
6
"strings"
7
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type Punch struct {
11
-
Did string
12
-
Date time.Time
13
-
Count int
14
-
}
15
-
16
12
// this adds to the existing count
17
-
func AddPunch(e Execer, punch Punch) error {
13
+
func AddPunch(e Execer, punch models.Punch) error {
18
14
_, err := e.Exec(`
19
15
insert into punchcard (did, date, count)
20
16
values (?, ?, ?)
···
24
20
return err
25
21
}
26
22
27
-
type Punchcard struct {
28
-
Total int
29
-
Punches []Punch
30
-
}
31
-
32
-
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
-
punchcard := &Punchcard{}
23
+
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
punchcard := &models.Punchcard{}
34
25
now := time.Now()
35
26
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
27
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
37
28
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
38
-
punchcard.Punches = append(punchcard.Punches, Punch{
29
+
punchcard.Punches = append(punchcard.Punches, models.Punch{
39
30
Date: d,
40
31
Count: 0,
41
32
})
···
68
59
defer rows.Close()
69
60
70
61
for rows.Next() {
71
-
var punch Punch
62
+
var punch models.Punch
72
63
var date string
73
64
var count sql.NullInt64
74
65
if err := rows.Scan(&date, &count); err != nil {
+14
-63
appview/db/reaction.go
+14
-63
appview/db/reaction.go
···
5
5
"time"
6
6
7
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
)
9
-
10
-
type ReactionKind string
11
-
12
-
const (
13
-
Like ReactionKind = "👍"
14
-
Unlike ReactionKind = "👎"
15
-
Laugh ReactionKind = "😆"
16
-
Celebration ReactionKind = "🎉"
17
-
Confused ReactionKind = "🫤"
18
-
Heart ReactionKind = "❤️"
19
-
Rocket ReactionKind = "🚀"
20
-
Eyes ReactionKind = "👀"
8
+
"tangled.org/core/appview/models"
21
9
)
22
10
23
-
func (rk ReactionKind) String() string {
24
-
return string(rk)
25
-
}
26
-
27
-
var OrderedReactionKinds = []ReactionKind{
28
-
Like,
29
-
Unlike,
30
-
Laugh,
31
-
Celebration,
32
-
Confused,
33
-
Heart,
34
-
Rocket,
35
-
Eyes,
36
-
}
37
-
38
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
-
k, ok := (map[string]ReactionKind{
40
-
"👍": Like,
41
-
"👎": Unlike,
42
-
"😆": Laugh,
43
-
"🎉": Celebration,
44
-
"🫤": Confused,
45
-
"❤️": Heart,
46
-
"🚀": Rocket,
47
-
"👀": Eyes,
48
-
})[raw]
49
-
return k, ok
50
-
}
51
-
52
-
type Reaction struct {
53
-
ReactedByDid string
54
-
ThreadAt syntax.ATURI
55
-
Created time.Time
56
-
Rkey string
57
-
Kind ReactionKind
58
-
}
59
-
60
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
11
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
61
12
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
13
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
14
return err
64
15
}
65
16
66
17
// Get a reaction record
67
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
18
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
68
19
query := `
69
20
select reacted_by_did, thread_at, created, rkey
70
21
from reactions
71
22
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
23
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
24
74
-
var reaction Reaction
25
+
var reaction models.Reaction
75
26
var created string
76
27
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
28
if err != nil {
···
90
41
}
91
42
92
43
// Remove a reaction
93
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
44
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
94
45
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
46
return err
96
47
}
···
101
52
return err
102
53
}
103
54
104
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
55
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
105
56
count := 0
106
57
err := e.QueryRow(
107
58
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
111
62
return count, nil
112
63
}
113
64
114
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
-
countMap := map[ReactionKind]int{}
116
-
for _, kind := range OrderedReactionKinds {
65
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
+
countMap := map[models.ReactionKind]int{}
67
+
for _, kind := range models.OrderedReactionKinds {
117
68
count, err := GetReactionCount(e, threadAt, kind)
118
69
if err != nil {
119
-
return map[ReactionKind]int{}, nil
70
+
return map[models.ReactionKind]int{}, nil
120
71
}
121
72
countMap[kind] = count
122
73
}
123
74
return countMap, nil
124
75
}
125
76
126
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
77
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
127
78
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
79
return false
129
80
} else {
···
131
82
}
132
83
}
133
84
134
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
-
statusMap := map[ReactionKind]bool{}
136
-
for _, kind := range OrderedReactionKinds {
85
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
86
+
statusMap := map[models.ReactionKind]bool{}
87
+
for _, kind := range models.OrderedReactionKinds {
137
88
count := GetReactionStatus(e, userDid, threadAt, kind)
138
89
statusMap[kind] = count
139
90
}
+4
-43
appview/db/registration.go
+4
-43
appview/db/registration.go
···
5
5
"fmt"
6
6
"strings"
7
7
"time"
8
-
)
9
-
10
-
// Registration represents a knot registration. Knot would've been a better
11
-
// name but we're stuck with this for historical reasons.
12
-
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
NeedsUpgrade bool
19
-
}
20
8
21
-
func (r *Registration) Status() Status {
22
-
if r.NeedsUpgrade {
23
-
return NeedsUpgrade
24
-
} else if r.Registered != nil {
25
-
return Registered
26
-
} else {
27
-
return Pending
28
-
}
29
-
}
30
-
31
-
func (r *Registration) IsRegistered() bool {
32
-
return r.Status() == Registered
33
-
}
34
-
35
-
func (r *Registration) IsNeedsUpgrade() bool {
36
-
return r.Status() == NeedsUpgrade
37
-
}
38
-
39
-
func (r *Registration) IsPending() bool {
40
-
return r.Status() == Pending
41
-
}
42
-
43
-
type Status uint32
44
-
45
-
const (
46
-
Registered Status = iota
47
-
Pending
48
-
NeedsUpgrade
9
+
"tangled.org/core/appview/models"
49
10
)
50
11
51
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
52
-
var registrations []Registration
12
+
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
var registrations []models.Registration
53
14
54
15
var conditions []string
55
16
var args []any
···
81
42
var createdAt string
82
43
var registeredAt sql.Null[string]
83
44
var needsUpgrade int
84
-
var reg Registration
45
+
var reg models.Registration
85
46
86
47
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
48
if err != nil {
+162
-78
appview/db/repos.go
+162
-78
appview/db/repos.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/models"
15
16
)
16
17
17
18
type Repo struct {
19
+
Id int64
18
20
Did string
19
21
Name string
20
22
Knot string
···
24
26
Spindle string
25
27
26
28
// optionally, populate this when querying for reverse mappings
27
-
RepoStats *RepoStats
29
+
RepoStats *models.RepoStats
28
30
29
31
// optional
30
32
Source string
···
39
41
return p
40
42
}
41
43
42
-
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
43
-
repoMap := make(map[syntax.ATURI]*Repo)
44
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
+
repoMap := make(map[syntax.ATURI]*models.Repo)
44
46
45
47
var conditions []string
46
48
var args []any
···
61
63
62
64
repoQuery := fmt.Sprintf(
63
65
`select
66
+
id,
64
67
did,
65
68
name,
66
69
knot,
···
84
87
}
85
88
86
89
for rows.Next() {
87
-
var repo Repo
90
+
var repo models.Repo
88
91
var createdAt string
89
92
var description, source, spindle sql.NullString
90
93
91
94
err := rows.Scan(
95
+
&repo.Id,
92
96
&repo.Did,
93
97
&repo.Name,
94
98
&repo.Knot,
···
115
119
repo.Spindle = spindle.String
116
120
}
117
121
118
-
repo.RepoStats = &RepoStats{}
122
+
repo.RepoStats = &models.RepoStats{}
119
123
repoMap[repo.RepoAt()] = &repo
120
124
}
121
125
···
132
136
i++
133
137
}
134
138
139
+
// Get labels for all repos
140
+
labelsQuery := fmt.Sprintf(
141
+
`select repo_at, label_at from repo_labels where repo_at in (%s)`,
142
+
inClause,
143
+
)
144
+
rows, err = e.Query(labelsQuery, args...)
145
+
if err != nil {
146
+
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
147
+
}
148
+
for rows.Next() {
149
+
var repoat, labelat string
150
+
if err := rows.Scan(&repoat, &labelat); err != nil {
151
+
log.Println("err", "err", err)
152
+
continue
153
+
}
154
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
155
+
r.Labels = append(r.Labels, labelat)
156
+
}
157
+
}
158
+
if err = rows.Err(); err != nil {
159
+
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
160
+
}
161
+
135
162
languageQuery := fmt.Sprintf(
136
163
`
137
-
select
138
-
repo_at, language
139
-
from
140
-
repo_languages r1
141
-
where
142
-
repo_at IN (%s)
164
+
select repo_at, language
165
+
from (
166
+
select
167
+
repo_at,
168
+
language,
169
+
row_number() over (
170
+
partition by repo_at
171
+
order by bytes desc
172
+
) as rn
173
+
from repo_languages
174
+
where repo_at in (%s)
143
175
and is_default_ref = 1
144
-
and id = (
145
-
select id
146
-
from repo_languages r2
147
-
where r2.repo_at = r1.repo_at
148
-
and r2.is_default_ref = 1
149
-
order by bytes desc
150
-
limit 1
151
-
);
176
+
)
177
+
where rn = 1
152
178
`,
153
179
inClause,
154
180
)
···
240
266
inClause,
241
267
)
242
268
args = append([]any{
243
-
PullOpen,
244
-
PullMerged,
245
-
PullClosed,
246
-
PullDeleted,
269
+
models.PullOpen,
270
+
models.PullMerged,
271
+
models.PullClosed,
272
+
models.PullDeleted,
247
273
}, args...)
248
274
rows, err = e.Query(
249
275
pullCountQuery,
···
270
296
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
271
297
}
272
298
273
-
var repos []Repo
299
+
var repos []models.Repo
274
300
for _, r := range repoMap {
275
301
repos = append(repos, *r)
276
302
}
277
303
278
-
slices.SortFunc(repos, func(a, b Repo) int {
304
+
slices.SortFunc(repos, func(a, b models.Repo) int {
279
305
if a.Created.After(b.Created) {
280
306
return -1
281
307
}
···
285
311
return repos, nil
286
312
}
287
313
314
+
// helper to get exactly one repo
315
+
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
316
+
repos, err := GetRepos(e, 0, filters...)
317
+
if err != nil {
318
+
return nil, err
319
+
}
320
+
321
+
if repos == nil {
322
+
return nil, sql.ErrNoRows
323
+
}
324
+
325
+
if len(repos) != 1 {
326
+
return nil, fmt.Errorf("too many rows returned")
327
+
}
328
+
329
+
return &repos[0], nil
330
+
}
331
+
288
332
func CountRepos(e Execer, filters ...filter) (int64, error) {
289
333
var conditions []string
290
334
var args []any
···
309
353
return count, nil
310
354
}
311
355
312
-
func GetRepo(e Execer, did, name string) (*Repo, error) {
313
-
var repo Repo
314
-
var description, spindle sql.NullString
315
-
316
-
row := e.QueryRow(`
317
-
select did, name, knot, created, description, spindle, rkey
318
-
from repos
319
-
where did = ? and name = ?
320
-
`,
321
-
did,
322
-
name,
323
-
)
324
-
325
-
var createdAt string
326
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil {
327
-
return nil, err
328
-
}
329
-
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
330
-
repo.Created = createdAtTime
331
-
332
-
if description.Valid {
333
-
repo.Description = description.String
334
-
}
335
-
336
-
if spindle.Valid {
337
-
repo.Spindle = spindle.String
338
-
}
339
-
340
-
return &repo, nil
341
-
}
342
-
343
-
func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
344
-
var repo Repo
356
+
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
+
var repo models.Repo
345
358
var nullableDescription sql.NullString
346
359
347
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
348
361
349
362
var createdAt string
350
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
351
364
return nil, err
352
365
}
353
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
362
375
return &repo, nil
363
376
}
364
377
365
-
func AddRepo(e Execer, repo *Repo) error {
366
-
_, err := e.Exec(
378
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
+
_, err := tx.Exec(
367
380
`insert into repos
368
381
(did, name, knot, rkey, at_uri, description, source)
369
382
values (?, ?, ?, ?, ?, ?, ?)`,
370
383
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
371
384
)
372
-
return err
385
+
if err != nil {
386
+
return fmt.Errorf("failed to insert repo: %w", err)
387
+
}
388
+
389
+
for _, dl := range repo.Labels {
390
+
if err := SubscribeLabel(tx, &models.RepoLabel{
391
+
RepoAt: repo.RepoAt(),
392
+
LabelAt: syntax.ATURI(dl),
393
+
}); err != nil {
394
+
return fmt.Errorf("failed to subscribe to label: %w", err)
395
+
}
396
+
}
397
+
398
+
return nil
373
399
}
374
400
375
401
func RemoveRepo(e Execer, did, name string) error {
···
386
412
return nullableSource.String, nil
387
413
}
388
414
389
-
func GetForksByDid(e Execer, did string) ([]Repo, error) {
390
-
var repos []Repo
415
+
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
+
var repos []models.Repo
391
417
392
418
rows, err := e.Query(
393
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
394
420
from repos r
395
421
left join collaborators c on r.at_uri = c.repo_at
396
422
where (r.did = ? or c.subject_did = ?)
···
405
431
defer rows.Close()
406
432
407
433
for rows.Next() {
408
-
var repo Repo
434
+
var repo models.Repo
409
435
var createdAt string
410
436
var nullableDescription sql.NullString
411
437
var nullableSource sql.NullString
412
438
413
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
414
440
if err != nil {
415
441
return nil, err
416
442
}
···
440
466
return repos, nil
441
467
}
442
468
443
-
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
444
-
var repo Repo
469
+
func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) {
470
+
var repo models.Repo
445
471
var createdAt string
446
472
var nullableDescription sql.NullString
447
473
var nullableSource sql.NullString
448
474
449
475
row := e.QueryRow(
450
-
`select did, name, knot, rkey, description, created, source
476
+
`select id, did, name, knot, rkey, description, created, source
451
477
from repos
452
478
where did = ? and name = ? and source is not null and source != ''`,
453
479
did, name,
454
480
)
455
481
456
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
457
483
if err != nil {
458
484
return nil, err
459
485
}
···
488
514
return err
489
515
}
490
516
491
-
type RepoStats struct {
492
-
Language string
493
-
StarCount int
494
-
IssueCount IssueCount
495
-
PullCount PullCount
517
+
func SubscribeLabel(e Execer, rl *models.RepoLabel) error {
518
+
query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
519
+
520
+
_, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
521
+
return err
522
+
}
523
+
524
+
func UnsubscribeLabel(e Execer, filters ...filter) error {
525
+
var conditions []string
526
+
var args []any
527
+
for _, filter := range filters {
528
+
conditions = append(conditions, filter.Condition())
529
+
args = append(args, filter.Arg()...)
530
+
}
531
+
532
+
whereClause := ""
533
+
if conditions != nil {
534
+
whereClause = " where " + strings.Join(conditions, " and ")
535
+
}
536
+
537
+
query := fmt.Sprintf(`delete from repo_labels %s`, whereClause)
538
+
_, err := e.Exec(query, args...)
539
+
return err
540
+
}
541
+
542
+
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
543
+
var conditions []string
544
+
var args []any
545
+
for _, filter := range filters {
546
+
conditions = append(conditions, filter.Condition())
547
+
args = append(args, filter.Arg()...)
548
+
}
549
+
550
+
whereClause := ""
551
+
if conditions != nil {
552
+
whereClause = " where " + strings.Join(conditions, " and ")
553
+
}
554
+
555
+
query := fmt.Sprintf(`select id, repo_at, label_at from repo_labels %s`, whereClause)
556
+
557
+
rows, err := e.Query(query, args...)
558
+
if err != nil {
559
+
return nil, err
560
+
}
561
+
defer rows.Close()
562
+
563
+
var labels []models.RepoLabel
564
+
for rows.Next() {
565
+
var label models.RepoLabel
566
+
567
+
err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
568
+
if err != nil {
569
+
return nil, err
570
+
}
571
+
572
+
labels = append(labels, label)
573
+
}
574
+
575
+
if err = rows.Err(); err != nil {
576
+
return nil, err
577
+
}
578
+
579
+
return labels, nil
496
580
}
+4
-9
appview/db/signup.go
+4
-9
appview/db/signup.go
···
1
1
package db
2
2
3
-
import "time"
3
+
import (
4
+
"tangled.org/core/appview/models"
5
+
)
4
6
5
-
type InflightSignup struct {
6
-
Id int64
7
-
Email string
8
-
InviteCode string
9
-
Created time.Time
10
-
}
11
-
12
-
func AddInflightSignup(e Execer, signup InflightSignup) error {
7
+
func AddInflightSignup(e Execer, signup models.InflightSignup) error {
13
8
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
9
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
10
return err
+9
-27
appview/db/spindle.go
+9
-27
appview/db/spindle.go
···
6
6
"strings"
7
7
"time"
8
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/models"
10
10
)
11
11
12
-
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
18
-
NeedsUpgrade bool
19
-
}
20
-
21
-
type SpindleMember struct {
22
-
Id int
23
-
Did syntax.DID // owner of the record
24
-
Rkey string // rkey of the record
25
-
Instance string
26
-
Subject syntax.DID // the member being added
27
-
Created time.Time
28
-
}
29
-
30
-
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
31
-
var spindles []Spindle
12
+
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
var spindles []models.Spindle
32
14
33
15
var conditions []string
34
16
var args []any
···
59
41
defer rows.Close()
60
42
61
43
for rows.Next() {
62
-
var spindle Spindle
44
+
var spindle models.Spindle
63
45
var createdAt string
64
46
var verified sql.NullString
65
47
var needsUpgrade int
···
100
82
}
101
83
102
84
// if there is an existing spindle with the same instance, this returns an error
103
-
func AddSpindle(e Execer, spindle Spindle) error {
85
+
func AddSpindle(e Execer, spindle models.Spindle) error {
104
86
_, err := e.Exec(
105
87
`insert into spindles (owner, instance) values (?, ?)`,
106
88
spindle.Owner,
···
151
133
return err
152
134
}
153
135
154
-
func AddSpindleMember(e Execer, member SpindleMember) error {
136
+
func AddSpindleMember(e Execer, member models.SpindleMember) error {
155
137
_, err := e.Exec(
156
138
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
157
139
member.Did,
···
181
163
return err
182
164
}
183
165
184
-
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
185
-
var members []SpindleMember
166
+
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
var members []models.SpindleMember
186
168
187
169
var conditions []string
188
170
var args []any
···
213
195
defer rows.Close()
214
196
215
197
for rows.Next() {
216
-
var member SpindleMember
198
+
var member models.SpindleMember
217
199
var createdAt string
218
200
219
201
if err := rows.Scan(
+80
-42
appview/db/star.go
+80
-42
appview/db/star.go
···
5
5
"errors"
6
6
"fmt"
7
7
"log"
8
+
"slices"
8
9
"strings"
9
10
"time"
10
11
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
12
14
)
13
15
14
-
type Star struct {
15
-
StarredByDid string
16
-
RepoAt syntax.ATURI
17
-
Created time.Time
18
-
Rkey string
19
-
20
-
// optionally, populate this when querying for reverse mappings
21
-
Repo *Repo
22
-
}
23
-
24
-
func (star *Star) ResolveRepo(e Execer) error {
25
-
if star.Repo != nil {
26
-
return nil
27
-
}
28
-
29
-
repo, err := GetRepoByAtUri(e, star.RepoAt.String())
30
-
if err != nil {
31
-
return err
32
-
}
33
-
34
-
star.Repo = repo
35
-
return nil
36
-
}
37
-
38
-
func AddStar(e Execer, star *Star) error {
16
+
func AddStar(e Execer, star *models.Star) error {
39
17
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
40
18
_, err := e.Exec(
41
19
query,
···
47
25
}
48
26
49
27
// Get a star record
50
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
28
+
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
51
29
query := `
52
30
select starred_by_did, repo_at, created, rkey
53
31
from stars
54
32
where starred_by_did = ? and repo_at = ?`
55
33
row := e.QueryRow(query, starredByDid, repoAt)
56
34
57
-
var star Star
35
+
var star models.Star
58
36
var created string
59
37
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
60
38
if err != nil {
···
94
72
return stars, nil
95
73
}
96
74
75
+
// getStarStatuses returns a map of repo URIs to star status for a given user
76
+
// This is an internal helper function to avoid N+1 queries
77
+
func getStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
78
+
if len(repoAts) == 0 || userDid == "" {
79
+
return make(map[string]bool), nil
80
+
}
81
+
82
+
placeholders := make([]string, len(repoAts))
83
+
args := make([]any, len(repoAts)+1)
84
+
args[0] = userDid
85
+
86
+
for i, repoAt := range repoAts {
87
+
placeholders[i] = "?"
88
+
args[i+1] = repoAt.String()
89
+
}
90
+
91
+
query := fmt.Sprintf(`
92
+
SELECT repo_at
93
+
FROM stars
94
+
WHERE starred_by_did = ? AND repo_at IN (%s)
95
+
`, strings.Join(placeholders, ","))
96
+
97
+
rows, err := e.Query(query, args...)
98
+
if err != nil {
99
+
return nil, err
100
+
}
101
+
defer rows.Close()
102
+
103
+
result := make(map[string]bool)
104
+
// Initialize all repos as not starred
105
+
for _, repoAt := range repoAts {
106
+
result[repoAt.String()] = false
107
+
}
108
+
109
+
// Mark starred repos as true
110
+
for rows.Next() {
111
+
var repoAt string
112
+
if err := rows.Scan(&repoAt); err != nil {
113
+
return nil, err
114
+
}
115
+
result[repoAt] = true
116
+
}
117
+
118
+
return result, nil
119
+
}
120
+
97
121
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
98
-
if _, err := GetStar(e, userDid, repoAt); err != nil {
122
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
123
+
if err != nil {
99
124
return false
100
-
} else {
101
-
return true
102
125
}
126
+
return statuses[repoAt.String()]
103
127
}
104
128
105
-
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
129
+
// GetStarStatuses returns a map of repo URIs to star status for a given user
130
+
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131
+
return getStarStatuses(e, userDid, repoAts)
132
+
}
133
+
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
106
134
var conditions []string
107
135
var args []any
108
136
for _, filter := range filters {
···
134
162
return nil, err
135
163
}
136
164
137
-
starMap := make(map[string][]Star)
165
+
starMap := make(map[string][]models.Star)
138
166
for rows.Next() {
139
-
var star Star
167
+
var star models.Star
140
168
var created string
141
169
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
142
170
if err != nil {
···
177
205
}
178
206
}
179
207
180
-
var stars []Star
208
+
var stars []models.Star
181
209
for _, s := range starMap {
182
210
stars = append(stars, s...)
183
211
}
184
212
213
+
slices.SortFunc(stars, func(a, b models.Star) int {
214
+
if a.Created.After(b.Created) {
215
+
return -1
216
+
}
217
+
if b.Created.After(a.Created) {
218
+
return 1
219
+
}
220
+
return 0
221
+
})
222
+
185
223
return stars, nil
186
224
}
187
225
···
209
247
return count, nil
210
248
}
211
249
212
-
func GetAllStars(e Execer, limit int) ([]Star, error) {
213
-
var stars []Star
250
+
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251
+
var stars []models.Star
214
252
215
253
rows, err := e.Query(`
216
254
select
···
233
271
defer rows.Close()
234
272
235
273
for rows.Next() {
236
-
var star Star
237
-
var repo Repo
274
+
var star models.Star
275
+
var repo models.Repo
238
276
var starCreatedAt, repoCreatedAt string
239
277
240
278
if err := rows.Scan(
···
272
310
}
273
311
274
312
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
275
-
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
313
+
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
276
314
// first, get the top repo URIs by star count from the last week
277
315
query := `
278
316
with recent_starred_repos as (
···
316
354
}
317
355
318
356
if len(repoUris) == 0 {
319
-
return []Repo{}, nil
357
+
return []models.Repo{}, nil
320
358
}
321
359
322
360
// get full repo data
···
326
364
}
327
365
328
366
// sort repos by the original trending order
329
-
repoMap := make(map[string]Repo)
367
+
repoMap := make(map[string]models.Repo)
330
368
for _, repo := range repos {
331
369
repoMap[repo.RepoAt().String()] = repo
332
370
}
333
371
334
-
orderedRepos := make([]Repo, 0, len(repoUris))
372
+
orderedRepos := make([]models.Repo, 0, len(repoUris))
335
373
for _, uri := range repoUris {
336
374
if repo, exists := repoMap[uri]; exists {
337
375
orderedRepos = append(orderedRepos, repo)
+5
-110
appview/db/strings.go
+5
-110
appview/db/strings.go
···
1
1
package db
2
2
3
3
import (
4
-
"bytes"
5
4
"database/sql"
6
5
"errors"
7
6
"fmt"
8
-
"io"
9
7
"strings"
10
8
"time"
11
-
"unicode/utf8"
12
9
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.org/core/appview/models"
15
11
)
16
12
17
-
type String struct {
18
-
Did syntax.DID
19
-
Rkey string
20
-
21
-
Filename string
22
-
Description string
23
-
Contents string
24
-
Created time.Time
25
-
Edited *time.Time
26
-
}
27
-
28
-
func (s *String) StringAt() syntax.ATURI {
29
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
-
}
31
-
32
-
type StringStats struct {
33
-
LineCount uint64
34
-
ByteCount uint64
35
-
}
36
-
37
-
func (s String) Stats() StringStats {
38
-
lineCount, err := countLines(strings.NewReader(s.Contents))
39
-
if err != nil {
40
-
// non-fatal
41
-
// TODO: log this?
42
-
}
43
-
44
-
return StringStats{
45
-
LineCount: uint64(lineCount),
46
-
ByteCount: uint64(len(s.Contents)),
47
-
}
48
-
}
49
-
50
-
func (s String) Validate() error {
51
-
var err error
52
-
53
-
if utf8.RuneCountInString(s.Filename) > 140 {
54
-
err = errors.Join(err, fmt.Errorf("filename too long"))
55
-
}
56
-
57
-
if utf8.RuneCountInString(s.Description) > 280 {
58
-
err = errors.Join(err, fmt.Errorf("description too long"))
59
-
}
60
-
61
-
if len(s.Contents) == 0 {
62
-
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
-
}
64
-
65
-
return err
66
-
}
67
-
68
-
func (s *String) AsRecord() tangled.String {
69
-
return tangled.String{
70
-
Filename: s.Filename,
71
-
Description: s.Description,
72
-
Contents: s.Contents,
73
-
CreatedAt: s.Created.Format(time.RFC3339),
74
-
}
75
-
}
76
-
77
-
func StringFromRecord(did, rkey string, record tangled.String) String {
78
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
-
if err != nil {
80
-
created = time.Now()
81
-
}
82
-
return String{
83
-
Did: syntax.DID(did),
84
-
Rkey: rkey,
85
-
Filename: record.Filename,
86
-
Description: record.Description,
87
-
Contents: record.Contents,
88
-
Created: created,
89
-
}
90
-
}
91
-
92
-
func AddString(e Execer, s String) error {
13
+
func AddString(e Execer, s models.String) error {
93
14
_, err := e.Exec(
94
15
`insert into strings (
95
16
did,
···
123
44
return err
124
45
}
125
46
126
-
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
-
var all []String
47
+
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
var all []models.String
128
49
129
50
var conditions []string
130
51
var args []any
···
167
88
defer rows.Close()
168
89
169
90
for rows.Next() {
170
-
var s String
91
+
var s models.String
171
92
var createdAt string
172
93
var editedAt sql.NullString
173
94
···
248
169
_, err := e.Exec(query, args...)
249
170
return err
250
171
}
251
-
252
-
func countLines(r io.Reader) (int, error) {
253
-
buf := make([]byte, 32*1024)
254
-
bufLen := 0
255
-
count := 0
256
-
nl := []byte{'\n'}
257
-
258
-
for {
259
-
c, err := r.Read(buf)
260
-
if c > 0 {
261
-
bufLen += c
262
-
}
263
-
count += bytes.Count(buf[:c], nl)
264
-
265
-
switch {
266
-
case err == io.EOF:
267
-
/* handle last line not having a newline at the end */
268
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
269
-
count++
270
-
}
271
-
return count, nil
272
-
case err != nil:
273
-
return 0, err
274
-
}
275
-
}
276
-
}
+93
-42
appview/db/timeline.go
+93
-42
appview/db/timeline.go
···
2
2
3
3
import (
4
4
"sort"
5
-
"time"
6
-
)
7
5
8
-
type TimelineEvent struct {
9
-
*Repo
10
-
*Follow
11
-
*Star
12
-
13
-
EventAt time.Time
14
-
15
-
// optional: populate only if Repo is a fork
16
-
Source *Repo
17
-
18
-
// optional: populate only if event is Follow
19
-
*Profile
20
-
*FollowStats
21
-
}
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
+
)
22
9
23
10
// TODO: this gathers heterogenous events from different sources and aggregates
24
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
25
-
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
26
-
var events []TimelineEvent
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
+
var events []models.TimelineEvent
27
14
28
-
repos, err := getTimelineRepos(e, limit)
15
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
29
16
if err != nil {
30
17
return nil, err
31
18
}
32
19
33
-
stars, err := getTimelineStars(e, limit)
20
+
stars, err := getTimelineStars(e, limit, loggedInUserDid)
34
21
if err != nil {
35
22
return nil, err
36
23
}
37
24
38
-
follows, err := getTimelineFollows(e, limit)
25
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
39
26
if err != nil {
40
27
return nil, err
41
28
}
···
56
43
return events, nil
57
44
}
58
45
59
-
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
46
+
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
47
+
if loggedInUserDid == "" {
48
+
return nil, nil
49
+
}
50
+
51
+
var repoAts []syntax.ATURI
52
+
for _, r := range repos {
53
+
repoAts = append(repoAts, r.RepoAt())
54
+
}
55
+
56
+
return GetStarStatuses(e, loggedInUserDid, repoAts)
57
+
}
58
+
59
+
func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
60
+
var isStarred bool
61
+
if starStatuses != nil {
62
+
isStarred = starStatuses[repo.RepoAt().String()]
63
+
}
64
+
65
+
var starCount int64
66
+
if repo.RepoStats != nil {
67
+
starCount = int64(repo.RepoStats.StarCount)
68
+
}
69
+
70
+
return isStarred, starCount
71
+
}
72
+
73
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
60
74
repos, err := GetRepos(e, limit)
61
75
if err != nil {
62
76
return nil, err
···
70
84
}
71
85
}
72
86
73
-
var origRepos []Repo
87
+
var origRepos []models.Repo
74
88
if args != nil {
75
89
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
76
90
}
···
78
92
return nil, err
79
93
}
80
94
81
-
uriToRepo := make(map[string]Repo)
95
+
uriToRepo := make(map[string]models.Repo)
82
96
for _, r := range origRepos {
83
97
uriToRepo[r.RepoAt().String()] = r
84
98
}
85
99
86
-
var events []TimelineEvent
100
+
starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
105
+
var events []models.TimelineEvent
87
106
for _, r := range repos {
88
-
var source *Repo
107
+
var source *models.Repo
89
108
if r.Source != "" {
90
109
if origRepo, ok := uriToRepo[r.Source]; ok {
91
110
source = &origRepo
92
111
}
93
112
}
94
113
95
-
events = append(events, TimelineEvent{
96
-
Repo: &r,
97
-
EventAt: r.Created,
98
-
Source: source,
114
+
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
115
+
116
+
events = append(events, models.TimelineEvent{
117
+
Repo: &r,
118
+
EventAt: r.Created,
119
+
Source: source,
120
+
IsStarred: isStarred,
121
+
StarCount: starCount,
99
122
})
100
123
}
101
124
102
125
return events, nil
103
126
}
104
127
105
-
func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) {
128
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
106
129
stars, err := GetStars(e, limit)
107
130
if err != nil {
108
131
return nil, err
···
118
141
}
119
142
stars = stars[:n]
120
143
121
-
var events []TimelineEvent
144
+
var repos []models.Repo
145
+
for _, s := range stars {
146
+
repos = append(repos, *s.Repo)
147
+
}
148
+
149
+
starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
150
+
if err != nil {
151
+
return nil, err
152
+
}
153
+
154
+
var events []models.TimelineEvent
122
155
for _, s := range stars {
123
-
events = append(events, TimelineEvent{
124
-
Star: &s,
125
-
EventAt: s.Created,
156
+
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
157
+
158
+
events = append(events, models.TimelineEvent{
159
+
Star: &s,
160
+
EventAt: s.Created,
161
+
IsStarred: isStarred,
162
+
StarCount: starCount,
126
163
})
127
164
}
128
165
129
166
return events, nil
130
167
}
131
168
132
-
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
169
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
133
170
follows, err := GetFollows(e, limit)
134
171
if err != nil {
135
172
return nil, err
···
154
191
return nil, err
155
192
}
156
193
157
-
var events []TimelineEvent
194
+
var followStatuses map[string]models.FollowStatus
195
+
if loggedInUserDid != "" {
196
+
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
197
+
if err != nil {
198
+
return nil, err
199
+
}
200
+
}
201
+
202
+
var events []models.TimelineEvent
158
203
for _, f := range follows {
159
204
profile, _ := profiles[f.SubjectDid]
160
205
followStatMap, _ := followStatMap[f.SubjectDid]
161
206
162
-
events = append(events, TimelineEvent{
163
-
Follow: &f,
164
-
Profile: profile,
165
-
FollowStats: &followStatMap,
166
-
EventAt: f.FollowedAt,
207
+
followStatus := models.IsNotFollowing
208
+
if followStatuses != nil {
209
+
followStatus = followStatuses[f.SubjectDid]
210
+
}
211
+
212
+
events = append(events, models.TimelineEvent{
213
+
Follow: &f,
214
+
Profile: profile,
215
+
FollowStats: &followStatMap,
216
+
FollowStatus: &followStatus,
217
+
EventAt: f.FollowedAt,
167
218
})
168
219
}
169
220
+1
-1
appview/dns/cloudflare.go
+1
-1
appview/dns/cloudflare.go
+198
-61
appview/ingester.go
+198
-61
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"maps"
9
+
"slices"
8
10
9
11
"time"
10
12
11
13
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/jetstream/pkg/models"
14
+
jmodels "github.com/bluesky-social/jetstream/pkg/models"
13
15
"github.com/go-git/go-git/v5/plumbing"
14
16
"github.com/ipfs/go-cid"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/validator"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/config"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/serververify"
22
+
"tangled.org/core/appview/validator"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
22
25
)
23
26
24
27
type Ingester struct {
···
30
33
Validator *validator.Validator
31
34
}
32
35
33
-
type processFunc func(ctx context.Context, e *models.Event) error
36
+
type processFunc func(ctx context.Context, e *jmodels.Event) error
34
37
35
38
func (i *Ingester) Ingest() processFunc {
36
-
return func(ctx context.Context, e *models.Event) error {
39
+
return func(ctx context.Context, e *jmodels.Event) error {
37
40
var err error
38
41
defer func() {
39
42
eventTime := e.TimeUS
···
45
48
46
49
l := i.Logger.With("kind", e.Kind)
47
50
switch e.Kind {
48
-
case models.EventKindAccount:
51
+
case jmodels.EventKindAccount:
49
52
if !e.Account.Active && *e.Account.Status == "deactivated" {
50
53
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
51
54
}
52
-
case models.EventKindIdentity:
55
+
case jmodels.EventKindIdentity:
53
56
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
54
-
case models.EventKindCommit:
57
+
case jmodels.EventKindCommit:
55
58
switch e.Commit.Collection {
56
59
case tangled.GraphFollowNSID:
57
60
err = i.ingestFollow(e)
···
77
80
err = i.ingestIssue(ctx, e)
78
81
case tangled.RepoIssueCommentNSID:
79
82
err = i.ingestIssueComment(e)
83
+
case tangled.LabelDefinitionNSID:
84
+
err = i.ingestLabelDefinition(e)
85
+
case tangled.LabelOpNSID:
86
+
err = i.ingestLabelOp(e)
80
87
}
81
88
l = i.Logger.With("nsid", e.Commit.Collection)
82
89
}
···
89
96
}
90
97
}
91
98
92
-
func (i *Ingester) ingestStar(e *models.Event) error {
99
+
func (i *Ingester) ingestStar(e *jmodels.Event) error {
93
100
var err error
94
101
did := e.Did
95
102
···
97
104
l = l.With("nsid", e.Commit.Collection)
98
105
99
106
switch e.Commit.Operation {
100
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
107
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
101
108
var subjectUri syntax.ATURI
102
109
103
110
raw := json.RawMessage(e.Commit.Record)
···
113
120
l.Error("invalid record", "err", err)
114
121
return err
115
122
}
116
-
err = db.AddStar(i.Db, &db.Star{
123
+
err = db.AddStar(i.Db, &models.Star{
117
124
StarredByDid: did,
118
125
RepoAt: subjectUri,
119
126
Rkey: e.Commit.RKey,
120
127
})
121
-
case models.CommitOperationDelete:
128
+
case jmodels.CommitOperationDelete:
122
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
123
130
}
124
131
···
129
136
return nil
130
137
}
131
138
132
-
func (i *Ingester) ingestFollow(e *models.Event) error {
139
+
func (i *Ingester) ingestFollow(e *jmodels.Event) error {
133
140
var err error
134
141
did := e.Did
135
142
···
137
144
l = l.With("nsid", e.Commit.Collection)
138
145
139
146
switch e.Commit.Operation {
140
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
147
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
141
148
raw := json.RawMessage(e.Commit.Record)
142
149
record := tangled.GraphFollow{}
143
150
err = json.Unmarshal(raw, &record)
···
146
153
return err
147
154
}
148
155
149
-
err = db.AddFollow(i.Db, &db.Follow{
156
+
err = db.AddFollow(i.Db, &models.Follow{
150
157
UserDid: did,
151
158
SubjectDid: record.Subject,
152
159
Rkey: e.Commit.RKey,
153
160
})
154
-
case models.CommitOperationDelete:
161
+
case jmodels.CommitOperationDelete:
155
162
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
156
163
}
157
164
···
162
169
return nil
163
170
}
164
171
165
-
func (i *Ingester) ingestPublicKey(e *models.Event) error {
172
+
func (i *Ingester) ingestPublicKey(e *jmodels.Event) error {
166
173
did := e.Did
167
174
var err error
168
175
···
170
177
l = l.With("nsid", e.Commit.Collection)
171
178
172
179
switch e.Commit.Operation {
173
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
180
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
174
181
l.Debug("processing add of pubkey")
175
182
raw := json.RawMessage(e.Commit.Record)
176
183
record := tangled.PublicKey{}
···
183
190
name := record.Name
184
191
key := record.Key
185
192
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
186
-
case models.CommitOperationDelete:
193
+
case jmodels.CommitOperationDelete:
187
194
l.Debug("processing delete of pubkey")
188
195
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
189
196
}
···
195
202
return nil
196
203
}
197
204
198
-
func (i *Ingester) ingestArtifact(e *models.Event) error {
205
+
func (i *Ingester) ingestArtifact(e *jmodels.Event) error {
199
206
did := e.Did
200
207
var err error
201
208
···
203
210
l = l.With("nsid", e.Commit.Collection)
204
211
205
212
switch e.Commit.Operation {
206
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
213
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
207
214
raw := json.RawMessage(e.Commit.Record)
208
215
record := tangled.RepoArtifact{}
209
216
err = json.Unmarshal(raw, &record)
···
232
239
createdAt = time.Now()
233
240
}
234
241
235
-
artifact := db.Artifact{
242
+
artifact := models.Artifact{
236
243
Did: did,
237
244
Rkey: e.Commit.RKey,
238
245
RepoAt: repoAt,
···
245
252
}
246
253
247
254
err = db.AddArtifact(i.Db, artifact)
248
-
case models.CommitOperationDelete:
255
+
case jmodels.CommitOperationDelete:
249
256
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
250
257
}
251
258
···
256
263
return nil
257
264
}
258
265
259
-
func (i *Ingester) ingestProfile(e *models.Event) error {
266
+
func (i *Ingester) ingestProfile(e *jmodels.Event) error {
260
267
did := e.Did
261
268
var err error
262
269
···
268
275
}
269
276
270
277
switch e.Commit.Operation {
271
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
278
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
272
279
raw := json.RawMessage(e.Commit.Record)
273
280
record := tangled.ActorProfile{}
274
281
err = json.Unmarshal(raw, &record)
···
296
303
}
297
304
}
298
305
299
-
var stats [2]db.VanityStat
306
+
var stats [2]models.VanityStat
300
307
for i, s := range record.Stats {
301
308
if i < 2 {
302
-
stats[i].Kind = db.VanityStatKind(s)
309
+
stats[i].Kind = models.VanityStatKind(s)
303
310
}
304
311
}
305
312
···
310
317
}
311
318
}
312
319
313
-
profile := db.Profile{
320
+
profile := models.Profile{
314
321
Did: did,
315
322
Description: description,
316
323
IncludeBluesky: includeBluesky,
···
336
343
}
337
344
338
345
err = db.UpsertProfile(tx, &profile)
339
-
case models.CommitOperationDelete:
346
+
case jmodels.CommitOperationDelete:
340
347
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
341
348
}
342
349
···
347
354
return nil
348
355
}
349
356
350
-
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
357
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error {
351
358
did := e.Did
352
359
var err error
353
360
···
355
362
l = l.With("nsid", e.Commit.Collection)
356
363
357
364
switch e.Commit.Operation {
358
-
case models.CommitOperationCreate:
365
+
case jmodels.CommitOperationCreate:
359
366
raw := json.RawMessage(e.Commit.Record)
360
367
record := tangled.SpindleMember{}
361
368
err = json.Unmarshal(raw, &record)
···
384
391
return fmt.Errorf("failed to index profile record, invalid db cast")
385
392
}
386
393
387
-
err = db.AddSpindleMember(ddb, db.SpindleMember{
394
+
err = db.AddSpindleMember(ddb, models.SpindleMember{
388
395
Did: syntax.DID(did),
389
396
Rkey: e.Commit.RKey,
390
397
Instance: record.Instance,
···
400
407
}
401
408
402
409
l.Info("added spindle member")
403
-
case models.CommitOperationDelete:
410
+
case jmodels.CommitOperationDelete:
404
411
rkey := e.Commit.RKey
405
412
406
413
ddb, ok := i.Db.Execer.(*db.DB)
···
453
460
return nil
454
461
}
455
462
456
-
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
463
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error {
457
464
did := e.Did
458
465
var err error
459
466
···
461
468
l = l.With("nsid", e.Commit.Collection)
462
469
463
470
switch e.Commit.Operation {
464
-
case models.CommitOperationCreate:
471
+
case jmodels.CommitOperationCreate:
465
472
raw := json.RawMessage(e.Commit.Record)
466
473
record := tangled.Spindle{}
467
474
err = json.Unmarshal(raw, &record)
···
477
484
return fmt.Errorf("failed to index profile record, invalid db cast")
478
485
}
479
486
480
-
err := db.AddSpindle(ddb, db.Spindle{
487
+
err := db.AddSpindle(ddb, models.Spindle{
481
488
Owner: syntax.DID(did),
482
489
Instance: instance,
483
490
})
···
499
506
500
507
return nil
501
508
502
-
case models.CommitOperationDelete:
509
+
case jmodels.CommitOperationDelete:
503
510
instance := e.Commit.RKey
504
511
505
512
ddb, ok := i.Db.Execer.(*db.DB)
···
567
574
return nil
568
575
}
569
576
570
-
func (i *Ingester) ingestString(e *models.Event) error {
577
+
func (i *Ingester) ingestString(e *jmodels.Event) error {
571
578
did := e.Did
572
579
rkey := e.Commit.RKey
573
580
···
582
589
}
583
590
584
591
switch e.Commit.Operation {
585
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
592
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
586
593
raw := json.RawMessage(e.Commit.Record)
587
594
record := tangled.String{}
588
595
err = json.Unmarshal(raw, &record)
···
591
598
return err
592
599
}
593
600
594
-
string := db.StringFromRecord(did, rkey, record)
601
+
string := models.StringFromRecord(did, rkey, record)
595
602
596
-
if err = string.Validate(); err != nil {
603
+
if err = i.Validator.ValidateString(&string); err != nil {
597
604
l.Error("invalid record", "err", err)
598
605
return err
599
606
}
···
605
612
606
613
return nil
607
614
608
-
case models.CommitOperationDelete:
615
+
case jmodels.CommitOperationDelete:
609
616
if err := db.DeleteString(
610
617
ddb,
611
618
db.FilterEq("did", did),
···
621
628
return nil
622
629
}
623
630
624
-
func (i *Ingester) ingestKnotMember(e *models.Event) error {
631
+
func (i *Ingester) ingestKnotMember(e *jmodels.Event) error {
625
632
did := e.Did
626
633
var err error
627
634
···
629
636
l = l.With("nsid", e.Commit.Collection)
630
637
631
638
switch e.Commit.Operation {
632
-
case models.CommitOperationCreate:
639
+
case jmodels.CommitOperationCreate:
633
640
raw := json.RawMessage(e.Commit.Record)
634
641
record := tangled.KnotMember{}
635
642
err = json.Unmarshal(raw, &record)
···
659
666
}
660
667
661
668
l.Info("added knot member")
662
-
case models.CommitOperationDelete:
669
+
case jmodels.CommitOperationDelete:
663
670
// we don't store knot members in a table (like we do for spindle)
664
671
// and we can't remove this just yet. possibly fixed if we switch
665
672
// to either:
···
673
680
return nil
674
681
}
675
682
676
-
func (i *Ingester) ingestKnot(e *models.Event) error {
683
+
func (i *Ingester) ingestKnot(e *jmodels.Event) error {
677
684
did := e.Did
678
685
var err error
679
686
···
681
688
l = l.With("nsid", e.Commit.Collection)
682
689
683
690
switch e.Commit.Operation {
684
-
case models.CommitOperationCreate:
691
+
case jmodels.CommitOperationCreate:
685
692
raw := json.RawMessage(e.Commit.Record)
686
693
record := tangled.Knot{}
687
694
err = json.Unmarshal(raw, &record)
···
716
723
717
724
return nil
718
725
719
-
case models.CommitOperationDelete:
726
+
case jmodels.CommitOperationDelete:
720
727
domain := e.Commit.RKey
721
728
722
729
ddb, ok := i.Db.Execer.(*db.DB)
···
776
783
777
784
return nil
778
785
}
779
-
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
786
+
func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error {
780
787
did := e.Did
781
788
rkey := e.Commit.RKey
782
789
···
791
798
}
792
799
793
800
switch e.Commit.Operation {
794
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
801
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
795
802
raw := json.RawMessage(e.Commit.Record)
796
803
record := tangled.RepoIssue{}
797
804
err = json.Unmarshal(raw, &record)
···
800
807
return err
801
808
}
802
809
803
-
issue := db.IssueFromRecord(did, rkey, record)
810
+
issue := models.IssueFromRecord(did, rkey, record)
804
811
805
812
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
813
return fmt.Errorf("failed to validate issue: %w", err)
···
827
834
828
835
return nil
829
836
830
-
case models.CommitOperationDelete:
837
+
case jmodels.CommitOperationDelete:
831
838
if err := db.DeleteIssues(
832
839
ddb,
833
840
db.FilterEq("did", did),
···
843
850
return nil
844
851
}
845
852
846
-
func (i *Ingester) ingestIssueComment(e *models.Event) error {
853
+
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
847
854
did := e.Did
848
855
rkey := e.Commit.RKey
849
856
···
858
865
}
859
866
860
867
switch e.Commit.Operation {
861
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
868
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
862
869
raw := json.RawMessage(e.Commit.Record)
863
870
record := tangled.RepoIssueComment{}
864
871
err = json.Unmarshal(raw, &record)
···
866
873
return fmt.Errorf("invalid record: %w", err)
867
874
}
868
875
869
-
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
876
+
comment, err := models.IssueCommentFromRecord(did, rkey, record)
870
877
if err != nil {
871
878
return fmt.Errorf("failed to parse comment from record: %w", err)
872
879
}
···
882
889
883
890
return nil
884
891
885
-
case models.CommitOperationDelete:
892
+
case jmodels.CommitOperationDelete:
886
893
if err := db.DeleteIssueComments(
887
894
ddb,
888
895
db.FilterEq("did", did),
···
896
903
897
904
return nil
898
905
}
906
+
907
+
func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error {
908
+
did := e.Did
909
+
rkey := e.Commit.RKey
910
+
911
+
var err error
912
+
913
+
l := i.Logger.With("handler", "ingestLabelDefinition", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
914
+
l.Info("ingesting record")
915
+
916
+
ddb, ok := i.Db.Execer.(*db.DB)
917
+
if !ok {
918
+
return fmt.Errorf("failed to index label definition, invalid db cast")
919
+
}
920
+
921
+
switch e.Commit.Operation {
922
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
923
+
raw := json.RawMessage(e.Commit.Record)
924
+
record := tangled.LabelDefinition{}
925
+
err = json.Unmarshal(raw, &record)
926
+
if err != nil {
927
+
return fmt.Errorf("invalid record: %w", err)
928
+
}
929
+
930
+
def, err := models.LabelDefinitionFromRecord(did, rkey, record)
931
+
if err != nil {
932
+
return fmt.Errorf("failed to parse labeldef from record: %w", err)
933
+
}
934
+
935
+
if err := i.Validator.ValidateLabelDefinition(def); err != nil {
936
+
return fmt.Errorf("failed to validate labeldef: %w", err)
937
+
}
938
+
939
+
_, err = db.AddLabelDefinition(ddb, def)
940
+
if err != nil {
941
+
return fmt.Errorf("failed to create labeldef: %w", err)
942
+
}
943
+
944
+
return nil
945
+
946
+
case jmodels.CommitOperationDelete:
947
+
if err := db.DeleteLabelDefinition(
948
+
ddb,
949
+
db.FilterEq("did", did),
950
+
db.FilterEq("rkey", rkey),
951
+
); err != nil {
952
+
return fmt.Errorf("failed to delete labeldef record: %w", err)
953
+
}
954
+
955
+
return nil
956
+
}
957
+
958
+
return nil
959
+
}
960
+
961
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962
+
did := e.Did
963
+
rkey := e.Commit.RKey
964
+
965
+
var err error
966
+
967
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968
+
l.Info("ingesting record")
969
+
970
+
ddb, ok := i.Db.Execer.(*db.DB)
971
+
if !ok {
972
+
return fmt.Errorf("failed to index label op, invalid db cast")
973
+
}
974
+
975
+
switch e.Commit.Operation {
976
+
case jmodels.CommitOperationCreate:
977
+
raw := json.RawMessage(e.Commit.Record)
978
+
record := tangled.LabelOp{}
979
+
err = json.Unmarshal(raw, &record)
980
+
if err != nil {
981
+
return fmt.Errorf("invalid record: %w", err)
982
+
}
983
+
984
+
subject := syntax.ATURI(record.Subject)
985
+
collection := subject.Collection()
986
+
987
+
var repo *models.Repo
988
+
switch collection {
989
+
case tangled.RepoIssueNSID:
990
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991
+
if err != nil || len(i) != 1 {
992
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
+
}
994
+
repo = i[0].Repo
995
+
default:
996
+
return fmt.Errorf("unsupport label subject: %s", collection)
997
+
}
998
+
999
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000
+
if err != nil {
1001
+
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
+
}
1003
+
1004
+
ops := models.LabelOpsFromRecord(did, rkey, record)
1005
+
1006
+
for _, o := range ops {
1007
+
def, ok := actx.Defs[o.OperandKey]
1008
+
if !ok {
1009
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
+
}
1011
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
+
return fmt.Errorf("failed to validate labelop: %w", err)
1013
+
}
1014
+
}
1015
+
1016
+
tx, err := ddb.Begin()
1017
+
if err != nil {
1018
+
return err
1019
+
}
1020
+
defer tx.Rollback()
1021
+
1022
+
for _, o := range ops {
1023
+
_, err = db.AddLabelOp(tx, &o)
1024
+
if err != nil {
1025
+
return fmt.Errorf("failed to add labelop: %w", err)
1026
+
}
1027
+
}
1028
+
1029
+
if err = tx.Commit(); err != nil {
1030
+
return err
1031
+
}
1032
+
}
1033
+
1034
+
return nil
1035
+
}
+71
-28
appview/issues/issues.go
+71
-28
appview/issues/issues.go
···
16
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
17
"github.com/go-chi/chi/v5"
18
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/config"
21
-
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/notify"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages"
25
-
"tangled.sh/tangled.sh/core/appview/pagination"
26
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
-
"tangled.sh/tangled.sh/core/appview/validator"
28
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
tlog "tangled.sh/tangled.sh/core/log"
31
-
"tangled.sh/tangled.sh/core/tid"
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/config"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/notify"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/pages"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/appview/reporesolver"
28
+
"tangled.org/core/appview/validator"
29
+
"tangled.org/core/appview/xrpcclient"
30
+
"tangled.org/core/idresolver"
31
+
tlog "tangled.org/core/log"
32
+
"tangled.org/core/tid"
32
33
)
33
34
34
35
type Issues struct {
···
75
76
return
76
77
}
77
78
78
-
issue, ok := r.Context().Value("issue").(*db.Issue)
79
+
issue, ok := r.Context().Value("issue").(*models.Issue)
79
80
if !ok {
80
81
l.Error("failed to get issue")
81
82
rp.pages.Error404(w)
···
87
88
l.Error("failed to get issue reactions", "err", err)
88
89
}
89
90
90
-
userReactions := map[db.ReactionKind]bool{}
91
+
userReactions := map[models.ReactionKind]bool{}
91
92
if user != nil {
92
93
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
94
}
94
95
96
+
labelDefs, err := db.GetLabelDefinitions(
97
+
rp.db,
98
+
db.FilterIn("at_uri", f.Repo.Labels),
99
+
db.FilterContains("scope", tangled.RepoIssueNSID),
100
+
)
101
+
if err != nil {
102
+
log.Println("failed to fetch labels", err)
103
+
rp.pages.Error503(w)
104
+
return
105
+
}
106
+
107
+
defs := make(map[string]*models.LabelDefinition)
108
+
for _, l := range labelDefs {
109
+
defs[l.AtUri().String()] = &l
110
+
}
111
+
95
112
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
96
113
LoggedInUser: user,
97
114
RepoInfo: f.RepoInfo(user),
98
115
Issue: issue,
99
116
CommentList: issue.CommentList(),
100
-
OrderedReactionKinds: db.OrderedReactionKinds,
117
+
OrderedReactionKinds: models.OrderedReactionKinds,
101
118
Reactions: reactionCountMap,
102
119
UserReacted: userReactions,
120
+
LabelDefs: defs,
103
121
})
104
122
}
105
123
···
112
130
return
113
131
}
114
132
115
-
issue, ok := r.Context().Value("issue").(*db.Issue)
133
+
issue, ok := r.Context().Value("issue").(*models.Issue)
116
134
if !ok {
117
135
l.Error("failed to get issue")
118
136
rp.pages.Error404(w)
···
208
226
return
209
227
}
210
228
211
-
issue, ok := r.Context().Value("issue").(*db.Issue)
229
+
issue, ok := r.Context().Value("issue").(*models.Issue)
212
230
if !ok {
213
231
l.Error("failed to get issue")
214
232
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
255
273
return
256
274
}
257
275
258
-
issue, ok := r.Context().Value("issue").(*db.Issue)
276
+
issue, ok := r.Context().Value("issue").(*models.Issue)
259
277
if !ok {
260
278
l.Error("failed to get issue")
261
279
rp.pages.Error404(w)
···
283
301
return
284
302
}
285
303
304
+
// notify about the issue closure
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
306
+
286
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
287
308
return
288
309
} else {
···
301
322
return
302
323
}
303
324
304
-
issue, ok := r.Context().Value("issue").(*db.Issue)
325
+
issue, ok := r.Context().Value("issue").(*models.Issue)
305
326
if !ok {
306
327
l.Error("failed to get issue")
307
328
rp.pages.Error404(w)
···
345
366
return
346
367
}
347
368
348
-
issue, ok := r.Context().Value("issue").(*db.Issue)
369
+
issue, ok := r.Context().Value("issue").(*models.Issue)
349
370
if !ok {
350
371
l.Error("failed to get issue")
351
372
rp.pages.Error404(w)
···
364
385
replyTo = &replyToUri
365
386
}
366
387
367
-
comment := db.IssueComment{
388
+
comment := models.IssueComment{
368
389
Did: user.Did,
369
390
Rkey: tid.TID(),
370
391
IssueAt: issue.AtUri().String(),
···
416
437
417
438
// reset atUri to make rollback a no-op
418
439
atUri = ""
440
+
441
+
// notify about the new comment
442
+
comment.Id = commentId
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
444
+
419
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
420
446
}
421
447
···
428
454
return
429
455
}
430
456
431
-
issue, ok := r.Context().Value("issue").(*db.Issue)
457
+
issue, ok := r.Context().Value("issue").(*models.Issue)
432
458
if !ok {
433
459
l.Error("failed to get issue")
434
460
rp.pages.Error404(w)
···
469
495
return
470
496
}
471
497
472
-
issue, ok := r.Context().Value("issue").(*db.Issue)
498
+
issue, ok := r.Context().Value("issue").(*models.Issue)
473
499
if !ok {
474
500
l.Error("failed to get issue")
475
501
rp.pages.Error404(w)
···
573
599
return
574
600
}
575
601
576
-
issue, ok := r.Context().Value("issue").(*db.Issue)
602
+
issue, ok := r.Context().Value("issue").(*models.Issue)
577
603
if !ok {
578
604
l.Error("failed to get issue")
579
605
rp.pages.Error404(w)
···
614
640
return
615
641
}
616
642
617
-
issue, ok := r.Context().Value("issue").(*db.Issue)
643
+
issue, ok := r.Context().Value("issue").(*models.Issue)
618
644
if !ok {
619
645
l.Error("failed to get issue")
620
646
rp.pages.Error404(w)
···
655
681
return
656
682
}
657
683
658
-
issue, ok := r.Context().Value("issue").(*db.Issue)
684
+
issue, ok := r.Context().Value("issue").(*models.Issue)
659
685
if !ok {
660
686
l.Error("failed to get issue")
661
687
rp.pages.Error404(w)
···
772
798
return
773
799
}
774
800
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
806
+
if err != nil {
807
+
log.Println("failed to fetch labels", err)
808
+
rp.pages.Error503(w)
809
+
return
810
+
}
811
+
812
+
defs := make(map[string]*models.LabelDefinition)
813
+
for _, l := range labelDefs {
814
+
defs[l.AtUri().String()] = &l
815
+
}
816
+
775
817
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
776
818
LoggedInUser: rp.oauth.GetUser(r),
777
819
RepoInfo: f.RepoInfo(user),
778
820
Issues: issues,
821
+
LabelDefs: defs,
779
822
FilteringByOpen: isOpen,
780
823
Page: page,
781
824
})
···
798
841
RepoInfo: f.RepoInfo(user),
799
842
})
800
843
case http.MethodPost:
801
-
issue := &db.Issue{
844
+
issue := &models.Issue{
802
845
RepoAt: f.RepoAt(),
803
846
Rkey: tid.TID(),
804
847
Title: r.FormValue("title"),
+2
-2
appview/issues/router.go
+2
-2
appview/issues/router.go
···
4
4
"net/http"
5
5
6
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
7
+
"tangled.org/core/appview/middleware"
8
8
)
9
9
10
10
func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
···
14
14
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
15
16
16
r.Route("/{issue}", func(r chi.Router) {
17
-
r.Use(mw.ResolveIssue())
17
+
r.Use(mw.ResolveIssue)
18
18
r.Get("/", i.RepoSingleIssue)
19
19
20
20
// authenticated routes
+14
-13
appview/knots/knots.go
+14
-13
appview/knots/knots.go
···
9
9
"time"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/eventconsumer"
21
-
"tangled.sh/tangled.sh/core/idresolver"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/eventconsumer"
22
+
"tangled.org/core/idresolver"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/tid"
24
25
25
26
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
27
lexutil "github.com/bluesky-social/indigo/lex/util"
···
119
120
}
120
121
121
122
// organize repos by did
122
-
repoMap := make(map[string][]db.Repo)
123
+
repoMap := make(map[string][]models.Repo)
123
124
for _, r := range repos {
124
125
repoMap[r.Did] = append(repoMap[r.Did], r)
125
126
}
+272
appview/labels/labels.go
+272
appview/labels/labels.go
···
1
+
package labels
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"log/slog"
9
+
"net/http"
10
+
"time"
11
+
12
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
lexutil "github.com/bluesky-social/indigo/lex/util"
15
+
"github.com/go-chi/chi/v5"
16
+
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/middleware"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/oauth"
22
+
"tangled.org/core/appview/pages"
23
+
"tangled.org/core/appview/validator"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/log"
26
+
"tangled.org/core/rbac"
27
+
"tangled.org/core/tid"
28
+
)
29
+
30
+
type Labels struct {
31
+
oauth *oauth.OAuth
32
+
pages *pages.Pages
33
+
db *db.DB
34
+
logger *slog.Logger
35
+
validator *validator.Validator
36
+
enforcer *rbac.Enforcer
37
+
}
38
+
39
+
func New(
40
+
oauth *oauth.OAuth,
41
+
pages *pages.Pages,
42
+
db *db.DB,
43
+
validator *validator.Validator,
44
+
enforcer *rbac.Enforcer,
45
+
) *Labels {
46
+
logger := log.New("labels")
47
+
48
+
return &Labels{
49
+
oauth: oauth,
50
+
pages: pages,
51
+
db: db,
52
+
logger: logger,
53
+
validator: validator,
54
+
enforcer: enforcer,
55
+
}
56
+
}
57
+
58
+
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
59
+
r := chi.NewRouter()
60
+
61
+
r.Use(middleware.AuthMiddleware(l.oauth))
62
+
r.Put("/perform", l.PerformLabelOp)
63
+
64
+
return r
65
+
}
66
+
67
+
// this is a tricky handler implementation:
68
+
// - the user selects the new state of all the labels in the label panel and hits save
69
+
// - this handler should calculate the diff in order to create the labelop record
70
+
// - we need the diff in order to maintain a "history" of operations performed by users
71
+
func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) {
72
+
user := l.oauth.GetUser(r)
73
+
74
+
noticeId := "add-label-error"
75
+
76
+
fail := func(msg string, err error) {
77
+
l.logger.Error("failed to add label", "err", err)
78
+
l.pages.Notice(w, noticeId, msg)
79
+
}
80
+
81
+
if err := r.ParseForm(); err != nil {
82
+
fail("Invalid form.", err)
83
+
return
84
+
}
85
+
86
+
did := user.Did
87
+
rkey := tid.TID()
88
+
performedAt := time.Now()
89
+
indexedAt := time.Now()
90
+
repoAt := r.Form.Get("repo")
91
+
subjectUri := r.Form.Get("subject")
92
+
93
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
94
+
if err != nil {
95
+
fail("Failed to get repository.", err)
96
+
return
97
+
}
98
+
99
+
// find all the labels that this repo subscribes to
100
+
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
101
+
if err != nil {
102
+
fail("Failed to get labels for this repository.", err)
103
+
return
104
+
}
105
+
106
+
var labelAts []string
107
+
for _, rl := range repoLabels {
108
+
labelAts = append(labelAts, rl.LabelAt.String())
109
+
}
110
+
111
+
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
112
+
if err != nil {
113
+
fail("Invalid form data.", err)
114
+
return
115
+
}
116
+
117
+
// calculate the start state by applying already known labels
118
+
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
119
+
if err != nil {
120
+
fail("Invalid form data.", err)
121
+
return
122
+
}
123
+
124
+
labelState := models.NewLabelState()
125
+
actx.ApplyLabelOps(labelState, existingOps)
126
+
127
+
var labelOps []models.LabelOp
128
+
129
+
// first delete all existing state
130
+
for key, vals := range labelState.Inner() {
131
+
for val := range vals {
132
+
labelOps = append(labelOps, models.LabelOp{
133
+
Did: did,
134
+
Rkey: rkey,
135
+
Subject: syntax.ATURI(subjectUri),
136
+
Operation: models.LabelOperationDel,
137
+
OperandKey: key,
138
+
OperandValue: val,
139
+
PerformedAt: performedAt,
140
+
IndexedAt: indexedAt,
141
+
})
142
+
}
143
+
}
144
+
145
+
// add all the new state the user specified
146
+
for key, vals := range r.Form {
147
+
if _, ok := actx.Defs[key]; !ok {
148
+
continue
149
+
}
150
+
151
+
for _, val := range vals {
152
+
labelOps = append(labelOps, models.LabelOp{
153
+
Did: did,
154
+
Rkey: rkey,
155
+
Subject: syntax.ATURI(subjectUri),
156
+
Operation: models.LabelOperationAdd,
157
+
OperandKey: key,
158
+
OperandValue: val,
159
+
PerformedAt: performedAt,
160
+
IndexedAt: indexedAt,
161
+
})
162
+
}
163
+
}
164
+
165
+
for i := range labelOps {
166
+
def := actx.Defs[labelOps[i].OperandKey]
167
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
168
+
fail(fmt.Sprintf("Invalid form data: %s", err), err)
169
+
return
170
+
}
171
+
}
172
+
173
+
// reduce the opset
174
+
labelOps = models.ReduceLabelOps(labelOps)
175
+
176
+
// next, apply all ops introduced in this request and filter out ones that are no-ops
177
+
validLabelOps := labelOps[:0]
178
+
for _, op := range labelOps {
179
+
if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
180
+
validLabelOps = append(validLabelOps, op)
181
+
}
182
+
}
183
+
184
+
// nothing to do
185
+
if len(validLabelOps) == 0 {
186
+
l.pages.HxRefresh(w)
187
+
return
188
+
}
189
+
190
+
// create an atproto record of valid ops
191
+
record := models.LabelOpsAsRecord(validLabelOps)
192
+
193
+
client, err := l.oauth.AuthorizedClient(r)
194
+
if err != nil {
195
+
fail("Failed to authorize user.", err)
196
+
return
197
+
}
198
+
199
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
200
+
Collection: tangled.LabelOpNSID,
201
+
Repo: did,
202
+
Rkey: rkey,
203
+
Record: &lexutil.LexiconTypeDecoder{
204
+
Val: &record,
205
+
},
206
+
})
207
+
if err != nil {
208
+
fail("Failed to create record on PDS for user.", err)
209
+
return
210
+
}
211
+
atUri := resp.Uri
212
+
213
+
tx, err := l.db.BeginTx(r.Context(), nil)
214
+
if err != nil {
215
+
fail("Failed to update labels. Try again later.", err)
216
+
return
217
+
}
218
+
219
+
rollback := func() {
220
+
err1 := tx.Rollback()
221
+
err2 := rollbackRecord(context.Background(), atUri, client)
222
+
223
+
// ignore txn complete errors, this is okay
224
+
if errors.Is(err1, sql.ErrTxDone) {
225
+
err1 = nil
226
+
}
227
+
228
+
if errs := errors.Join(err1, err2); errs != nil {
229
+
return
230
+
}
231
+
}
232
+
defer rollback()
233
+
234
+
for _, o := range validLabelOps {
235
+
if _, err := db.AddLabelOp(l.db, &o); err != nil {
236
+
fail("Failed to update labels. Try again later.", err)
237
+
return
238
+
}
239
+
}
240
+
241
+
err = tx.Commit()
242
+
if err != nil {
243
+
return
244
+
}
245
+
246
+
// clear aturi when everything is successful
247
+
atUri = ""
248
+
249
+
l.pages.HxRefresh(w)
250
+
}
251
+
252
+
// this is used to rollback changes made to the PDS
253
+
//
254
+
// it is a no-op if the provided ATURI is empty
255
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
256
+
if aturi == "" {
257
+
return nil
258
+
}
259
+
260
+
parsed := syntax.ATURI(aturi)
261
+
262
+
collection := parsed.Collection().String()
263
+
repo := parsed.Authority().String()
264
+
rkey := parsed.RecordKey().String()
265
+
266
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
267
+
Collection: collection,
268
+
Repo: repo,
269
+
Rkey: rkey,
270
+
})
271
+
return err
272
+
}
+62
-47
appview/middleware/middleware.go
+62
-47
appview/middleware/middleware.go
···
12
12
13
13
"github.com/bluesky-social/indigo/atproto/identity"
14
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/pagination"
19
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pagination"
19
+
"tangled.org/core/appview/reporesolver"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
22
22
)
23
23
24
24
type Middleware struct {
···
42
42
}
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
+
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
45
54
46
55
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
56
return func(next http.Handler) http.Handler {
···
213
222
return
214
223
}
215
224
216
-
repo, err := db.GetRepo(mw.db, id.DID.String(), repoName)
225
+
repo, err := db.GetRepo(
226
+
mw.db,
227
+
db.FilterEq("did", id.DID.String()),
228
+
db.FilterEq("name", repoName),
229
+
)
217
230
if err != nil {
218
-
// invalid did or handle
219
-
log.Println("failed to resolve repo")
231
+
log.Println("failed to resolve repo", "err", err)
220
232
mw.pages.ErrorKnot404(w)
221
233
return
222
234
}
···
276
288
}
277
289
278
290
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
279
-
func (mw Middleware) ResolveIssue() middlewareFunc {
280
-
return func(next http.Handler) http.Handler {
281
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282
-
f, err := mw.repoResolver.Resolve(r)
283
-
if err != nil {
284
-
log.Println("failed to fully resolve repo", err)
285
-
mw.pages.ErrorKnot404(w)
286
-
return
287
-
}
291
+
func (mw Middleware) ResolveIssue(next http.Handler) http.Handler {
292
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
293
+
f, err := mw.repoResolver.Resolve(r)
294
+
if err != nil {
295
+
log.Println("failed to fully resolve repo", err)
296
+
mw.pages.ErrorKnot404(w)
297
+
return
298
+
}
288
299
289
-
issueIdStr := chi.URLParam(r, "issue")
290
-
issueId, err := strconv.Atoi(issueIdStr)
291
-
if err != nil {
292
-
log.Println("failed to fully resolve issue ID", err)
293
-
mw.pages.ErrorKnot404(w)
294
-
return
295
-
}
300
+
issueIdStr := chi.URLParam(r, "issue")
301
+
issueId, err := strconv.Atoi(issueIdStr)
302
+
if err != nil {
303
+
log.Println("failed to fully resolve issue ID", err)
304
+
mw.pages.ErrorKnot404(w)
305
+
return
306
+
}
296
307
297
-
issues, err := db.GetIssues(
298
-
mw.db,
299
-
db.FilterEq("repo_at", f.RepoAt()),
300
-
db.FilterEq("issue_id", issueId),
301
-
)
302
-
if err != nil {
303
-
log.Println("failed to get issues", "err", err)
304
-
return
305
-
}
306
-
if len(issues) != 1 {
307
-
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
308
-
return
309
-
}
310
-
issue := issues[0]
308
+
issues, err := db.GetIssues(
309
+
mw.db,
310
+
db.FilterEq("repo_at", f.RepoAt()),
311
+
db.FilterEq("issue_id", issueId),
312
+
)
313
+
if err != nil {
314
+
log.Println("failed to get issues", "err", err)
315
+
return
316
+
}
317
+
if len(issues) != 1 {
318
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
319
+
return
320
+
}
321
+
issue := issues[0]
311
322
312
-
ctx := context.WithValue(r.Context(), "issue", &issue)
313
-
next.ServeHTTP(w, r.WithContext(ctx))
314
-
})
315
-
}
323
+
ctx := context.WithValue(r.Context(), "issue", &issue)
324
+
next.ServeHTTP(w, r.WithContext(ctx))
325
+
})
316
326
}
317
327
318
328
// this should serve the go-import meta tag even if the path is technically
319
329
// a 404 like tangled.sh/oppi.li/go-git/v5
330
+
//
331
+
// we're keeping the tangled.sh go-import tag too to maintain backward
332
+
// compatiblity for modules that still point there. they will be redirected
333
+
// to fetch source from tangled.org
320
334
func (mw Middleware) GoImport() middlewareFunc {
321
335
return func(next http.Handler) http.Handler {
322
336
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
332
346
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
333
347
if r.URL.Query().Get("go-get") == "1" {
334
348
html := fmt.Sprintf(
335
-
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
336
-
fullName,
337
-
fullName,
349
+
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>
350
+
<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`,
351
+
fullName, fullName,
352
+
fullName, fullName,
338
353
)
339
354
w.Header().Set("Content-Type", "text/html")
340
355
w.Write([]byte(html))
+30
appview/models/artifact.go
+30
appview/models/artifact.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
"github.com/ipfs/go-cid"
10
+
"tangled.org/core/api/tangled"
11
+
)
12
+
13
+
type Artifact struct {
14
+
Id uint64
15
+
Did string
16
+
Rkey string
17
+
18
+
RepoAt syntax.ATURI
19
+
Tag plumbing.Hash
20
+
CreatedAt time.Time
21
+
22
+
BlobCid cid.Cid
23
+
Name string
24
+
Size uint64
25
+
MimeType string
26
+
}
27
+
28
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
30
+
}
+21
appview/models/collaborator.go
+21
appview/models/collaborator.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Collaborator struct {
10
+
// identifiers for the record
11
+
Id int64
12
+
Did syntax.DID
13
+
Rkey string
14
+
15
+
// content
16
+
SubjectDid syntax.DID
17
+
RepoAt syntax.ATURI
18
+
19
+
// meta
20
+
Created time.Time
21
+
}
+16
appview/models/email.go
+16
appview/models/email.go
+38
appview/models/follow.go
+38
appview/models/follow.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type Follow struct {
8
+
UserDid string
9
+
SubjectDid string
10
+
FollowedAt time.Time
11
+
Rkey string
12
+
}
13
+
14
+
type FollowStats struct {
15
+
Followers int64
16
+
Following int64
17
+
}
18
+
19
+
type FollowStatus int
20
+
21
+
const (
22
+
IsNotFollowing FollowStatus = iota
23
+
IsFollowing
24
+
IsSelf
25
+
)
26
+
27
+
func (s FollowStatus) String() string {
28
+
switch s {
29
+
case IsNotFollowing:
30
+
return "IsNotFollowing"
31
+
case IsFollowing:
32
+
return "IsFollowing"
33
+
case IsSelf:
34
+
return "IsSelf"
35
+
default:
36
+
return "IsNotFollowing"
37
+
}
38
+
}
+194
appview/models/issue.go
+194
appview/models/issue.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"sort"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
)
11
+
12
+
type Issue struct {
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
25
+
// optionally, populate this when querying for reverse mappings
26
+
// like comment counts, parent repo etc.
27
+
Comments []IssueComment
28
+
Labels LabelState
29
+
Repo *Repo
30
+
}
31
+
32
+
func (i *Issue) AtUri() syntax.ATURI {
33
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
34
+
}
35
+
36
+
func (i *Issue) AsRecord() tangled.RepoIssue {
37
+
return tangled.RepoIssue{
38
+
Repo: i.RepoAt.String(),
39
+
Title: i.Title,
40
+
Body: &i.Body,
41
+
CreatedAt: i.Created.Format(time.RFC3339),
42
+
}
43
+
}
44
+
45
+
func (i *Issue) State() string {
46
+
if i.Open {
47
+
return "open"
48
+
}
49
+
return "closed"
50
+
}
51
+
52
+
type CommentListItem struct {
53
+
Self *IssueComment
54
+
Replies []*IssueComment
55
+
}
56
+
57
+
func (i *Issue) CommentList() []CommentListItem {
58
+
// Create a map to quickly find comments by their aturi
59
+
toplevel := make(map[string]*CommentListItem)
60
+
var replies []*IssueComment
61
+
62
+
// collect top level comments into the map
63
+
for _, comment := range i.Comments {
64
+
if comment.IsTopLevel() {
65
+
toplevel[comment.AtUri().String()] = &CommentListItem{
66
+
Self: &comment,
67
+
}
68
+
} else {
69
+
replies = append(replies, &comment)
70
+
}
71
+
}
72
+
73
+
for _, r := range replies {
74
+
parentAt := *r.ReplyTo
75
+
if parent, exists := toplevel[parentAt]; exists {
76
+
parent.Replies = append(parent.Replies, r)
77
+
}
78
+
}
79
+
80
+
var listing []CommentListItem
81
+
for _, v := range toplevel {
82
+
listing = append(listing, *v)
83
+
}
84
+
85
+
// sort everything
86
+
sortFunc := func(a, b *IssueComment) bool {
87
+
return a.Created.Before(b.Created)
88
+
}
89
+
sort.Slice(listing, func(i, j int) bool {
90
+
return sortFunc(listing[i].Self, listing[j].Self)
91
+
})
92
+
for _, r := range listing {
93
+
sort.Slice(r.Replies, func(i, j int) bool {
94
+
return sortFunc(r.Replies[i], r.Replies[j])
95
+
})
96
+
}
97
+
98
+
return listing
99
+
}
100
+
101
+
func (i *Issue) Participants() []string {
102
+
participantSet := make(map[string]struct{})
103
+
participants := []string{}
104
+
105
+
addParticipant := func(did string) {
106
+
if _, exists := participantSet[did]; !exists {
107
+
participantSet[did] = struct{}{}
108
+
participants = append(participants, did)
109
+
}
110
+
}
111
+
112
+
addParticipant(i.Did)
113
+
114
+
for _, c := range i.Comments {
115
+
addParticipant(c.Did)
116
+
}
117
+
118
+
return participants
119
+
}
120
+
121
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
122
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
123
+
if err != nil {
124
+
created = time.Now()
125
+
}
126
+
127
+
body := ""
128
+
if record.Body != nil {
129
+
body = *record.Body
130
+
}
131
+
132
+
return Issue{
133
+
RepoAt: syntax.ATURI(record.Repo),
134
+
Did: did,
135
+
Rkey: rkey,
136
+
Created: created,
137
+
Title: record.Title,
138
+
Body: body,
139
+
Open: true, // new issues are open by default
140
+
}
141
+
}
142
+
143
+
type IssueComment struct {
144
+
Id int64
145
+
Did string
146
+
Rkey string
147
+
IssueAt string
148
+
ReplyTo *string
149
+
Body string
150
+
Created time.Time
151
+
Edited *time.Time
152
+
Deleted *time.Time
153
+
}
154
+
155
+
func (i *IssueComment) AtUri() syntax.ATURI {
156
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
157
+
}
158
+
159
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
160
+
return tangled.RepoIssueComment{
161
+
Body: i.Body,
162
+
Issue: i.IssueAt,
163
+
CreatedAt: i.Created.Format(time.RFC3339),
164
+
ReplyTo: i.ReplyTo,
165
+
}
166
+
}
167
+
168
+
func (i *IssueComment) IsTopLevel() bool {
169
+
return i.ReplyTo == nil
170
+
}
171
+
172
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
173
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
174
+
if err != nil {
175
+
created = time.Now()
176
+
}
177
+
178
+
ownerDid := did
179
+
180
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
181
+
return nil, err
182
+
}
183
+
184
+
comment := IssueComment{
185
+
Did: ownerDid,
186
+
Rkey: rkey,
187
+
Body: record.Body,
188
+
IssueAt: record.Issue,
189
+
ReplyTo: record.ReplyTo,
190
+
Created: created,
191
+
}
192
+
193
+
return &comment, nil
194
+
}
+542
appview/models/label.go
+542
appview/models/label.go
···
1
+
package models
2
+
3
+
import (
4
+
"context"
5
+
"crypto/sha1"
6
+
"encoding/hex"
7
+
"encoding/json"
8
+
"errors"
9
+
"fmt"
10
+
"slices"
11
+
"time"
12
+
13
+
"github.com/bluesky-social/indigo/api/atproto"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/bluesky-social/indigo/xrpc"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/consts"
18
+
"tangled.org/core/idresolver"
19
+
)
20
+
21
+
type ConcreteType string
22
+
23
+
const (
24
+
ConcreteTypeNull ConcreteType = "null"
25
+
ConcreteTypeString ConcreteType = "string"
26
+
ConcreteTypeInt ConcreteType = "integer"
27
+
ConcreteTypeBool ConcreteType = "boolean"
28
+
)
29
+
30
+
type ValueTypeFormat string
31
+
32
+
const (
33
+
ValueTypeFormatAny ValueTypeFormat = "any"
34
+
ValueTypeFormatDid ValueTypeFormat = "did"
35
+
)
36
+
37
+
// ValueType represents an atproto lexicon type definition with constraints
38
+
type ValueType struct {
39
+
Type ConcreteType `json:"type"`
40
+
Format ValueTypeFormat `json:"format,omitempty"`
41
+
Enum []string `json:"enum,omitempty"`
42
+
}
43
+
44
+
func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType {
45
+
return tangled.LabelDefinition_ValueType{
46
+
Type: string(vt.Type),
47
+
Format: string(vt.Format),
48
+
Enum: vt.Enum,
49
+
}
50
+
}
51
+
52
+
func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType {
53
+
return ValueType{
54
+
Type: ConcreteType(record.Type),
55
+
Format: ValueTypeFormat(record.Format),
56
+
Enum: record.Enum,
57
+
}
58
+
}
59
+
60
+
func (vt ValueType) IsConcreteType() bool {
61
+
return vt.Type == ConcreteTypeNull ||
62
+
vt.Type == ConcreteTypeString ||
63
+
vt.Type == ConcreteTypeInt ||
64
+
vt.Type == ConcreteTypeBool
65
+
}
66
+
67
+
func (vt ValueType) IsNull() bool {
68
+
return vt.Type == ConcreteTypeNull
69
+
}
70
+
71
+
func (vt ValueType) IsString() bool {
72
+
return vt.Type == ConcreteTypeString
73
+
}
74
+
75
+
func (vt ValueType) IsInt() bool {
76
+
return vt.Type == ConcreteTypeInt
77
+
}
78
+
79
+
func (vt ValueType) IsBool() bool {
80
+
return vt.Type == ConcreteTypeBool
81
+
}
82
+
83
+
func (vt ValueType) IsEnum() bool {
84
+
return len(vt.Enum) > 0
85
+
}
86
+
87
+
func (vt ValueType) IsDidFormat() bool {
88
+
return vt.Format == ValueTypeFormatDid
89
+
}
90
+
91
+
func (vt ValueType) IsAnyFormat() bool {
92
+
return vt.Format == ValueTypeFormatAny
93
+
}
94
+
95
+
type LabelDefinition struct {
96
+
Id int64
97
+
Did string
98
+
Rkey string
99
+
100
+
Name string
101
+
ValueType ValueType
102
+
Scope []string
103
+
Color *string
104
+
Multiple bool
105
+
Created time.Time
106
+
}
107
+
108
+
func (l *LabelDefinition) AtUri() syntax.ATURI {
109
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey))
110
+
}
111
+
112
+
func (l *LabelDefinition) AsRecord() tangled.LabelDefinition {
113
+
vt := l.ValueType.AsRecord()
114
+
return tangled.LabelDefinition{
115
+
Name: l.Name,
116
+
Color: l.Color,
117
+
CreatedAt: l.Created.Format(time.RFC3339),
118
+
Multiple: &l.Multiple,
119
+
Scope: l.Scope,
120
+
ValueType: &vt,
121
+
}
122
+
}
123
+
124
+
// random color for a given seed
125
+
func randomColor(seed string) string {
126
+
hash := sha1.Sum([]byte(seed))
127
+
hexStr := hex.EncodeToString(hash[:])
128
+
r := hexStr[0:2]
129
+
g := hexStr[2:4]
130
+
b := hexStr[4:6]
131
+
132
+
return fmt.Sprintf("#%s%s%s", r, g, b)
133
+
}
134
+
135
+
func (ld LabelDefinition) GetColor() string {
136
+
if ld.Color == nil {
137
+
seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey)
138
+
color := randomColor(seed)
139
+
return color
140
+
}
141
+
142
+
return *ld.Color
143
+
}
144
+
145
+
func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) {
146
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
147
+
if err != nil {
148
+
created = time.Now()
149
+
}
150
+
151
+
multiple := false
152
+
if record.Multiple != nil {
153
+
multiple = *record.Multiple
154
+
}
155
+
156
+
var vt ValueType
157
+
if record.ValueType != nil {
158
+
vt = ValueTypeFromRecord(*record.ValueType)
159
+
}
160
+
161
+
return &LabelDefinition{
162
+
Did: did,
163
+
Rkey: rkey,
164
+
165
+
Name: record.Name,
166
+
ValueType: vt,
167
+
Scope: record.Scope,
168
+
Color: record.Color,
169
+
Multiple: multiple,
170
+
Created: created,
171
+
}, nil
172
+
}
173
+
174
+
type LabelOp struct {
175
+
Id int64
176
+
Did string
177
+
Rkey string
178
+
Subject syntax.ATURI
179
+
Operation LabelOperation
180
+
OperandKey string
181
+
OperandValue string
182
+
PerformedAt time.Time
183
+
IndexedAt time.Time
184
+
}
185
+
186
+
func (l LabelOp) SortAt() time.Time {
187
+
createdAt := l.PerformedAt
188
+
indexedAt := l.IndexedAt
189
+
190
+
// if we don't have an indexedat, fall back to now
191
+
if indexedAt.IsZero() {
192
+
indexedAt = time.Now()
193
+
}
194
+
195
+
// if createdat is invalid (before epoch), treat as null -> return zero time
196
+
if createdAt.Before(time.UnixMicro(0)) {
197
+
return time.Time{}
198
+
}
199
+
200
+
// if createdat is <= indexedat, use createdat
201
+
if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) {
202
+
return createdAt
203
+
}
204
+
205
+
// otherwise, createdat is in the future relative to indexedat -> use indexedat
206
+
return indexedAt
207
+
}
208
+
209
+
type LabelOperation string
210
+
211
+
const (
212
+
LabelOperationAdd LabelOperation = "add"
213
+
LabelOperationDel LabelOperation = "del"
214
+
)
215
+
216
+
// a record can create multiple label ops
217
+
func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp {
218
+
performed, err := time.Parse(time.RFC3339, record.PerformedAt)
219
+
if err != nil {
220
+
performed = time.Now()
221
+
}
222
+
223
+
mkOp := func(operand *tangled.LabelOp_Operand) LabelOp {
224
+
return LabelOp{
225
+
Did: did,
226
+
Rkey: rkey,
227
+
Subject: syntax.ATURI(record.Subject),
228
+
OperandKey: operand.Key,
229
+
OperandValue: operand.Value,
230
+
PerformedAt: performed,
231
+
}
232
+
}
233
+
234
+
var ops []LabelOp
235
+
// deletes first, then additions
236
+
for _, o := range record.Delete {
237
+
if o != nil {
238
+
op := mkOp(o)
239
+
op.Operation = LabelOperationDel
240
+
ops = append(ops, op)
241
+
}
242
+
}
243
+
for _, o := range record.Add {
244
+
if o != nil {
245
+
op := mkOp(o)
246
+
op.Operation = LabelOperationAdd
247
+
ops = append(ops, op)
248
+
}
249
+
}
250
+
251
+
return ops
252
+
}
253
+
254
+
func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp {
255
+
if len(ops) == 0 {
256
+
return tangled.LabelOp{}
257
+
}
258
+
259
+
// use the first operation to establish common fields
260
+
first := ops[0]
261
+
record := tangled.LabelOp{
262
+
Subject: string(first.Subject),
263
+
PerformedAt: first.PerformedAt.Format(time.RFC3339),
264
+
}
265
+
266
+
var addOperands []*tangled.LabelOp_Operand
267
+
var deleteOperands []*tangled.LabelOp_Operand
268
+
269
+
for _, op := range ops {
270
+
operand := &tangled.LabelOp_Operand{
271
+
Key: op.OperandKey,
272
+
Value: op.OperandValue,
273
+
}
274
+
275
+
switch op.Operation {
276
+
case LabelOperationAdd:
277
+
addOperands = append(addOperands, operand)
278
+
case LabelOperationDel:
279
+
deleteOperands = append(deleteOperands, operand)
280
+
default:
281
+
return tangled.LabelOp{}
282
+
}
283
+
}
284
+
285
+
record.Add = addOperands
286
+
record.Delete = deleteOperands
287
+
288
+
return record
289
+
}
290
+
291
+
type set = map[string]struct{}
292
+
293
+
type LabelState struct {
294
+
inner map[string]set
295
+
}
296
+
297
+
func NewLabelState() LabelState {
298
+
return LabelState{
299
+
inner: make(map[string]set),
300
+
}
301
+
}
302
+
303
+
func (s LabelState) Inner() map[string]set {
304
+
return s.inner
305
+
}
306
+
307
+
func (s LabelState) ContainsLabel(l string) bool {
308
+
if valset, exists := s.inner[l]; exists {
309
+
if valset != nil {
310
+
return true
311
+
}
312
+
}
313
+
314
+
return false
315
+
}
316
+
317
+
// go maps behavior in templates make this necessary,
318
+
// indexing a map and getting `set` in return is apparently truthy
319
+
func (s LabelState) ContainsLabelAndVal(l, v string) bool {
320
+
if valset, exists := s.inner[l]; exists {
321
+
if _, exists := valset[v]; exists {
322
+
return true
323
+
}
324
+
}
325
+
326
+
return false
327
+
}
328
+
329
+
func (s LabelState) GetValSet(l string) set {
330
+
if valset, exists := s.inner[l]; exists {
331
+
return valset
332
+
} else {
333
+
return make(set)
334
+
}
335
+
}
336
+
337
+
type LabelApplicationCtx struct {
338
+
Defs map[string]*LabelDefinition // labelAt -> labelDef
339
+
}
340
+
341
+
var (
342
+
LabelNoOpError = errors.New("no-op")
343
+
)
344
+
345
+
func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error {
346
+
def, ok := c.Defs[op.OperandKey]
347
+
if !ok {
348
+
// this def was deleted, but an op exists, so we just skip over the op
349
+
return nil
350
+
}
351
+
352
+
switch op.Operation {
353
+
case LabelOperationAdd:
354
+
// if valueset is empty, init it
355
+
if state.inner[op.OperandKey] == nil {
356
+
state.inner[op.OperandKey] = make(set)
357
+
}
358
+
359
+
// if valueset is populated & this val alr exists, this labelop is a noop
360
+
if valueSet, exists := state.inner[op.OperandKey]; exists {
361
+
if _, exists = valueSet[op.OperandValue]; exists {
362
+
return LabelNoOpError
363
+
}
364
+
}
365
+
366
+
if def.Multiple {
367
+
// append to set
368
+
state.inner[op.OperandKey][op.OperandValue] = struct{}{}
369
+
} else {
370
+
// reset to just this value
371
+
state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}}
372
+
}
373
+
374
+
case LabelOperationDel:
375
+
// if label DNE, then deletion is a no-op
376
+
if valueSet, exists := state.inner[op.OperandKey]; !exists {
377
+
return LabelNoOpError
378
+
} else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op
379
+
return LabelNoOpError
380
+
}
381
+
382
+
if def.Multiple {
383
+
// remove from set
384
+
delete(state.inner[op.OperandKey], op.OperandValue)
385
+
} else {
386
+
// reset the entire label
387
+
delete(state.inner, op.OperandKey)
388
+
}
389
+
390
+
// if the map becomes empty, then set it to nil, this is just the inverse of add
391
+
if len(state.inner[op.OperandKey]) == 0 {
392
+
state.inner[op.OperandKey] = nil
393
+
}
394
+
395
+
}
396
+
397
+
return nil
398
+
}
399
+
400
+
func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) {
401
+
// sort label ops in sort order first
402
+
slices.SortFunc(ops, func(a, b LabelOp) int {
403
+
return a.SortAt().Compare(b.SortAt())
404
+
})
405
+
406
+
// apply ops in sequence
407
+
for _, o := range ops {
408
+
_ = c.ApplyLabelOp(state, o)
409
+
}
410
+
}
411
+
412
+
// IsInverse checks if one label operation is the inverse of another
413
+
// returns true if one is an add and the other is a delete with the same key and value
414
+
func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
415
+
if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
416
+
return false
417
+
}
418
+
419
+
return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
420
+
(op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
421
+
}
422
+
423
+
// removes pairs of label operations that are inverses of each other
424
+
// from the given slice. the function preserves the order of remaining operations.
425
+
func ReduceLabelOps(ops []LabelOp) []LabelOp {
426
+
if len(ops) <= 1 {
427
+
return ops
428
+
}
429
+
430
+
keep := make([]bool, len(ops))
431
+
for i := range keep {
432
+
keep[i] = true
433
+
}
434
+
435
+
for i := range ops {
436
+
if !keep[i] {
437
+
continue
438
+
}
439
+
440
+
for j := i + 1; j < len(ops); j++ {
441
+
if !keep[j] {
442
+
continue
443
+
}
444
+
445
+
if ops[i].IsInverse(ops[j]) {
446
+
keep[i] = false
447
+
keep[j] = false
448
+
break // move to next i since this one is now eliminated
449
+
}
450
+
}
451
+
}
452
+
453
+
// build result slice with only kept operations
454
+
var result []LabelOp
455
+
for i, op := range ops {
456
+
if keep[i] {
457
+
result = append(result, op)
458
+
}
459
+
}
460
+
461
+
return result
462
+
}
463
+
464
+
var (
465
+
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
+
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
+
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
+
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
+
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
+
)
471
+
472
+
func DefaultLabelDefs() []string {
473
+
return []string{
474
+
LabelWontfix,
475
+
LabelDuplicate,
476
+
LabelAssignee,
477
+
LabelGoodFirstIssue,
478
+
LabelDocumentation,
479
+
}
480
+
}
481
+
482
+
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
483
+
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
484
+
if err != nil {
485
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
486
+
}
487
+
pdsEndpoint := resolved.PDSEndpoint()
488
+
if pdsEndpoint == "" {
489
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
490
+
}
491
+
client := &xrpc.Client{
492
+
Host: pdsEndpoint,
493
+
}
494
+
495
+
var labelDefs []LabelDefinition
496
+
497
+
for _, dl := range DefaultLabelDefs() {
498
+
atUri := syntax.ATURI(dl)
499
+
parsedUri, err := syntax.ParseATURI(string(atUri))
500
+
if err != nil {
501
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
502
+
}
503
+
record, err := atproto.RepoGetRecord(
504
+
context.Background(),
505
+
client,
506
+
"",
507
+
parsedUri.Collection().String(),
508
+
parsedUri.Authority().String(),
509
+
parsedUri.RecordKey().String(),
510
+
)
511
+
if err != nil {
512
+
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
513
+
}
514
+
515
+
if record != nil {
516
+
bytes, err := record.Value.MarshalJSON()
517
+
if err != nil {
518
+
return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err)
519
+
}
520
+
521
+
raw := json.RawMessage(bytes)
522
+
labelRecord := tangled.LabelDefinition{}
523
+
err = json.Unmarshal(raw, &labelRecord)
524
+
if err != nil {
525
+
return nil, fmt.Errorf("invalid record for %s: %w", atUri, err)
526
+
}
527
+
528
+
labelDef, err := LabelDefinitionFromRecord(
529
+
parsedUri.Authority().String(),
530
+
parsedUri.RecordKey().String(),
531
+
labelRecord,
532
+
)
533
+
if err != nil {
534
+
return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err)
535
+
}
536
+
537
+
labelDefs = append(labelDefs, *labelDef)
538
+
}
539
+
}
540
+
541
+
return labelDefs, nil
542
+
}
+14
appview/models/language.go
+14
appview/models/language.go
+82
appview/models/notifications.go
+82
appview/models/notifications.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type NotificationType string
8
+
9
+
const (
10
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
11
+
NotificationTypeIssueCreated NotificationType = "issue_created"
12
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
13
+
NotificationTypePullCreated NotificationType = "pull_created"
14
+
NotificationTypePullCommented NotificationType = "pull_commented"
15
+
NotificationTypeFollowed NotificationType = "followed"
16
+
NotificationTypePullMerged NotificationType = "pull_merged"
17
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
18
+
NotificationTypePullClosed NotificationType = "pull_closed"
19
+
)
20
+
21
+
type Notification struct {
22
+
ID int64
23
+
RecipientDid string
24
+
ActorDid string
25
+
Type NotificationType
26
+
EntityType string
27
+
EntityId string
28
+
Read bool
29
+
Created time.Time
30
+
31
+
// foreign key references
32
+
RepoId *int64
33
+
IssueId *int64
34
+
PullId *int64
35
+
}
36
+
37
+
// lucide icon that represents this notification
38
+
func (n *Notification) Icon() string {
39
+
switch n.Type {
40
+
case NotificationTypeRepoStarred:
41
+
return "star"
42
+
case NotificationTypeIssueCreated:
43
+
return "circle-dot"
44
+
case NotificationTypeIssueCommented:
45
+
return "message-square"
46
+
case NotificationTypeIssueClosed:
47
+
return "ban"
48
+
case NotificationTypePullCreated:
49
+
return "git-pull-request-create"
50
+
case NotificationTypePullCommented:
51
+
return "message-square"
52
+
case NotificationTypePullMerged:
53
+
return "git-merge"
54
+
case NotificationTypePullClosed:
55
+
return "git-pull-request-closed"
56
+
case NotificationTypeFollowed:
57
+
return "user-plus"
58
+
default:
59
+
return ""
60
+
}
61
+
}
62
+
63
+
type NotificationWithEntity struct {
64
+
*Notification
65
+
Repo *Repo
66
+
Issue *Issue
67
+
Pull *Pull
68
+
}
69
+
70
+
type NotificationPreferences struct {
71
+
ID int64
72
+
UserDid string
73
+
RepoStarred bool
74
+
IssueCreated bool
75
+
IssueCommented bool
76
+
PullCreated bool
77
+
PullCommented bool
78
+
Followed bool
79
+
PullMerged bool
80
+
IssueClosed bool
81
+
EmailNotifications bool
82
+
}
+130
appview/models/pipeline.go
+130
appview/models/pipeline.go
···
1
+
package models
2
+
3
+
import (
4
+
"slices"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
spindle "tangled.org/core/spindle/models"
10
+
"tangled.org/core/workflow"
11
+
)
12
+
13
+
type Pipeline struct {
14
+
Id int
15
+
Rkey string
16
+
Knot string
17
+
RepoOwner syntax.DID
18
+
RepoName string
19
+
TriggerId int
20
+
Sha string
21
+
Created time.Time
22
+
23
+
// populate when querying for reverse mappings
24
+
Trigger *Trigger
25
+
Statuses map[string]WorkflowStatus
26
+
}
27
+
28
+
type WorkflowStatus struct {
29
+
Data []PipelineStatus
30
+
}
31
+
32
+
func (w WorkflowStatus) Latest() PipelineStatus {
33
+
return w.Data[len(w.Data)-1]
34
+
}
35
+
36
+
// time taken by this workflow to reach an "end state"
37
+
func (w WorkflowStatus) TimeTaken() time.Duration {
38
+
var start, end *time.Time
39
+
for _, s := range w.Data {
40
+
if s.Status.IsStart() {
41
+
start = &s.Created
42
+
}
43
+
if s.Status.IsFinish() {
44
+
end = &s.Created
45
+
}
46
+
}
47
+
48
+
if start != nil && end != nil && end.After(*start) {
49
+
return end.Sub(*start)
50
+
}
51
+
52
+
return 0
53
+
}
54
+
55
+
func (p Pipeline) Counts() map[string]int {
56
+
m := make(map[string]int)
57
+
for _, w := range p.Statuses {
58
+
m[w.Latest().Status.String()] += 1
59
+
}
60
+
return m
61
+
}
62
+
63
+
func (p Pipeline) TimeTaken() time.Duration {
64
+
var s time.Duration
65
+
for _, w := range p.Statuses {
66
+
s += w.TimeTaken()
67
+
}
68
+
return s
69
+
}
70
+
71
+
func (p Pipeline) Workflows() []string {
72
+
var ws []string
73
+
for v := range p.Statuses {
74
+
ws = append(ws, v)
75
+
}
76
+
slices.Sort(ws)
77
+
return ws
78
+
}
79
+
80
+
// if we know that a spindle has picked up this pipeline, then it is Responding
81
+
func (p Pipeline) IsResponding() bool {
82
+
return len(p.Statuses) != 0
83
+
}
84
+
85
+
type Trigger struct {
86
+
Id int
87
+
Kind workflow.TriggerKind
88
+
89
+
// push trigger fields
90
+
PushRef *string
91
+
PushNewSha *string
92
+
PushOldSha *string
93
+
94
+
// pull request trigger fields
95
+
PRSourceBranch *string
96
+
PRTargetBranch *string
97
+
PRSourceSha *string
98
+
PRAction *string
99
+
}
100
+
101
+
func (t *Trigger) IsPush() bool {
102
+
return t != nil && t.Kind == workflow.TriggerKindPush
103
+
}
104
+
105
+
func (t *Trigger) IsPullRequest() bool {
106
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
107
+
}
108
+
109
+
func (t *Trigger) TargetRef() string {
110
+
if t.IsPush() {
111
+
return plumbing.ReferenceName(*t.PushRef).Short()
112
+
} else if t.IsPullRequest() {
113
+
return *t.PRTargetBranch
114
+
}
115
+
116
+
return ""
117
+
}
118
+
119
+
type PipelineStatus struct {
120
+
ID int
121
+
Spindle string
122
+
Rkey string
123
+
PipelineKnot string
124
+
PipelineRkey string
125
+
Created time.Time
126
+
Workflow string
127
+
Status spindle.StatusKind
128
+
Error *string
129
+
ExitCode int
130
+
}
+177
appview/models/profile.go
+177
appview/models/profile.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/api/tangled"
8
+
)
9
+
10
+
type Profile struct {
11
+
// ids
12
+
ID int
13
+
Did string
14
+
15
+
// data
16
+
Description string
17
+
IncludeBluesky bool
18
+
Location string
19
+
Links [5]string
20
+
Stats [2]VanityStat
21
+
PinnedRepos [6]syntax.ATURI
22
+
}
23
+
24
+
func (p Profile) IsLinksEmpty() bool {
25
+
for _, l := range p.Links {
26
+
if l != "" {
27
+
return false
28
+
}
29
+
}
30
+
return true
31
+
}
32
+
33
+
func (p Profile) IsStatsEmpty() bool {
34
+
for _, s := range p.Stats {
35
+
if s.Kind != "" {
36
+
return false
37
+
}
38
+
}
39
+
return true
40
+
}
41
+
42
+
func (p Profile) IsPinnedReposEmpty() bool {
43
+
for _, r := range p.PinnedRepos {
44
+
if r != "" {
45
+
return false
46
+
}
47
+
}
48
+
return true
49
+
}
50
+
51
+
type VanityStatKind string
52
+
53
+
const (
54
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
55
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
56
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
57
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
58
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
59
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
60
+
)
61
+
62
+
func (v VanityStatKind) String() string {
63
+
switch v {
64
+
case VanityStatMergedPRCount:
65
+
return "Merged PRs"
66
+
case VanityStatClosedPRCount:
67
+
return "Closed PRs"
68
+
case VanityStatOpenPRCount:
69
+
return "Open PRs"
70
+
case VanityStatOpenIssueCount:
71
+
return "Open Issues"
72
+
case VanityStatClosedIssueCount:
73
+
return "Closed Issues"
74
+
case VanityStatRepositoryCount:
75
+
return "Repositories"
76
+
}
77
+
return ""
78
+
}
79
+
80
+
type VanityStat struct {
81
+
Kind VanityStatKind
82
+
Value uint64
83
+
}
84
+
85
+
func (p *Profile) ProfileAt() syntax.ATURI {
86
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
87
+
}
88
+
89
+
type RepoEvent struct {
90
+
Repo *Repo
91
+
Source *Repo
92
+
}
93
+
94
+
type ProfileTimeline struct {
95
+
ByMonth []ByMonth
96
+
}
97
+
98
+
func (p *ProfileTimeline) IsEmpty() bool {
99
+
if p == nil {
100
+
return true
101
+
}
102
+
103
+
for _, m := range p.ByMonth {
104
+
if !m.IsEmpty() {
105
+
return false
106
+
}
107
+
}
108
+
109
+
return true
110
+
}
111
+
112
+
type ByMonth struct {
113
+
RepoEvents []RepoEvent
114
+
IssueEvents IssueEvents
115
+
PullEvents PullEvents
116
+
}
117
+
118
+
func (b ByMonth) IsEmpty() bool {
119
+
return len(b.RepoEvents) == 0 &&
120
+
len(b.IssueEvents.Items) == 0 &&
121
+
len(b.PullEvents.Items) == 0
122
+
}
123
+
124
+
type IssueEvents struct {
125
+
Items []*Issue
126
+
}
127
+
128
+
type IssueEventStats struct {
129
+
Open int
130
+
Closed int
131
+
}
132
+
133
+
func (i IssueEvents) Stats() IssueEventStats {
134
+
var open, closed int
135
+
for _, issue := range i.Items {
136
+
if issue.Open {
137
+
open += 1
138
+
} else {
139
+
closed += 1
140
+
}
141
+
}
142
+
143
+
return IssueEventStats{
144
+
Open: open,
145
+
Closed: closed,
146
+
}
147
+
}
148
+
149
+
type PullEvents struct {
150
+
Items []*Pull
151
+
}
152
+
153
+
func (p PullEvents) Stats() PullEventStats {
154
+
var open, merged, closed int
155
+
for _, pull := range p.Items {
156
+
switch pull.State {
157
+
case PullOpen:
158
+
open += 1
159
+
case PullMerged:
160
+
merged += 1
161
+
case PullClosed:
162
+
closed += 1
163
+
}
164
+
}
165
+
166
+
return PullEventStats{
167
+
Open: open,
168
+
Merged: merged,
169
+
Closed: closed,
170
+
}
171
+
}
172
+
173
+
type PullEventStats struct {
174
+
Closed int
175
+
Open int
176
+
Merged int
177
+
}
+25
appview/models/pubkey.go
+25
appview/models/pubkey.go
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/json"
5
+
"time"
6
+
)
7
+
8
+
type PublicKey struct {
9
+
Did string `json:"did"`
10
+
Key string `json:"key"`
11
+
Name string `json:"name"`
12
+
Rkey string `json:"rkey"`
13
+
Created *time.Time
14
+
}
15
+
16
+
func (p PublicKey) MarshalJSON() ([]byte, error) {
17
+
type Alias PublicKey
18
+
return json.Marshal(&struct {
19
+
Created string `json:"created"`
20
+
*Alias
21
+
}{
22
+
Created: p.Created.Format(time.RFC3339),
23
+
Alias: (*Alias)(&p),
24
+
})
25
+
}
+352
appview/models/pull.go
+352
appview/models/pull.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"slices"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/patchutil"
13
+
"tangled.org/core/types"
14
+
)
15
+
16
+
type PullState int
17
+
18
+
const (
19
+
PullClosed PullState = iota
20
+
PullOpen
21
+
PullMerged
22
+
PullDeleted
23
+
)
24
+
25
+
func (p PullState) String() string {
26
+
switch p {
27
+
case PullOpen:
28
+
return "open"
29
+
case PullMerged:
30
+
return "merged"
31
+
case PullClosed:
32
+
return "closed"
33
+
case PullDeleted:
34
+
return "deleted"
35
+
default:
36
+
return "closed"
37
+
}
38
+
}
39
+
40
+
func (p PullState) IsOpen() bool {
41
+
return p == PullOpen
42
+
}
43
+
func (p PullState) IsMerged() bool {
44
+
return p == PullMerged
45
+
}
46
+
func (p PullState) IsClosed() bool {
47
+
return p == PullClosed
48
+
}
49
+
func (p PullState) IsDeleted() bool {
50
+
return p == PullDeleted
51
+
}
52
+
53
+
type Pull struct {
54
+
// ids
55
+
ID int
56
+
PullId int
57
+
58
+
// at ids
59
+
RepoAt syntax.ATURI
60
+
OwnerDid string
61
+
Rkey string
62
+
63
+
// content
64
+
Title string
65
+
Body string
66
+
TargetBranch string
67
+
State PullState
68
+
Submissions []*PullSubmission
69
+
70
+
// stacking
71
+
StackId string // nullable string
72
+
ChangeId string // nullable string
73
+
ParentChangeId string // nullable string
74
+
75
+
// meta
76
+
Created time.Time
77
+
PullSource *PullSource
78
+
79
+
// optionally, populate this when querying for reverse mappings
80
+
Labels LabelState
81
+
Repo *Repo
82
+
}
83
+
84
+
func (p Pull) AsRecord() tangled.RepoPull {
85
+
var source *tangled.RepoPull_Source
86
+
if p.PullSource != nil {
87
+
s := p.PullSource.AsRecord()
88
+
source = &s
89
+
source.Sha = p.LatestSha()
90
+
}
91
+
92
+
record := tangled.RepoPull{
93
+
Title: p.Title,
94
+
Body: &p.Body,
95
+
CreatedAt: p.Created.Format(time.RFC3339),
96
+
Target: &tangled.RepoPull_Target{
97
+
Repo: p.RepoAt.String(),
98
+
Branch: p.TargetBranch,
99
+
},
100
+
Patch: p.LatestPatch(),
101
+
Source: source,
102
+
}
103
+
return record
104
+
}
105
+
106
+
type PullSource struct {
107
+
Branch string
108
+
RepoAt *syntax.ATURI
109
+
110
+
// optionally populate this for reverse mappings
111
+
Repo *Repo
112
+
}
113
+
114
+
func (p PullSource) AsRecord() tangled.RepoPull_Source {
115
+
var repoAt *string
116
+
if p.RepoAt != nil {
117
+
s := p.RepoAt.String()
118
+
repoAt = &s
119
+
}
120
+
record := tangled.RepoPull_Source{
121
+
Branch: p.Branch,
122
+
Repo: repoAt,
123
+
}
124
+
return record
125
+
}
126
+
127
+
type PullSubmission struct {
128
+
// ids
129
+
ID int
130
+
131
+
// at ids
132
+
PullAt syntax.ATURI
133
+
134
+
// content
135
+
RoundNumber int
136
+
Patch string
137
+
Comments []PullComment
138
+
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
+
140
+
// meta
141
+
Created time.Time
142
+
}
143
+
144
+
type PullComment struct {
145
+
// ids
146
+
ID int
147
+
PullId int
148
+
SubmissionId int
149
+
150
+
// at ids
151
+
RepoAt string
152
+
OwnerDid string
153
+
CommentAt string
154
+
155
+
// content
156
+
Body string
157
+
158
+
// meta
159
+
Created time.Time
160
+
}
161
+
162
+
func (p *Pull) LatestPatch() string {
163
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
+
return latestSubmission.Patch
165
+
}
166
+
167
+
func (p *Pull) LatestSha() string {
168
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
+
return latestSubmission.SourceRev
170
+
}
171
+
172
+
func (p *Pull) PullAt() syntax.ATURI {
173
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174
+
}
175
+
176
+
func (p *Pull) LastRoundNumber() int {
177
+
return len(p.Submissions) - 1
178
+
}
179
+
180
+
func (p *Pull) IsPatchBased() bool {
181
+
return p.PullSource == nil
182
+
}
183
+
184
+
func (p *Pull) IsBranchBased() bool {
185
+
if p.PullSource != nil {
186
+
if p.PullSource.RepoAt != nil {
187
+
return p.PullSource.RepoAt == &p.RepoAt
188
+
} else {
189
+
// no repo specified
190
+
return true
191
+
}
192
+
}
193
+
return false
194
+
}
195
+
196
+
func (p *Pull) IsForkBased() bool {
197
+
if p.PullSource != nil {
198
+
if p.PullSource.RepoAt != nil {
199
+
// make sure repos are different
200
+
return p.PullSource.RepoAt != &p.RepoAt
201
+
}
202
+
}
203
+
return false
204
+
}
205
+
206
+
func (p *Pull) IsStacked() bool {
207
+
return p.StackId != ""
208
+
}
209
+
210
+
func (p *Pull) Participants() []string {
211
+
participantSet := make(map[string]struct{})
212
+
participants := []string{}
213
+
214
+
addParticipant := func(did string) {
215
+
if _, exists := participantSet[did]; !exists {
216
+
participantSet[did] = struct{}{}
217
+
participants = append(participants, did)
218
+
}
219
+
}
220
+
221
+
addParticipant(p.OwnerDid)
222
+
223
+
for _, s := range p.Submissions {
224
+
for _, sp := range s.Participants() {
225
+
addParticipant(sp)
226
+
}
227
+
}
228
+
229
+
return participants
230
+
}
231
+
232
+
func (s PullSubmission) IsFormatPatch() bool {
233
+
return patchutil.IsFormatPatch(s.Patch)
234
+
}
235
+
236
+
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
237
+
patches, err := patchutil.ExtractPatches(s.Patch)
238
+
if err != nil {
239
+
log.Println("error extracting patches from submission:", err)
240
+
return []types.FormatPatch{}
241
+
}
242
+
243
+
return patches
244
+
}
245
+
246
+
func (s *PullSubmission) Participants() []string {
247
+
participantSet := make(map[string]struct{})
248
+
participants := []string{}
249
+
250
+
addParticipant := func(did string) {
251
+
if _, exists := participantSet[did]; !exists {
252
+
participantSet[did] = struct{}{}
253
+
participants = append(participants, did)
254
+
}
255
+
}
256
+
257
+
addParticipant(s.PullAt.Authority().String())
258
+
259
+
for _, c := range s.Comments {
260
+
addParticipant(c.OwnerDid)
261
+
}
262
+
263
+
return participants
264
+
}
265
+
266
+
type Stack []*Pull
267
+
268
+
// position of this pull in the stack
269
+
func (stack Stack) Position(pull *Pull) int {
270
+
return slices.IndexFunc(stack, func(p *Pull) bool {
271
+
return p.ChangeId == pull.ChangeId
272
+
})
273
+
}
274
+
275
+
// all pulls below this pull (including self) in this stack
276
+
//
277
+
// nil if this pull does not belong to this stack
278
+
func (stack Stack) Below(pull *Pull) Stack {
279
+
position := stack.Position(pull)
280
+
281
+
if position < 0 {
282
+
return nil
283
+
}
284
+
285
+
return stack[position:]
286
+
}
287
+
288
+
// all pulls below this pull (excluding self) in this stack
289
+
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
290
+
below := stack.Below(pull)
291
+
292
+
if len(below) > 0 {
293
+
return below[1:]
294
+
}
295
+
296
+
return nil
297
+
}
298
+
299
+
// all pulls above this pull (including self) in this stack
300
+
func (stack Stack) Above(pull *Pull) Stack {
301
+
position := stack.Position(pull)
302
+
303
+
if position < 0 {
304
+
return nil
305
+
}
306
+
307
+
return stack[:position+1]
308
+
}
309
+
310
+
// all pulls below this pull (excluding self) in this stack
311
+
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
312
+
above := stack.Above(pull)
313
+
314
+
if len(above) > 0 {
315
+
return above[:len(above)-1]
316
+
}
317
+
318
+
return nil
319
+
}
320
+
321
+
// the combined format-patches of all the newest submissions in this stack
322
+
func (stack Stack) CombinedPatch() string {
323
+
// go in reverse order because the bottom of the stack is the last element in the slice
324
+
var combined strings.Builder
325
+
for idx := range stack {
326
+
pull := stack[len(stack)-1-idx]
327
+
combined.WriteString(pull.LatestPatch())
328
+
combined.WriteString("\n")
329
+
}
330
+
return combined.String()
331
+
}
332
+
333
+
// filter out PRs that are "active"
334
+
//
335
+
// PRs that are still open are active
336
+
func (stack Stack) Mergeable() Stack {
337
+
var mergeable Stack
338
+
339
+
for _, p := range stack {
340
+
// stop at the first merged PR
341
+
if p.State == PullMerged || p.State == PullClosed {
342
+
break
343
+
}
344
+
345
+
// skip over deleted PRs
346
+
if p.State != PullDeleted {
347
+
mergeable = append(mergeable, p)
348
+
}
349
+
}
350
+
351
+
return mergeable
352
+
}
+14
appview/models/punchcard.go
+14
appview/models/punchcard.go
+57
appview/models/reaction.go
+57
appview/models/reaction.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type ReactionKind string
10
+
11
+
const (
12
+
Like ReactionKind = "👍"
13
+
Unlike ReactionKind = "👎"
14
+
Laugh ReactionKind = "😆"
15
+
Celebration ReactionKind = "🎉"
16
+
Confused ReactionKind = "🫤"
17
+
Heart ReactionKind = "❤️"
18
+
Rocket ReactionKind = "🚀"
19
+
Eyes ReactionKind = "👀"
20
+
)
21
+
22
+
func (rk ReactionKind) String() string {
23
+
return string(rk)
24
+
}
25
+
26
+
var OrderedReactionKinds = []ReactionKind{
27
+
Like,
28
+
Unlike,
29
+
Laugh,
30
+
Celebration,
31
+
Confused,
32
+
Heart,
33
+
Rocket,
34
+
Eyes,
35
+
}
36
+
37
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
38
+
k, ok := (map[string]ReactionKind{
39
+
"👍": Like,
40
+
"👎": Unlike,
41
+
"😆": Laugh,
42
+
"🎉": Celebration,
43
+
"🫤": Confused,
44
+
"❤️": Heart,
45
+
"🚀": Rocket,
46
+
"👀": Eyes,
47
+
})[raw]
48
+
return k, ok
49
+
}
50
+
51
+
type Reaction struct {
52
+
ReactedByDid string
53
+
ThreadAt syntax.ATURI
54
+
Created time.Time
55
+
Rkey string
56
+
Kind ReactionKind
57
+
}
+44
appview/models/registration.go
+44
appview/models/registration.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
// Registration represents a knot registration. Knot would've been a better
6
+
// name but we're stuck with this for historical reasons.
7
+
type Registration struct {
8
+
Id int64
9
+
Domain string
10
+
ByDid string
11
+
Created *time.Time
12
+
Registered *time.Time
13
+
NeedsUpgrade bool
14
+
}
15
+
16
+
func (r *Registration) Status() Status {
17
+
if r.NeedsUpgrade {
18
+
return NeedsUpgrade
19
+
} else if r.Registered != nil {
20
+
return Registered
21
+
} else {
22
+
return Pending
23
+
}
24
+
}
25
+
26
+
func (r *Registration) IsRegistered() bool {
27
+
return r.Status() == Registered
28
+
}
29
+
30
+
func (r *Registration) IsNeedsUpgrade() bool {
31
+
return r.Status() == NeedsUpgrade
32
+
}
33
+
34
+
func (r *Registration) IsPending() bool {
35
+
return r.Status() == Pending
36
+
}
37
+
38
+
type Status uint32
39
+
40
+
const (
41
+
Registered Status = iota
42
+
Pending
43
+
NeedsUpgrade
44
+
)
+93
appview/models/repo.go
+93
appview/models/repo.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
securejoin "github.com/cyphar/filepath-securejoin"
9
+
"tangled.org/core/api/tangled"
10
+
)
11
+
12
+
type Repo struct {
13
+
Id int64
14
+
Did string
15
+
Name string
16
+
Knot string
17
+
Rkey string
18
+
Created time.Time
19
+
Description string
20
+
Spindle string
21
+
Labels []string
22
+
23
+
// optionally, populate this when querying for reverse mappings
24
+
RepoStats *RepoStats
25
+
26
+
// optional
27
+
Source string
28
+
}
29
+
30
+
func (r *Repo) AsRecord() tangled.Repo {
31
+
var source, spindle, description *string
32
+
33
+
if r.Source != "" {
34
+
source = &r.Source
35
+
}
36
+
37
+
if r.Spindle != "" {
38
+
spindle = &r.Spindle
39
+
}
40
+
41
+
if r.Description != "" {
42
+
description = &r.Description
43
+
}
44
+
45
+
return tangled.Repo{
46
+
Knot: r.Knot,
47
+
Name: r.Name,
48
+
Description: description,
49
+
CreatedAt: r.Created.Format(time.RFC3339),
50
+
Source: source,
51
+
Spindle: spindle,
52
+
Labels: r.Labels,
53
+
}
54
+
}
55
+
56
+
func (r Repo) RepoAt() syntax.ATURI {
57
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
58
+
}
59
+
60
+
func (r Repo) DidSlashRepo() string {
61
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
+
return p
63
+
}
64
+
65
+
type RepoStats struct {
66
+
Language string
67
+
StarCount int
68
+
IssueCount IssueCount
69
+
PullCount PullCount
70
+
}
71
+
72
+
type IssueCount struct {
73
+
Open int
74
+
Closed int
75
+
}
76
+
77
+
type PullCount struct {
78
+
Open int
79
+
Merged int
80
+
Closed int
81
+
Deleted int
82
+
}
83
+
84
+
type RepoLabel struct {
85
+
Id int64
86
+
RepoAt syntax.ATURI
87
+
LabelAt syntax.ATURI
88
+
}
89
+
90
+
type RepoGroup struct {
91
+
Repo *Repo
92
+
Issues []Issue
93
+
}
+10
appview/models/signup.go
+10
appview/models/signup.go
+25
appview/models/spindle.go
+25
appview/models/spindle.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Spindle struct {
10
+
Id int
11
+
Owner syntax.DID
12
+
Instance string
13
+
Verified *time.Time
14
+
Created time.Time
15
+
NeedsUpgrade bool
16
+
}
17
+
18
+
type SpindleMember struct {
19
+
Id int
20
+
Did syntax.DID // owner of the record
21
+
Rkey string // rkey of the record
22
+
Instance string
23
+
Subject syntax.DID // the member being added
24
+
Created time.Time
25
+
}
+17
appview/models/star.go
+17
appview/models/star.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Star struct {
10
+
StarredByDid string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
15
+
// optionally, populate this when querying for reverse mappings
16
+
Repo *Repo
17
+
}
+95
appview/models/string.go
+95
appview/models/string.go
···
1
+
package models
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"io"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.org/core/api/tangled"
12
+
)
13
+
14
+
type String struct {
15
+
Did syntax.DID
16
+
Rkey string
17
+
18
+
Filename string
19
+
Description string
20
+
Contents string
21
+
Created time.Time
22
+
Edited *time.Time
23
+
}
24
+
25
+
func (s *String) StringAt() syntax.ATURI {
26
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
27
+
}
28
+
29
+
func (s *String) AsRecord() tangled.String {
30
+
return tangled.String{
31
+
Filename: s.Filename,
32
+
Description: s.Description,
33
+
Contents: s.Contents,
34
+
CreatedAt: s.Created.Format(time.RFC3339),
35
+
}
36
+
}
37
+
38
+
func StringFromRecord(did, rkey string, record tangled.String) String {
39
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
40
+
if err != nil {
41
+
created = time.Now()
42
+
}
43
+
return String{
44
+
Did: syntax.DID(did),
45
+
Rkey: rkey,
46
+
Filename: record.Filename,
47
+
Description: record.Description,
48
+
Contents: record.Contents,
49
+
Created: created,
50
+
}
51
+
}
52
+
53
+
type StringStats struct {
54
+
LineCount uint64
55
+
ByteCount uint64
56
+
}
57
+
58
+
func (s String) Stats() StringStats {
59
+
lineCount, err := countLines(strings.NewReader(s.Contents))
60
+
if err != nil {
61
+
// non-fatal
62
+
// TODO: log this?
63
+
}
64
+
65
+
return StringStats{
66
+
LineCount: uint64(lineCount),
67
+
ByteCount: uint64(len(s.Contents)),
68
+
}
69
+
}
70
+
71
+
func countLines(r io.Reader) (int, error) {
72
+
buf := make([]byte, 32*1024)
73
+
bufLen := 0
74
+
count := 0
75
+
nl := []byte{'\n'}
76
+
77
+
for {
78
+
c, err := r.Read(buf)
79
+
if c > 0 {
80
+
bufLen += c
81
+
}
82
+
count += bytes.Count(buf[:c], nl)
83
+
84
+
switch {
85
+
case err == io.EOF:
86
+
/* handle last line not having a newline at the end */
87
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
88
+
count++
89
+
}
90
+
return count, nil
91
+
case err != nil:
92
+
return 0, err
93
+
}
94
+
}
95
+
}
+23
appview/models/timeline.go
+23
appview/models/timeline.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
type TimelineEvent struct {
6
+
*Repo
7
+
*Follow
8
+
*Star
9
+
10
+
EventAt time.Time
11
+
12
+
// optional: populate only if Repo is a fork
13
+
Source *Repo
14
+
15
+
// optional: populate only if event is Follow
16
+
*Profile
17
+
*FollowStats
18
+
*FollowStatus
19
+
20
+
// optional: populate only if event is Repo
21
+
IsStarred bool
22
+
StarCount int64
23
+
}
+168
appview/notifications/notifications.go
+168
appview/notifications/notifications.go
···
1
+
package notifications
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
9
+
"github.com/go-chi/chi/v5"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/middleware"
12
+
"tangled.org/core/appview/oauth"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
)
16
+
17
+
type Notifications struct {
18
+
db *db.DB
19
+
oauth *oauth.OAuth
20
+
pages *pages.Pages
21
+
}
22
+
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
24
+
return &Notifications{
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
}
29
+
}
30
+
31
+
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
32
+
r := chi.NewRouter()
33
+
34
+
r.Use(middleware.AuthMiddleware(n.oauth))
35
+
36
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
37
+
38
+
r.Get("/count", n.getUnreadCount)
39
+
r.Post("/{id}/read", n.markRead)
40
+
r.Post("/read-all", n.markAllRead)
41
+
r.Delete("/{id}", n.deleteNotification)
42
+
43
+
return r
44
+
}
45
+
46
+
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
47
+
userDid := n.oauth.GetDid(r)
48
+
49
+
page, ok := r.Context().Value("page").(pagination.Page)
50
+
if !ok {
51
+
log.Println("failed to get page")
52
+
page = pagination.FirstPage()
53
+
}
54
+
55
+
total, err := db.CountNotifications(
56
+
n.db,
57
+
db.FilterEq("recipient_did", userDid),
58
+
)
59
+
if err != nil {
60
+
log.Println("failed to get total notifications:", err)
61
+
n.pages.Error500(w)
62
+
return
63
+
}
64
+
65
+
notifications, err := db.GetNotificationsWithEntities(
66
+
n.db,
67
+
page,
68
+
db.FilterEq("recipient_did", userDid),
69
+
)
70
+
if err != nil {
71
+
log.Println("failed to get notifications:", err)
72
+
n.pages.Error500(w)
73
+
return
74
+
}
75
+
76
+
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
77
+
if err != nil {
78
+
log.Println("failed to mark notifications as read:", err)
79
+
}
80
+
81
+
unreadCount := 0
82
+
83
+
user := n.oauth.GetUser(r)
84
+
if user == nil {
85
+
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
+
return
87
+
}
88
+
89
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
90
+
LoggedInUser: user,
91
+
Notifications: notifications,
92
+
UnreadCount: unreadCount,
93
+
Page: page,
94
+
Total: total,
95
+
}))
96
+
}
97
+
98
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
99
+
user := n.oauth.GetUser(r)
100
+
count, err := db.CountNotifications(
101
+
n.db,
102
+
db.FilterEq("recipient_did", user.Did),
103
+
db.FilterEq("read", 0),
104
+
)
105
+
if err != nil {
106
+
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
107
+
return
108
+
}
109
+
110
+
params := pages.NotificationCountParams{
111
+
Count: count,
112
+
}
113
+
err = n.pages.NotificationCount(w, params)
114
+
if err != nil {
115
+
http.Error(w, "Failed to render count", http.StatusInternalServerError)
116
+
return
117
+
}
118
+
}
119
+
120
+
func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
121
+
userDid := n.oauth.GetDid(r)
122
+
123
+
idStr := chi.URLParam(r, "id")
124
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
125
+
if err != nil {
126
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
127
+
return
128
+
}
129
+
130
+
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
131
+
if err != nil {
132
+
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
133
+
return
134
+
}
135
+
136
+
w.WriteHeader(http.StatusNoContent)
137
+
}
138
+
139
+
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
140
+
userDid := n.oauth.GetDid(r)
141
+
142
+
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
143
+
if err != nil {
144
+
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
145
+
return
146
+
}
147
+
148
+
http.Redirect(w, r, "/notifications", http.StatusSeeOther)
149
+
}
150
+
151
+
func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
152
+
userDid := n.oauth.GetDid(r)
153
+
154
+
idStr := chi.URLParam(r, "id")
155
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
156
+
if err != nil {
157
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
158
+
return
159
+
}
160
+
161
+
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
162
+
if err != nil {
163
+
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
164
+
return
165
+
}
166
+
167
+
w.WriteHeader(http.StatusOK)
168
+
}
+429
appview/notify/db/db.go
+429
appview/notify/db/db.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
"tangled.org/core/idresolver"
11
+
)
12
+
13
+
type databaseNotifier struct {
14
+
db *db.DB
15
+
res *idresolver.Resolver
16
+
}
17
+
18
+
func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
19
+
return &databaseNotifier{
20
+
db: database,
21
+
res: resolver,
22
+
}
23
+
}
24
+
25
+
var _ notify.Notifier = &databaseNotifier{}
26
+
27
+
func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
28
+
// no-op for now
29
+
}
30
+
31
+
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
+
var err error
33
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
+
if err != nil {
35
+
log.Printf("NewStar: failed to get repos: %v", err)
36
+
return
37
+
}
38
+
39
+
// don't notify yourself
40
+
if repo.Did == star.StarredByDid {
41
+
return
42
+
}
43
+
44
+
// check if user wants these notifications
45
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
+
if err != nil {
47
+
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
+
return
49
+
}
50
+
if !prefs.RepoStarred {
51
+
return
52
+
}
53
+
54
+
notification := &models.Notification{
55
+
RecipientDid: repo.Did,
56
+
ActorDid: star.StarredByDid,
57
+
Type: models.NotificationTypeRepoStarred,
58
+
EntityType: "repo",
59
+
EntityId: string(star.RepoAt),
60
+
RepoId: &repo.Id,
61
+
}
62
+
err = n.db.CreateNotification(ctx, notification)
63
+
if err != nil {
64
+
log.Printf("NewStar: failed to create notification: %v", err)
65
+
return
66
+
}
67
+
}
68
+
69
+
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
70
+
// no-op
71
+
}
72
+
73
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
+
if err != nil {
76
+
log.Printf("NewIssue: failed to get repos: %v", err)
77
+
return
78
+
}
79
+
80
+
if repo.Did == issue.Did {
81
+
return
82
+
}
83
+
84
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
85
+
if err != nil {
86
+
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
87
+
return
88
+
}
89
+
if !prefs.IssueCreated {
90
+
return
91
+
}
92
+
93
+
notification := &models.Notification{
94
+
RecipientDid: repo.Did,
95
+
ActorDid: issue.Did,
96
+
Type: models.NotificationTypeIssueCreated,
97
+
EntityType: "issue",
98
+
EntityId: string(issue.AtUri()),
99
+
RepoId: &repo.Id,
100
+
IssueId: &issue.Id,
101
+
}
102
+
103
+
err = n.db.CreateNotification(ctx, notification)
104
+
if err != nil {
105
+
log.Printf("NewIssue: failed to create notification: %v", err)
106
+
return
107
+
}
108
+
}
109
+
110
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
111
+
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
112
+
if err != nil {
113
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
114
+
return
115
+
}
116
+
if len(issues) == 0 {
117
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
118
+
return
119
+
}
120
+
issue := issues[0]
121
+
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
+
if err != nil {
124
+
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
+
return
126
+
}
127
+
128
+
recipients := make(map[string]bool)
129
+
130
+
// notify issue author (if not the commenter)
131
+
if issue.Did != comment.Did {
132
+
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
+
if err == nil && prefs.IssueCommented {
134
+
recipients[issue.Did] = true
135
+
} else if err != nil {
136
+
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
137
+
}
138
+
}
139
+
140
+
// notify repo owner (if not the commenter and not already added)
141
+
if repo.Did != comment.Did && repo.Did != issue.Did {
142
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
+
if err == nil && prefs.IssueCommented {
144
+
recipients[repo.Did] = true
145
+
} else if err != nil {
146
+
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
+
}
148
+
}
149
+
150
+
// create notifications for all recipients
151
+
for recipientDid := range recipients {
152
+
notification := &models.Notification{
153
+
RecipientDid: recipientDid,
154
+
ActorDid: comment.Did,
155
+
Type: models.NotificationTypeIssueCommented,
156
+
EntityType: "issue",
157
+
EntityId: string(issue.AtUri()),
158
+
RepoId: &repo.Id,
159
+
IssueId: &issue.Id,
160
+
}
161
+
162
+
err = n.db.CreateNotification(ctx, notification)
163
+
if err != nil {
164
+
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
+
}
166
+
}
167
+
}
168
+
169
+
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
170
+
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
+
if err != nil {
172
+
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
+
return
174
+
}
175
+
if !prefs.Followed {
176
+
return
177
+
}
178
+
179
+
notification := &models.Notification{
180
+
RecipientDid: follow.SubjectDid,
181
+
ActorDid: follow.UserDid,
182
+
Type: models.NotificationTypeFollowed,
183
+
EntityType: "follow",
184
+
EntityId: follow.UserDid,
185
+
}
186
+
187
+
err = n.db.CreateNotification(ctx, notification)
188
+
if err != nil {
189
+
log.Printf("NewFollow: failed to create notification: %v", err)
190
+
return
191
+
}
192
+
}
193
+
194
+
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
195
+
// no-op
196
+
}
197
+
198
+
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
199
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
200
+
if err != nil {
201
+
log.Printf("NewPull: failed to get repos: %v", err)
202
+
return
203
+
}
204
+
205
+
if repo.Did == pull.OwnerDid {
206
+
return
207
+
}
208
+
209
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
210
+
if err != nil {
211
+
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
212
+
return
213
+
}
214
+
if !prefs.PullCreated {
215
+
return
216
+
}
217
+
218
+
notification := &models.Notification{
219
+
RecipientDid: repo.Did,
220
+
ActorDid: pull.OwnerDid,
221
+
Type: models.NotificationTypePullCreated,
222
+
EntityType: "pull",
223
+
EntityId: string(pull.RepoAt),
224
+
RepoId: &repo.Id,
225
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
+
}
227
+
228
+
err = n.db.CreateNotification(ctx, notification)
229
+
if err != nil {
230
+
log.Printf("NewPull: failed to create notification: %v", err)
231
+
return
232
+
}
233
+
}
234
+
235
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
+
pulls, err := db.GetPulls(n.db,
237
+
db.FilterEq("repo_at", comment.RepoAt),
238
+
db.FilterEq("pull_id", comment.PullId))
239
+
if err != nil {
240
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
241
+
return
242
+
}
243
+
if len(pulls) == 0 {
244
+
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
+
return
246
+
}
247
+
pull := pulls[0]
248
+
249
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
+
if err != nil {
251
+
log.Printf("NewPullComment: failed to get repos: %v", err)
252
+
return
253
+
}
254
+
255
+
recipients := make(map[string]bool)
256
+
257
+
// notify pull request author (if not the commenter)
258
+
if pull.OwnerDid != comment.OwnerDid {
259
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
+
if err == nil && prefs.PullCommented {
261
+
recipients[pull.OwnerDid] = true
262
+
} else if err != nil {
263
+
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
+
}
265
+
}
266
+
267
+
// notify repo owner (if not the commenter and not already added)
268
+
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
+
if err == nil && prefs.PullCommented {
271
+
recipients[repo.Did] = true
272
+
} else if err != nil {
273
+
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
+
}
275
+
}
276
+
277
+
for recipientDid := range recipients {
278
+
notification := &models.Notification{
279
+
RecipientDid: recipientDid,
280
+
ActorDid: comment.OwnerDid,
281
+
Type: models.NotificationTypePullCommented,
282
+
EntityType: "pull",
283
+
EntityId: comment.RepoAt,
284
+
RepoId: &repo.Id,
285
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
+
}
287
+
288
+
err = n.db.CreateNotification(ctx, notification)
289
+
if err != nil {
290
+
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
+
}
292
+
}
293
+
}
294
+
295
+
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
296
+
// no-op
297
+
}
298
+
299
+
func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
300
+
// no-op
301
+
}
302
+
303
+
func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
304
+
// no-op
305
+
}
306
+
307
+
func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
308
+
// no-op
309
+
}
310
+
311
+
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
+
// Get repo details
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
314
+
if err != nil {
315
+
log.Printf("NewIssueClosed: failed to get repos: %v", err)
316
+
return
317
+
}
318
+
319
+
// Don't notify yourself
320
+
if repo.Did == issue.Did {
321
+
return
322
+
}
323
+
324
+
// Check if user wants these notifications
325
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
+
if err != nil {
327
+
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
+
return
329
+
}
330
+
if !prefs.IssueClosed {
331
+
return
332
+
}
333
+
334
+
notification := &models.Notification{
335
+
RecipientDid: repo.Did,
336
+
ActorDid: issue.Did,
337
+
Type: models.NotificationTypeIssueClosed,
338
+
EntityType: "issue",
339
+
EntityId: string(issue.AtUri()),
340
+
RepoId: &repo.Id,
341
+
IssueId: &issue.Id,
342
+
}
343
+
344
+
err = n.db.CreateNotification(ctx, notification)
345
+
if err != nil {
346
+
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
+
return
348
+
}
349
+
}
350
+
351
+
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
352
+
// Get repo details
353
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
354
+
if err != nil {
355
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
356
+
return
357
+
}
358
+
359
+
// Don't notify yourself
360
+
if repo.Did == pull.OwnerDid {
361
+
return
362
+
}
363
+
364
+
// Check if user wants these notifications
365
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
366
+
if err != nil {
367
+
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
368
+
return
369
+
}
370
+
if !prefs.PullMerged {
371
+
return
372
+
}
373
+
374
+
notification := &models.Notification{
375
+
RecipientDid: pull.OwnerDid,
376
+
ActorDid: repo.Did,
377
+
Type: models.NotificationTypePullMerged,
378
+
EntityType: "pull",
379
+
EntityId: string(pull.RepoAt),
380
+
RepoId: &repo.Id,
381
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
382
+
}
383
+
384
+
err = n.db.CreateNotification(ctx, notification)
385
+
if err != nil {
386
+
log.Printf("NewPullMerged: failed to create notification: %v", err)
387
+
return
388
+
}
389
+
}
390
+
391
+
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
+
// Get repo details
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
+
if err != nil {
395
+
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
+
return
397
+
}
398
+
399
+
// Don't notify yourself
400
+
if repo.Did == pull.OwnerDid {
401
+
return
402
+
}
403
+
404
+
// Check if user wants these notifications - reuse pull_merged preference for now
405
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
406
+
if err != nil {
407
+
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
408
+
return
409
+
}
410
+
if !prefs.PullMerged {
411
+
return
412
+
}
413
+
414
+
notification := &models.Notification{
415
+
RecipientDid: pull.OwnerDid,
416
+
ActorDid: repo.Did,
417
+
Type: models.NotificationTypePullClosed,
418
+
EntityType: "pull",
419
+
EntityId: string(pull.RepoAt),
420
+
RepoId: &repo.Id,
421
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
422
+
}
423
+
424
+
err = n.db.CreateNotification(ctx, notification)
425
+
if err != nil {
426
+
log.Printf("NewPullClosed: failed to create notification: %v", err)
427
+
return
428
+
}
429
+
}
+51
-10
appview/notify/merged_notifier.go
+51
-10
appview/notify/merged_notifier.go
···
3
3
import (
4
4
"context"
5
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
6
+
"tangled.org/core/appview/models"
7
7
)
8
8
9
9
type mergedNotifier struct {
···
16
16
17
17
var _ Notifier = &mergedNotifier{}
18
18
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
20
for _, notifier := range m.notifiers {
21
21
notifier.NewRepo(ctx, repo)
22
22
}
23
23
}
24
24
25
-
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
26
for _, notifier := range m.notifiers {
27
27
notifier.NewStar(ctx, star)
28
28
}
29
29
}
30
-
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
31
for _, notifier := range m.notifiers {
32
32
notifier.DeleteStar(ctx, star)
33
33
}
34
34
}
35
35
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
37
for _, notifier := range m.notifiers {
38
38
notifier.NewIssue(ctx, issue)
39
39
}
40
40
}
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
45
+
}
41
46
42
-
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
43
54
for _, notifier := range m.notifiers {
44
55
notifier.NewFollow(ctx, follow)
45
56
}
46
57
}
47
-
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
58
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
48
59
for _, notifier := range m.notifiers {
49
60
notifier.DeleteFollow(ctx, follow)
50
61
}
51
62
}
52
63
53
-
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
64
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
54
65
for _, notifier := range m.notifiers {
55
66
notifier.NewPull(ctx, pull)
56
67
}
57
68
}
58
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
69
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
59
70
for _, notifier := range m.notifiers {
60
71
notifier.NewPullComment(ctx, comment)
61
72
}
62
73
}
63
74
64
-
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
85
+
}
86
+
87
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
65
88
for _, notifier := range m.notifiers {
66
89
notifier.UpdateProfile(ctx, profile)
67
90
}
68
91
}
92
+
93
+
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
+
for _, notifier := range m.notifiers {
95
+
notifier.NewString(ctx, string)
96
+
}
97
+
}
98
+
99
+
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
+
for _, notifier := range m.notifiers {
101
+
notifier.EditString(ctx, string)
102
+
}
103
+
}
104
+
105
+
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
+
for _, notifier := range m.notifiers {
107
+
notifier.DeleteString(ctx, did, rkey)
108
+
}
109
+
}
+35
-19
appview/notify/notifier.go
+35
-19
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
6
+
"tangled.org/core/appview/models"
7
7
)
8
8
9
9
type Notifier interface {
10
-
NewRepo(ctx context.Context, repo *db.Repo)
10
+
NewRepo(ctx context.Context, repo *models.Repo)
11
11
12
-
NewStar(ctx context.Context, star *db.Star)
13
-
DeleteStar(ctx context.Context, star *db.Star)
12
+
NewStar(ctx context.Context, star *models.Star)
13
+
DeleteStar(ctx context.Context, star *models.Star)
14
14
15
-
NewIssue(ctx context.Context, issue *db.Issue)
15
+
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
18
+
19
+
NewFollow(ctx context.Context, follow *models.Follow)
20
+
DeleteFollow(ctx context.Context, follow *models.Follow)
16
21
17
-
NewFollow(ctx context.Context, follow *db.Follow)
18
-
DeleteFollow(ctx context.Context, follow *db.Follow)
22
+
NewPull(ctx context.Context, pull *models.Pull)
23
+
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
19
26
20
-
NewPull(ctx context.Context, pull *db.Pull)
21
-
NewPullComment(ctx context.Context, comment *db.PullComment)
27
+
UpdateProfile(ctx context.Context, profile *models.Profile)
22
28
23
-
UpdateProfile(ctx context.Context, profile *db.Profile)
29
+
NewString(ctx context.Context, s *models.String)
30
+
EditString(ctx context.Context, s *models.String)
31
+
DeleteString(ctx context.Context, did, rkey string)
24
32
}
25
33
26
34
// BaseNotifier is a listener that does nothing
···
28
36
29
37
var _ Notifier = &BaseNotifier{}
30
38
31
-
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
39
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {}
32
40
33
-
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
34
-
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
41
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
35
43
36
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
47
+
48
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
37
50
38
-
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
39
-
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
51
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
40
55
41
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
42
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
56
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
43
57
44
-
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
58
+
func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {}
59
+
func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {}
60
+
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
+219
appview/notify/posthog/notifier.go
···
1
+
package posthog
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.Did,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: pull.OwnerDid,
104
+
Event: "pull_closed",
105
+
Properties: posthog.Properties{
106
+
"repo_at": pull.RepoAt,
107
+
"pull_id": pull.PullId,
108
+
},
109
+
})
110
+
if err != nil {
111
+
log.Println("failed to enqueue posthog event:", err)
112
+
}
113
+
}
114
+
115
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
116
+
err := n.client.Enqueue(posthog.Capture{
117
+
DistinctId: follow.UserDid,
118
+
Event: "follow",
119
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
120
+
})
121
+
if err != nil {
122
+
log.Println("failed to enqueue posthog event:", err)
123
+
}
124
+
}
125
+
126
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
127
+
err := n.client.Enqueue(posthog.Capture{
128
+
DistinctId: follow.UserDid,
129
+
Event: "unfollow",
130
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
131
+
})
132
+
if err != nil {
133
+
log.Println("failed to enqueue posthog event:", err)
134
+
}
135
+
}
136
+
137
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
138
+
err := n.client.Enqueue(posthog.Capture{
139
+
DistinctId: profile.Did,
140
+
Event: "edit_profile",
141
+
})
142
+
if err != nil {
143
+
log.Println("failed to enqueue posthog event:", err)
144
+
}
145
+
}
146
+
147
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
148
+
err := n.client.Enqueue(posthog.Capture{
149
+
DistinctId: did,
150
+
Event: "delete_string",
151
+
Properties: posthog.Properties{"rkey": rkey},
152
+
})
153
+
if err != nil {
154
+
log.Println("failed to enqueue posthog event:", err)
155
+
}
156
+
}
157
+
158
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
159
+
err := n.client.Enqueue(posthog.Capture{
160
+
DistinctId: string.Did.String(),
161
+
Event: "edit_string",
162
+
Properties: posthog.Properties{"rkey": string.Rkey},
163
+
})
164
+
if err != nil {
165
+
log.Println("failed to enqueue posthog event:", err)
166
+
}
167
+
}
168
+
169
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
170
+
err := n.client.Enqueue(posthog.Capture{
171
+
DistinctId: string.Did.String(),
172
+
Event: "new_string",
173
+
Properties: posthog.Properties{"rkey": string.Rkey},
174
+
})
175
+
if err != nil {
176
+
log.Println("failed to enqueue posthog event:", err)
177
+
}
178
+
}
179
+
180
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
181
+
err := n.client.Enqueue(posthog.Capture{
182
+
DistinctId: comment.Did,
183
+
Event: "new_issue_comment",
184
+
Properties: posthog.Properties{
185
+
"issue_at": comment.IssueAt,
186
+
},
187
+
})
188
+
if err != nil {
189
+
log.Println("failed to enqueue posthog event:", err)
190
+
}
191
+
}
192
+
193
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
194
+
err := n.client.Enqueue(posthog.Capture{
195
+
DistinctId: issue.Did,
196
+
Event: "issue_closed",
197
+
Properties: posthog.Properties{
198
+
"repo_at": issue.RepoAt.String(),
199
+
"issue_id": issue.IssueId,
200
+
},
201
+
})
202
+
if err != nil {
203
+
log.Println("failed to enqueue posthog event:", err)
204
+
}
205
+
}
206
+
207
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
208
+
err := n.client.Enqueue(posthog.Capture{
209
+
DistinctId: pull.OwnerDid,
210
+
Event: "pull_merged",
211
+
Properties: posthog.Properties{
212
+
"repo_at": pull.RepoAt,
213
+
"pull_id": pull.PullId,
214
+
},
215
+
})
216
+
if err != nil {
217
+
log.Println("failed to enqueue posthog event:", err)
218
+
}
219
+
}
+18
-25
appview/oauth/handler/handler.go
+18
-25
appview/oauth/handler/handler.go
···
16
16
"github.com/gorilla/sessions"
17
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
18
"github.com/posthog/posthog-go"
19
+
tangled "tangled.org/core/api/tangled"
20
+
sessioncache "tangled.org/core/appview/cache/session"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/middleware"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/oauth/client"
26
+
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/consts"
28
+
"tangled.org/core/idresolver"
29
+
"tangled.org/core/rbac"
30
+
"tangled.org/core/tid"
19
31
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
20
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
21
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
22
-
"tangled.sh/tangled.sh/core/appview/config"
23
-
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/middleware"
25
-
"tangled.sh/tangled.sh/core/appview/oauth"
26
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
27
-
"tangled.sh/tangled.sh/core/appview/pages"
28
-
"tangled.sh/tangled.sh/core/idresolver"
29
-
"tangled.sh/tangled.sh/core/rbac"
30
-
"tangled.sh/tangled.sh/core/tid"
31
32
)
32
33
33
34
const (
···
353
354
return pubKey, nil
354
355
}
355
356
356
-
var (
357
-
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
358
-
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
359
-
360
-
defaultSpindle = "spindle.tangled.sh"
361
-
defaultKnot = "knot1.tangled.sh"
362
-
)
363
-
364
357
func (o *OAuthHandler) addToDefaultSpindle(did string) {
365
358
// use the tangled.sh app password to get an accessJwt
366
359
// and create an sh.tangled.spindle.member record with that
···
380
373
}
381
374
382
375
log.Printf("adding %s to default spindle", did)
383
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
376
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
384
377
if err != nil {
385
378
log.Printf("failed to create session: %s", err)
386
379
return
···
389
382
record := tangled.SpindleMember{
390
383
LexiconTypeID: "sh.tangled.spindle.member",
391
384
Subject: did,
392
-
Instance: defaultSpindle,
385
+
Instance: consts.DefaultSpindle,
393
386
CreatedAt: time.Now().Format(time.RFC3339),
394
387
}
395
388
···
411
404
return
412
405
}
413
406
414
-
if slices.Contains(allKnots, defaultKnot) {
407
+
if slices.Contains(allKnots, consts.DefaultKnot) {
415
408
log.Printf("did %s is already a member of the default knot", did)
416
409
return
417
410
}
418
411
419
412
log.Printf("adding %s to default knot", did)
420
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
413
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
421
414
if err != nil {
422
415
log.Printf("failed to create session: %s", err)
423
416
return
···
426
419
record := tangled.KnotMember{
427
420
LexiconTypeID: "sh.tangled.knot.member",
428
421
Subject: did,
429
-
Domain: defaultKnot,
422
+
Domain: consts.DefaultKnot,
430
423
CreatedAt: time.Now().Format(time.RFC3339),
431
424
}
432
425
···
435
428
return
436
429
}
437
430
438
-
if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil {
431
+
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
439
432
log.Printf("failed to set up enforcer rules: %s", err)
440
433
return
441
434
}
+4
-4
appview/oauth/oauth.go
+4
-4
appview/oauth/oauth.go
···
9
9
10
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
11
"github.com/gorilla/sessions"
12
+
sessioncache "tangled.org/core/appview/cache/session"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/oauth/client"
15
+
xrpc "tangled.org/core/appview/xrpcclient"
12
16
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
17
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
14
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
17
-
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
18
18
)
19
19
20
20
type OAuth struct {
+32
-18
appview/pages/funcmap.go
+32
-18
appview/pages/funcmap.go
···
19
19
20
20
"github.com/dustin/go-humanize"
21
21
"github.com/go-enry/go-enry/v2"
22
-
"tangled.sh/tangled.sh/core/appview/filetree"
23
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
22
+
"tangled.org/core/appview/filetree"
23
+
"tangled.org/core/appview/pages/markup"
24
+
"tangled.org/core/crypto"
25
25
)
26
26
27
27
func (p *Pages) funcMap() template.FuncMap {
···
29
29
"split": func(s string) []string {
30
30
return strings.Split(s, "\n")
31
31
},
32
+
"trimPrefix": func(s, prefix string) string {
33
+
return strings.TrimPrefix(s, prefix)
34
+
},
35
+
"join": func(elems []string, sep string) string {
36
+
return strings.Join(elems, sep)
37
+
},
32
38
"contains": func(s string, target string) bool {
33
39
return strings.Contains(s, target)
40
+
},
41
+
"mapContains": func(m any, key any) bool {
42
+
mapValue := reflect.ValueOf(m)
43
+
if mapValue.Kind() != reflect.Map {
44
+
return false
45
+
}
46
+
keyValue := reflect.ValueOf(key)
47
+
return mapValue.MapIndex(keyValue).IsValid()
34
48
},
35
49
"resolve": func(s string) string {
36
50
identity, err := p.resolver.ResolveIdent(context.Background(), s)
···
127
141
"relTimeFmt": humanize.Time,
128
142
"shortRelTimeFmt": func(t time.Time) string {
129
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
130
-
{time.Second, "now", time.Second},
131
-
{2 * time.Second, "1s %s", 1},
132
-
{time.Minute, "%ds %s", time.Second},
133
-
{2 * time.Minute, "1min %s", 1},
134
-
{time.Hour, "%dmin %s", time.Minute},
135
-
{2 * time.Hour, "1hr %s", 1},
136
-
{humanize.Day, "%dhrs %s", time.Hour},
137
-
{2 * humanize.Day, "1d %s", 1},
138
-
{20 * humanize.Day, "%dd %s", humanize.Day},
139
-
{8 * humanize.Week, "%dw %s", humanize.Week},
140
-
{humanize.Year, "%dmo %s", humanize.Month},
141
-
{18 * humanize.Month, "1y %s", 1},
142
-
{2 * humanize.Year, "2y %s", 1},
143
-
{humanize.LongTime, "%dy %s", humanize.Year},
144
-
{math.MaxInt64, "a long while %s", 1},
144
+
{D: time.Second, Format: "now", DivBy: time.Second},
145
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
146
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
147
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
148
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
149
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
150
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
151
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
152
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
153
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
154
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
155
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
156
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
157
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
158
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
145
159
})
146
160
},
147
161
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
+30
appview/pages/funcmap_test.go
···
1
+
package pages
2
+
3
+
import (
4
+
"html/template"
5
+
"tangled.org/core/appview/config"
6
+
"tangled.org/core/idresolver"
7
+
"testing"
8
+
)
9
+
10
+
func TestPages_funcMap(t *testing.T) {
11
+
tests := []struct {
12
+
name string // description of this test case
13
+
// Named input parameters for receiver constructor.
14
+
config *config.Config
15
+
res *idresolver.Resolver
16
+
want template.FuncMap
17
+
}{
18
+
// TODO: Add test cases.
19
+
}
20
+
for _, tt := range tests {
21
+
t.Run(tt.name, func(t *testing.T) {
22
+
p := NewPages(tt.config, tt.res)
23
+
got := p.funcMap()
24
+
// TODO: update the condition below to compare got with tt.want.
25
+
if true {
26
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
27
+
}
28
+
})
29
+
}
30
+
}
+156
appview/pages/legal/privacy.md
+156
appview/pages/legal/privacy.md
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
4
+
collects, uses, and shares your personal information when you use our
5
+
platform and services (the "Service").
6
+
7
+
## 1. Information We Collect
8
+
9
+
### Account Information
10
+
11
+
When you create an account, we collect:
12
+
13
+
- Your chosen username
14
+
- Email address
15
+
- Profile information you choose to provide
16
+
- Authentication data
17
+
18
+
### Content and Activity
19
+
20
+
We store:
21
+
22
+
- Code repositories and associated metadata
23
+
- Issues, pull requests, and comments
24
+
- Activity logs and usage patterns
25
+
- Public keys for authentication
26
+
27
+
## 2. Data Location and Hosting
28
+
29
+
### EU Data Hosting
30
+
31
+
**All Tangled service data is hosted within the European Union.**
32
+
Specifically:
33
+
34
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
35
+
(*.tngl.sh) are located in Finland
36
+
- **Application Data:** All other service data is stored on EU-based
37
+
servers
38
+
- **Data Processing:** All data processing occurs within EU
39
+
jurisdiction
40
+
41
+
### External PDS Notice
42
+
43
+
**Important:** If your account is hosted on Bluesky's PDS or other
44
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
45
+
that data. The data protection, storage location, and privacy
46
+
practices for such accounts are governed by the respective PDS
47
+
provider's policies, not this Privacy Policy. We only control data
48
+
processing within our own services and infrastructure.
49
+
50
+
## 3. Third-Party Data Processors
51
+
52
+
We only share your data with the following third-party processors:
53
+
54
+
### Resend (Email Services)
55
+
56
+
- **Purpose:** Sending transactional emails (account verification,
57
+
notifications)
58
+
- **Data Shared:** Email address and necessary message content
59
+
60
+
### Cloudflare (Image Caching)
61
+
62
+
- **Purpose:** Caching and optimizing image delivery
63
+
- **Data Shared:** Public images and associated metadata for caching
64
+
purposes
65
+
66
+
### Posthog (Usage Metrics Tracking)
67
+
68
+
- **Purpose:** Tracking usage and platform metrics
69
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
70
+
information
71
+
72
+
## 4. How We Use Your Information
73
+
74
+
We use your information to:
75
+
76
+
- Provide and maintain the Service
77
+
- Process your transactions and requests
78
+
- Send you technical notices and support messages
79
+
- Improve and develop new features
80
+
- Ensure security and prevent fraud
81
+
- Comply with legal obligations
82
+
83
+
## 5. Data Sharing and Disclosure
84
+
85
+
We do not sell, trade, or rent your personal information. We may share
86
+
your information only in the following circumstances:
87
+
88
+
- With the third-party processors listed above
89
+
- When required by law or legal process
90
+
- To protect our rights, property, or safety, or that of our users
91
+
- In connection with a merger, acquisition, or sale of assets (with
92
+
appropriate protections)
93
+
94
+
## 6. Data Security
95
+
96
+
We implement appropriate technical and organizational measures to
97
+
protect your personal information against unauthorized access,
98
+
alteration, disclosure, or destruction. However, no method of
99
+
transmission over the Internet is 100% secure.
100
+
101
+
## 7. Data Retention
102
+
103
+
We retain your personal information for as long as necessary to provide
104
+
the Service and fulfill the purposes outlined in this Privacy Policy,
105
+
unless a longer retention period is required by law.
106
+
107
+
## 8. Your Rights
108
+
109
+
Under applicable data protection laws, you have the right to:
110
+
111
+
- Access your personal information
112
+
- Correct inaccurate information
113
+
- Request deletion of your information
114
+
- Object to processing of your information
115
+
- Data portability
116
+
- Withdraw consent (where applicable)
117
+
118
+
## 9. Cookies and Tracking
119
+
120
+
We use cookies and similar technologies to:
121
+
122
+
- Maintain your login session
123
+
- Remember your preferences
124
+
- Analyze usage patterns to improve the Service
125
+
126
+
You can control cookie settings through your browser preferences.
127
+
128
+
## 10. Children's Privacy
129
+
130
+
The Service is not intended for children under 16 years of age. We do
131
+
not knowingly collect personal information from children under 16. If
132
+
we become aware that we have collected such information, we will take
133
+
steps to delete it.
134
+
135
+
## 11. International Data Transfers
136
+
137
+
While all our primary data processing occurs within the EU, some of our
138
+
third-party processors may process data outside the EU. When this
139
+
occurs, we ensure appropriate safeguards are in place, such as Standard
140
+
Contractual Clauses or adequacy decisions.
141
+
142
+
## 12. Changes to This Privacy Policy
143
+
144
+
We may update this Privacy Policy from time to time. We will notify you
145
+
of any changes by posting the new Privacy Policy on this page and
146
+
updating the "Last updated" date.
147
+
148
+
## 13. Contact Information
149
+
150
+
If you have any questions about this Privacy Policy or wish to exercise
151
+
your rights, please contact us through our platform or via email.
152
+
153
+
---
154
+
155
+
This Privacy Policy complies with the EU General Data Protection
156
+
Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
+107
appview/pages/legal/terms.md
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
4
+
to and use of the Tangled platform and services (the "Service")
5
+
operated by us ("Tangled," "we," "us," or "our").
6
+
7
+
## 1. Acceptance of Terms
8
+
9
+
By accessing or using our Service, you agree to be bound by these Terms.
10
+
If you disagree with any part of these terms, then you may not access
11
+
the Service.
12
+
13
+
## 2. Account Registration
14
+
15
+
To use certain features of the Service, you must register for an
16
+
account. You agree to provide accurate, current, and complete
17
+
information during the registration process and to update such
18
+
information to keep it accurate, current, and complete.
19
+
20
+
## 3. Account Termination
21
+
22
+
> **Important Notice**
23
+
>
24
+
> **We reserve the right to terminate, suspend, or restrict access to
25
+
> your account at any time, for any reason, or for no reason at all, at
26
+
> our sole discretion.** This includes, but is not limited to,
27
+
> termination for violation of these Terms, inappropriate conduct, spam,
28
+
> abuse, or any other behavior we deem harmful to the Service or other
29
+
> users.
30
+
>
31
+
> Account termination may result in the loss of access to your
32
+
> repositories, data, and other content associated with your account. We
33
+
> are not obligated to provide advance notice of termination, though we
34
+
> may do so in our discretion.
35
+
36
+
## 4. Acceptable Use
37
+
38
+
You agree not to use the Service to:
39
+
40
+
- Violate any applicable laws or regulations
41
+
- Infringe upon the rights of others
42
+
- Upload, store, or share content that is illegal, harmful, threatening,
43
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
44
+
objectionable
45
+
- Engage in spam, phishing, or other deceptive practices
46
+
- Attempt to gain unauthorized access to the Service or other users'
47
+
accounts
48
+
- Interfere with or disrupt the Service or servers connected to the
49
+
Service
50
+
51
+
## 5. Content and Intellectual Property
52
+
53
+
You retain ownership of the content you upload to the Service. By
54
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
55
+
license to use, reproduce, modify, and distribute your content as
56
+
necessary to provide the Service.
57
+
58
+
## 6. Privacy
59
+
60
+
Your privacy is important to us. Please review our [Privacy
61
+
Policy](/privacy), which also governs your use of the Service.
62
+
63
+
## 7. Disclaimers
64
+
65
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
66
+
no warranties, expressed or implied, and hereby disclaim and negate all
67
+
other warranties including without limitation, implied warranties or
68
+
conditions of merchantability, fitness for a particular purpose, or
69
+
non-infringement of intellectual property or other violation of rights.
70
+
71
+
## 8. Limitation of Liability
72
+
73
+
In no event shall Tangled, nor its directors, employees, partners,
74
+
agents, suppliers, or affiliates, be liable for any indirect,
75
+
incidental, special, consequential, or punitive damages, including
76
+
without limitation, loss of profits, data, use, goodwill, or other
77
+
intangible losses, resulting from your use of the Service.
78
+
79
+
## 9. Indemnification
80
+
81
+
You agree to defend, indemnify, and hold harmless Tangled and its
82
+
affiliates, officers, directors, employees, and agents from and against
83
+
any and all claims, damages, obligations, losses, liabilities, costs,
84
+
or debt, and expenses (including attorney's fees).
85
+
86
+
## 10. Governing Law
87
+
88
+
These Terms shall be interpreted and governed by the laws of Finland,
89
+
without regard to its conflict of law provisions.
90
+
91
+
## 11. Changes to Terms
92
+
93
+
We reserve the right to modify or replace these Terms at any time. If a
94
+
revision is material, we will try to provide at least 30 days notice
95
+
prior to any new terms taking effect.
96
+
97
+
## 12. Contact Information
98
+
99
+
If you have any questions about these Terms of Service, please contact
100
+
us through our platform or via email.
101
+
102
+
---
103
+
104
+
These terms are effective as of the last updated date shown above and
105
+
will remain in effect except with respect to any changes in their
106
+
provisions in the future, which will be in effect immediately after
107
+
being posted on this page.
+15
-17
appview/pages/markup/format.go
+15
-17
appview/pages/markup/format.go
···
1
1
package markup
2
2
3
-
import "strings"
3
+
import (
4
+
"regexp"
5
+
)
4
6
5
7
type Format string
6
8
···
10
12
)
11
13
12
14
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
16
}
15
17
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
26
}
27
27
28
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
34
32
}
35
33
}
36
34
// default format
+2
-2
appview/pages/markup/markdown.go
+2
-2
appview/pages/markup/markdown.go
···
22
22
"github.com/yuin/goldmark/util"
23
23
htmlparse "golang.org/x/net/html"
24
24
25
-
"tangled.sh/tangled.sh/core/api/tangled"
26
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
25
+
"tangled.org/core/api/tangled"
26
+
"tangled.org/core/appview/pages/repoinfo"
27
27
)
28
28
29
29
// RendererType defines the type of renderer to use based on context
+243
-115
appview/pages/pages.go
+243
-115
appview/pages/pages.go
···
16
16
"strings"
17
17
"sync"
18
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/commitverify"
21
-
"tangled.sh/tangled.sh/core/appview/config"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
-
"tangled.sh/tangled.sh/core/appview/pagination"
27
-
"tangled.sh/tangled.sh/core/idresolver"
28
-
"tangled.sh/tangled.sh/core/patchutil"
29
-
"tangled.sh/tangled.sh/core/types"
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/commitverify"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/oauth"
24
+
"tangled.org/core/appview/pages/markup"
25
+
"tangled.org/core/appview/pages/repoinfo"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/idresolver"
28
+
"tangled.org/core/patchutil"
29
+
"tangled.org/core/types"
30
30
31
31
"github.com/alecthomas/chroma/v2"
32
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
38
"github.com/go-git/go-git/v5/plumbing/object"
39
39
)
40
40
41
-
//go:embed templates/* static
41
+
//go:embed templates/* static legal
42
42
var Files embed.FS
43
43
44
44
type Pages struct {
···
81
81
}
82
82
83
83
return p
84
-
}
85
-
86
-
func (p *Pages) pathToName(s string) string {
87
-
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
84
}
89
85
90
86
// reverse of pathToName
···
219
215
}
220
216
221
217
func (p *Pages) Favicon(w io.Writer) error {
222
-
return p.executePlain("favicon", w, nil)
218
+
return p.executePlain("fragments/dolly/silhouette", w, nil)
223
219
}
224
220
225
221
type LoginParams struct {
···
230
226
return p.executePlain("user/login", w, params)
231
227
}
232
228
233
-
func (p *Pages) Signup(w io.Writer) error {
234
-
return p.executePlain("user/signup", w, nil)
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
235
235
}
236
236
237
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
247
filename := "terms.md"
248
248
filePath := filepath.Join("legal", filename)
249
-
markdownBytes, err := os.ReadFile(filePath)
249
+
250
+
file, err := p.embedFS.Open(filePath)
251
+
if err != nil {
252
+
return fmt.Errorf("failed to read %s: %w", filename, err)
253
+
}
254
+
defer file.Close()
255
+
256
+
markdownBytes, err := io.ReadAll(file)
250
257
if err != nil {
251
258
return fmt.Errorf("failed to read %s: %w", filename, err)
252
259
}
···
267
274
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
275
filename := "privacy.md"
269
276
filePath := filepath.Join("legal", filename)
270
-
markdownBytes, err := os.ReadFile(filePath)
277
+
278
+
file, err := p.embedFS.Open(filePath)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to read %s: %w", filename, err)
281
+
}
282
+
defer file.Close()
283
+
284
+
markdownBytes, err := io.ReadAll(file)
271
285
if err != nil {
272
286
return fmt.Errorf("failed to read %s: %w", filename, err)
273
287
}
···
280
294
return p.execute("legal/privacy", w, params)
281
295
}
282
296
297
+
type BrandParams struct {
298
+
LoggedInUser *oauth.User
299
+
}
300
+
301
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
302
+
return p.execute("brand/brand", w, params)
303
+
}
304
+
283
305
type TimelineParams struct {
284
306
LoggedInUser *oauth.User
285
-
Timeline []db.TimelineEvent
286
-
Repos []db.Repo
307
+
Timeline []models.TimelineEvent
308
+
Repos []models.Repo
309
+
GfiLabel *models.LabelDefinition
287
310
}
288
311
289
312
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
290
313
return p.execute("timeline/timeline", w, params)
291
314
}
292
315
316
+
type GoodFirstIssuesParams struct {
317
+
LoggedInUser *oauth.User
318
+
Issues []models.Issue
319
+
RepoGroups []*models.RepoGroup
320
+
LabelDefs map[string]*models.LabelDefinition
321
+
GfiLabel *models.LabelDefinition
322
+
Page pagination.Page
323
+
}
324
+
325
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
326
+
return p.execute("goodfirstissues/index", w, params)
327
+
}
328
+
293
329
type UserProfileSettingsParams struct {
294
330
LoggedInUser *oauth.User
295
331
Tabs []map[string]any
···
300
336
return p.execute("user/settings/profile", w, params)
301
337
}
302
338
339
+
type NotificationsParams struct {
340
+
LoggedInUser *oauth.User
341
+
Notifications []*models.NotificationWithEntity
342
+
UnreadCount int
343
+
Page pagination.Page
344
+
Total int64
345
+
}
346
+
347
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
348
+
return p.execute("notifications/list", w, params)
349
+
}
350
+
351
+
type NotificationItemParams struct {
352
+
Notification *models.Notification
353
+
}
354
+
355
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
356
+
return p.executePlain("notifications/fragments/item", w, params)
357
+
}
358
+
359
+
type NotificationCountParams struct {
360
+
Count int64
361
+
}
362
+
363
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
364
+
return p.executePlain("notifications/fragments/count", w, params)
365
+
}
366
+
303
367
type UserKeysSettingsParams struct {
304
368
LoggedInUser *oauth.User
305
-
PubKeys []db.PublicKey
369
+
PubKeys []models.PublicKey
306
370
Tabs []map[string]any
307
371
Tab string
308
372
}
···
313
377
314
378
type UserEmailsSettingsParams struct {
315
379
LoggedInUser *oauth.User
316
-
Emails []db.Email
380
+
Emails []models.Email
317
381
Tabs []map[string]any
318
382
Tab string
319
383
}
···
322
386
return p.execute("user/settings/emails", w, params)
323
387
}
324
388
389
+
type UserNotificationSettingsParams struct {
390
+
LoggedInUser *oauth.User
391
+
Preferences *models.NotificationPreferences
392
+
Tabs []map[string]any
393
+
Tab string
394
+
}
395
+
396
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
397
+
return p.execute("user/settings/notifications", w, params)
398
+
}
399
+
325
400
type UpgradeBannerParams struct {
326
-
Registrations []db.Registration
327
-
Spindles []db.Spindle
401
+
Registrations []models.Registration
402
+
Spindles []models.Spindle
328
403
}
329
404
330
405
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
333
408
334
409
type KnotsParams struct {
335
410
LoggedInUser *oauth.User
336
-
Registrations []db.Registration
411
+
Registrations []models.Registration
337
412
}
338
413
339
414
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
342
417
343
418
type KnotParams struct {
344
419
LoggedInUser *oauth.User
345
-
Registration *db.Registration
420
+
Registration *models.Registration
346
421
Members []string
347
-
Repos map[string][]db.Repo
422
+
Repos map[string][]models.Repo
348
423
IsOwner bool
349
424
}
350
425
···
353
428
}
354
429
355
430
type KnotListingParams struct {
356
-
*db.Registration
431
+
*models.Registration
357
432
}
358
433
359
434
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
362
437
363
438
type SpindlesParams struct {
364
439
LoggedInUser *oauth.User
365
-
Spindles []db.Spindle
440
+
Spindles []models.Spindle
366
441
}
367
442
368
443
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
370
445
}
371
446
372
447
type SpindleListingParams struct {
373
-
db.Spindle
448
+
models.Spindle
374
449
}
375
450
376
451
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
379
454
380
455
type SpindleDashboardParams struct {
381
456
LoggedInUser *oauth.User
382
-
Spindle db.Spindle
457
+
Spindle models.Spindle
383
458
Members []string
384
-
Repos map[string][]db.Repo
459
+
Repos map[string][]models.Repo
385
460
}
386
461
387
462
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
410
485
type ProfileCard struct {
411
486
UserDid string
412
487
UserHandle string
413
-
FollowStatus db.FollowStatus
414
-
Punchcard *db.Punchcard
415
-
Profile *db.Profile
488
+
FollowStatus models.FollowStatus
489
+
Punchcard *models.Punchcard
490
+
Profile *models.Profile
416
491
Stats ProfileStats
417
492
Active string
418
493
}
···
438
513
439
514
type ProfileOverviewParams struct {
440
515
LoggedInUser *oauth.User
441
-
Repos []db.Repo
442
-
CollaboratingRepos []db.Repo
443
-
ProfileTimeline *db.ProfileTimeline
516
+
Repos []models.Repo
517
+
CollaboratingRepos []models.Repo
518
+
ProfileTimeline *models.ProfileTimeline
444
519
Card *ProfileCard
445
520
Active string
446
521
}
···
452
527
453
528
type ProfileReposParams struct {
454
529
LoggedInUser *oauth.User
455
-
Repos []db.Repo
530
+
Repos []models.Repo
456
531
Card *ProfileCard
457
532
Active string
458
533
}
···
464
539
465
540
type ProfileStarredParams struct {
466
541
LoggedInUser *oauth.User
467
-
Repos []db.Repo
542
+
Repos []models.Repo
468
543
Card *ProfileCard
469
544
Active string
470
545
}
···
476
551
477
552
type ProfileStringsParams struct {
478
553
LoggedInUser *oauth.User
479
-
Strings []db.String
554
+
Strings []models.String
480
555
Card *ProfileCard
481
556
Active string
482
557
}
···
488
563
489
564
type FollowCard struct {
490
565
UserDid string
491
-
FollowStatus db.FollowStatus
566
+
LoggedInUser *oauth.User
567
+
FollowStatus models.FollowStatus
492
568
FollowersCount int64
493
569
FollowingCount int64
494
-
Profile *db.Profile
570
+
Profile *models.Profile
495
571
}
496
572
497
573
type ProfileFollowersParams struct {
···
520
596
521
597
type FollowFragmentParams struct {
522
598
UserDid string
523
-
FollowStatus db.FollowStatus
599
+
FollowStatus models.FollowStatus
524
600
}
525
601
526
602
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
529
605
530
606
type EditBioParams struct {
531
607
LoggedInUser *oauth.User
532
-
Profile *db.Profile
608
+
Profile *models.Profile
533
609
}
534
610
535
611
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
538
614
539
615
type EditPinsParams struct {
540
616
LoggedInUser *oauth.User
541
-
Profile *db.Profile
617
+
Profile *models.Profile
542
618
AllRepos []PinnedRepo
543
619
}
544
620
545
621
type PinnedRepo struct {
546
622
IsPinned bool
547
-
db.Repo
623
+
models.Repo
548
624
}
549
625
550
626
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
554
630
type RepoStarFragmentParams struct {
555
631
IsStarred bool
556
632
RepoAt syntax.ATURI
557
-
Stats db.RepoStats
633
+
Stats models.RepoStats
558
634
}
559
635
560
636
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
587
663
EmailToDidOrHandle map[string]string
588
664
VerifiedCommits commitverify.VerifiedCommits
589
665
Languages []types.RepoLanguageDetails
590
-
Pipelines map[string]db.Pipeline
666
+
Pipelines map[string]models.Pipeline
591
667
NeedsKnotUpgrade bool
592
668
types.RepoIndexResponse
593
669
}
···
630
706
Active string
631
707
EmailToDidOrHandle map[string]string
632
708
VerifiedCommits commitverify.VerifiedCommits
633
-
Pipelines map[string]db.Pipeline
709
+
Pipelines map[string]models.Pipeline
634
710
}
635
711
636
712
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
643
719
RepoInfo repoinfo.RepoInfo
644
720
Active string
645
721
EmailToDidOrHandle map[string]string
646
-
Pipeline *db.Pipeline
722
+
Pipeline *models.Pipeline
647
723
DiffOpts types.DiffOpts
648
724
649
725
// singular because it's always going to be just one
···
663
739
Active string
664
740
BreadCrumbs [][]string
665
741
TreePath string
742
+
Raw bool
743
+
HTMLReadme template.HTML
666
744
types.RepoTreeResponse
667
745
}
668
746
···
689
767
690
768
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
691
769
params.Active = "overview"
770
+
771
+
p.rctx.RepoInfo = params.RepoInfo
772
+
p.rctx.RepoInfo.Ref = params.Ref
773
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
774
+
775
+
if params.ReadmeFileName != "" {
776
+
ext := filepath.Ext(params.ReadmeFileName)
777
+
switch ext {
778
+
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
779
+
params.Raw = false
780
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
781
+
sanitized := p.rctx.SanitizeDefault(htmlString)
782
+
params.HTMLReadme = template.HTML(sanitized)
783
+
default:
784
+
params.Raw = true
785
+
}
786
+
}
787
+
692
788
return p.executeRepo("repo/tree", w, params)
693
789
}
694
790
···
709
805
RepoInfo repoinfo.RepoInfo
710
806
Active string
711
807
types.RepoTagsResponse
712
-
ArtifactMap map[plumbing.Hash][]db.Artifact
713
-
DanglingArtifacts []db.Artifact
808
+
ArtifactMap map[plumbing.Hash][]models.Artifact
809
+
DanglingArtifacts []models.Artifact
714
810
}
715
811
716
812
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
721
817
type RepoArtifactParams struct {
722
818
LoggedInUser *oauth.User
723
819
RepoInfo repoinfo.RepoInfo
724
-
Artifact db.Artifact
820
+
Artifact models.Artifact
725
821
}
726
822
727
823
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
818
914
}
819
915
820
916
type RepoGeneralSettingsParams struct {
821
-
LoggedInUser *oauth.User
822
-
RepoInfo repoinfo.RepoInfo
823
-
Active string
824
-
Tabs []map[string]any
825
-
Tab string
826
-
Branches []types.Branch
917
+
LoggedInUser *oauth.User
918
+
RepoInfo repoinfo.RepoInfo
919
+
Labels []models.LabelDefinition
920
+
DefaultLabels []models.LabelDefinition
921
+
SubscribedLabels map[string]struct{}
922
+
ShouldSubscribeAll bool
923
+
Active string
924
+
Tabs []map[string]any
925
+
Tab string
926
+
Branches []types.Branch
827
927
}
828
928
829
929
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
865
965
LoggedInUser *oauth.User
866
966
RepoInfo repoinfo.RepoInfo
867
967
Active string
868
-
Issues []db.Issue
968
+
Issues []models.Issue
969
+
LabelDefs map[string]*models.LabelDefinition
869
970
Page pagination.Page
870
971
FilteringByOpen bool
871
972
}
···
876
977
}
877
978
878
979
type RepoSingleIssueParams struct {
879
-
LoggedInUser *oauth.User
880
-
RepoInfo repoinfo.RepoInfo
881
-
Active string
882
-
Issue *db.Issue
883
-
CommentList []db.CommentListItem
884
-
IssueOwnerHandle string
980
+
LoggedInUser *oauth.User
981
+
RepoInfo repoinfo.RepoInfo
982
+
Active string
983
+
Issue *models.Issue
984
+
CommentList []models.CommentListItem
985
+
LabelDefs map[string]*models.LabelDefinition
885
986
886
-
OrderedReactionKinds []db.ReactionKind
887
-
Reactions map[db.ReactionKind]int
888
-
UserReacted map[db.ReactionKind]bool
987
+
OrderedReactionKinds []models.ReactionKind
988
+
Reactions map[models.ReactionKind]int
989
+
UserReacted map[models.ReactionKind]bool
889
990
}
890
991
891
992
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
896
997
type EditIssueParams struct {
897
998
LoggedInUser *oauth.User
898
999
RepoInfo repoinfo.RepoInfo
899
-
Issue *db.Issue
1000
+
Issue *models.Issue
900
1001
Action string
901
1002
}
902
1003
···
907
1008
908
1009
type ThreadReactionFragmentParams struct {
909
1010
ThreadAt syntax.ATURI
910
-
Kind db.ReactionKind
1011
+
Kind models.ReactionKind
911
1012
Count int
912
1013
IsReacted bool
913
1014
}
···
919
1020
type RepoNewIssueParams struct {
920
1021
LoggedInUser *oauth.User
921
1022
RepoInfo repoinfo.RepoInfo
922
-
Issue *db.Issue // existing issue if any -- passed when editing
1023
+
Issue *models.Issue // existing issue if any -- passed when editing
923
1024
Active string
924
1025
Action string
925
1026
}
···
933
1034
type EditIssueCommentParams struct {
934
1035
LoggedInUser *oauth.User
935
1036
RepoInfo repoinfo.RepoInfo
936
-
Issue *db.Issue
937
-
Comment *db.IssueComment
1037
+
Issue *models.Issue
1038
+
Comment *models.IssueComment
938
1039
}
939
1040
940
1041
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
944
1045
type ReplyIssueCommentPlaceholderParams struct {
945
1046
LoggedInUser *oauth.User
946
1047
RepoInfo repoinfo.RepoInfo
947
-
Issue *db.Issue
948
-
Comment *db.IssueComment
1048
+
Issue *models.Issue
1049
+
Comment *models.IssueComment
949
1050
}
950
1051
951
1052
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
955
1056
type ReplyIssueCommentParams struct {
956
1057
LoggedInUser *oauth.User
957
1058
RepoInfo repoinfo.RepoInfo
958
-
Issue *db.Issue
959
-
Comment *db.IssueComment
1059
+
Issue *models.Issue
1060
+
Comment *models.IssueComment
960
1061
}
961
1062
962
1063
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
966
1067
type IssueCommentBodyParams struct {
967
1068
LoggedInUser *oauth.User
968
1069
RepoInfo repoinfo.RepoInfo
969
-
Issue *db.Issue
970
-
Comment *db.IssueComment
1070
+
Issue *models.Issue
1071
+
Comment *models.IssueComment
971
1072
}
972
1073
973
1074
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
994
1095
type RepoPullsParams struct {
995
1096
LoggedInUser *oauth.User
996
1097
RepoInfo repoinfo.RepoInfo
997
-
Pulls []*db.Pull
1098
+
Pulls []*models.Pull
998
1099
Active string
999
-
FilteringBy db.PullState
1000
-
Stacks map[string]db.Stack
1001
-
Pipelines map[string]db.Pipeline
1100
+
FilteringBy models.PullState
1101
+
Stacks map[string]models.Stack
1102
+
Pipelines map[string]models.Pipeline
1103
+
LabelDefs map[string]*models.LabelDefinition
1002
1104
}
1003
1105
1004
1106
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1028
1130
LoggedInUser *oauth.User
1029
1131
RepoInfo repoinfo.RepoInfo
1030
1132
Active string
1031
-
Pull *db.Pull
1032
-
Stack db.Stack
1033
-
AbandonedPulls []*db.Pull
1133
+
Pull *models.Pull
1134
+
Stack models.Stack
1135
+
AbandonedPulls []*models.Pull
1034
1136
MergeCheck types.MergeCheckResponse
1035
1137
ResubmitCheck ResubmitResult
1036
-
Pipelines map[string]db.Pipeline
1138
+
Pipelines map[string]models.Pipeline
1037
1139
1038
-
OrderedReactionKinds []db.ReactionKind
1039
-
Reactions map[db.ReactionKind]int
1040
-
UserReacted map[db.ReactionKind]bool
1140
+
OrderedReactionKinds []models.ReactionKind
1141
+
Reactions map[models.ReactionKind]int
1142
+
UserReacted map[models.ReactionKind]bool
1143
+
1144
+
LabelDefs map[string]*models.LabelDefinition
1041
1145
}
1042
1146
1043
1147
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1048
1152
type RepoPullPatchParams struct {
1049
1153
LoggedInUser *oauth.User
1050
1154
RepoInfo repoinfo.RepoInfo
1051
-
Pull *db.Pull
1052
-
Stack db.Stack
1155
+
Pull *models.Pull
1156
+
Stack models.Stack
1053
1157
Diff *types.NiceDiff
1054
1158
Round int
1055
-
Submission *db.PullSubmission
1056
-
OrderedReactionKinds []db.ReactionKind
1159
+
Submission *models.PullSubmission
1160
+
OrderedReactionKinds []models.ReactionKind
1057
1161
DiffOpts types.DiffOpts
1058
1162
}
1059
1163
···
1065
1169
type RepoPullInterdiffParams struct {
1066
1170
LoggedInUser *oauth.User
1067
1171
RepoInfo repoinfo.RepoInfo
1068
-
Pull *db.Pull
1172
+
Pull *models.Pull
1069
1173
Round int
1070
1174
Interdiff *patchutil.InterdiffResult
1071
-
OrderedReactionKinds []db.ReactionKind
1175
+
OrderedReactionKinds []models.ReactionKind
1072
1176
DiffOpts types.DiffOpts
1073
1177
}
1074
1178
···
1097
1201
1098
1202
type PullCompareForkParams struct {
1099
1203
RepoInfo repoinfo.RepoInfo
1100
-
Forks []db.Repo
1204
+
Forks []models.Repo
1101
1205
Selected string
1102
1206
}
1103
1207
···
1118
1222
type PullResubmitParams struct {
1119
1223
LoggedInUser *oauth.User
1120
1224
RepoInfo repoinfo.RepoInfo
1121
-
Pull *db.Pull
1225
+
Pull *models.Pull
1122
1226
SubmissionId int
1123
1227
}
1124
1228
···
1129
1233
type PullActionsParams struct {
1130
1234
LoggedInUser *oauth.User
1131
1235
RepoInfo repoinfo.RepoInfo
1132
-
Pull *db.Pull
1236
+
Pull *models.Pull
1133
1237
RoundNumber int
1134
1238
MergeCheck types.MergeCheckResponse
1135
1239
ResubmitCheck ResubmitResult
1136
-
Stack db.Stack
1240
+
Stack models.Stack
1137
1241
}
1138
1242
1139
1243
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1143
1247
type PullNewCommentParams struct {
1144
1248
LoggedInUser *oauth.User
1145
1249
RepoInfo repoinfo.RepoInfo
1146
-
Pull *db.Pull
1250
+
Pull *models.Pull
1147
1251
RoundNumber int
1148
1252
}
1149
1253
···
1154
1258
type RepoCompareParams struct {
1155
1259
LoggedInUser *oauth.User
1156
1260
RepoInfo repoinfo.RepoInfo
1157
-
Forks []db.Repo
1261
+
Forks []models.Repo
1158
1262
Branches []types.Branch
1159
1263
Tags []*types.TagReference
1160
1264
Base string
···
1173
1277
type RepoCompareNewParams struct {
1174
1278
LoggedInUser *oauth.User
1175
1279
RepoInfo repoinfo.RepoInfo
1176
-
Forks []db.Repo
1280
+
Forks []models.Repo
1177
1281
Branches []types.Branch
1178
1282
Tags []*types.TagReference
1179
1283
Base string
···
1208
1312
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1209
1313
}
1210
1314
1315
+
type LabelPanelParams struct {
1316
+
LoggedInUser *oauth.User
1317
+
RepoInfo repoinfo.RepoInfo
1318
+
Defs map[string]*models.LabelDefinition
1319
+
Subject string
1320
+
State models.LabelState
1321
+
}
1322
+
1323
+
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1324
+
return p.executePlain("repo/fragments/labelPanel", w, params)
1325
+
}
1326
+
1327
+
type EditLabelPanelParams struct {
1328
+
LoggedInUser *oauth.User
1329
+
RepoInfo repoinfo.RepoInfo
1330
+
Defs map[string]*models.LabelDefinition
1331
+
Subject string
1332
+
State models.LabelState
1333
+
}
1334
+
1335
+
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1336
+
return p.executePlain("repo/fragments/editLabelPanel", w, params)
1337
+
}
1338
+
1211
1339
type PipelinesParams struct {
1212
1340
LoggedInUser *oauth.User
1213
1341
RepoInfo repoinfo.RepoInfo
1214
-
Pipelines []db.Pipeline
1342
+
Pipelines []models.Pipeline
1215
1343
Active string
1216
1344
}
1217
1345
···
1243
1371
type WorkflowParams struct {
1244
1372
LoggedInUser *oauth.User
1245
1373
RepoInfo repoinfo.RepoInfo
1246
-
Pipeline db.Pipeline
1374
+
Pipeline models.Pipeline
1247
1375
Workflow string
1248
1376
LogUrl string
1249
1377
Active string
···
1259
1387
Action string
1260
1388
1261
1389
// this is supplied in the case of editing an existing string
1262
-
String db.String
1390
+
String models.String
1263
1391
}
1264
1392
1265
1393
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1269
1397
type StringsDashboardParams struct {
1270
1398
LoggedInUser *oauth.User
1271
1399
Card ProfileCard
1272
-
Strings []db.String
1400
+
Strings []models.String
1273
1401
}
1274
1402
1275
1403
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1278
1406
1279
1407
type StringTimelineParams struct {
1280
1408
LoggedInUser *oauth.User
1281
-
Strings []db.String
1409
+
Strings []models.String
1282
1410
}
1283
1411
1284
1412
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1290
1418
ShowRendered bool
1291
1419
RenderToggle bool
1292
1420
RenderedContents template.HTML
1293
-
String db.String
1294
-
Stats db.StringStats
1421
+
String models.String
1422
+
Stats models.StringStats
1295
1423
Owner identity.Identity
1296
1424
}
1297
1425
+7
-6
appview/pages/repoinfo/repoinfo.go
+7
-6
appview/pages/repoinfo/repoinfo.go
···
7
7
"strings"
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/state/userutil"
12
12
)
13
13
14
14
func (r RepoInfo) OwnerWithAt() string {
···
24
24
}
25
25
26
26
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if strings.HasPrefix(r.OwnerWithAt(), "@") {
28
-
return strings.TrimPrefix(r.OwnerWithAt(), "@")
27
+
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
+
return after
29
29
} else {
30
30
return userutil.FlattenDid(r.OwnerDid)
31
31
}
···
52
52
53
53
type RepoInfo struct {
54
54
Name string
55
+
Rkey string
55
56
OwnerDid string
56
57
OwnerHandle string
57
58
Description string
···
59
60
Spindle string
60
61
RepoAt syntax.ATURI
61
62
IsStarred bool
62
-
Stats db.RepoStats
63
+
Stats models.RepoStats
63
64
Roles RolesInRepo
64
-
Source *db.Repo
65
+
Source *models.Repo
65
66
SourceHandle string
66
67
Ref string
67
68
DisableFork bool
+224
appview/pages/templates/brand/brand.html
+224
appview/pages/templates/brand/brand.html
···
1
+
{{ define "title" }}brand{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Assets and guidelines for using Tangled's logo and brand elements.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="space-y-16">
14
+
15
+
<!-- Introduction Section -->
16
+
<section>
17
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
+
follow the below guidelines when using Dolly and the logotype.
20
+
</p>
21
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
+
</p>
24
+
</section>
25
+
26
+
<!-- Black Logotype Section -->
27
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
28
+
<div class="order-2 lg:order-1">
29
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
30
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
31
+
alt="Tangled logo - black version"
32
+
class="w-full max-w-sm mx-auto" />
33
+
</div>
34
+
</div>
35
+
<div class="order-1 lg:order-2">
36
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
+
<p class="text-gray-700 dark:text-gray-300">
39
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
+
backgrounds and designs.
41
+
</p>
42
+
</div>
43
+
</section>
44
+
45
+
<!-- White Logotype Section -->
46
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
47
+
<div class="order-2 lg:order-1">
48
+
<div class="bg-black p-8 sm:p-16 rounded">
49
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
50
+
alt="Tangled logo - white version"
51
+
class="w-full max-w-sm mx-auto" />
52
+
</div>
53
+
</div>
54
+
<div class="order-1 lg:order-2">
55
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
+
<p class="text-gray-700 dark:text-gray-300">
58
+
This version features white text and elements, ideal for dark backgrounds
59
+
and inverted designs.
60
+
</p>
61
+
</div>
62
+
</section>
63
+
64
+
<!-- Mark Only Section -->
65
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
66
+
<div class="order-2 lg:order-1">
67
+
<div class="grid grid-cols-2 gap-2">
68
+
<!-- Black mark on light background -->
69
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
70
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
71
+
alt="Dolly face - black version"
72
+
class="w-full max-w-16 mx-auto" />
73
+
</div>
74
+
<!-- White mark on dark background -->
75
+
<div class="bg-black p-8 sm:p-12 rounded">
76
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
77
+
alt="Dolly face - white version"
78
+
class="w-full max-w-16 mx-auto" />
79
+
</div>
80
+
</div>
81
+
</div>
82
+
<div class="order-1 lg:order-2">
83
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
+
</p>
87
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
88
+
<strong class="font-semibold">Note</strong>: for situations where the background
89
+
is unknown, use the black version for ideal contrast in most environments.
90
+
</p>
91
+
</div>
92
+
</section>
93
+
94
+
<!-- Colored Backgrounds Section -->
95
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
96
+
<div class="order-2 lg:order-1">
97
+
<div class="grid grid-cols-2 gap-2">
98
+
<!-- Pastel Green background -->
99
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
100
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
101
+
alt="Tangled logo on pastel green background"
102
+
class="w-full max-w-16 mx-auto" />
103
+
</div>
104
+
<!-- Pastel Blue background -->
105
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
106
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
107
+
alt="Tangled logo on pastel blue background"
108
+
class="w-full max-w-16 mx-auto" />
109
+
</div>
110
+
<!-- Pastel Yellow background -->
111
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
112
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
113
+
alt="Tangled logo on pastel yellow background"
114
+
class="w-full max-w-16 mx-auto" />
115
+
</div>
116
+
<!-- Pastel Red background -->
117
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
118
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
119
+
alt="Tangled logo on pastel red background"
120
+
class="w-full max-w-16 mx-auto" />
121
+
</div>
122
+
</div>
123
+
</div>
124
+
<div class="order-1 lg:order-2">
125
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
+
White logo mark on colored backgrounds.
128
+
</p>
129
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
130
+
The white logo mark provides contrast on colored backgrounds.
131
+
Perfect for more fun design contexts.
132
+
</p>
133
+
</div>
134
+
</section>
135
+
136
+
<!-- Black Logo on Pastel Backgrounds Section -->
137
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
138
+
<div class="order-2 lg:order-1">
139
+
<div class="grid grid-cols-2 gap-2">
140
+
<!-- Pastel Green background -->
141
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
142
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
143
+
alt="Tangled logo on pastel green background"
144
+
class="w-full max-w-16 mx-auto" />
145
+
</div>
146
+
<!-- Pastel Blue background -->
147
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
148
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
149
+
alt="Tangled logo on pastel blue background"
150
+
class="w-full max-w-16 mx-auto" />
151
+
</div>
152
+
<!-- Pastel Yellow background -->
153
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
154
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
155
+
alt="Tangled logo on pastel yellow background"
156
+
class="w-full max-w-16 mx-auto" />
157
+
</div>
158
+
<!-- Pastel Pink background -->
159
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
160
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
161
+
alt="Tangled logo on pastel pink background"
162
+
class="w-full max-w-16 mx-auto" />
163
+
</div>
164
+
</div>
165
+
</div>
166
+
<div class="order-1 lg:order-2">
167
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
+
Dark logo mark on lighter, pastel backgrounds.
170
+
</p>
171
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
172
+
The dark logo mark works beautifully on pastel backgrounds,
173
+
providing crisp contrast.
174
+
</p>
175
+
</div>
176
+
</section>
177
+
178
+
<!-- Recoloring Section -->
179
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
180
+
<div class="order-2 lg:order-1">
181
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
182
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
183
+
alt="Recolored Tangled logotype in gray/sand color"
184
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
185
+
</div>
186
+
</div>
187
+
<div class="order-1 lg:order-2">
188
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
+
Custom coloring of the logotype is permitted.
191
+
</p>
192
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
193
+
Recoloring the logotype is allowed as long as readability is maintained.
194
+
</p>
195
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
+
</p>
198
+
</div>
199
+
</section>
200
+
201
+
<!-- Silhouette Section -->
202
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
+
<div class="order-2 lg:order-1">
204
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
+
alt="Dolly silhouette"
207
+
class="w-full max-w-32 mx-auto" />
208
+
</div>
209
+
</div>
210
+
<div class="order-1 lg:order-2">
211
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
+
<p class="text-gray-700 dark:text-gray-300">
214
+
The silhouette can be used where a subtle brand presence is needed,
215
+
or as a background element. Works on any background color with proper contrast.
216
+
For example, we use this as the site's favicon.
217
+
</p>
218
+
</div>
219
+
</section>
220
+
221
+
</div>
222
+
</main>
223
+
</div>
224
+
{{ end }}
+4
-11
appview/pages/templates/errors/500.html
+4
-11
appview/pages/templates/errors/500.html
···
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
6
<div class="mb-6">
7
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
11
···
14
14
500 — internal server error
15
15
</h1>
16
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
26
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
20
<button onclick="location.reload()" class="btn-create gap-2">
28
21
{{ i "refresh-cw" "w-4 h-4" }}
29
22
try again
30
23
</button>
31
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
25
+
{{ i "arrow-left" "w-4 h-4" }}
33
26
back to home
34
27
</a>
35
28
</div>
-26
appview/pages/templates/favicon.html
-26
appview/pages/templates/favicon.html
···
1
-
{{ define "favicon" }}
2
-
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
3
-
<style>
4
-
.favicon-text {
5
-
fill: #000000;
6
-
stroke: none;
7
-
}
8
-
9
-
@media (prefers-color-scheme: dark) {
10
-
.favicon-text {
11
-
fill: #ffffff;
12
-
stroke: none;
13
-
}
14
-
}
15
-
</style>
16
-
17
-
<g style="display:inline">
18
-
<path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/>
19
-
<path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z"
20
-
aria-label="tangled.sh"
21
-
class="favicon-text"
22
-
style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1"
23
-
transform="translate(11.01 6.9)"/>
24
-
</g>
25
-
</svg>
26
-
{{ end }}
+56
appview/pages/templates/fragments/dolly/logo.html
+56
appview/pages/templates/fragments/dolly/logo.html
···
1
+
{{ define "fragments/dolly/logo" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{.}}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only.png"
10
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+
xmlns:xlink="http://www.w3.org/1999/xlink"
13
+
xmlns="http://www.w3.org/2000/svg"
14
+
xmlns:svg="http://www.w3.org/2000/svg">
15
+
<title>Dolly</title>
16
+
<defs
17
+
id="defs1" />
18
+
<sodipodi:namedview
19
+
id="namedview1"
20
+
pagecolor="#ffffff"
21
+
bordercolor="#000000"
22
+
borderopacity="0.25"
23
+
inkscape:showpageshadow="2"
24
+
inkscape:pageopacity="0.0"
25
+
inkscape:pagecheckerboard="true"
26
+
inkscape:deskcolor="#d5d5d5">
27
+
<inkscape:page
28
+
x="0"
29
+
y="0"
30
+
width="25"
31
+
height="25"
32
+
id="page2"
33
+
margin="0"
34
+
bleed="0" />
35
+
</sodipodi:namedview>
36
+
<g
37
+
inkscape:groupmode="layer"
38
+
inkscape:label="Image"
39
+
id="g1">
40
+
<image
41
+
width="252.48"
42
+
height="248.96001"
43
+
preserveAspectRatio="none"
44
+
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ KJGkgTBUSJI6ry8stIGhDQhTwAwJCu1CfhrYCGwGZoENwCZgS9/7pkgAmKuf0+5QTNbPnagfu9L3 7zYkMCwA54BL9deQYHGlvm+eXkBZrH/P1fpnl+mFj4K7HZLWiaFCktQpNUDcuMMwScLCDFn0tyFh K7Ctvt1Q/9yG+vsd9WNbgO319zvrxxrgInCaLO6n6t87S4JHA5ytN/o+PkMCwTHgDHCNXrg5A5yq f+9ivf9X6/tPA5dJ8Gjftrsd1+qt3eX4S8gwcEhaLYYKSdLYakuXqnY3YANZ2LeBoD88bCMBof/X 2+vHZ+kFjvbX01y/s9H+GrKIX7zh327DzK0+PkEW/m0IKH0fu0hCROn7d88DJ+pbyA7HMeAreuHi PAkdZ4EL9e9u+zkWgCXDhaS7ZaiQJI2VvmbpdvehDQJtydJOYD9wD7CL3i5DGyTaALGJBJAN9Pok 2tsk6/8a2u40tOGkIQGhLX1q6IWIy/XPXwC+Bo4DX5LAcY5e6dU5ElYuk7KqtsfDXQxJK2KokCSN tBuap2folS5tIWFhL7CHXnnSXmAfsJvrdyA2kgAxQy84jJq2vKkNVldIaDhHSqdOkL6Mufq+L0jg OFk/3n5sjoSMhaZplpCk72CokCSNnL7diP6m6V0kPOyhFyT2kV2JvVzfAzFLr4ToxtfCcXttbMfW zpOdjXac7QWye3GclEsdIyVS50j4OFH/zEUSMtpGcJu/JX3DuD1xSpLG1E12JNogcS9wCHgBeIIE ie314+3kprbXYYLeORFdew3sPy8DeqNr5+ntZiyR4PEZ8A4JHZ+R0HGalFbN1c9bBsukJEXXnlAl SSOmlNI/inUrCQy7gfuAB+vtAeBx0ifR9kBM3Ozv000tcf342kv0+i2OkHDxFfAB8DHZxbhcP+ea Z2VIMlRIkoZO3ZWYoje+dScJD48CB+mVNe2rH2snNU0P4v6OsWWyQ3GJlEZ9BLxFgsbx+rGv6fVi LLhzIXWToUKSNDRKKW1p0yzpgdhHQsQDwHPAs6TcqT03Ygpfy9bLItm9+JTsXHxZf/0JCRhfkzMz 2gP6Ft3BkLrDJ2JJ0sDccJL1NNlx2E9CxEP19gBwgISLvSR0tH0RWl9L9EbPXiFh4hgJGJ+TgPEp mSp1ht4hfDZ3S2POJ2RJ0kDUXon2JOk2TDwBvAQcJiGiHfnaP+rV167BakfWtk3d8/TKo06Sxu4/ AG/XX5+h9l+4cyGNL5+YJUnrrpQyTQLDPWQn4kGyK/EE8BRpwt4ysDuolWpP527Pv/iEhIr3SbA4 Shq9zwPz7lpI48dQIUlac7XMqb9fYjcZA3uY9Ek8SALGbrJr0ZY4afQsk7Knk/RO8X6LhIyP6/vb xu5FA4Y0HgwVkqQ10xcmNpEpTfvJLsTDwNPA88Aj5EyJ5oabRld74N4SCQ/tzsV7JFgcITsXZ4Gr wJKlUdJo80lbkrTq+sLEBjLq9SHge8CL9HYldtWPbcLXo3FWSN/FGTKC9jTZufgDKY9qT/K+gjsX 0sjySVyStOpKKRtJaLiPnC3xPAkUT5Eg0Z507etQdyyRSVDXyOF5H5JQ8RE5VO8jUhp11V0LafT4 ZC5JWhV1mtMMabC+D3iG7E48QfonDpKzJ3zt0TJp2j5F+i7eB34PvElG057DcCGNFJ/YJUl3rO+c iRmyA7GflDo9B7xMQsUeeqNgfd1Rq9CbGnUKeAf4d64vi7qAo2ilkeCTuyTpjtRAMUWarO8lOxOv 0pvmtJeMjfVsCX2bQkqjLpPm7feBN8jOxUfkpO7L2G8hDbWpQd8BSdLoqYFihkx0epT0S7xCeicO kKAxhWNh9d3acLqNHIS4k4TUg8C79HovTpZSrjRNszSoOyrp1rxyJEm6bTVMbCS9EfeQ0bAvklKn J8nuxMaB3UGNg/aci6/ILsUR4DXSb/EJmSI1Z0mUNFwMFZKk79RX6rSVNGE/ScqdHqu/PkR6KtyZ 0GppdyTOkhG0v6+3d0i/xWXst5CGhqFCknRLfY3YG8iI2MfIrsSPSTP2TrIzMY2BQmtjmRyQ9yXw R+B10sz9MWnwvmpJlDR4hgpJ0k3VQDHN9Y3YPyHlTg+TUqfpgd1Bdc08OTjvE3o7F38CjpIRtDZy SwNko7akNdN3lfu6d/e/8NezDdo/U/o/3vf5xcXC+qpf+00kTDwCPE3vROwHgFl8DdH62kD6eLYA +8hAgP0kXLxHGrmv+lwhDYYvCJJWpC424fqwMFFvk/VtU99Ok+eZ6fqxkr+iLJOSBur72ylBi8By /Xg7w576vkUyz779WPu2v+yhDSXWWN+FUko7iecBUur0A+Dx+vv92IitwZkkj81NpIdnNwkYu4E/ A1+UUi42TbM4uLsodZOhQtI33BAc2sDQkBf0NgRM9n18hrzIz5KridP17eb6vtn68Wv0QkAbCKbq xydJ3fRC/fhy/fgyKXu4TCbCtMFiAZirn7NE30FapZQ5vhlArgsqXs38prprNE0Oq3uCnDnxH8m5 E1vpPRakQZsiQWKW9PrsI+V4vwc+KqWcIU3c/pxL68RQIQm4rgypf9ehDQdtYNhSb9vq23aHoR0x urN+bCu5irij/rnZ+nefJiGgrdVvw8gmEgxu9vENwEXgJJkCU+q/t1h/f4IEDurnzdU/ewq4VH9/ pb69Vj9voe58XFdu1WWllEkSAg8CLwH/AXiB7E5swzCh4dJe5NhMTnDfSkLFPaSR+23gy1LKnE3c 0vowVEgdVnckJsnivQ0Ms+SFenP9/VayqNxOLyhsp3dQVbvwn62/33DDrS19gixY252IdrejnRpU 6sfbsoV2R2SKhIHLZMeC+jntCbyX6ue0i97LZLZ9GygukvGTbWCZI2HkNGnuvFJKuVY/d4k0e3aq fKqUMk0C4ZMkUPyI9E60pU4O9dCwaieT7adXErWX7Fz8O3C0lHK+aZqFW/8VklaDLxTSmOtrdm4D xBTXL/pnyYLyHvJCvIteeGh3HbbSK2VqdxbaQNH2Taz380lb1gS98izolUq1pU6XyW7GhfqxK8Dx +r4v6q8vkkBxBThff9+WUC3B+JZLlVJmSLnTU8AvSaB4gjwmZvB1QqOj0Bs9+zbwW+B35ETuk3hg nrSmfLGQxlANEm0J0wwJABtJMGibG/vDQztJZS+90qY2QGyof0fbQwHXP3cM4/NI6Xvbho/2frY7 FZfJ4uMYCRILZPfiS3KS74n6566SnZB5ej0hy6MeMmq520byPX8K+Cvgb0ig2MJwfl+l71LIz+sZ 4CMSKl4nI2i/AC7ZxC2tDV80pDHRtyPRNk7PksCwh4SG9nYvCRB7SKhodyH6DzDrv91sLOwoaxvE l0nAmO/72CkSJr4GPiMBoy2TOlE/fp56km/9e0auJ6P2T8ySWvQXyISn75Pyp+2M1/db3bRIdh6P kbMsXiNN3B8A5yyHklafPRXSCOsLEu2OxGZ6k1AeIVegHyE7EzeWMLU7EG1vQ1c09J77psnXpV1E byd9HXPkSudFsji5BBwBPidXO4+RwHEGuFxKmWdEDt7q6594GHgF+CkJFnvJ48NAoXEwRX62D5Gd t930SvreK6Wcbprm2q0/XdJKGSqkEVTDRNsb0TZS7yY7EA/U26PkbIEDJEQ4veebblxAz9TbNrIA WSaBa5GUBZ2h14vRhotPSNg4U0q5Sp0wNWy1233lTveQg+xeJedPPEMeIz4+NG7aAxzb58B2R3Yr 8FYp5SvSZzH0FwOkUeAVKWkE3GRHoi1tupdciTtQf32QXmnTTvLiabPt6mgXHnOk6fsCKZN6lxy6 9TEplTpNr2djKEqk+sqdHgCeB35MGrIfII+R6UHdN2mdLJOfyY9JA/c/k3KoY8BVx85Kd8+FhjTE +hqu2/McttNrqn6IHEr2Arn6vIleQ3X/ydb+nK+u/sP0FkiA+Aw4SibMfFJvX9LrwfjLJKn1Dhd1 h2Iz8CBpxv5r4DngPhI03KFQVxSyk3iCNHD/f8BvyM/vJYOFdHdcbEhDqq/EaTuZwf4g2ZV4CLif 3q6EZwkM1iK9A/baxtCvyKjaj4FPyaLlK9LwfXW9SqPqY6htyP4x8F9IQ/ZuUjrnY0Zd1E56e4sE i38kY2cvDlvZojRK7KmQhki9qjxJr+53H+mNeIHUvt9DFoTbyNXn9nA5F4eDM0Xv4MBCSs8eI83d X5Hdig9JidSH5JTfc9RRtWu1c1EfS9tIoHiZ3gnZ++lWY750o2nyXDpJLgjMkefQT0op5xw5K90Z FyLSEKgLwHZayV7SH3E/mdDzNClXeYiUN2k0LJNxtcskXLxDr/fiU7Kj8TW192I1r5DWHortZETs j0jZ0/NkIbVhtf4dacRdI2WKfyJlUK8D7wGnHTkrrZw7FdKA1NKU9oTrTWQUbHtuwGFS7rSf3jhY m2lHSzttCfJ93UPG+54g42nfrrejwNlSyhVWoe+ilDJFdigeB34O/C0JpZtxh0LqN01KSGfJYItt 9X1vllJOMyJjoqVhYaiQBqBvvGfbeH0/WXA+Q64oHyIvdG3jddt0rdHSfs8myYJlI5m49Dj5Xr9D pkcdIbsXx4HzpZT5le5c9PXg7Kh//09IqHiahFIbsqXrNeQ5djcZejFbb9NkV/FkKWXBYCHdHkOF tE5umOS0g5Q2PUcWfYdIaco+ckV782DupdZQGyQ3ku/vNlLmdpiUYLxHyjCOAMfbvovbCRf1sTVN FkfPAD8jB9s9TR5rBgrp1tpywSfIrvEW8vP5W+BYKeWawUL6boYKaR307UzsJAvJtvn6ZXJVeTcJ Gy7+umGC3gnnB0jvw+PkcfEOKYn6EPi4hotr3xEupkj53GHgF6Qp+xBZKFnyJH239mfycXqlgov1 drLuHhospG9hqJDWUA0T0+TK173kyvHzZPH3MFlQbsOfxS5qe2r6DzTcT66WniTlF78h5VEnSymX uEm4KKVMk0DxDDmDoi152oQlc9JKTZNy1JfJBLdrZAfxZCnF07elb+FCRloDffXt7VjYh0mYeJUs /vaRheQ07k6oV9vdnoL+KHnMPEom0vwJ+Ag4UUq5TG3mrk3Ze8jZE39F+iiexHNLpLsxSe+wyIl6 +3fS83RtgPdLGmqGCmmV1YXeZrIz8QxZ8D1Hrn4dIDXu/uzpZtqdiw2koXsrecw8AbxBdi+OAudK KddIedOzwP9CRsceIrtiBlXp7syS4RnT9bYM/K6UctKTt6Wbc2EjrZJa6tRebX6YhImXSe/EIVKO 4mJPt2uanFmylTTxt2eXvAd8AVwkO14/JldUH6Y3wlbS3ZkgAf0hMur5HHAFWC6lnPEcC+mbDBXS Xeprwm4PrmsDxU/Jycq7sRxFd6YhYfQg2f06SMrovgDOkF6K50mphofaSatvhoT5H5JwAfUcC0/e lq5nqJDuUN+I2M3AfcBTpEzlyfrrR+mdD2Cg0N2YJGVzW8mu18V6a6c+2ZQtrY123Oxz5OLQNJkI 9edSyrmVnicjjTNDhXTnZsiC7lFS5tS/M7EFy520uvonRW0kjz3I87iPM2ntTJJA/xT5WbsIXAU+ KKVcsMdCCkOFtAJ9h4y1jdiHySFj3ycvOHtwgae11wYMSeuj7bF4hExZu1Lfb7CQKkOFdJtKKZNk Isg+0rz3FPADEijurx8zUEjS+NpMJq4tk2DfAO/VYGEplDrNUCF9h7o70W5/Pwi8SHYnnqi/30fK UQwUkjTepslz/sv0hiNcIefIXLnVJ0ldYKiQvkVfudNuEiJ+RMZ3HibNe+0BdjbJSlI3TJHBCU8C F4BTwOVSyhdN08wP9J5JA2SokG6hjoqdJWM8XwR+BrxEpu/sxCAhSV01SS42PUuCxWkSLE5aBqWu MlRIN6i7E1MkOLSTnX5U396PZU6SpJQ/3Uv66r4AzgLzpZTzBgt1kaFC6lMDxQy5AvUM8HPgF/R2 JwwUkqTWRuAA8Cq9U7ffLaXMGSzUNYYKqeo7GXsP8DQJFD8nU548c0KSdKMJYBvwAnCJlEGdBb6q waIM8s5J68lQIfVsITsSz5PeiVfIYXabsX9CknRz7anbT5Ay2XPAEnAcsHFbnWGoUOeVUqbJlaZH SJD4KdmpuI+MkTVQSJK+zSQpg3qFBIpFYKGUcsKD8dQVhgp1Vi132gDsJSVOLwM/JNvYe8ioWEmS bsdWslsxTXYoLpCJUBcsg1IXGCrUSTVQbCa7ES+SsydeJidlbyZXnSRJWolN5FDUHwFfkTMsPiyl XDNYaNwZKtQ5NVBsAR4mT/y/IIfZHajvt9xJknSnNpELVD8j/RXnSX/FwgDvk7TmDBXqlL4dikOk 1OlvgB+QEbIzGCgkSXdngowgfx44CXxKyqDO21+hcWaoUGeUUiZJzesh0kz3MzLl6R78WZAkrY6G 9FXcA3wP+Bw4A3xQSrloGZTGlQspdUINFNvIiNi/An5MriLtx58DSdLqmwQeBf4D8CUphZoDrg3y TklrxcWUxl49JbudyvFz4H8l4WI7/gxIktZGQ8ptHyQXsT4CzpRSzrpboXHkgkpjrZ5B0R5K9FPg l+QMih2DvF+SpM7YBjwJPAOcAK562rbGkaFCY6uUMkPOmzhMpjz9BHiO7FpIkrQeNtA7XPUUmQb1 JTkgTxobhgqNnVruNE0mOh0G/hMJFI+SkbETg7t3kqSOmSb9ez8gDdsngIt1GtTyQO+ZtIoMFRor NVBMkUDxDJnw9FPSQ7GV1Lg6NlaStJ6mgH1kGtRxEizmgSuDvFPSajJUaNxMAXvJE/dfk7KnR0ig cIdCkjQIbdP2E2S34h3gRO2tcLdCY8FFlsZGKWWKNGA/Sw61+1vgKdIk52NdkjRIk8AucqHrCbJz sWGg90haRS60NBbqORTtlKefAb8g/RQ7yRO5JEmD1pDXpadJWe7uekFMGnk+kDXySikTXB8ofkmu BBmaJUnDZhuZRPglKYW6VJu2HTGrkWao0EirjdmbSYhoz6F4BNg0yPslSdItbCQH4r1MGrZPk7Mr rhksNMq8kquRVXcoZoEHgJfI2NinyVUgJzxJkobRBBlv/jg5u+KZ+ntftzTSDBUaSX2B4hB5Uv4J eWLegTtwkqThNkFGnz8LvECatqcHeo+ku2So0MipJU8bgAMkTPxn4FXgHnxSliQNvwaYIa9jT5Gy 3c31gpk0knzwahRN0ztE6JekLvU+Uqfq9rEkaRS0PYEPkxLe+4FN9cKZNHIMFRopdfTebjI54+fA 94F7yRUfH8+SpFEyCRwEfgg8SSYZ+lqmkeQDVyOj7yyKx8lJ2T8mT8aWPEmSRlFDegGfJGcrtRfJ pJFjqNBIqHWmW0jd6avkqs4j+OQrSRptEyRYPA08Cuy0t0KjyAetRsVGMjr2x6SP4jBp1rb2VJI0 6jaQXfiXyAWzjfZWaNQYKjT0SikzZLLT90ioeJpc1fHxK0kaB9PkwtnLZMysvRUaOT5gNdTqFvBO MnLvVbJDsYc0t0mSNA76S3yfAPbimUsaMYYKDa0aKNpxez8AXsQmNknSeGqAbWRE+n1kvKzrNI0M H6waSrWWdIZMd/oeCRUPk5Bhnakkadw0pH/wAPAYGZ/udEONDEOFhlV7HsVhUmP6NKkxtexJkjSu pkioeJ5cSNtiw7ZGhaFCQ6du924lV2peIU+u+/GKjSRpvDWkn+I54Blycc3eCo0EQ4WG0SbgfjJa 7yXgUH2fV2skSeNuE5kE9TS5oLZhsHdHuj2GCg2VUso0GR/7HDng7gnSuCZJUldsIMHifmCrJVAa BYYKDY0bxsf+iASLXfg4lSR1Szuo5DFq+a/BQsPOxZqGQg0UW4BHSWO242MlSV01DewjPYXtjr1r Ng01H6AaFu1VmRfJ+NhHsI9CktRNE2Ti4bP15mF4GnqGCg1cKWUS2AE8SQLFU+TJ1MenJKmL2rOa 7iMlUPfjYXgacj44NVC1RnSW7FK8QM6luAfPo5AkdVsbLO4lUxC34WujhpihQoM2RbZ1D5PSp/vJ iaKWPUmSuq4hZ1U8RF4r7TPU0DJUaGDqNu42es3ZbdmTgUKSpNhGLrgdICVQvkZqKBkqNBD1SXGG PEm+QELFAbwKI0lSvy0kVDxC+g8tgdJQMlRoUCbJ1ZengVfIboWnhkqSdL3NpPzpJdJ/6GulhpKh QoMyRU4LfZkccrcVH4+SJN1oivRVPE6atmctgdIwchGnddc3QvZRMipvDz4WJUm6lQ2kUXs/XoTT kPJBqXVVr65sIePxnsU+CkmSvsskee28l+xazLhboWFjqNB6mwb2Ac+T+tBDeHK2JEnfZRPwIOmr 2Iyvmxoyhgqtm75digfJxKfHcYSsJEm3Y5LsVNwP7MQpUBoyhgqtixoopslp2YeB75H60KlB3i9J kkbEJNnpf5C8lk5bAqVhYqjQepkgzdlPAT8g87Y3DfQeSZI0OibJYJPHgYexdFhDxlChNdd30N1B 0kfxIjmjwsefJEm3p70490y9bcUSKA0RF3VaDw0wSw7veZyMxLPsSZKklZkk058OArtIWbE0FAwV Wg8zpP7zCdJg5patJEkr15DX0D31tsG+Cg0LQ4XWVCllgmzRPkK2a+8luxQ+CUqStHIzZHLiHnIo nms5DQUfiFpr02TK0zPAk+RJ0BpQSZLu3Bby2jqLazkNCR+IWmubyKnZT9a3TnySJOnubCavqdux R1FDwlChNVNKmSYNZYdIL8Xmgd4hSZLGwyZSTryTlENJA2eo0JqovRTbSC/F8+Swno0DvVOSJI2H jaT8aQdOgNKQcMtMq6ZOoGjI42ozCRQ/IGdT3ItXUyRJWg0zJFBsx9dWDQlDhe5aDROT5PE0S7Zj 7yeB4qfkbAp3KSRJWh1TJFDsBGZLKRNN0ywP+D6p4wwVumN9YWIjKXXaQw64O0zOpHiM9FN4erYk Satnioxr308OwTtWSplrmqYM9m6pywwVWrFSyiTZbt1CGrHvBe4juxNP0juPYhuZoe3jTJKk1TNB bwJUO1p2HjBUaGBc7Ok79fVKtLsSO0iIeKjeHgAOklOz9wP7SOOYB9xJkrQ2ZuidrD0LnBvs3VHX GSp0SzVMTJAnrk1k52E/KWt6md5Up60kbEyR4NGGEEmStDYmyOvvdvIa7euuBspQoZuqgWKKlDjt J70RD5OJTo/V236y/doGCUmStD4myGv0DurJ2qWUxr4KDYqhQn/RFyQ2kKsfe0mQeL7eHqA3wm5L /XOGCUmS1l/bV7GjvnUgigbKUKH+KU5bSD/E/fV2iOxIPEPCxdYB3UVJknS9NlRsr2+ngGvYrK0B MVR02A07EztIf8Tz5LC6h+mNqmt3JSRJ0nBo6A1PaQ/BuzrQe6ROM1R0UA0TkKCwi0xyehx4gQSK J0lT9mS9TWCZkyRJw6Qhr+O7yfTFHcCVUsqyfRUaBENFx/SdMbGNlDi9ALxCgsQB8uS0FWszJUka Zg15Pb+HHDh7hJQ/nSqlzBsstN4MFR1RSpmgtzPxAPAo8DQpd3qWPClND+wOSpKklZogQ1WeJ6VP 20m4OFlKOV/ft9A0zfLg7qK6wpKWDiilTJO+iHtJkHgZeI4cXLe3fmwKHw+SJI2aBXLw3RfARyRU fAwcrbfTwJX65yyN0ppxETmm+g6umyZ1lo8A3wd+SEqeDpIGLw+rkyRpdJV6uwZcIgHjaxIwfge8 QwLH2frxeQwXWgMuJMdQLXXaSEqdDpBA8T2yQ/F4ff8Mfv8lSRony8AiCRjngE+Bd8nOxcfAh8CX 9WPXgGK40GpxUTlG+kbEbiPjYZ8jdZaPkDMn7if1ln7fJUkab0ukp+IU2aX4DPgT8DbwCXAcaPsu Fg0XulsuLsdEneq0AdhDDqx7CXiVhIo9ZOfCvglJkrqjPyhcIGHiPdJrcQT4gJRGnSF9F4s2detO ucAccXV3om3E3k8asX8M/IAcYLe7ftzvtSRJ3dX2XZwjAeIY8Md6a8uiTpO+C3cutGIuNEdYDRQb yVSnJ4DDpAn7BVL+tIFeI7YkSdIS6b2YI6VRR+rtfVIa9X59/xw2dGsFXGyOqFLKFOmPeAj4Edmd eITsVuwmYUOSJOlWFoGL9XaMTIr6HfAm6cE4A8xZEqXbYagYMX2H2O0jOxM/AP6K7E5sIzsTkiRJ KzEPnCA9F38kTd3vAZ+TfgwP0dO3MlSMiFrqNEl6Jw6SBuy/AV4kJ2RvxUAhSZLu3BJwmfRXvAf8 HniDNHZ/TfotDBe6KUPFCOgbFbud9E68QkqeXgTuIaVOEwO7g5IkaVws0zul+yjwZ9Jz8SbptzhJ xtAu2W+hfoaKEVD7J3aTcqdfAj8lY2N34mQnSZK0+tpdizOk/OlN4DVSFvUJmRQ1766FWlODvgO6 tbpDsZnsRjxBmrF/ATxDyqAkSZLWwiTp1dxKxtHuBQ6Qw3R/R5q6vyylXCQlUe5adJyhYgj19U9s JtOdvkdKda4xXAAAIABJREFUnl4g4cJAIUmS1kNTb/eSkHEvCRcHSWnUx8DXpZQrTdMsDexeauAs mxkyNVDMADvI4XWvkOlO36c3KtaGbEmStN7aA/TO0Ou3+C2ZFvUZcB4PzussdyqGSB0Xu5HeuNgf Ay+R3Yl7sH9CkiQNTkPG2u8l1RR7yM7FflIS9T5wspQyb7DoHkPFkKiBYpachP094IfAq/X3W8nu hSRJ0qBNkVKojaQkexcJGntIQ/fnpZSLNnF3i6FiCNSSpzZQ/IQ0Yz9Hzp+YHeBdkyRJupUZUl2x hUykbIPFG8CRUspZbOLuDEPFgPUFigfI2RP/iexS7MLvjyRJGm7tYJlHyXla++rtdTIh6kQpZQ5Y NlyMNxetA1RLnraRCU+vkh2KF0nKtxlbkiSNggnSa7Ef2EQujN5DwsUfSVP3xVKKTdxjzFAxIDVQ 7ACeJIfZ/Qx4mvwQGigkSdKomSJlUBtIP+geEix+D3wAnCqlXDVYjCdDxTrrO4NiG/A4CRR/Czxf 32egkCRJo2qC9FgcIsGibeLeBbwNfFFKueSZFuPHULGOaqCYJjWHj5Meip+R8bHbMVBIkqTx0DZx z5JztvaRnYvfkSbuC8CSuxbjw1CxvibJtuAzpH/iVVL+tBMDhSRJGi+TZLfiYXLxdDepypgBPgTO AgsDu3daVYaKdVJKmSRbf4dJoPhb0qC9Fb8PkiRpPLXncE2TXotNZO3zb8CbpZSTwDV3LEafi9l1 UEqZJlt+zwB/TULFk2QEmyRJ0rhr10IvkN2KXWQd9Afgy1LKnIfljTZDxRorpUyRH5zvA39FDrd7 miR1SZKkrmgnXz5T326ut98Cn5VSrhgsRpehYg3VkqcdJET8FxIqHiSBYmKAd02SJGlQZoD7gL8h 66RZ4J9JsLhssBhNhoo1UgPFdlLm9AtySvYh8oPTDO6eSZIkDVRDgsVe4FngMmnYngGOllIuOHJ2 9Bgq1kA92G4rGRv7E7JDcQgDhSRJUmuSjJr9PgkU24DXgPdLKWcNFqPFULHKaqCYJWVOr5DG7KdI yDBQSJIk9bRrpi0kVGwiYeOdUsq5pmkWB3nndPsMFauoHm63CThIUvdPgOfwHApJkqRbaUuhXiTr qHYE7dullDPAgiNnh5+hYpXUQLGRBIq25Ok58kPi11mSJOnWpkgv6nOkumMnmQz1BnCilOLp20PO xe7qmQbuJadk/2+k8Wh/fb8kSZK+XXsC9+Nk92IOmAeWgdOlFHcshpihYhXUw+32kW27/0CCxV4c GytJkrRSG0mfxY+Aq/V9b5Jg4enbQ8pQcZfq4XY7yc7EL8gPwE4MFJIkSXdqA/AEUOitV/8EnCbj ZzVkDBV3oZ5FsRV4FPgx8DJwAEueJEmS7kY7nv8ZEjCmSCnUm3UqlONmh4yh4g7VxuwtwMMkUPwQ eIhs2Tk6VpIk6e5MkLXWIeAl4BRwjd45Fp68PUQMFXfghklPLwE/JWdRbMdAIUmStJo2A4+RnQpI 2Hi7lHLeYDE8DBV3ZgrYQ7bkfkTGn+3BsygkSZJW2zRZZ71ISqEgAeO9Usplg8VwMFSsUF8fxWOk h+J7ZPKTfRSSJElroz3H4gVSAnWJjJw9arAYDoaKFahlTxvImLMf1NuD9FKzJEmS1sYk6bF4EjhP pkBNAJ+UUi4ZLAbLULEy0+RAuxeAV8jhLFtwfKwkSdJ6aEgp1PdJyJis7/uI7F5oQAwVt6mWPe0E niaTng6TB7WBQpIkaf1sIMNy2kqRq8DlUsqngKduD4ih4jbUsqetwCNkh+JF4F7so5AkSRqEKWAX OXz4DHCO7FR8DSwO8H51lqHiO/T1UdxHttpeJvOSZwd4tyRJkrquLUt/GbhAAsXVUsoF+yvWn6Hi u02R6U6HyYP2SWAblj1JkiQNUgNsAu4nVSTHyG7Fh8DFAd6vTjJUfIvaR7GNNGS/DDxPAoZfN0mS pMFryOF4j5LDiM8Bl0op14Br9lesHxfHt1DLnmbJyNiXSOnTg8AMnpotSZI0LCaAvWS34hRwErgM fF1KsXF7nRgqbm2aPECfJ83Zj+H4WEmSpGHTkDXtTuA50lsxB/yJhAwbt9eBoeIm+qY9PUzKnp4G dmOgkCRJGlYbyaTOJRI0loA3Sinnbdxee4aKG9RAsRE4QNLuC2R87Mwg75ckSZK+1QSwg1wMXqY3 YvZD4MoA71cnGCq+aZLMPX6K9FEcwvGxkiRJo2ILKVs/B3wBXCilfNE0zcJg79Z4M1T0KaVMkNFk 7SF33yf1eZODvF+SJElaka1kx+JvSV/FpVLKacug1o6h4nrtmRQvkEBxP5Y9SZIkjZppsqZ7ETgC nCAH410xWKwNQ0VVdym2kbKnV4EnsOxJkiRpFPX3yP6U7FZcAI6WUuYcM7v6DBU9MyTRPkdOz947 2LsjSZKku7SRrO1OAp8BZ4EFHDO76hyRynW7FA+Sfood2EchSZI06iZIf8Uj5KLxvcDGOu1Tq6jz OxV9I2TvI3V3z5BQIUkajP6yBF/4Jd2tBrgHeBb4mEyFulZvWiWdDxUkwe7m+hGymwZ5hySpwwq9 0oRJUppqsJB0t3aQC8enydkVToNaZZ0OFX0jZB8Aniejx3bQ8a+LJA3QIjmw6gyZ3rKH7CZbkirp brRVKS+RsytOk2lQl23aXh1dXzxPkReswyRU3EtexCRJ668A88CXwLskSDxOprdsIc/P47RrUfre 2uMorb2N5LiAHwFfkR2LOWzaXhWdDRV1l2KWnLj4Ehkhuxmf2CV9t1Jvy2SRO8F4LXYHZYFcPfx3 4N/IC/3npN/tEWAXKYcah+fpQv5/banXRsYvNEnDZhLYTi4kfwy8B5wspSy5W3H3OhsquP6gu+eA /bi9Lun2LANXgctkgTtLSinHYbE7KIV8Tb8A3gBeJ1/fz8koyJdIieoB8vUe5efrZbIjc4b8f78g YeIZcqHLx5G0dqZJL+2j9XaUhHubtu9SJ0NFnfi0GTgIPElepDYM9E5JGhUFuEIWu0fJ4vYgGUk9 6ovdQVomB1O1Vw8/r78/V29fk8OrnicDNbYzmk3ciyQ8nSAlXm8Ab5MSjJeAvyHhaSs+lqS1MknK oJ4nZ1dcLaWcbZpmabB3a7R1MlSQlLqLJNQHSK2uJN2Oa2RB2F5Nb8hu5wS9YDFqC91hsECCwxFS 63ylaZrFUsoZsuA+R3YsvgZ+QK7o76G3QzTMX/O2XG4BuEh2Jv4MvAb8DviEBI236q//K3lM7SMX vIb5/yaNognSR/t94Di1aZvsjuoOdS5U1F2KTWQCwAskWGwd6J2SNCqWyGms7wK/An5d3/c1WfzN kheqUbyCPkhLJDQcBT4g4eEaQNM0y6WUdmfoEtmtOA68SoZsHCQ7z1Pk6uMwfd37w8Q8eex8QsLo v5EQcaJ+bJmEjf9Gdmv+K/Az4CHs95PWwjZyceKHZDjE2VLKnLsVd65zoYK84Owk51K8COzFLWZJ 362Qq1gfkUXhG2Shu1g/Nkt2Pb9PDlkal4bitbZMvq5HSIP2e8DF/tnx9ddzpZR2Uku7a/EFKRW6 jzyXb+f68bODChhtmFgkpXKnyH39gOxQ/IH8f880TbPQ93mLpZS2Uf08CSD/kTymduNrlbSaGnKM wNNkd/RTcoHoyiDv1CjrVKiouxQz5AX/EeylkHR7Fkl9/4dkd+I18gJ0hSyKj5GgMU0Wvd8j9bqz GCy+TSFfr2NkId0Gtfmb/eFaDnWufs4FeqNnHwEerreDZKEwQ17j1rM0qg0S10hQOkv+bx8B79fb kXq/L93s0K06geZyKeVNsjNztt5eJeFpZu3/G1JntGvCF8hz0Ed1t8ID8e5Ap0IFeXHZQbaTD2Ht s6TvtkCm9LxPSlZ+RRayF9tt8lqe8ykp47lIrjK/Sp5nttK959rb1X5t3wR+S3YpzpOgdlN10X21 lPJl/dwjZHrfIXLF8en66z3ka7+JXDxqy6NgdZ73+8+YaMcLz9PbRTlGypg+IKHic+opvsC171q0 NE1TSimfkjrvs/Xz/rr+37wYJq2OhqwFHyDPHW8B50opVw0WK9eZF7q6S7GBXOk5TA5U2obbyZJu rpCQcIZM5/lHskvxPmnq+8thSXUBeJUEi/bq8nlSE/8YuZjhc831lkkA+wj4DSkLOgEs3s68+KZp lmqYmyPfo8/JTtKfSK9cu2uxj5QObSe9CTNc33uxkp2M/gCxTB4D82Rn4gp5XBwlofNtEiiOkcfE Ink8ldudh1//j6fIztil+m/8ov7/NuIumLQaJsh68Fny/P4l8FUp5ZpnV6xMZ0IFedBsJYfcPUtS qadnS7qZNlCcA94hgeIfyQL4LLBw44tNbSieJ1ep/51cYZ4nV+OfIRPnFO2ZFJ+TsrHf11/Pr+RF vP7ZJWCplLJASo7aUa27yA7G/fV2oN7uIa8F0yRc9JdJQe8ww/b3y/XfaEPEAgkyV+jtSnxFmse/ rP+PY2RX4jxwV6UUTdMs1AlYf6TX7P0fyVVVL4xJd68hIf0Z8vP7PvnZXqC3I6nb0KVQ0R528jAJ FNuw9EnSzbXjTd8G/oWUPL0PnGuaZvFWn1QXuQu12fYteiduT5KLGVvweQeyOD9OFsqvkTKhi3e5 +F4CrpRS2p2LL0hp1HZSCrWPhIr95LVgK/l+bKIXKqbJ4mKWhI1FejsEcyQInSf9HOfJzsQxEmTO kZ2Xi/XPLaxW+UTtJTlLdnPmSHiaI2Nnd2GwkO7WJHmeeIIM8jlKfu49EG8FOhEq+g67u4+Eit24 bSzpm9pJRMdInf+vSa3/B3xHoOhXy1baXY4pUno5Qw7b3Ey3g0U7lvcdEijeIlOQbutr+13qQr7d VZirV/k/J1/3reSCUntry6GmSKDYRAJFe4jhHAkP7eKi/f0FEjYukjBxZa3rr2tgvVhKeYfsWEzU +/4sCU5dfkxJq2GSXHRoy6DO1QPx3K24TZ0IFeSJdw95QX+U1DdLUqud2nOe3sjYfyU7FV+RBeRK Z5e3J0S/S2+87CR5DurquQPtFK33SaB4g3x9bzrtaTX07R61YeAEee1r6PVHTJFA0R6kt0QvRCyQ 71u7m7HYd1sCltZz0dE0zdVSylHgf5LXslny2ubgEenu7SKh4hPqlDbcrbhtYx8q+nYpHiSzvg/V 30sSZFE5R8px3iSB4nek6fdU/djySheOtXl7kd50o/7a/MdI6U2XgsUC+Xq+A/wTCW2fkJ6DNV+U 13+jANdq/wX9/25ttG+4PmyU+n1s3/+XP37j56+zy2T37P+lF3iexj5B6W5toXcg3pukjNJQcZvG PlSQF4Lt5OrgUySFdumFXNLNtScdX6B3TsJr5GCyT+v7V9Q4fKO6IG0X02/Vd28ki7/H6q+7cHV5 gV4PxT+T4PYhcH4Qp9fe7Hv6bfejL5AMhfq4ukQWPW153XZS4tuF13VprbSToB4mF6PfLKVctATq 9oz1k0+9ujRNGvQepnfKraS1116Zv3GazjBYJLXwJ8jV8nfI7sTbZMv7MqtU1lL/jmt1NOjb9Gr2 p8jQiHE+IK8Nbl+TUa//QHYoPiWBYlX6KLqoPq7OlVL+TMq2tpFzLO7DcyykuzFFem8fIuvGk46X vT1jHSqqzWTix/306mUlra1r9CbktFd+trD+Jxy3+seBXiElSR+RMPEOKSU5ShqI59eo6XaRTAv6 I7m4sQS8TK6GbSMXQMZp16ItK/uaXFH/J3J44EfU0DawezZezpNdtkJ+tn5OXu98rZPuzAR5vXqE XJD+nBvOJtLNjXuoaBczB8ghSF2rYZYGoS11+RNZrG+gd4r9XjKBZ4Ze/fpaLqTbBuzLpATpc7Ko fY+MMf2C3nkCl7nNg9fuRC1ZuUZ2R35LJgedAH5ESjP3kq/VOASLZRLeviTlZL8i/+cjwKVBlDyN qzpu9hT5Ou8m02t20d1hANLdashF6CeA56kjr0sp6zqUYRSNbaiopU8bydbVfeTJdmz/v9KQWCYL yX8B/o4sIjeQMp/H6tt7620fvYDRf/jYalkg5wWcJiVO75Mw8SEpvzlNgsQ1EibWdCQo/CVYzJPQ NU/v8LQTwAtk12Irox0slslO0PskSLxGyr6OYaBYEzVYnCFf792keftFPMldulNT5ELY86Qf7jPy nO3z17cY50X2BEmaB8lV0r345CqtlUKecE+RReTfkWBxmvwsfkzKjO4hV1LvIwGjPYxsF72zAWZI EJmmd7I19WM3/gy3JU3zpNRmjowAPFv/7TNk0X6UBIv2pOOLJEyseKrT3ar/3ny9utyeynyq3s8f AI+TheGoTfIp5P9znLwIv07vjI9TrF1ZmWKB7MT9igTTfWR33tc9aeX6J4c+SkoMz2Go+FbjHira for7yGQMt4KltTFPzhv4PRlz+WuySF6oV+fnyMLyCFno7CAL5wOkZvV+Eiw20TukrH8yUnvScduL 0AaJeXqnHJ+jFyI+JaVNp+kdUna1/vlF1vlsgZupV5cvkcBzkYSdkyQQPUvC1gYG04Nyu9qpSIvk //AZOXvi16T87TPSW7Mw6K/3uOv7OTtCwtzD5HXvIKMXUKVhsZ3sWBwkF0wcL/stxjlUtAfeHSQP Cklro53u83vg/yELyuP0LSTrCdPLXN8o/Tm5iv0GCRS7SA/UDL3Z+xvpnX68sf57F8nC+wq9CVNt cLjU9/ELZOdiqd4K9dyBtfkyrFzTNMu1HOoEva/L8fr7F0nYmiXhYi1KxO5G2/x+iQTGT0hd/2vk wL/j5P808ADXFX2jZt8C/hv5mfkZKTeUtHLbSLXL48DHpZTLTq27tbEMFaWUtnP/MXIg0H7cApbW Qls//2cyLvR16mFBNy4k+2b9LwOLdTF9lewwHCcL5/bk6XYM7XTf+yfplVnNkQVtewX/Wr21Jx2v W5/E3apfl8VSygVqQyAJFe+TBu5HSLjYy/CUsyyT791J0qPyLilve5eEizOk3MlSgXVWg+pZ0j/0 Z3La9h7crZDuxCZSAvUyeY47XUq55IWSmxvLUEFedHcBz5FgsYPhLR+QRtlFsnj5Z+A3pNzl6u08 4baL6XqbL6Vc5vqf05udb1H6btzq/aP4hF93c9rG8YtkJ+ctUsbyZL0dImVj/RO01ssSaWw/Q0LP Z6TU5iMSJNpJWpdYwylaui3tMIA3yONnB+lhGoZAKo2SadIL+DzprfiYugM7yDs1rMY1VGygd+Dd Hsb3/ykNSiGL36MkTPwrWVhevtPF5C1OLe7UE3ffQXlnSLD4kuxYvE12LZ4kW/EH6ZWLzZIXvrUo j1oioW+O7Ch9Rm9n4n3y/T9DgsQcKXkb+t2hcVd3Ky6SK6u/IYFiP905wV1aLW3D9v1k13gvcKqU su5DPkbBuC62N9ObKOMJ2tLqWyRXQn9PDjT7ALjik+zqqIvCa6QHpd0d+JQ0P99HGtwfIAHjfmAn 1weMW51ifuPZIG05Wvu2vS2Rq90XSZj4mgSKd8n3+jOyW3Gh3sdlhqxfRSyQ3qIPSOB/kfE5B0Va b20Z1EHyXDw/2LsznMYuVJRS2uPVHyEPgNnB3iNp7CyTaUt/JmVPfwLOWz+/uvp7UEop7W7BGbI7 sJWUtOwjAePBemvP/thEQsZmUvKyUP/aDfVjG0hwmCMvjldIgGhPQW9LnI6Svolz9KZrXSBBZx7L nIZW35kon5LdpdPkMTNMzf7SqJgiz7X3k5+jC3jC9jeMXaggOxM7yTd/FzanSavtMlmk/COZ3X3S aRhrqy7cF4CFUko7JeorcgV6K7mQso9sze8gwWETeT6cIWUv/e8r5EXxEr2+lkskULTjeU+T3ajz 9BrjF6m7GYaJ4Vf7dL4mfU/vkcfHTgwW0kpNkufY++vbrzxh+5vGKlTUU7Rnyfi8B+mNoJS0OhbI AXKvkVrtz3Fu97qqPQvL9AJGOz3rQzIdajO9Eijqr7fW97fN3ddIqLhS/8wkvala8323K3jGxEhr mma+lHKETGY7SCYibh7svZJGzgS5ePMEqYT5iDyPukPfZ6xCBfmmbyW1xg/jLoW0mgpZwL5FTsv+ mLtozNbdq1/7JWCplrpcJgGhv2digt5p5O37276Jtqm64fq+ir9M0/L7OxaOkwsBD5BgYaiQVmaC 7PI9BRwGfkcuzBgq+oxrqNhHvvnj9v+TBmWZXJU5Qp5M38Y+iqHSBox6yOBN/8gNv79pWDBEjKXL pFTuT8Bfk/JgSbevITu995IBGbvJrr079X3GZtFdS5/aULEFJ1xIq6E9BfsyGW/6Gpn4dJxe86+G yLeEAsNCRzVNs1jHFL9HJkJJWrl2vOxecvF6UynlqmO0e8YmVJBv9kZyLsUuxuv/Jq2nQmrpT5GG 4LNkEtDHpI/iPVL25BOpNDqukiurX5CyjS3YsC2t1BSphLmPTNg7T6+MtPPGaeE9Qb7BB8gZFbP4 hCndjrYuf5HsPlwk5xC8RYLEV2Qxcrz++pxlT9LIWSIXCN4mhyg+jofhSSvVkAl7D5GL2Cdx1/4v xilUTJJv9AGyNeUhP9J3K2TKz1myM/EVOZvgbRIqviRXNS+Q2tFFdyik0VPPrbhMyhcfJa+T+/F1 Ulqp9oTtg8BnpRQHllRjESpqP8VGUuN2kJQ/uUshfbdrZBfi30iQOEpvV+IMKZlY9BwKaSwskmEL 7wLPkddMXyulldlE1pqHyM/SKTwIDxiTUEGvK38nufKyY7B3Rxp6hZy0+y7wDvArcs7BKXqnJXvA mTRelkh/1IfkjJmnGJ91gLReNpKdiufJRLVPMVQA4/Nk0pDyp81k+pOlT9LNLZKeidPkROx/IguM T0ivhE+M0pjqK4H6jCyELGWUVm6alA8eJg3bm0opc16EG59QAQkVsyRMLNXfS4r2MLOzwB+AN8ju xJvkQLs5XGBIY6+Olz1BdirmSCmHpJWZJuWD+8nF7Au4WzE2oWKKTH7aT8bkSeoppHfiHAkR/xfw OtmduEB6Jjp/hUXqkPNkp+JzUsphsJBWpj0XbS8puT9eSlnq+mvpuISKGfKNfQDPqJButETKHd4A /hn4F7KgcGKF1E1XSKB4i7x2zuDuvrQSEySQ7wK2k3Vn50/XHpepD5uAe+htQ43L/0u6W4tkktO/ Av8n8Pfk7AkDhdRRTdMsAF8D75P+qs6XbUh3YIqEij3YywuMweK7lDJBein2km/uhsHeI2loFDIW 9jXgv5Edis8AG8okXSTjo0/jFVbpTjTAbnJRezNjsKa+W+PwBZggQWIHaZqxNlRKoDhPxt39H6SH 4uumaTpf8ykJSJP2MXLg5ZUB3xdpFDXkYrZVMtU49B40pAt/Q30rdd0ymfL0R+DvgN8BJx0XK6nP AtnJ/BpDhXQnGhImdpMhQZY/DfoOrIIpsu20A7+h0jK9HYq/J2dRHG+axvIGSf36z6y5OuD7Io2q WbL+3MJ4XKi/KyMdKkop7S7FTnIAyQwp+5C6qJDTsD8A/gH4n8BH5HRsSeq3TMLEWQwV0p3aRNag u4ANdV3aWSMdKqpJMs7rIL3D76QumidjIv+x3j4ArjZN46F2km7UhopzwCWcACWtVENK73cD95Lz 0jq9WzHSoaI2nM6QpDiBgULdVYCTwG+A/0FGRV4yUEi6hWUy9elyvS3gTr+0UlOk/OkA2a2YGezd GayRDhV1m2kL2anw4B512VnSR/E/gbeBc055knQr9flhgRyOuYBjZaU7NUvOqthBxwcGjfo2zSTZ btpL70RDqUsKGQ35HvAr4LfAaXcoJN2G5XpbwPIn6U7NkDXoVjoeKkZ2p6LuUrSTn3aTYGGoUNdc Az4lgeKfgc/qabmSdDsWSbCQdGfaULEdmOlys/bIhoqq6Xs7iT0V6pYlMg7y30hj9vuOjpW0Am2j 6S4cyy7dqWmyS7Ed2Mjor63v2Lj8xwtZYFlDri65ALxJr4/i4mDvjqQRU8jUuPbCnKSVa0PFDgwV I2+63hYxVKg75oCjpOzp34GvbcyWtEKFXIzwRG3pzk2RZu1t9e1UV0ugRj1UtI3a23DbVt2xDJwA 3gD+BfjSsidJd6AhfVlzpFlb0spNkKMNtpE+3842a49yqGhIc8wu4B56Z1VI46w9NfttskvxQf29 JK1UQ8o1CgkX7nZKK9euR7eRMqgZOnqhe9QX4VPkmzeN9aDqhnngCPBrUvZ0tmmapcHeJUkjbGN9 606FdOemSahom7U7aZRDRUPuf8H52uqGRXJq9mv19ikuBCTduYYc2rWTjp8ELN2lSa6fANXJnYpR P9eh3Z1wxrbGXSHTnt4G/gl4q2maSwO9R5JG3QQJFHtIg6mkO9MfKjbR0VAx6jsV0+Sb19mtJnXG PPAZ6aP4PdmxkKS7sQycI31ZBXsqpDs1QYL5LKN/wf6OjXKomCSH9uypt06mQnXCMnCK9FD8DzLt yRd/SXdrAfiQXKj4CJu1pTs1QS5wT9Ph9eiop6m2MWYbox2QpG+zTKY8/Xfgg6Zprg74/kgaD4vA x8DfkzDxc+BhYAu+pkor0YaKGbK2niilNF27ADjqoWKy3jqbCjX2CnAM+Ld685AqSauiaZqlUsoZ 4LfkuWUe+CXwOJ7/JK1EO555MynLn6KDg1RGOVQUcv+XyME90rgpwCWyQ/HfSR+F42MlrZoaLM4D b5Hnl0KqAJ6gw1NspBVq+3y319sGsjZ1p2KETJInQetANY6ukFrnfwDeBa51bStV0trrCxbv0TvE ayNwiCyODBbSd2snQG2ho6dqj3KoaOs9l/CcCo2fBeBz0pj9R3LInYFC0pqoweIc8CYp4ZgkpVAP MdprBWm9NGT6U/vz07kwPpJPFKWU9uC7CZIGPbRH46SQaU+/B/4F+MJTsyWttRosTgOvk0XRxnq7 lxFdL0jrqCG7FG2o6JxRfpJoyP1vj0V3UoXGQQGuAu8D/0jKES4P9B5J6oymaUop5RRp3p4iF+5+ Chz6hBLrAAAgAElEQVRgtNcM0lpr6E0k7eRo2VF+gmi/WRvxJFCNjyVS9vRrMu3pvGVPktZTX7B4 nV6J8c+AB+jgQkm6TQ3pqdhGR3uRRjlULJO682vkCc8SKI26ZeACuUL4K+ALOjiSTtLgNU2zUEo5 CbxBqgH2A/vIYsnKAOmbGlL6tIWsSSfp2Gv4SIaKehVlmYSJK/W2iQ6mQo2VeVL29K9kvONVdykk DdAC8DXwOxIotgKH8XA86VamSPCeAZquHYA3kqGiWiYhYoEsxqRRtkSas/8V+ANwqmma5cHeJUld Vi/gXQM+IaduT5Fdi4fJhTxJ15snr+ednP40ylcaGvJNW8ZzKjT6LpKm7NeBo03TGJQlDVy9uDEH HCUT6d4kF0A6VdYh3YZl4AxwnqxJS5d2KWD0Q0VDvomO29QouwocIdOe3gLODfbuSFJPXRhdAt4B /m9SDnVmoHdKGk6L9E6m71y1wSiXP7XbStN0tMteY2EROE5Knl4HvmqaxiuAkoZK0zTLdSLUG2QK 1P3ADvL6Kykm6Z1R0aldChjRnYp6+F1720zGdxkqNGqWydW/D0lZwUdk10KShlHbuP2HevuSDl6N lW6hoTf1qZM/F6O8U1HIVd5pPKdCo2mBvCj/EfgzqVNeHOg9kqRbqLsVF8jz1S4yZvZecl6UpPws TNMbJtQpI7lTUes7C2nQvorTnzR6lslj90MSKj4G5rvW1CVp5CwBZ8lgibeB09i0LUFCxKZ6mwYm a2VNZ4xkqKjaqU+XgMt0sHZNI20BOElKCN4FzjZN48ABSUOtXvhYIIdzvknO1rlER8s9pBtMkhKo KdypGCnLZIfiAhnHaajQqChk5NyfSdPj53ilT9LoWCa7Fe+T57CvsGJAKqQCYW7Qd2RQRjlUtOVP hgqNmnngUzJC9m3gnGVPkkZF0zSlaZpF4BgZMvEuGTHrbqu6rJCfg7Nkfbrctdf2UQ4VkKsll7H8 SaOjkBGybwB/Iidn+0IsaRSdJxdGXiN9YZcHe3ekgVui16TduZLAUQ4V7fSnKxgqNBoK2Vn7kPRS fIYjZCWNrnkywe5NcoDnBXwtVrdNkb6KdqBQp4xyqIDUobflT17t1bBreymOkFrks/i4lTSi+k7a /oSUQH1FBxdSUtV/TkUnX9tHOVQUeo3a50nA8MlMw2yRTHw6QnYp5rpWbylp7CyRA/H+DHxAdl87 V/YhVTNkbd3JM6dGOVRAvmlXSfnTHIYKDa92tvsfSenTcTp6JUPSWGl3K94hz2/HSJOqr8fqogl6 5fmOlB0V9QrvMlmYLZBQ4dURDat58mL7B+AjPOhOY6qUMlFKmay3zr2odk19Hmt3Yf8A/AY4hRdN 1D3L5LHfHgjZudf4qUHfgVXQHoJnqNCwWiS7FO+Q8oAzdPDJRuOpBocpsu2/id72f8mHS0Ovxrgd rLFEB8ctjqv6fZwrpXxEQsVjwE7GY40h3a5l8vp+hjpSdrB3Z/2Nww/8Anmhso5Tw2qelDv9mexW 2EuhkVZKmSBBYZoEiW3ALmAPsKV+rOn7MxvIhZ8TZFrQReBiKeUysOBY5bHxNZkE9RHwMLB5sHdH WlcNeb2/ijsVI6k9vfAcToDS8LpKwsQH5FwKT8/WyCqlTAOz5Er0LmA/cC9wsL7dQXYrpugFio3k 4s+X9fY5OdfgM+DrUspF8iLs7sUIa5rmSinlc+At4AUSMkd9nSGtRHtBZRlDxcgp5OrXeQwVGk7t ZJT3gKOkoVEaSaWUSWA38ARwGHiIhIq9JGBsJ4Fjmry4trcJUg7wJAkXJ8nV7HfJuS2fkuDdnkSr 0XWO9FY8Qx4rBwd7d6R11emRsqMeKqAXKi6QK13SsCikfvwoKX36imyNSiOlljttITsRzwCvAC8C D9T3byQ7Em2YmOCbk09mSZlUqX/PfcCj5OfjCPkZebuU8gVwxR2LkTVPzq34IymB2kUeHzbta5y1 xxxcJK/7nRwpO+qhopCrWhdJsGjH2PnkpWFQSMPWR2Sn4nzTNPb9DIG6SG5vDb3TT5fp681yYfuX r9V24BHg5Xp7HjhU33+7z7dtjwXAVtKLsbv+PU/XtzuAX5OgMXf3914DsEim37xJGrYPkgA5M8g7 Ja2xNlScIjuu9lSMqAWSCi/Qa9ae/NbPkNZeIY/Nr0gvxRd09MrFMKgTiNrm4nZK0Sy5ut5OKmoP LLpWb1dKKdfo1fp3KhD2TW3aRnYUfgz8EniOlDtNc3cXcKbILscsqb3fTa5ozwNXSynHgWsGu9HS NM1ybcB/H3iDhNHd5Ps9smPspdt0kZQ5W/40apqmKaWURVKje6G+XcRQocFrS5/eAv5E6ow7tSgd Bn1hYgNZHO8kC+IHSFnGlvqxGXLVfTO5OPExqfc/Tq48nakLpaUOLXLbHYqngB8BPyU7FHvJ12w1 tMFlgvRmvEiexxeA3wJfllIWOvQ1HxeLpJesbcY/TH62DBUaVw250LJERw++gxEPFdUy1zdrL7B6 L3jSnVomi6MjpJTDhdE6K6VMkRDxAPBgvR2kN61oG7ky3vYBtH0B10iQOEF2mN4jZ4x8DBwvpVwa 9wlefT0UjwM/J4HiCVY3UPRr6t97EHiV3k7fNdLU7S7fCKnPdfOllFOkAf88ToLSeGvP67lZP1ln jMMP+DLZLm/Hyo71i71GxhxZiL6Ph92tuxoo9gEvAT8jtd0HSBnGVlJy0x7S9o1PJ8HjMKmNPUx2 Ld6vtw9qM/HFMT5fYSOpg/8h8Nek5Gkba18Xv4n0VkyS5/SzpBTqwhh/rcfZBTI++Bj5eZzG3QqN p2Wyy32NXATpZGXCOIQKSKg4T69ZWxqkJXKl+5/IVe55dynWR73Cvgm4n5TS/Cfgb0ip0xS9K0jf diWpv6F4N7li/wDwfbJb8TppJn6nlHJ23HYt6tdwJwlTP61vd7M+i8GGXrB4hewWnSNB7qo/RyPn EhkX/C7ZHZytN2nctCV/p0mVQpdKZf9i5ENF7atYIFdEDBUaBhdJmHiXHHZn6cb6mSVnIfzvwC9I k2h7wvOdaMty9pKF9kESMA4Cfwf8vvz/7L1nkxxXmqX5eKRGJpDQBAGQoGZRgaIUS4vu6p6e6e6x td39m/tlx2xnp7tna0qwWCSLWpNQhNbIBFJnxt0P5156IAmVQEb4dffzmIUFFAlHhPu997zivCFc algUfRRldb6P+im2M/jo8gjKLv0MCfSLlBFAUx/mUQnhp6h8bj8WFaaZrKFz6MX43qQ94Z6pvaiI LKNo1kXUHGtMVSyjxsS/oI30WrWX0w5idH0rOgSn7MSzqIznQUmZi+QcNYS+5xlU5vZxw4TFbuAl VDq2j2p61AqUXXoeRbrfR2WEFhX1IvUnfYVKoJ5H36sxTaRAZ9DWztlpiqhYQbW3Z9FGv0pz/m2m XsyjwU9/A846S9F/oqDYBbyCshO/QkO3NkNQrKdAkdbHgZ9T1s1+GEK4XGfb2eiUNYGas3+ERNlU hZc0jDJETyBxcxTVLJuaEO1lb6BsxVmUxV3DDo2meSQn0jla3NvblIP3Kko3nUXRrGW0aLW2A99U QhdF5b5EA+88PbvPxIPwJJry/C/A39P/EothFG19Mf44bSYLIYT5GguLJM5eoJxFUfXhL5We7QMm QwgzbY0A1phlFPRLZSErtNwhxzSOgLLW54iB7RBC0ca1qhGiIvZVLCFBcQVFsyaqvSrTMtJcii9R qcZ5XKoxCFL9/28oXZ7SpOx+MoRmOLyEvveLSFCeCiEs1nQzGUHNtE+hz7RqQQG6pu3ourYDl0II HohXI3r252uUfY+2fTdNokBBxJPEadptXaOaZO22hpwmruG+CjN4VlGU4h0kKuZqHLGuBTFLMYXK dH6GmrIHObW3g7IkT6JyodcobTNrRU9PyjPo89xNHpHkIfQdH0DX1I+SNtN/ltGB6xu0R7fywGUa z3UU1G7t3t8kUdFFouIySrF60TKDoovuuQ+Ad1H9sLMU/aeDypAOI0emKrKTBfAQ6uf4ITr81jEK O4wyAa8gkTRBHqICJCT2otKscfK5LnPvrCJR8RXK4np/Nk0izaiYR+V9FhUNoItU4jmUfvKiZQbF MnI2eQsNR2ut88OAmUIH4FdQKVJVn/kWNCjuWSQq6miZOYr6Fp5BB/ic9oZhlEWZQlkgP1v1o4sm o3+JxEVrD12mkSyigHZyBGzt/Z3TxvGgdFHZ0zn05XrjMYPiBhITHwHn7PjUf2Lp0wFUdvQSEhVV rmejqDxnP7AthJBDP8JGmEQuS3vJL9PSQRmKEaCVzY91J5aC3kDOeCdoqYe/aSwLSDRfQKXPrV2j miQqAooYp2ZtH+zMIFhD99snqF7Y/Tx9JgqKEVTy9BLVzVLoZQQJm32ojGi02su5d2I/xU7k+rSH /HpCOuj77Z2IbupHFxkanMVDak1zSEPvziJR0WrXxyaJCijnVVym5SkoMzCuocnZ7wLni6JorT/1 AEmD0Q6hsqMcmneHgG0oe7If2BoP63VgHImJR1CZUW7XPYQyKWNAJ4pKUzNi9PY6ckmbw9kK0wzW 0LnzJLq3W30GyG3zuG/igrWGDnmXUKq1tSkoMxDS9Ox3UKZittrLaQ3D6OCek/VpOvjuQ/an08Bw TQ7Ak5SN0FVnfG5FcoAawfMN6s4iOnx9iDK8Fham7iQ7+Qvo3Nnqe7oxoiKyRtmsfRYtYMb0g1T2 9CnKUpx1lqL/xOj/BIqqP4MO8TmsYwUqedqBsihT5CF27kgUPVvR57iL/EqfQJ9tatDuYlFRW2K/ 2RHgv6MJ6d6jTd3poHL7lH1rdTA7h81404jZikXgOPAesq5z7abZbFL/zgkkKD6l5SnPAVIgUfE4 yggMkc8hcwg5P31bqlPt5dyZKCiSEDqAshU59oKsogz0dfTcuay13qQetOO4B83Um4B6KG4gO9nW r01Zb3r3yQoSE58BZ2h504zpG2so0vYF6uFp/WIyIEZQ/f/TKLqei6AAXctwfEE90uBpMvhDSAzl uCcsomftHLDQZmeVhjCPBMWXyILTmDpzBVnKX0bnzVavTzluIA9Kapo5hr7ohWovxzSQNdS38wXa HBc9PXtgjKOypyeRy1JuomIEZSmGqIfQ7KDPcQ/5uistoQDRRbye155YJnoZlUFZVJg606UUFZeA 5bYHPRonKuIXOod6Kk6itJQxm8kiirJ9jHspBs0W4HuoQTu36cqpnGhLfO9mvsGkUrJdaMbGEPlF 2QKKbKfBUi5nbQaLyOTiatUXYsx9kkqfLqEs6jU8yqB5oiKyjDrxj6Iv2pjN5DpyL/kC318DIw6U m0a9FDvIr6m4N1MxfJc/mwMFEhT7UXP5CHmJNNDh8wKKBM46I9gYVlGZ8hnkmufv1dSNLrp3T8fX DXwfN1ZUJBeo0ygS4kiy2SzmUYP2x8AlH3IGyhQaePcIqv/PkQJtLAEYytxStkCzNXagrE+O+0ES FUdx1rlJrKLv9W20lra+Ft3Uki4KLF7F/btAnpvIAxMPemkzuojKobxgmQeli+6pD5ERgOdSDJYJ lKV4FJUY5UZyn7tMPe6NDhJq0+jzzE0ApdKnc6iU1f0UzSEgkXgclUHN4z3a1It0v95AQezcy10H QiNFRSQ1gx1FGQtHQsyDsgKcAv6GshU+5AyW3vr/HIe0rSExcQ5FrtZy3WRiBmUcZSl2ocxPbqKi iz7HVHvf+nrlphCfi1XU5HqeeCir9KKM2Rir6IyZ7t8s1/pB02RRkRas94GP0KbkRcvcLwGJiOMo UzGb64GxiYQQRlBEfQ95zlKA0nnuMvlPVh1CQ+/SfIox8hMVq8hw4ytcWtBEuqiS4Bv0zOT8vBiz niU0o+oYFsXf0mRRkVLn36Av3bMEzIOwhkowPkH3lF1oBsswaih+AUXYc2QFHZKuAkuZi84CCYkD KPOTG6k85hTKCs67f6lxpD36KnL28vdr6kSaiXYJ2crnvN4PjMaKivgFr6Aa+BPx3ZEQcz90USTi beCvOOs1UGKpzhga0PYoeWYqUunTGbTRzFd7OXdlCPVT7I7vuRFQA+QF9Lm69KmZLKGKgkt4fzb1 YRGV1R9HAWsHGSONFRWRNRQBOYluAG9M5n5YAr5GguJz8o9CN43kUrQLNRQPVXs5t2SVcujmBfIv 1xkHDqLSp9zmfUBZvnoJN/E2mUUkwk/hg5mpD4sogHQCuFYUhQVxpOmiArQhnUGK0j7CZqOkiZlv otKnGZdhDJw0T2E3+a5ZKyiyfgxtMrkfgseAp1H2J7d+ioAOmKmJ16UxDSQ+I0vI2OAodmk09WGB cuhd7lnpgZLrBr0p9JRAXQSOoGyFo15mI8yhDe9d7Pg0cGLp0wgSFQ+hjEWO61Y6BJ9C90zuDKHP cpL8hggmS/DLaO2+gdfsprKGxPhpJB5dTWByJwnhL6hHVnqg5Lg5byoxLTWDylfeQW4iHoZn7pW0 gBynHhHoprHe+nSK/NatgATFF/E96/UlhDCMnLTSfIocy8lST8UM9n9vLPF7vYGqCS7gEiiTN2so 2PEFqly4hrOoN5Hb5twvFpBjzztEJ5FqL8fUhBW02b2P+nIWq72cVlKgxuyp+Bohr1KdxGVkL1iH Up1xZM37EJr9kRtdtEafR5u2aTBFUaR+pPN4jTV5E1DZ01eo1NWudOtohaiI2YqrwJdIVFyv9opM DUiR0o+APwLn4+ZnBk9vqU6OEes0aPMU2mRyvMZehtHnuY88RcUqKns6gyOBbWERPT+z5PmMGxNQ puISulcvkXlWugpaISoiy6iM5RgqUfDCZW5H6sU5iUTFEdxLURUFOgTvQeVPuRFQwOIc6qWowwE4 9ansIE973jT07hI6bHqtbj7zyFnvBPXoSTLto0vZ/3MWnQns+rSONomKlK34CtXHOyJibkcXRZ4/ RqLC0zKrIx2At5JnPwWoFvwoEhZZR65i4/skEmlTSLDlxhoK/FzD9s1tYQGJik+RmDQmN9IE+GPE rDQ+Q36HHDfoftFFEZCvgQ9QRCTrA4CpjGQjewT14vhgUx0dVKozgQRGTv0UKaN1FUWurlCPyNU0 8Aj5WclCOWX5W1FR7eWYAbGCnqHjKKDjII7JjWUUQEqlT8s+F3yX1oiK+OUvoxviQ9S979S6WU+g rO89jhYP91JUxxDl0LscD8BLSFRcBRZz32Ti9U2ifoqxii/nVqR5H+eAWQ+Vag1d5AJ1DomKVbw3 m3xI5hFnUa+XK11uQ2tERSTZyx5DUWiXtZj1rFKWPn2BDotePKojZSq2k2dT8RJyrblY9YXcC7H8 aQwJizHy2wNWUJbiG2yo0SbSoe0cepbsAmVyYpVyuOlZYCH3AFJV5Lah9JWeYXgXUPnTFRyFNjez jBaNz1BDllOc1dNBPRWT5JWtSG4g59E9k/V90tNPsQ3ZyuZoz5syhWex9Xdr6Nmbr6PAn+vVTU4s o6qF47h64Y60SlQARE/hWaQ4P6dMtRqTBjF9gxpvPeyueobRwbdDngfgJXTPLFKPrOcW5Pq0jTyH 3i2gNXkW97y1jS4SFen7r8PzZJpPGniXnCBnPJvi9rROVEQWUKbir6hx2xZ2BsrF4wNkJ2sb2QoJ IXSQ5ekkeR6A0yGoTs3EyUlrO3l+ptfRszePD5VtIwX8zqMqAn//JgduoCDj31DA0eeCO9BWUbGK Fq53kfq8Uu3lmExYQSVPbwEX3CSaBaPAXtRPkdshI/mWp/kUuWe1UqZnDNnJ5pb5AYmKNKsg98/T bD7zqLzEDlAmFy6jqpYv0VnRGdQ70EpREUta5lB93CdIYHgDazdpgvZxHI3IhfRM7kA9ALkdMgI6 BF+jHvX/aebHeHzlKCoWiDX1FvWtI/VVXMOZCpMHAa1H3yDXJzdo34VWiorIKlq4PkXZilPUw2Pe 9IdVJDB/j9xH3GdTMXHxDuggnHorcqODBMWNGmw2BSp92kaen+c8Kn9ZwM9fW1lF98BVfA+Yaumi rNmXlFkK35N3oc2iIqDN6xgqd/k0/jz3g4HZfLqo7Okd4D1grgYHxMYTQhiitD0dIr8egDV076zW KKq+FQ2/y63xPa3HM6iGuS6fp9lcVpG4TL1KXodNFSQXumNortnXaF1y9uwutFZUxEPjKlKiH6Js xQzezNpGKoX7AHgf2Q37HsiDYVT7P0x+a1UapjkLLEe71twJqOxpgvw+T9B6nMrJvHm3jLgnryEx MR9fXotNFaRhjF8je/mz2F7+nshxYxkY0RZsEbmNfIFKoDxlu12k5uz3ULbqmu3ismEIHYJzLNVJ ouIsEqW5ZVFuReqpGEMN8Dl+potIVPgw2U7SELxrOMhnqiPdh8eRccQMPhfeE60WFfCtsJhB3f1/ RcKiThaR5sFIkzKPY8enHOlQCouc6MTXFWpgSR3tebcge94tSFjkJCq6qPzpOh581lpiJHiB0gFq udorMi1lHs2k+AJVL6w6S3FvtF5URBbRofJN4GPsPNEWkqD8AtVOzlZ7OWYdAX1HW1CEPTdSpnOJ /COqBcpOJOen3KZp9zpprWJR0WaW0H1wCdt3msGzDJxD5dBf4yzFhsgt+lcJRVF0QwiXkaA4AOxB B5ntlV6Y6TddJCDfRZZxi9VejllHQAffKXQgzok1yina3ZpEsQrytZMtUMbnetUXYipnFdWzX8ai wgyeG5QN2qexjeyGsKgoWaIcfPYQsBu5pOS2+ZrNI01E/gK46oUjOzqUcxVyW6uSS00SPnUgoGBJ joMEQRHCJeoxSND0jzUkMF3+ZKpgBpU+HUHnAtvIbgCXP0Vib8V1dCN9iJq3V/Dm1mQuI0FxEffR 5MgIylKkMqic6KIDT+5lT70UaEbFVvISQsmJbxHV09sso9100Xo8h0WFGRypn+c0OhecxdULG8ai 4mZW0dCdI2jYyXksLJrMZWQX59rdPBmhPADn+Ax20X1TB2GReipSpiKntT+gQ+QCyv4s2oGt1aSm /TSp3veCGQRraGr2Byiw7HPBfZDTxlI5sfxlGSnVdGPN4EWtiXTRpnUKmLXrU5YkS9ncmopB11Og w08dNp4CCYpkJZvb2r+KhMUSnlrbdpKouIT2X2crTL9Jcyk+A94GvgLmXRK9cXKrU86BNVQO8z7q rdiLDjZT5HewMfdHGnh3ETVqe9PKmxHymwORRMU8sBJCKDLfgDoo65Nbw3siZStWyE/wmAFSFEUI ISyitfkKKkEZr/aqTMNZQiXv7wGfoIoVBxrvAy/e6+jxyT6Bmrb/Gn+8UOV1mU0loNKnI8jloQ6R 5jbSRVHrHCdqJ1aAlcwFBZTOTylbkSNzKFpY1GRCuekfyQHqCt57TX9JZe8fowqV08BSDdb0LHGm 4hbESMksSoH9BWUsdqIN2dSfFGWeRX7UjkhkRjxUFkhYDJGfqEjN48Pkl0W5HV009C7XqO88OkgG b+itJ000vhzfjekXyQHyLUonSJe83ye5bdTZUBTFClrQPgE+QlMVTTNYQ4eXWdwUmjM5R6tTk/YY eQ7mW08HfZ5j5JepCOiZXKY+je+mvyQHqBnswGP6SxIVH6JGbTtBPgAWFXdmEdmKfYTcoK7ipu26 E9DB5Qgqa8OlFvnRE6keIs+5BUlU5JhFuYl4fw8j16dR8rzeZfSZ5ur0ZQZLMk2ZxeVPpn+soN7K r1BPxQ1nSR+MHDeXnOiiRe1j4P9DrgBzeNOrO12UgfqEetTDt5UOiqyvkWf0OlCDtSDe3x3KyeS5 XXMqdVnFe5IRyQHqAtqDHcwzm00X9U+8i4x5LuL+ygfGPRV3IPZWLKNsxTvAw8ATqNxhjLzLM8yt 6SJheB1lorxZ5UuyPl0lv++pIE+r29sRKNes3D7L3jkVdmIzoHt0EdnKXkOHvWSHbMyDkoYdf4Z6 KY5gC9lNwVGhuxDr7ReQS9CbwBuo7s6Ktp6sAEdRg7ZrJ/MmlRjlKCrSDI1hoFODErpAnta8iWTP 60yw6Z0ZNYv6KlwCZTaLgATrMeBvqLzdg+42CYuKeyAKixlULvM/UDnUVbz51ZEVVDt5CtdP5s4q cQ4E+T1raUJ1h7IJOmdSX8VYfM/tejtoo7fTj0msIZF5jegKVu3lmIawikx43kMVKCeQYYvvr03A 5U/3SFEUqyGENBTvMWAPanycJN/on/kuaeFYxNGv3FlBh4rUwJsTqTQrWcvmThI/Y/GVE13s/GS+ S6oSuIpKVXzoMw9KGnx7FPXIfo4NeDYVi4qNsYQae/6ANuZV4HlgOxYWdSA5P60Bq45MZE/KVORI OqQPAUXOtsSxNCv1f+ToVtVFkehl8hOPplrW0Bowj0WFeTBS79YZ1Jz9MXAeWPZZYPOwqNgARVF0 Qwg3kLodoYz8vYSyFiZv0qKSGv9M3iSv+lVKp6WcDp0d9Nznvo520Ho1Tp7Bj4BERbLoNQbKINB1 dH8Y8yB0kZvYhyhLcRyYs6DYXHLfDLMjlkFdBT5Fm/RuYC+wn3q5wbSRVKN7GpgLIXRyjjAboCyN ya0sJgmcSfIbJncrhoBpdK1d8stWLKLv2OunSaRm7RlsK2sejIDuoS+Av6Lm7MtxyLHZRCwq7oMo LC5T3qB70We5D3+mObOKFpZTKPpl8qaDnqdUspbjgXML+U/UTsMDt6PMam6RuTRR2z0VZj1LKEsx h9bvOgh4kxfJ7Sn1UbyDzFo8qb0P+AB8/6yierx3gG3Azvi+lTwPP0ZRrytIUCyT3+HK3EwRXzlm KkDR/zSlOlvivJ01tN4Pkd/61KWndt7lCCYSKO2k049zK4E0+bNC2UfxFvA1Knty5qsP5JYCrw1x 45sHvkHq9010s9r6Ll9Sjf4a0PXhJXuSs9ICOlDkRG+fwmgIIdu1NF7bGGWTdk6HsnRYLIA1lyOY daygoMJSfPkgaDbCGgokfoTOaJ8CV4qiyG0/aQzOVDwAPY3bX6OheNtRpuIx8i+JaCuppCangwZ6 K7AAACAASURBVJW5NenAmYRgTiRHpSHKDECuB54R1PuRBEVO934qfVoFuiGEwmLfwE0Ztt5Bkznd uyZvAqpK+BqVqX8AnCmKYrnSq2o4FhUPSFEUa7Fx+yNgB2rc3oLmWLhxOy/SALDk3GXyJh04U/lD TiRxCjr0jIQQcrUpToey3LIUiWQpu4qu1VFEkwjo3t1OKYyNuRvJ6fEUmpqd3J5ytShvDBYVm0Pq r3ibclP8EXCAzOutW8RNEdGKr8XcG8n5KUdRAaVATVOqcyaJoNwOZUnoXEONkzmKHlMtnrRuNsoa pX3sm8hUZzbToE+jyH0jrAUxTbuIHAWgbDz8GfAo3ihzINXmX0NR0dzKacx3WUPf2Up85WaFOoSy khPkPV8hrUej5HmdQ+h7XiJP8WiqZQbVxfveMPdCF7k8fkaPfSzOgA4Ei4pNIvZXzKHG7YA28GnU Y7GdvA5DbSVFvheBNddvZ88a+q7SALw18nqO0pyKXIfKJVLD+xR5Zk5X48uWsmY9HbQG3MAZZnNv LFDax76LSqCWvNcPBouKTSQKi15HqB0okvl8/LF7LKojlX8MUdbpm4yJGcAkAlMZVE50kKiYIC+x cyvSteYmKlZRaUsXCN74TSKEUKD7Nk3WdqTZ3I0l4CylfewR4IbtYweHRcUmE4XFArqZ/ydaDBeB lymbt83gSRtUgQ4wtpStB2kKeo5zRQo0myZlAAryu0YoG7VzDGikEjdnKMytSPdsb39V7gLeVMMa cAm5PL2Byp+uFEXhtWWAWFT0gR6r2a/iL3VQxmILKoUy1TCMorVT+N6vCwHVx6Zodk6k8qdtlO5K uV0jqJE8lWDmJnpsoGBuScxUdtG+OYnuk9zuX5MHAfXdvA/8OxpKfA4JUTNAfLDqE1FYzKKMReqv GEGlUNPkGTVsOsNog9pGfmUgZh2x/KFAgmKR0gUql2enQAf2LURRkWmfzggSFUn45ETv1HSLCrOe lJlI90huz5apnjSI+Avgz6js6SQwn+Fa3HgsKvpIFBYzwJdoQwdFW15AwiLn5s4mkspAcm+sNTez QJ6HzjQAb4LSASrHidBp+vcE+a35vcJxJVNRZqojzRuYQ+tAbmuAqZ4l1Jj9BvAX4Bjuo6iM3DaY xhGH411DtmZrlIeOV5AzlBkcHUpbzVyi3ebOdCkzFan8IZfvrkD301bKkroc+yq6aN1JRgU5kZyp 5tDhwJj1zCNb2UXye7ZMdaQ+m3PAH4HfA58C1y0oqsOiYjCsAlfRDd9BB5DdwGPk20DZRIbRZ5+j C465NavA9fjKbY5BmtC+DQmL3A7sqYRsiHwnySdnH5snmFuRjDWS3bDvDwO6D26gMqf3gP8APkZz qNyYXSEWFQMgbpQrIYQrSFikw8cvgWdQKVRum30TST0VY8BICGHIzhD5Ehs1kztQimTndqjoUJbU 5Ugq0RojQ9FD6f6U3NmMAW4SxMkAIYlP024CylB8g3oo/oAatC8CKw5MVItFxQApimI1hHAJPQBp oM8cat7ehe1m+80QqiufouyrsKjImzW0gcyhZyYnUj/AODq0p0bynAiUwif9PCfSnArPIDC3okNp hjCGhafRWnEOZSj+F5qafRYPuMsCi4oBE4XFFdRjsUxZL/4iKonK0aGlSYwBO1G5ygi2nNswIYQ0 SHCN/pespEbN6yiinRvpwD5Gfgf2lO1JvR85ip4uWv/myLPJ3VRLgQJBO+PLZ5Z2s0ppHftGfD8L LFpQ5IEf0AqIzdvXkSvUGhIWV4FXgUdRJN30hxEk3nYAoyEE285tgCgoptEGfx01UPazwbaLxMRV lNnLjRRJzTmKmkRFjnMqUj/ZLPllokyF9JQ/JoONZIRg2st14EPUQ/EmcApnKLLCoqIiorCYQQPy FtHDkgb9WFT0jyEkKJJvv9kY24AfAz9Bi/tfgDN9/jtTs/YN8jsUJyGRmrZzdH9KwidHg4JUN7+M MxXmu6TBiKvo/sjt2TKD4waaRfFvqJfiKDBnp6e8sKiokJ4BecfQpjpM+Z3sJ78DQBMYQlH2XSit bu6BEMIE8AjwfeAfULleQI4bff2r0YHiRnytkk/EMh2Iu/GVy3WtJ8TXGPldX/rsIL9rM9WT7gmL ivayilydvkI9FH9EQ4WvO0ORHxYVFRNTvPPIGu1PlPXFvwAOoMxFrmUVdSRlKvbF95M4QnpLetxX poBDwG+Af0RleksMruQniYpUIpPT4bhDGU3N9TktyLcReg3dS2mGRq7XaQZMXH/GUPBnnHxFu+kf qYfiQ3Q++iMqG79hQZEnFhUZEIXFIrE+EB2eZoGfA0+hkhMPbNscUk/Aoygb9FUIYc0p1JuJG3rq P3kWlTv9GjiMmtwvouhRvxvdU6biOmVfxRR5HeADOhxn5yQWQhimtJPNcRNOcypG0H7kAXgmkZq0 dwMPYev1NpGyq9eBT1APxR9Q+dOsreDzxaIiE3qExXlKm8VLaJbFYWAPjtRsBh2U/XkYOIg2qkXs AvUtPW5Be4HXkJh4HXgClY6lBb3v5QjxuVihnKp7HR0ycrFfTtkc0DXmJk7T/IdhtK7kNJEcSkGW 47RvUy2pT2kryipPYFHRBtIcitRz+h+UGYoZMgzemBKLioyI6bylEMJFFLGbQ84388BL6CA8SV6H grqRUurPAK+gtOpMCMFDc0omUCbnB8CvgB+h6e+TlAe/FP0eCiF0+pzpSQ5QMyhbcZA8JtGnKPsK el6XM7yH0hTiJCq65Hl4z+1zM3kwjPrfdmFB0RbW0ByKD5HD0/9CguJaURQuj8wci4oMKYpiJYRw DaX6VihLP15Bte3T5BOprSPDqF/lBeBJ1Cjf+gFcsVRmCgmIn6MMxSuoTKz3ED8Uf55KVtLE275d Gvp+LqJM3pPcLHCqIqAs1zl0bbllKZIZRJfSTjY3UdGJr2Va/vyZ75CyynuQqKg6iGD6TxdVaLwH /DsSFUdRU7bXhxpgUZEp0XJ2ltiUhB6088APge+hhTZ5z5v7YydlSc9VWnyoCSGMoHKn76H+id8B z6Gyg/X9PKnfYuQWv9cv5oDTwHEkBtN1VUlA1/U1Eqa5Rtt7xU5uB7M0g8AliGY9Q6ifcCf59VGZ zSX1zs0A76KSpz8hl6d59zzWB4uKjFnnDJWExTngAuWgPLtD3T/TSFQ8ApwNIbRyKmcIYRSV1v0S +C26tw6hWubbHdyH0b3X9zUkPgcLaHLqEZQV2I9KsKpkFYnR0+jZzHXjW6WcRp6jqBihbMw0JlGg NXoc19E3nQVkVPMu8HvKDIUFRc2wqMiceMhdjH0Wi0jJX0IHqx8id6iduBzqftiKPr9X0AC3ecrD V+OJ07G3IWH1IyQovo8Ext3sYocY7ADBZC14HH1Xz6DvryoCEvofoixFzvfNKsqo5HgwS/MHOuQn eEy1DKPs6U48s6nJLKPA6RvA/wT+hgSGB9vVEIuKmlAUxWrss0jTty/F14+B59HcBbtjbIwJ4HHg p+hgeL4t2YoQwhCKAr6AxMQvuLms7m6kBsqBDBCM2Yo5JChOUbpAVXW/L6KN8C107+Tc6B9QI3lq 2s6J5HTXwXMqTCQGPCaRqNiN1hmLzmbRpexJexsJijdR5rcV+3ATsaioET22s2dQlPQKKoW6yM0l K7aevTeG0Yb1MvApSrfOhBCWmhohWTd/4mXg7+LrGTa2cafJ5IOcSr4MXEYlUCfRv2GKanor5pCR wifAhVybCKN47FC6VOV2Xydnr2R9awxobZ6mnE/hs0qzWENnmG+A95GgeAudbSwoaowf1JoRH7bk DvU5Koe6gJq4f4QGlaVos4XF3UmH45+hmv2rwMkoLJq4sI2g8qa/A/4PdM9MsfH7JUUSB1aWEJ2M ZoCPKAX0s1Rjs3wdlWKdRZH2nEk9FfOUg+ZyWRuSLW9u8zNMtQyh5/sAEhWmOayg4b5H0UC7P6M1 3YKiAVhU1JToDpWauOdR1uI8EhiHUfOxHTPuToH6Al5EadgTaMG7TMNKMUIIUygj8VvgH9Bgu133 +b9bRgJsicEeBueRI9pfUWnELiRsBllzfRX4GKXqz6JNMle66Prm0X29RJ6lJKvk2fNhqiGty48g pzfTDNbQmeVDtIb/FWV8L5PnnB+zQSwqakzPsLzzlMPBLiLFfxh4GqWPB1miUkdSf8HL6JC4CLwX QriUa1nLRoh2sVvR/fAr4J+RiHqQCOAaitavMcDyo9hbdBmVqz2EmsULJDD6PRBvBQn3j5Dl4fvA TM4bYSyZXKXsxVoir76KIWQKsEje4swMlmGUgZzEJiRNIGUnzgHvoAnZf0PlTzNFUTig0BAsKhpA zFrMoI35KooEHEGlLa+haE/qtTC35wAa+JYmJH8QQrha1/6K2D8xiqL5z6OG9F+i5uxtPFgWK/23 I/HvGKT70RoSzm9RlvK8TOla1Q9WkOD8M5rw+hfgbFEUdTgIp/Knmfie0wC81EuxXNfnzPSFCRQo KND9a/en+tJFlRSfIiHxFspUnAQWcg7KmI3jQ2ZDiPXmyUlhFkVUTyInhR+hQWZ7KSfYuizqu0yg 2R8/RunYeeDzEMJs3Q480T1lDPXXHAb+HngdTaPexoMfKpNbzygwFEIoBrU5xOj7dTR0Ll3LCMpU pHt8s0hTqC8hIfH/oE3xNPUZ1rZC2Xs1iw5pOUR/06T0KzSs1DARhT0+OG2YSWR1vZX8SvXMvZHW zmvAZ2hC9p/Ruu1yp4ZiUdEgepq4r6MDT0o3fo2Excsoar2PcrH2gn0zk6jvYI3SO/+TEMKNugiL eJCZRBmql1H25SeouXmSzYlSF2j9qGS+QBQWN1CzX5rK3EH/3t2Uk74f5NoCpUD/APhvSFCcol4R thVUFnkEZVsepf+lYvfCGvos/4RET6OIwn4Yie7GOsptNj1214+ifoocBLDZGMnG+gpyyPt9fH2F RMZqjdZPswEsKhpIT9ZiGdVRX0RZi89R9OcwOjjvxi5R6xlCG9mL6LOZQpOjPwkhXM653CWKiQl0 /U+h4Yg/BV5CE6i3bOJfl/6uISqq0Y9lf7NINHdQac9FdG+noVmT3N86l6xOP0YZirdQLfA56udQ soo28mMow3KDPJpf00TyE+Q9PPB+GUU9P1vQ575U7eXUhgm0N+1kcMM1zebSO8fnDbR2HgFmm9Cn aG6PRUVDiYee5BB1Cm3ex1G0+iQ6HD1PadnnaFBJmhb9MioVmkYZnvdCCGeA+ZwOlTGyN4Ku9SAq dfsBZdnbNJtf7pYsH/vVw3BP9PQTfYqyCidQQ/ozyG42RTuTQ1TvmreGxMMaOuAuxddc/H9dQP0T f0COUzN13BBjkGEB9aGcRGvBfqpf/y+gTNMszXR+GkV9PgeBxRDCeTek3hNT6HPbh3sp6kbqnziK hMS/of6Jc9Qru2vuk6o3FdNnesTFdRQNvExZEvUK6h9Ik5THcb9FokAH5qdR1Gw/2uTeAI7Ez3Ot ykUylleMosP9HtQv8QrqnUg9NOk73Ww6SKxsoSw1qipj0Y2lUMfQQfUTJCZeRE3ph5Ao3Is+qw7K 4i0jEbGAMnoXUHnQ6fj6Bm2OF9CGWOfylVW02X+DyrmepvzeBk0gOqwh0dZE++aUyTuExP0q8G4I 4WLN76NBMImE2NNsbnbV9IcUmEm9W+8gd7y3cblT67CoaAk9/RbJq/4yiup+iQ6hL6Ma/O0o4u17 o+wb2AX8AlmYPoamf74PXAohDNy1Jh5Yhigjes+hw/P3UNnTIfQ99rO0bQjdJ8nysdISuthjsYQ2 tuvo/j6OfND3IZFxAImKdKhNsxGW0GZ4Lr4uoej5DeLAuAZsiMkC+Gx83eC7mZtBXstR5ATzBZll /h6U+HwOo2fzNTRoch+6394IITTq37tZxM9tBGUWd+PseR1IZgvnUMDiM1Qu+i7KjM7VMbtr7h8f HFtGPAAvhBBSv8Ul9PB/joTFYVQ2sh1nLBId9Hmk2Q4PA4+jQ9GxWH6zNIjShhDCMNp0U5nTD5Gg OBB/fStlr0M/SZHYKfJo+v02K4cycyvo4Jyyctvja4JyivNq/PEaEhk3UOnTUvy9blMOfz2i6xjl 4MBX0eFt0PtAoBR9Z2hYliIyjtaJQ0jQ7kX/3m/QmtEEobrZpOzwM+jedOlTvqR1cw71SryDzCw+ RwGD82hPdFauZVhUtJRYi76AopYpSjuD0s0HefA5Bk1kCyox2omyOk+gEo4jwIUQwiXg+mZFZnoy EqPo+9iJypweQ1mJw0gI7mPwcwcCKhtK0cXKRUUv8cC2Gl8LIYSraL1La17aFNPBrguEJh/04jN/ GpUl7ESCYiq+BkVA5RAfo9KIGw38zDvoOX0aPatT8ec/Qz0ty/G9iWLqQUiudYfQOutMRb6kQaBf oWbsvyBBcQEJDZc7tRSLihazrpn7CmrgXMF9FXcilUOloYKHKaMznwAn4gF2AR0eVtGBFdZFvpOH PeWBPFmgJivKCZR52IuyRy+iQ8ohJCR2IKFTxXe1ipyWkrDImp7yv9Wen7eOoijmQggnUFTxCZS9 Ochgsk0rKEPxAZr38Rnls9EkxtAz+gJ6XifQM/oKKqubQett42x0H5AOKjF9CH2G2a8rLWMN7WnJ Ze89ZAf9N7T/XaMZpaLmAbCoaDk90fBdKDr0JIqseUG/PR3K8oadyLL1CjoknUZuW5+g0o6rlPX7 S7HsbA19vkPxlQbJjaDNdAId9h5GpRNPoYzEs/H3Ui18leIvRfqJ11CLjcQbHqDD7IfoPlpDtsOP o3urX8/9Kurh+iMagvU+DcxS9JQnPooOx0lQFCgz9BLKbH4eQlgsisI2s3y7D42hz+cFlJn1HpQP yejhKOqD+gLtcZ+jaod5GlQuau4fiwrTQQv4s6g+/ykU/faCfmdS2c8IEmGpuXCR0mXnBuVU82Sb OULZTLyEDuMddPjYicTdLlQusTe+p1/fOpB/2b2RhNUUOgx4M6kPK6hM4V1KB6gRlLnox57QRUL7 fwH/N4psXmioveoYEhPPoCzFtp7f66AM4+vo85hHfRam3IcOonXP85PyIM3ruYBKFv+KMo3HUbYi 9RN6/TeARUWr6YkOPQp8H6Xn7bpxf6SyKNDB4fH440to8Z2PPx9DYuIa5TCs3lKn6fi+FYm7UQbf L3EvdFD98zS6dlMTepq2z6ED/hjlkMd9bN6+0EXlEleBPwP/HdVfN1JQRIvnaRSgOYzME9bPcZlE /VC/A46HEK6gPqy2H8om0D70BAqiuPy2elZR8Os4yiy+Gd+PIzGx4kZssx6LinaThry9hETFIey4 sRkku1fQQe0Rbt4k03yEZD05wneFQ+5RuuT+NI0OSkM0c4BZI+kRFqeRc0sqhXoFRYq3UM44ud97 8Toq9fkI+G+ombORgiIygoIyL6Im7e23+DPD6ND8CmrcPg98EkJo7WCwHjH2PSTIdt35vzB9ZhmJ hovcLCg+Q/frfIOfYfOAWFS0m3F04H0Npetdx7r53CriNkqZDUrN2XWjQPdPyqyM2CazXkRhsYwO /jOoVO8DZFX8DGUj9wiluOi9V0PPe7fnlSaUfwz8X8D/QP0UTT+MjCFR8ShaS2+XYRxGZY2/QY3r 14CTIYS2lpGMUbplHcCZzypIz+4y6pH4G3KJ+wTZUF9EQQKv8eaOWFS0lNhQuBMt5IewoBgkdRUS vRRIHE2he2cc1d56w6kRPa5YF9Ah4jgqUXoa+AEyCHiIsiQv1bp3UW9Gmkh+DdVdJ6OC00isfAac bElD8k4kxh5H2bvbPeOp7PQ5JCwuAX9Axg6L/b/MfIhZiikU3HqWm3tQzGBYRc/vadSI/Snqt0qZ iTlc6mTuEYuKFhJ7KbahSORryPEppyZgkz+pdGsrimZPomi3N54aEjMI10IIN9Dh9hjyoH8TNc8e iu8j6OCbLJPTAKyLSEx8gyKdqYlzseHZCQBCCCPo8zmMMhV3i7anxuQXkaiYB/4aQjhbFMVyP681 M4aQaH0JCbJBzkxpOykwcBG5wb2LsotHkMC4Aiw7M2E2gkVFy4iCYgTVTb+EopH3sgkas55UC70X uV+dq/ZyzIMSBzeuxsGY5ykH5R0C9sc/NodERbJGXkbuZlfi+xKw1pbDSIy270KZh5fij+/V7GIP ct2bi6/FEMKFNnx2cS+aRp/bjyjnpZj+E5A74SkkKP6ASp5OoGd4GVvEmvvAoqJ9FCga9ARqFnwK RZmN2SjpULAf1ZLn6FJl7oOiKLqx3wKUeThP+f32Nm+neSVrtGAq+W0YRevoa2hdXe/4dCeS69FP UWZnFk2Ab7QjVBQU4+hz+wnaizwfaXAElKF4G/U8vY2yE4u08xk2m4RFRYvoyVLsB15Fm+AOfBg0 988YKuOYxjaQjaLnYJFEw0r6vZ5p8K0eKBhCGEIlgM+gnoA9bHw9TcLil6i+vYscuZrchzKMmrJ/ BfyczbUyNncnufeNUmYal9w3YR4UP8TtooNKGV5F6WZbyJoHZQgJizEsKlpDm4XEOlIp6TPokHw/ M346SJh8L/54DZgJIRwFGmc1G4XYLjQE8NdIjG0ku2MenAIFFJ9E2bW/Ud57xtw3FhXtYgxlKV6m dNrwQdA8CMOojGECryemRcRsTRIDL6Asxf0yjA55L1BOMf5/gSMhhLmGRZC3As+jAYCHseNTVUyg PpZnUbP8BXqykcbcDz4EtITYTLgV2R0+jjIWLnsyD0Iqp0uTwMdDCJ2GHYCMuR2jwMOoJ+B5bj3s biOkfrfDKACU3LWOhhDm656xiCJsAkXGf40+t71VXpNhEtn5PoLsZOeqvRxTdxylbg/jqG71WVS/ a+s+sxkMo+nLvZO1jWkDW9E8j9dRpHczmowL9Bw9Bfxd/H8fRIK9tk3M8drHgMdQH8XvkCBzYLNa xtH38DSwJ4QwWuf7zFSPH+gWELMUaS7Fy6j21zWsZjPooOhjmqw9jFPopuHEg9dOVJO+m83tTUsZ i9dQpmIM+CNlKVStMhZx/5lA+84vkFh6FgUjTLWMoLK9V9D07AvIGtq9Fea+sKhoB8OoMe4ZlKbf ibNUZvMYQ6Uf2/CaYtrBJIq6v0B/nM/SULjX0eF7G/B74MsQwtW6DBSM4msSBbR+hDIUr2K3uFzo oLX7ZeB94GtgNoTgGRXmvvABoOH01LHuR6JiH85SmM1lDB0SdmA3MdNgemy5DwHfR4exftlyd1DP wY/i37ETCYsPQwjnyXzaccxQTKLSmt+isqdX0L/JgiIfxlBJ9PeQC9QplG3O9t4y+WJR0WDiBjiM 0vNPosjaCFosXDdpNotRdOjZgwWraTZD6HD/QzRf4Qnuz0Z2IyS3pN3o8PfvwJ+BE3HyeVZR5bjv pPkdz6Jyp39FQa2tuO8qR4ZQedqjwGeoYduGG2bDWFQ0m5SleAx4CW2A41hQmM1lFJXXPYzuN2Oa yhgK0PwUZSkm6X/UPa3jB5Br0j7k1vMfwOfA1RDCag4lUT2Z8QPoM/ot8AN0WJ3Ee0+udCgbtj8B LocQ1nISq6YeWFQ0mw6KID8PvIjSzv2Oqpn2kRygtuFMhWkoIYQ06O511EQ96DKeYZQN3ILq4B8C 3gA+Bs6EEGbRsLyBi4s40G4cBReeRkLiF6jc6WFc7pQ7Bbq3XkR9FceBRdywbTaIRUWzScPunkGR rQkcKTKbTwdlK8aQ9eVwURSrFV+TMZtGjMBPo0PXr9Gsn6oCNJPAc+gQ+BjwNhIWJ5G4uILKV1b7 HWmOfRPjKHh1AAWwXgd+jPYcD1itB8kh8lnUSP82cBUNYTTmnrGoaCghhGG0CT6GhMVkpRdkmkwH CYrJ+BoFLCpMkxhGJTy/QWVPDzro7kFJWYufoqbx14AjwBeoJOob4FoIYQ49i2uoRn7D/Rfr5hYU 6HkfQs/5NJqjkZwFDyPBkwxBHMSqD6OUs6weAb4JISy6BMpsBIuK5pKG2ryABintwAu86R8jKNI1 jQ4T89VejjGbQzxUb0XuOD9DDdM5RN+HUCnUIVQKdRg4g0pXTsXXJ8A5YAa4ASyEEHrLWtKecLuD Y4dSRAyjfWUalX49hqLaP4h//zSar7EFl9nWlXEkVh8HPgVm8dwhswEsKhpIz7C7p1AE6yBe5E1/ GaYUFeMVX4sxm0kHRW5fRFnfnNbSNKk62TrvRIf962iI2QlUxnIm/vgKsIzKo67G13L8/6x3ZQrx 1yaQqJpGB86D6PN4HPVPPI57qZpCh7LCYS9wHosKswEsKprJMNpcnkZOJduqvRzTApKF5A7sAGUa QgzQbEeC4jC6x3PIUtyOccqG6YMou7IKXEKZi1lUBnUNCY3L8fdH46/fiO/JjjyJle3o2d6FDpu7 4q+lckfTHCZRqd9+4Fic4m57WXNPWFQ0jJiqH0ML/+NIUNgX3PSbIVT6sB2LCtMcJlCPwOuoZ2CK vEVFIg3pG0YZhylUJpXKnG6gbEZAImQs/toZlLkYQWVMU/E1EX8tlUMVPS/TLMaQIH0S+BIJz+VK r8jUBouKZrIVpaefxN7gZjCk6bk7gC0hhI6jW6bOhBBGkaPRr1BD9EHqF6C53cF/Gu0TUIqErSgD kYaj9vZT1EFImc1hHJU/vYaa/o+HEFbcsG3uBYuK5jFMae+3G6emzWDooIjmw8TJ2nYOMTVnEvWl /TS+59RL8aAkwdBL6p8w7WYIVTg8iYT0JMpieS03d8XRhwYR63+3IEFxCJWi1C2yZupJuvceQfef M2SmtkRL7v3AL5GDnvvSTJsYQcGhfXjWiNkAvlGaRYEWgEeRqHA/hRkUBcqK7UX2khYVps7sBL4P /A7d08a0iXSW2IfExei6eSXG3BKLimaR0pYPoYVgHB/szODooPK7SVQK5XvP1I4QwhByevonbJdq 2kmBSuH2o+xzXQwKTMX4JmkIPaVP+1GmYhf+fs1gSdmKZEHpLJmpFTEauw3ZcT+HBLLXHGDV2QAA IABJREFUUdM2knvYXnSe2EmzeopMn/Bi2RxSluIZtCHuxN+vGTyjSFB4EzJ1ZARlJ55FByqbmZi2 0kHr+CFkwDHuEihzN3zobA6jqOTpJeTa4PITM2hSpmI7ypSNxwyaMdkTy562A6+i5uxpvIaadpOG 6D6NzxTmHvCG3xzGUOnTk+hA54ffVMEIypjtxkYBpl5MognUr6NMxQReR027mUbVD4eRVb3Xc3NH LCoaQIwGp4F30/jBN9UxTDlEaweek2JqQAhhhNJC9nVU7mFBYdpOr6PkTryem7tgUdEMRlBk+Nn4 7lp2UwUFEhUTSNxOoyF4XmdM7mxF6+ffo54K91IYI8ZQafUu5ChpzG3xZt8MJpCN7CPYdcdUS3Ih 24XuySl8P5qMiVmKp5CgeAmVQRljxAil+cYWB4nMnfDNUXN6Sp8ewlkKUz0Fuh8PAo+hEihHfU2W xPVzH/Ab4F/w5Gxj1jOEgkO7kOB2kMjcFouKGhPt3YZRBOEJ1KS9pdKLMkYbzwFkRbgdC12TL6Mo Q/FfUB+FD0zG3EyBzhUPoyCR13NzWywq6k8HRRF2ogff36mpmiHK7Nk2nKkwGRJCGEV9FD9AAZkR 3JxtzK0YoayG8LwKc1t8AK0/I0hUbAVCfBlTNaNI6E5jxxCTGbHsaTfwYzyTwpi7MYSelx2ocduY W2JRUW8K1KS9Bz3wTt2bXBhGpU/bcWTLZES8F6fRTIqf4LJRY+5GBz0z27EDlLkDFhX1pkAP+aPI +WkcR9tMHgxTDsGzA5TJiTHU7/Nz4PvoHvW6acztSdPm03rus6O5Jb4xakqMto0Ce5F7yVZ8cDP5 kBxD9hCbtZ2tMFUTQhhGteGvoUF3j+JyDmPuRspUPIzOHJ4/ZG6Jb4r6kkqf9iGnnW042mbyoUCZ s714aJLJgBDCEDoYvQD8NL5P4XXTmLuR5g/tR8JiEp8fzS3wTVFfOuigtgtFg7fi79PkxTC6P3ej DcmHN1MJMUs2gaZl/wT4Ibo3nd015u4UKKO3m7IywucN8x18U9SXNKNiDJVBeXM0udFBpU97UYTY 642pilFU9vRj1EvxJLY6NmYjJKvwnbjc2twGb/L1JUUOtiBbWVvJmtwYQlm0Z9B07TH3VZhBE++5 7cDLwG+B53DmzJiNUqCyp91IWFhUmO9gUVFfRtBGuT++G5MbHbT5PIfsO+0aYqog9VH8DngVlT1Z UBizcbYgUbEXWYV7PTc34RuihsTI2xhK5z+CRIU3SZMbqY79UeBpNDjJJSdmYIQQxoAnkNPTL1AQ xvegMffHGBLlD+MSKHMLLCrqyzhyfdqDDm4WFSZH0qCx/ZRWhL5XTd8JIYygNfJ1JCoO4enuxtwv BaqQ2ElZITFS6RWZ7LCoqCdpBsABFP31g21yZgzdp7vjjy0qTF+J9rE7gZ8Bf4/Knxx8MebBGKac V7ETi3SzDouKepLsZLehximn803OpPkAaZ6KU+amb8RMWBIU/4gG3bmx1JgHZwidOfagMqgJ91WY Xnwz1JNhFHUbx1kKUw+m0YyAPXiCsekvU8CLwP+GhMXDOPBizGaQ+uR2o57OrfjZMj1YVNSTEbRx TuEH2tSDKdSs/STKVhizqYQQithH8SQqefo7ZBLgEg1jNo/UrH0Ql1+bdVhU1Iwe56edqJxkotor MuaeSKLi+8DBePgzZjMZQmYAvwT+Kzr4eI8zZnPpoMDQQfSMWbSbb/GCWz+SA8MU0Su62ssx5p4Y BfYBryCLzym7QJnNIjZm70CzKP4Z3WMWrsb0h1F0/tiFzyCmB4uKepJExQh2MzH1IE1jPYQyFg/h xlmzeUygidn/hLJhdnoypn8Mo2qJPThAZHqwqKgfBRIUO+LPQ4XXYsxGSBO2XwSeBbbYOcQ8KCGE cWQC8I/AD9A9ZozpHykzuD++D1tYGLCoqCPDaOjMfmAL/g5NfeigbMUrwA9RlMvZCnPfxN6cQ6js 6R9Qn5kxpr8Mob6KA6is1ZlBA/hAWitiJGCYMlMxhQ9lpl4MA48Bh1F02fW45r6IWa49wI+B/4zu K9sVG9N/krXsw0hYTOOziMGioo50KCMCfohN3ShQk98B1FsxHUKwLbLZEFFQbEVZr98ikTpV6UUZ 0x7SOv4oZY/ciEugjEVF/ShQH8Uq0K34Woy5X3agvor9OLpsNkA8uIwjh6dfAa+jPgrvZ8YMjiFU +vQUspf1Om68CNeQDiohsV2iqTPTwDPx5WF4ZiMMIzvLn8fXY3gvM2bQ9JZAPRJ/7Oew5fgGqBep p2IbivQ61WjqyiSafPxjNAzPA5TMXYllTzuBV1Fj9rMoQuq10JhqmEblrFtxSXbrsaioF2nw3Q6U dvRBzNSVURTh+jHwPdRb4fXI3I1pNI/iv6B+ih13/uPGmD4zRZxXgUVF6/EmXi8CylSMx5e/P1NX 0jC8x1EJ1EPo3jbmlsR5FE8Dv0G9FA/hNdCYqtmCRMU0LstuPV6Q60VA31kXWMCD70y96aA63KfQ YXG7naDMekIIRRQUj6Eeil8h1xkfYIypnnFgFxIW43aAajcWFfUilT91kPuTRYWpO2PA94FfA8+h KdvelEwvo6gR9O+BfwJewILCmFwYQQN59+ESqNZjUVEvOigqsAVttD58mbozhA6MrwIvos3Jm5IB IDbwPwn8n8B/BV5CDaFe+4zJgw4qZd2FRUXrsaioCTF6O4QExU70APvhNXUnzRx4BImKJ4CtzlaY EMIQ8r//FfCvKKO1CwsKY3JjDJ1LpnAWsdW4frleDKEHdiuK6FoUmqawA4mK48BlYBH1DZkWEgXF LuCHwH8CnkfrnjEmP0bQ87odu1K2GouKepF6KLrAWpUXYswmM4VmDiwAJ4FLIYTFoijcN9QyoqCY Rpaxv0MTs7dUelHGmDsxitzYHkLP6pVqL8dUhSPd9SJQljwt40Zt0xyGUJTrKdSIewA7ibSVLege +FdU+rQTl3oakzMjwG60bu+0i1978RdfPzpITFhUmKYxjGwJfw1cAmaBEyGEZWcs2kEIYQJZx/4G +BmyjvU+ZUzeDKPs4h5gGzASQljzut0+vFjXi4JSVNhS1jSNNBDvVeAicBqYBy4gEW0aSpymPokE xS+A36KmfddnG5M/aebQNGWz9hI+o7QOlz/Vi+QANYy+O5eGmKaR3KCeRYfLl1A63Y4iDaWnh+IF 4B+AfwYO48ZsY+pCWre3oed2DJ9PWokzFfUiWcruiC+LQtNECuBxNOwMlKX4IIRwpSiKbnWXZTab mKHYipy//gWVvh3CsyiMqRNpMO8UEhbj+PltJRYV9aGIry7lA+yH1jSVMTS74kfISWQJ+Bi4VuVF mc0jNnNOA08jl6ffoqnqY7gx25i6kQIE0yj46fNJC7GoqBeB0r+/izde02zGUF39b+LPh0IIHwDX i6KwpXKNiSVPe5CIeB0JiqexdawxdSX1xG2P766kaCEWFfUhoNkUS0hYLKPvz9EA01SGkZ3oK2iT GkX3/2chhOsuhaonsT9mO/Aa8HcoG/UC7qEwps4UKCgwTRQVIYTCDlDtwqKiXgT04CZL2QksKkyz Sa5A30MZugvAHHA8hDBnYVEvoqDYhYTiv6Cyp4fwWmZM3ekVFVvx+bKV+EuvFwEdsrp4ToVpDx20 WT2DDqErqPTvWAjhhkuh6kEIIZWz/QMqaXsF2I/6w4wx9SY5QCVRMYLWbq/PLcKiol54ToVpMztQ ycwQchh5A5VCXS6KYrXSKzO3JTZk70XZpp8iUXEYHT6MMc2gQH1wW+NrHLiORUWrsKioF2lOxSh2 fzLtI03c/gmwO/54K/B+COEisOT63XwIISSXul1ITPwT+u72I+tJY0xzSM/7JKWtrJu1W4ZFRb0o kKBItm12fzJtI/VYvIA2rj0og/E2cDKEMO8+i+qJ8yfGUYbiZeA/A79CNsFD+LBhTBMZRqWqKVPh wGfLsKioH8sonejvzrSVDkqzP4o2sH3xx38APgkhXLWwqI6YoRhH/ROvAz9HmYpHcP+EMU2mQGeT ERz0bCU+mNaLgBxw5pGw8ENr2swo8DAqpdkRf/x74O0QwtmiKJYqvLZW0jMh+zE0Hfuf0fyJfVhQ GNMGRlDQx+fLFuIvvV50kaC4jvz63Vdh2k6ByqBeQqVQD6Ma/j+GEL4B5u0O1X/iMLtRJO4OAz9A ouKnuAzCmDaRmrXdU9FCLCpqQlEUIYSwRikqFlDph7MVxkhgH0AC4+H4/ifgSAjhKrDiJu7Np6cZ ezv63J8D/nfgVTR/woLCmHaxBQUXpvD5pHVYVNSLNJ9iDriBHlw/tMaIDhITLyIjg2eA/0BN3KdD CIsWFptDFBNDKCq5B4mIH6HP/pX4a6NYUBjTNtKsiil8xmwd/sLrRUC9FAtIVPiAZMzNFGgzexoJ jIdQPf+fga9CCDNu4t4UxlCZ2UHgedSM/UOUrZjG/RPGtJVRlK0YA0ZCCIWDOe3BoqJepKF3c6gE yocjY27NMCqHmgJ2InHxB+CjEML5oiiWq7y4uhJCSAeGQyg78RoScM+gz3usuqszxmTACDBB2azt qdotwqKifiwDs8A1/KAaczem0cH3IWRp+u/AmyGEM0icrzmKdntimVMHHRS2okzEAdSI/Vvg++gA 0cFNmcYYZYvHUUDHJZAtw6KifvSKCmcqjLk7Y0hQbEPzLJ5F1rMfApdDCCtAsLgouYWYOIhcnV5H 8ycOosnYk1hMGGNuZhyVR9qooWVYVNSPVeQANR9/bIy5M2kS/W4UVd+D5ij8G/AecAa4EUJYsP3s t4ygLM/DqLTpMCp3egkZRIzFP2NBYYxZzxhaJ2x73zIsKupHF4mJlfgK+KE15l5ITdxPoT6LXcCT wGdIWHwTQrgA3GibuIiZiWGUediNPpcXUe/EY/F9P/rcLCSMMXdiFNlM27ChZVhU1I9AKSyW449t K2vMvTOEDs4/R4flY8ApVA71IXA8hHANuaytNFVgrCtxmkIZnEeRm9NP4msnijp2cPDCGHPvpCZt 0yIsKmpEzwC8RWQpO4+atS0qjNkYBTosP4b6AxZRec/7wAfAV8A3wPkQwhzKCnaRqK9l/0UUEaB/ +xASE1uAvajE6VXUb5J6Jnbh8gVjzMZYQ+eTKyj4aWHRIiwq6kcXHYBm0IO7ilKNxpiNkaZBJwvE cZTBeAk4h4TF+8Bx4CpwGT17SyGEJUqjhGxFRhQSKSMxjNaKSdS0nuZMpJ6Jl+KvTcY/5/3BGHM/ pOCnzWRahjeN+tFFZRkzaFaFm7WN2RwmUN/AXjR74TngZSQwziCRMQ9ciD+fQZvnfAhhEehWOf9i XSYiiYhhygm3+5Ed7D70b9yLGrH3x/fdOOtpjHkwhtD6Y0HRQiwq6kcAlpCl7CwWFcZsJkPxNYas VB9DvUuXUMZiCTgNHEXZixXUj3EBWI0ZjHkUpVuMfz6l/1eA5d6sxt2mzYYQUi9DEgzpz6bG6uF4 vamkaTS+xpFImkQNkwdR4/XTSEDsiP++LbjEyRizuaTghNeVlmFRUU+WkKCYQQcVY8zmk8TAOIrm P4QO9bMoS5g2zE+RyAjxv7mIMhnnkfCYQOL/EnAphJCyGQHoxjkZ3fjfJlGzvmyp99eGkRCYRKIg TbEeR0Jhuue1DQmI/cjRaVf88+n/Dd74jTGbS1rLvLa0DIuKerKCDjWzSGAYY/pHygKk6NsuFP1P G+ZONFk6/fwqejZX0Oa6BQmML5GwmEDP7TmU9ZhFa/EOytKkFdTDkX5vPP5/kkjYRdlIPYcCDEPI xWkaiYtJyl6R9LLFozGm3wyjtWgYC4tWYVFRM6ID1Apq0p6lbIayw4Ixg6HDzc9bygokdqHMRMoq DKGD/7PoeR1FouJqfK1ROjFtQ5vxMhIKi/H3xijLmrb0vIr4Zxbi3z0S/9wIZXmUN3VjzCAZRWui gxgtw6KinqSp2jPosGIHKGPyIR3me5mMr8RafMGtexrS7ydhcidhMI4yJ8YYUzUBrV1dShtu0xIs KupJspWdRRmLZSwqjKkTveVU9/P7xhiTI2lORbLgtqhoES6ZqSddVD4xg4RFZTaWxhhjjDGRgtL6 PuDyy1ZhUVFfVigdoBYrvhZjjDHGmGSD7dKnFmJRUUOir/0ySi9eRH0VxhhjjDFVktzy1s/WMS3A oqK+pIFcp1G2wg+uMcYYY6pmvUOeaQn+0uvLGhITZ4ErlE4yxhhjjDFVUSBXu4ADnq3CoqK+BNQI lQZtLeOH1xhjjDHVsYrcn5IzZbfayzGDxKKiphRF0eXmZu3ktGCMMcYYUwVpOO9ldC6xqGgRFhX1 ZhW4jsqfbmBRYYwxxphqSZb3SzEAalqCRUW96aIH9zwSFn54jTHGGFMVw8j9aQkghOA5FS3CoqLe BJShuICcoCwqjDHGGFMVI5RN2uDhd63CoqLeBBQNuIxFhTHGGGOqZRHNzlrB7k+tw6Ki/qwgB6hL qMfCGGOMMaYKbqDzyDywEof1mpZgUVFj4sO6ivopzmIHKGOMMcZUxwLq9UzZCtMiLCrqzxpwDTiD eisWsbAwxhhjzOApUCn2Ei7Jbh0WFfUnoIjACeB94CIugzLGGGPM4BmNr7WqL8QMHouKmhNLoJaA c8DH8X250osyxhhjTNsIKDuxRtmobVqERUUzWEUlUCeQqFio9nKMMcYY0zK6lAN5l7CoaB0WFQ0g ZivmgFPxdb3aKzLGGGNMC7kMnERnEpdAtQyLiuawhBq1TyDnBWOMMcaYQdFBVrIzqAzbmYqWYVHR HNbQg3wKlUIZY4wxxgyKVRTgXMA9Fa3EoqI5dNGDfBZlLBawnZsxxhhj+k8XlTz1zqiwqGgZFhXN Yhk1an+NahqdfjTGGGNMvwlIUJxHjdrLnqbdPiwqGkJ8eFeAS8AnwKfADZytMMYYY0x/SXay11EJ tpu0W4hFRbPoIiFxAjiKH2xjjDHG9J8kKhawrX1rsahoEDFbsYyman+Dshaerm2MMcaYfpLMYmZQ s7ZpIRYVDaMoijXgKhIVp1FJlDHGGGNMP0jl1ydRT4X7OVuKRUUzmQPOAMeBxWovxRhjjDENJqCz xllUIWFR0VIsKprJMrKV/RwJi/lKr8YYY4wxTWUZZShOoonadn5qKRYVzWQNWbp9DryPHnY3bBtj jDFms1lFZ44LwCwuu24tFhUNJEYI5lFfxfuUMyuMMcYYYzaTgITFHLDoLEV7sahoKEVRrKCG7a+Q qJjDNY7GGGOM2VwCqoZYxo6TrcaiotksIQeok2hmhTHGGGPMZpEG3p0m9lNUezmmSiwqmk3qrfgC ZSxm8IRtY4wxxmwOAQ3dPU7p/GRaikVFs0kRhM+Bd1GPhRuojDHGGLMZLKPgZaqI8BmjxVhUNJjY LLWExMTHwBEcRTDGGGPM5jCDshRngAXcu9lqLCqaT6B86I/iYXjGGGOM2RzmUdnTFez81HosKhpO T7biPBIVV7E7gzHGGGMenDWUoZjDZ4vWY1HRDtZQreNR4EsUUfAwPGOMMcbcL6uob/MKatb2uaLl DFd9Aab/FEURQghzwDHgT8AuYALYWumFGWOMMaaOJNenkyhgOYtFRetxpqIlFEWxDJwD3gE+Q5kL 1z4aY4wxZqOkKdoXgVPAkvspjEVFu5hHEYVPkVPDChYWxhhjjNkYq8AF4ER8dz+FsahoGWkY3qfI YvY8HoZnjDHGmI3RRXb1x5HDpEufjEVFywjIpeFL4C1UBuW5FcYYY4y5VwKqdDgFnEbnCgcojUVF m4j1jisoQ/ERylbM4xIoY4wxxtwbK6hH8xvUU+HgpAEsKlpHFBYLKMLwCXKEmsPCwhhjjDF3JiCn p8+Br1FJ9aqbtA1YVLSVLhqC9wXwNnAWRR6MMcYYY27HCpqg/RkKSs5YUJiERUUL6clWnADeQIuD B+IZY4wx5k4sIVFxDJVSL1Z7OSYnPPyupRRFsRZCuAJ8ABxAg/BGgR1AUeW1GWOMMSY7AhIRl5Gg uF4UhYOR5lssKtrNInJueBt4KL6mgaEqL8oYY4wx2bGGshRfoUnaC9VejskNlz+1mKIousB1yoF4 p3EJlDHGGGO+S+rHPIpdn8wtsKgwq6if4ghq3L6Im7aNMcYYUxKQBf05lKW4jqdom3VYVBhQCvM4 8A7wLnAND7IxxhhjjAioj+IoEhZLdn0y63FPRcspiiKEENaAC6hpez9wENgSX27aNsYYY9pNQFUN p1CjtkufzHewqDAURdENIcyjlOZbwKPAOPAYMFHhpRljjDGmWnobtI+gagb3X5rv4PInA3w7u2IW +BLNrvgc1Uw6vWmMMca0k9RLcQT4EJU/zeGzgbkFFhXmW4qiSJMyP0FuUOdwI5YxxhjTVroo4Pg5 GpR7DlhxP4W5FRYVZj3LwBngI7SIXMVpTmOMMaaNdFFm4ijwDRp4Z0Fhbol7Ksx6uqhe8kNgF5qw /TPUtG2MMcaY9nAdlT59iSoZXL1gbotFhbmJ6Aa1hBwe3gb2AI8AjwOj2A3KGGOMaQNLqHLhfeAY cMNZCnMnLCrMd4jCYh7NrngTCYoJZDU7VOGlGWOMMWYwzAJfo8qFs8BitZdjcsc9FeZ2pDKoT4B/ Q/0VN/BQPGOMMabpBNRTeTS+ZoqicOmTuSPOVJhbElOcKyGEy8B7aGbFVuB5YBsWpMYYY0wTCaj0 6Rwqezoff27MHbGoMHcj9Vf8CRhDTVrPoQZu3z/G1IMu6odyT5Qx5m6sAReAL9DAOw+7M/eED4Xm jsRp2zfQ3IoOOpSMox6LqSqvzRhzV9ZQHfQyMAmMYGFhjLk9AVhAJU9p2N18URQufTZ3xaLC3JUo LK6ivoopYD9yhRpDhxRjTH50gRnk3nIDGS7swuu+Meb2rCEb2S/Rnn8B28iae8Sbi7kniqJY7REW f0L9FSPAARz5NCY3uuhg8DnqibqBMo1b0LNrjDG3Yg0FI44Ap4E528iae8WiwmyEZdS49Q46mKTh eBO4cduYnFhGVpB/BP4CrKDypyngCVTCaIwx67kK/A34CGUp3Eth7hmLCnPPxDKoOeAEEhapDOpJ JCycsTCmelaQp/z/396dPdlV13sff6+9e246ExkYQhgFDiAochSfc3xuPGWVWPVY5Y3/hTeWF97K n2CVVVrlpVX+AXrx1HNkEFFmAmHMQBIghCSdsaf0sNd6Lj5ruTeoB5J0p3vvfr+qdnWTENho77XW 9/edXiJZxf0k6N8C7AS2ks+un1dJvWZJY/ZfSPnTjFkKXQmDCl2RnsDiMPA8sINun8Xoer43SZTA NDlp/AvwFhkH2SZNl7eR8dA34SJLSV0dMunxFZKlmCYHFNKXZlChK1YURaeqqnPkgWU7CSxa5EHF wEJaH83CyreA/0seDk4VRbFcVVWHlC6+AzwKPEJKoMxWSCpJ2dNBcvjwMZn4ZJZCV8SgQlelbtw+ RR5cRkgN9zeAvaQZVNL1UZETxU/JCeMzwAtk6tPlnr9njiyy2k92zdxLyhYlbV4luTYcJhnOt4Gz OPFJV8GgQtdiAfiQPLA0zVyjwO3r9o6kzadDHgJeA54G/kr6nv5+0lgURVVVVdNrsR+4hwxa2INj oaXNrDlweB94g1w7FsxS6GoYVOiq1Q8q8ySwgJRTbOl5WbMtra0SuERGxz5fvw6SMZCfWVZV90Nd qn//BdJbMULKF70XSJvTMjmUOEjGyJ4ngYZ0xbyR6JrUDyoLpAbzBfKQ0iKlUDtIYGHdtrQ2mmkt z5HP31H+SUDRqPuhTpPdFbeQIQvjuLtC2owqcijxdv36FFgxS6GrZVCha1YHFvPkgaZFTk87pCF0 D2YspLWwSD5zz9WvQ8ClfxVQ9GjKFl8nfRW3YVAhbUbNffuvOEJWq8CgQquiPgG9RJq9KpKxmCIL t6ZwOZ60mjrARyQ78TQ5ZTxXFMUXNlfWhwAz5GHifeB+sm9mGLOK0maxTIY5vEoOGD4lA1ekq2ZQ oVXTU7N9hPRX7Ky/foVuj4UPLdLVK0mm4RTZlP00aa6cLoriSmbKr5A59O+Qz+ceUg5lVlEafBXp nXiLXEeOkbJJsxS6JgYVWlV18/YsOQFtRs0ukhGW2/FnTroW88AHZLHdM2Sk8xmufPxj0+D9DslS 7CKfz0kM/KVBVpF78hHgJTKG+iwuutMq8AFPq64uhbpIHliacbNt4KskYyHpylQkQP8QeBb4bzIa 9hSwfKUnjHXwvwicIKUPe4CbyahZAwtpcK2QUqe36tcJ4LJZCq0GgwqtiXo53jngXRJQTJAdFveT iTOSvpwOcJFuD8WfSEDxaVEUV10DXZcrzpHSh5dJw/ZWUrLovUEaPB3gAunBepU0Z18imUvpmnnj 0JrpCSzeIT9rJdnw+wB5eLF+W/qfNQHFO8CLZA/F68BpVqFcoQ4szpNyxVdI0L8b7w3SIJoj5ZMv kYOJk9dyMCF9njcOrak6sJgmpyIz5JRkHvgasA0nzkj/SkV3D8UzpOzpHVL/vLRa5QpFUSxXVXWm /mcfBPaSrKL3B2lwLNKd9vQSCS5m1/UdaeB409D10Jy2vktOVxfr18OklnsUAwup1wqZ8nSUTHh6 hpQsnGVtllM1/64/kyziMOmxcBS01P9KMtDhANlJ8S5w0T4KrTaDCq25+sK1Uo+bPUT3gek88Bhw B+m5kNR9AHiHjHt8hjqguMKxsVeiQwKWV4EbSQnULjLBTVL/Ksmh3vukJ+tNMk7aaU9adQYVum56 lm4dJr0VcyRDMUZKLnyA0WZXAudIvfNTdDfdXvgyi+2uVj0N6jKZLvUKcBMJKu4kmURJ/aci99pj ZBjDq2Tgg9OetCYMKnRdfW7iTAcYJ8FERcotxrEUSptPRTJ4zUKqp0kPxUFgpiiKNZ/OUgcWc+RE c4rsrdhKAgw/k1L/WSZjp18nQcVhYLYois66visNLIMKXXc9M/I/Jg9PF8np7H9x/ax4AAAWuklE QVSSUqhJrOXW5lGSHRQnSc3zn4HnSKngdQkoGvWOmfOk5no3mQY1ScZA+5mU+kdJBqM0k+OaIQ8G FFozBhVaFz3lFifICe0sCS4eJ9u3d+PpqAZf87P/MSlNeK7++iE5UVyP+fHN6eYrwC3kIeQBMq1t CD+XUj+YJROeXiTllJ8Ai5Y9aS0ZVGjd9GQsTpJpUHMkYzEDPEpquh05q0HVlDu9S0oTXgTeIDf/ hfUqUag/lwvAcdIk3mQovkoCCz+P0sa2SA7sXiPXlmPkkMKAQmvKoELrqr7ILdW7LBbJ6co8aS57 hPRZTOCiPA2OivyMnyY1zs+Shuz3WeUdFFerp/fpPdLzdAMJKEbJ59HAQtqYOmS60wEy7eld4Pw6 ZT21yRhUaEOol+RdJHXkS8AlcmF8jEyg2YJZC/W/igTPR8gJ4ivkNPEwKf8r1zugaNT9FRfIZ3IX 2SkzCdyGE6GkjahD7p3NOOr95PBizSbHSb0MKrRh9JyOHiWlUNPkgvjvpKa7WZRnw6j61TIJKP5M RsYeIP0L8xtxIksdWJwls+1vIFOhtpCeJ0kbyxz5rD5Ngorj2Eeh68igQhtKHVjMk3rQeTK94jRZ BvYIsI+MubQcSv1kmfwsHwP+QnoV9pOAYt3Lnb5AU5/9Khkzu4vslpnCzKG0UTT7KJ4nAcUR1m/Y gzYpgwptOD19FmfI5u0LJKiYBr4B3EMebCzB0EZXktKDT8n+ib/Vr/fIz/TyBg8oejOIHwIvkb0V 28i42eH1fG+SgFxjTpCSyr/RXZhpQKHryqBCG1bPBu6jdHssTgHfIpNo9pITU8uhtBF1SDnCSbJ8 6llyw/+QDCRY2egBRaP+LM6Sh5XnSfnTDmAnaeQ2YyFdfxW5zpwm15hngLeBs0VR2Eeh686gQhta z9jZ0yRrcY6c+p4iTdz3kBpvf5a1UVQkQ9E8hD9FSp7eIT+7ixuxf+JLWCGfvzfoLsP7NtllMYaB hXQ9VWSoyQVSSvkMyVScqn9duu58ENOGV5/mLteTaBbJHouzJNBoluU1Tdw+2Gg9rZCfyw/J1KTX yf6JQ6QcYXkd39s16fkcfkrKoCry3/s4cDswvo5vT9pslskhxaskoPgrWaJ5uV8yoBo8BhXqG3V9 6FxVVR+RspLzJLiYplsO1Yyela6nptTpNDk1fJn0UBwhy+zmBqi++TLwEQkqChJMTAC34gAF6Xoo yf3vLeBPJBN6lEyRM6DQujGoUN8pimK5XpY3Ty6sp0iT2iPAfaTeexx7LbT2mprmCySAeIP0HOwn p4az9EEz9pWo/1sWqqpqNvZuJ+VQw8CNuE9GWksV6TF8n1xrXgSOFkUxs67vSsKgQn2qnp8/R0bo XSTzuA+SJu7ehXktfMDR6qtI+cEC+fk7SE4LX6i/P01O9DfMMrs10GQs/kzKoC6TnTI3Y7ZQWgvN 8sz3SK/Wc8AHJEsqrTuDCvWtupzkcj16dpaUQp0k9eyPAQ+TkqhWz0taDQuk3OA90i/xHmnEPkaC jIHKTvwzPTtljtJtTh8iWYsd6/nepAG1Qg4tniHT5N4HZgaotFJ9zqBCfa8enTdTT4m6RJrXjpMH vIdJOdQeUqbhz7yuxRIpuTtCshKvkKDiJAkm5jfTDb7OGM6SwKIFTJKA4iGypNJAXrp2HXKQcZJk J54D3gXOOzpWG4kPWBoYRVEsVVV1lmQtTpMHvzfJdKim32In9lvoyjQL7BZIwPo2mX70Ejk1PEvG xA50ZuJf6dlhcZTs4Zgi/5s9QJbkDWEJonS1mh6Ko2TS01OkQfucAYU2GoMKDZT6Aecyad6+SEqh DpLMxWPkBLXpt/BhR19khW6Q+hEJUl8hN/WBbMS+GvU+mVlSjlGQjM4S8DWSuXAqlHTlKrqlls8C T5NhEKfJtUnaUAwqNHDqB7yVupH7MtlrcYY0tL1DgouvkxGYY80fwwBDXU2PwDmSmXiR3MwPkxKE 86RhcpAbsa9IXQp1ifSXtMhnazvJDE7i50u6EiW5xhwjOyieIlPlzgBLXne0ERlUaGA1wQVwqc5e nCOnzcdIBuNBEljsIWUao+vzTrXBzJLdEsdIluvN+nWMlCEsbqa+iStRFMVKVVUXScZihAQXl0kJ 4nYsO5S+jGZM9UHSu/UMCShOF0XhtmxtWAYV2hR6+i2arMXHwAHgHvLAcy8ZhTlJxmFaGrV5lGQ8 7CIJKJra5VfJw/HHJCB1U+2XUGcszpNA7DL533SOLKjcheNmpS9ykWRI/0R2UbwDTBdFsbyu70r6 AgYV2jTquu8lsoF7lpw8v0ECi6+TzMVtpAZ8J9kSbC34YLtMbuBn6U4Ne4ssdTtCgolFoGNA8eXV GYvz5GFongQV88A3yDS2JoshKZpFmvPkMOOp+vUucKEois46vjfpSzGo0KZSPxh26vn6zQjaU6RW /iXgLpK1eLD+emP9R9v4EDQomn6JJZKV2E8CiSMkK3GKBBlzwIrBxNWpMxYzpJepWdo1RwL4fWSf hdlAKZZJA/YhslDyKRJcXDSgUL8wqNCm1NPM3aF7Wn2CXMTfJqdDD5EgYzdZotdMjFL/Kkmm6k1y in6A1C2fJDXM8yTYMJhYBT17LI7QLS+bA75LGrj9PEnp/ZsmJZfPkcEQ75NrkgGF+oYXdG1qTeaC jO1bqKrqAjktOk4eOm8H7iZL9O4lZVGjuOui33TKsry4vLx8fGVl5dXh4eFnhoaGDrRarY/xJHBN 1U3ts1VVHSMBW4dulqJ3Apu0GTVT5t4kI2P/QrJ7M1h2qT5jUCH1qGvBL5DT1JNkPObNJHPxDdJ/ sQu4iSz5GiX14W0cS7thVFXF0tJSZ2VlZbGqqosjIyMny7I8eP78+RcPHDjw/IULFw5t27Zt5nvf +54jYa+ToiguV1V1gpRCtUhW6NskcLd/SZtNUxJ4npRfNk3Zh4EZJ8ypHxlUSJ9TN3QvkxrXBVIa 9QkJLPaRZu47gFvqv76VBBgj9cvJUeujAsqyLFeWl5cXT5w4cfG999774IMPPnhp9+7dz957770H t2/fPj03Nzf/1ltvLT/55JPetK+/JfJZep70M50D/jfJAm7Fe5I2h4qU3X4EvEy35OkoMGtAoX7l BVz6J3pOr3uX6F0g+y22kjKo3eSU9U6SzdhLAo7dpDxK19fiysrKh5cuXXr50qVLr50+ffrYxx9/ fPoXv/jF6TNnzpwi5QTerNdR/blarKrqFKkjnyOBxX+Q7ds348hZDbYVMgjiIBkO8gIpfToBLBhQ qJ8ZVEhfoGeJ3gowX4/K/ITstHiPboBxJ9l58RW65VE7SIBRYg/GqquqaqUsy0+qqnqj1Wq93+l0 jpw5c+bd3/3ud+8/+eST0+T/M20w9d6YMyRzMUuyFgvAo3T3xZjt0yApyc/4GZL1fo4EFIeAM0VR LK7je5NWhRdt6SpVVdUideBDpNl0J93G7n3k4egesrF7nGwUburG26RUSlfnMnCuLMujS0tLL12+ fPmPs7Ozr//+978//7Of/cweiT5RVVWbBBC3Af9OMhZfJwG609Y0KEq6izUPAK/QDSguYUO2BoRB hXSNqqoqSBaiRRq3byABxE66JVF3kCzGWP33TdENNobpNnrrHzUTupqt1wukDO0vwH+vrKy8vbCw MD09Pb141113WTrQZ+rgfJQE4Q8C36pf99PdwG2WT/2qImV+H5DpTs+TseWfkP4Js6kaGD7ESKuo J8AYIg9KE/VrCthGdxztTpLF+CrwACmXarYM+7mMZkndZXID3k+aGj+o//pT6iV1joTtb/XnZpiU C95NyqAeBx4hWT+nQ6kfNdev48D/q19vkhKoJa9bGjSmlqVV1LP3okMaUmfoBgpNNmOYlHzcSCZH 3Vp/v63+/iEScGy73u9/g2jmth8n5QHHSNnA4fo1XRTF/Lq9O626+nPT9Fk0yyinyc6Yb5A+pR1Y Mqj+cpJkJV4GniW7j84URbG0ru9KWiOeiEobQFVVQ6SG/A5SW/7Vqqr2VFW1DdhVFMWeoihuZPAm 4zSjFWdI1mG6/vohaYJ/iwQUZ4HL1h0PvjprMUaGH9wHPEayFv9GMnoTWA6ljatDyp3OkDGxz5Ae iqNk/4TZCQ0sgwppg6gfptpAe3l5eWh+fn68qqpbR0dHvzY8PPwf7Xb763VgMQ6MV1U1DgwVRdEP D1gl6YlYIkHEEumPmCeBxAekLOANkqE4V/99nfrPVgYUm0f9WRgi45tvJ83bj9dfb6fbxO09TBtB U6pZktHj75NG7OfJde1Tcihiz5cGmhdkaYN64oknit/85jcj27ZtmxgZGZkaGhraVhTFrrIs7+p0 Ot8CHiuK4rZ2uz1RBxbNRu8myCg+9/V6qHq+Nq9lkon4hKT/D5AyppP1ry+SAGK+fi3avCj4+3So CTLU4AHgmyST92/1r1kOpfXWe407SwKK54G/kuvceXJN81BEA8+gQuojnU5ntNPpbCvLci9wS6vV 2tput8dardYkaf7eR0qobiYTqCZIydTVlk01gUHzfVlV1d9/rSgKyHWkOalr9g6cJun+4ySYmCbZ h9PAqfr7GU/u9EXqrMUo+Xlumri/RbeJ+wa8l2n9LJJMxAFSrvkW6aM4TgINx8Vq0/BCLA2AqqrG q6raCdxWFMUddIOKSVIm0oytHSElJdtJzfrW+vcrMi/9HN0AYIGUH1H/flUHFE0gUBV1VEE3+Fgk QcUZukHFp8AlAwhdizprcQMZ0fwwyVg8QgKN3STw8J6m66UkAwWOk8l0TanTR6QEyh4wbTpegKUB UZZlART1g37zoudrRXfR2B1kJ8A+0hRbkozCofp1lAQZKz1/nsQUf/9nfT5T0au3/Ml+CK2KnqzF LhJMPEQauR8DbiFBx6ANM9DGUZJr4iLJQrxLypxeJKWdTe+E1zttSgYV0ibSsw9gmDycDdO9DqyQ 8qUlMkPdzII2nJ4m7htIcHEfKYf6dv39jSQj1xtYS9eq2Yp9imQnjpAMxetk0MQlct00oNCm5QVX ktR36k3cI2Sfyz6ySPJxkr24lSycvAH3MenaNLuHZkkW92Uype4IKXU6jaNiJcCgQpLUx+pei2ZL /f0kqLiPlEfdQ7ffQroay8AJsjfnJTIq9iCZ9LQALJvVlcKgQpLU13p2vDQlUXeS4OLfSe/QXpK5 MGuhL6NDxlvPkD6J14G/kQzFMdKI7VQn6XMMKiRJA6EuiWqCi1tIxuJh4Gv1110kq9HsdZF6NY3Y F0lm4nUyKvZdepZympmQ/jkvqpKkgdLTbzFJxibfTHou/ovsubgFS6L0WSukb+ITkpF4of56nCyw mwdWzE5I/5pBhSRpoFVVNUx6Lh4izdzfBf4Xjp9VXCaN12/QXWB3GDgJzGEwIX0p1pdK2jDq2ni8 gWs1FUWxDJysquoUcKgsy0/LslwqiuL+Vqu1qyiKsfV+j7quOiSQuEimN50EXiF9EwfJAlAzE9IV MlMhaUOoA4oWLszTGqqqqnXo0KHtY2NjD+/YseP/jI6OPt5qte4EtrZarRHyM6jB04yGvUwmN30I vA28SvZMfAScISVQNmFLV8GgQtKGUVVV4c1ca+23v/1t69vf/vb4nj17dp47d+4rw8PD39myZct3 t27den+r1dpKmr29Pw6Gpvl6gQQTR0lGYj/plzhFpjw5Hla6Rl40JUmb1qOPPjrxy1/+cu9NN930 ULvd/tbIyMh/Tk5O3j8xMbF1aGiovd7vT9dkiUxxegN4n2QjPiVZijPU/RKYmZBWhUGFJEkw+vOf //yuJ5544vGbb7750YmJifvb7fZXxsfHbxofHx8dGrIFsU+UJFg4T3ZKPA08R8bCnqn7ayStAU9h JEmCzve///1z7Xb77T/84Q/7Dx8+fGxmZmZhYmJieWxsbH5oaGiuqqpF0u/T9P94MLcxVCSYWCTB xEESSPwB+BPJVpwvimJl3d6htAl4QZQk6bPaP/nJT8a++c1v3vDggw9OtFqtHcPDw3dOTU09vH37 9kfGxsbuabfbe6qqmiCHc+060GheWhtNiVJJN5BogokZMsnpCJnk9DIpeToHLNkrIa09L36SJP3P 2r/61a+2fOc737lx7969O4eHh3cuLi7etLS0dHu73f7KxMTEfWNjY7e22+2tZOmeVl9JeiRmyMjX U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu 0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1 MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7 hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
+
id="image1"
46
+
x="-233.6257"
47
+
y="10.383364"
48
+
style="display:none" />
49
+
<path
50
+
fill="currentColor"
51
+
style="stroke-width:0.111183"
52
+
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
+
id="path4" />
54
+
</g>
55
+
</svg>
56
+
{{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
+57
appview/pages/templates/fragments/dolly/silhouette.html
···
1
+
{{ define "fragments/dolly/silhouette" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
width="32"
6
+
height="32"
7
+
viewBox="0 0 25 25"
8
+
sodipodi:docname="tangled_dolly_silhouette.png"
9
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
+
xmlns="http://www.w3.org/2000/svg"
12
+
xmlns:svg="http://www.w3.org/2000/svg">
13
+
<style>
14
+
.dolly {
15
+
color: #000000;
16
+
}
17
+
18
+
@media (prefers-color-scheme: dark) {
19
+
.dolly {
20
+
color: #ffffff;
21
+
}
22
+
}
23
+
</style>
24
+
<title>Dolly</title>
25
+
<defs
26
+
id="defs1" />
27
+
<sodipodi:namedview
28
+
id="namedview1"
29
+
pagecolor="#ffffff"
30
+
bordercolor="#000000"
31
+
borderopacity="0.25"
32
+
inkscape:showpageshadow="2"
33
+
inkscape:pageopacity="0.0"
34
+
inkscape:pagecheckerboard="true"
35
+
inkscape:deskcolor="#d1d1d1">
36
+
<inkscape:page
37
+
x="0"
38
+
y="0"
39
+
width="25"
40
+
height="25"
41
+
id="page2"
42
+
margin="0"
43
+
bleed="0" />
44
+
</sodipodi:namedview>
45
+
<g
46
+
inkscape:groupmode="layer"
47
+
inkscape:label="Image"
48
+
id="g1">
49
+
<path
50
+
class="dolly"
51
+
fill="currentColor"
52
+
style="stroke-width:1.12248"
53
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
54
+
id="path1" />
55
+
</g>
56
+
</svg>
57
+
{{ end }}
+2
-1
appview/pages/templates/fragments/logotype.html
+2
-1
appview/pages/templates/fragments/logotype.html
···
1
1
{{ define "fragments/logotype" }}
2
2
<span class="flex items-center gap-2">
3
-
<span class="font-bold italic">tangled</span>
3
+
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
4
+
<span class="font-bold text-4xl not-italic">tangled</span>
4
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
5
6
alpha
6
7
</span>
+9
appview/pages/templates/fragments/logotypeSmall.html
+9
appview/pages/templates/fragments/logotypeSmall.html
···
1
+
{{ define "fragments/logotypeSmall" }}
2
+
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
+
<span class="font-bold text-xl not-italic">tangled</span>
5
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
+
alpha
7
+
</span>
8
+
<span>
9
+
{{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
+90
appview/pages/templates/fragments/multiline-select.html
···
1
+
{{ define "fragments/multiline-select" }}
2
+
<script>
3
+
function highlight(scroll = false) {
4
+
document.querySelectorAll(".hl").forEach(el => {
5
+
el.classList.remove("hl");
6
+
});
7
+
8
+
const hash = window.location.hash;
9
+
if (!hash || !hash.startsWith("#L")) {
10
+
return;
11
+
}
12
+
13
+
const rangeStr = hash.substring(2);
14
+
const parts = rangeStr.split("-");
15
+
let startLine, endLine;
16
+
17
+
if (parts.length === 2) {
18
+
startLine = parseInt(parts[0], 10);
19
+
endLine = parseInt(parts[1], 10);
20
+
} else {
21
+
startLine = parseInt(parts[0], 10);
22
+
endLine = startLine;
23
+
}
24
+
25
+
if (isNaN(startLine) || isNaN(endLine)) {
26
+
console.log("nan");
27
+
console.log(startLine);
28
+
console.log(endLine);
29
+
return;
30
+
}
31
+
32
+
let target = null;
33
+
34
+
for (let i = startLine; i<= endLine; i++) {
35
+
const idEl = document.getElementById(`L${i}`);
36
+
if (idEl) {
37
+
const el = idEl.closest(".line");
38
+
if (el) {
39
+
el.classList.add("hl");
40
+
target = el;
41
+
}
42
+
}
43
+
}
44
+
45
+
if (scroll && target) {
46
+
target.scrollIntoView({
47
+
behavior: "smooth",
48
+
block: "center",
49
+
});
50
+
}
51
+
}
52
+
53
+
document.addEventListener("DOMContentLoaded", () => {
54
+
console.log("DOMContentLoaded");
55
+
highlight(true);
56
+
});
57
+
window.addEventListener("hashchange", () => {
58
+
console.log("hashchange");
59
+
highlight();
60
+
});
61
+
window.addEventListener("popstate", () => {
62
+
console.log("popstate");
63
+
highlight();
64
+
});
65
+
66
+
const lineNumbers = document.querySelectorAll('a[href^="#L"');
67
+
let startLine = null;
68
+
69
+
lineNumbers.forEach(el => {
70
+
el.addEventListener("click", (event) => {
71
+
event.preventDefault();
72
+
const currentLine = parseInt(el.href.split("#L")[1]);
73
+
74
+
if (event.shiftKey && startLine !== null) {
75
+
const endLine = currentLine;
76
+
const min = Math.min(startLine, endLine);
77
+
const max = Math.max(startLine, endLine);
78
+
const newHash = `#L${min}-${max}`;
79
+
history.pushState(null, '', newHash);
80
+
} else {
81
+
const newHash = `#L${currentLine}`;
82
+
history.pushState(null, '', newHash);
83
+
startLine = currentLine;
84
+
}
85
+
86
+
highlight();
87
+
});
88
+
});
89
+
</script>
90
+
{{ end }}
+167
appview/pages/templates/goodfirstissues/index.html
+167
appview/pages/templates/goodfirstissues/index.html
···
1
+
{{ define "title" }}good first issues{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="good first issues · tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org/goodfirstissues" />
7
+
<meta property="og:description" content="Find good first issues to contribute to open source projects" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
<div class="grid grid-cols-10">
12
+
<header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8">
13
+
<h1 class="scale-150 dark:text-white mb-4">
14
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
+
Find beginner-friendly issues across all repositories to get started with open source contributions.
18
+
</p>
19
+
</header>
20
+
21
+
<div class="col-span-full md:col-span-10 space-y-6">
22
+
{{ if eq (len .RepoGroups) 0 }}
23
+
<div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
24
+
<div class="text-center py-16">
25
+
<div class="text-gray-500 dark:text-gray-400 mb-4">
26
+
{{ i "circle-dot" "w-16 h-16 mx-auto" }}
27
+
</div>
28
+
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3>
29
+
<p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto">
30
+
There are currently no open issues labeled as "good-first-issue" across all repositories.
31
+
</p>
32
+
<p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto">
33
+
Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started.
34
+
</p>
35
+
</div>
36
+
</div>
37
+
{{ else }}
38
+
{{ range .RepoGroups }}
39
+
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
+
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
+
<div class="font-medium dark:text-white flex items-center justify-between">
42
+
<div class="flex items-center min-w-0 flex-1 mr-2">
43
+
{{ if .Repo.Source }}
44
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
45
+
{{ else }}
46
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
47
+
{{ end }}
48
+
{{ $repoOwner := resolve .Repo.Did }}
49
+
<a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a>
50
+
</div>
51
+
</div>
52
+
53
+
54
+
{{ if .Repo.RepoStats }}
55
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4">
56
+
{{ with .Repo.RepoStats.Language }}
57
+
<div class="flex gap-2 items-center text-sm">
58
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
59
+
<span>{{ . }}</span>
60
+
</div>
61
+
{{ end }}
62
+
{{ with .Repo.RepoStats.StarCount }}
63
+
<div class="flex gap-1 items-center text-sm">
64
+
{{ i "star" "w-3 h-3 fill-current" }}
65
+
<span>{{ . }}</span>
66
+
</div>
67
+
{{ end }}
68
+
{{ with .Repo.RepoStats.IssueCount.Open }}
69
+
<div class="flex gap-1 items-center text-sm">
70
+
{{ i "circle-dot" "w-3 h-3" }}
71
+
<span>{{ . }}</span>
72
+
</div>
73
+
{{ end }}
74
+
{{ with .Repo.RepoStats.PullCount.Open }}
75
+
<div class="flex gap-1 items-center text-sm">
76
+
{{ i "git-pull-request" "w-3 h-3" }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{ end }}
80
+
</div>
81
+
{{ end }}
82
+
</div>
83
+
84
+
{{ with .Repo.Description }}
85
+
<div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
86
+
{{ . | description }}
87
+
</div>
88
+
{{ end }}
89
+
90
+
{{ if gt (len .Issues) 0 }}
91
+
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
92
+
{{ range .Issues }}
93
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
94
+
<div class="py-2 px-6">
95
+
<div class="flex-grow min-w-0 w-full">
96
+
<div class="flex text-sm items-center justify-between w-full">
97
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
98
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
99
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
100
+
{{ .Title | description }}
101
+
</span>
102
+
</div>
103
+
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
+
<span>
105
+
<div class="inline-flex items-center gap-1">
106
+
{{ i "message-square" "w-3 h-3" }}
107
+
{{ len .Comments }}
108
+
</div>
109
+
</span>
110
+
<span class="before:content-['·'] before:select-none"></span>
111
+
<span class="text-sm">
112
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
113
+
</span>
114
+
<div class="hidden md:inline-flex md:gap-1">
115
+
{{ $labelState := .Labels }}
116
+
{{ range $k, $d := $.LabelDefs }}
117
+
{{ range $v, $s := $labelState.GetValSet $d.AtUri.String }}
118
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
119
+
{{ end }}
120
+
{{ end }}
121
+
</div>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
</div>
126
+
</a>
127
+
{{ end }}
128
+
</div>
129
+
{{ end }}
130
+
</div>
131
+
{{ end }}
132
+
133
+
{{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }}
134
+
<div class="flex justify-center mt-8">
135
+
<div class="flex gap-2">
136
+
{{ if gt .Page.Offset 0 }}
137
+
{{ $prev := .Page.Previous }}
138
+
<a
139
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
140
+
hx-boost="true"
141
+
href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
142
+
>
143
+
{{ i "chevron-left" "w-4 h-4" }}
144
+
previous
145
+
</a>
146
+
{{ else }}
147
+
<div></div>
148
+
{{ end }}
149
+
150
+
{{ if eq (len .RepoGroups) .Page.Limit }}
151
+
{{ $next := .Page.Next }}
152
+
<a
153
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
154
+
hx-boost="true"
155
+
href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
156
+
>
157
+
next
158
+
{{ i "chevron-right" "w-4 h-4" }}
159
+
</a>
160
+
{{ end }}
161
+
</div>
162
+
</div>
163
+
{{ end }}
164
+
{{ end }}
165
+
</div>
166
+
</div>
167
+
{{ end }}
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/knots/index.html
···
5
5
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
6
<span class="flex items-center gap-1">
7
7
{{ i "book" "w-3 h-3" }}
8
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
8
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a>
9
9
</span>
10
10
</div>
11
11
+39
appview/pages/templates/labels/fragments/label.html
+39
appview/pages/templates/labels/fragments/label.html
···
1
+
{{ define "labels/fragments/label" }}
2
+
{{ $d := .def }}
3
+
{{ $v := .val }}
4
+
{{ $withPrefix := .withPrefix }}
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
+
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
+
8
+
{{ $lhs := printf "%s" $d.Name }}
9
+
{{ $rhs := "" }}
10
+
11
+
{{ if not $d.ValueType.IsNull }}
12
+
{{ if $d.ValueType.IsDidFormat }}
13
+
{{ $v = resolve $v }}
14
+
{{ end }}
15
+
16
+
{{ if not $withPrefix }}
17
+
{{ $lhs = "" }}
18
+
{{ else }}
19
+
{{ $lhs = printf "%s/" $d.Name }}
20
+
{{ end }}
21
+
22
+
{{ $rhs = printf "%s" $v }}
23
+
{{ end }}
24
+
25
+
{{ printf "%s%s" $lhs $rhs }}
26
+
</span>
27
+
{{ end }}
28
+
29
+
30
+
{{ define "labelVal" }}
31
+
{{ $d := .def }}
32
+
{{ $v := .val }}
33
+
34
+
{{ if $d.ValueType.IsDidFormat }}
35
+
{{ resolve $v }}
36
+
{{ else }}
37
+
{{ $v }}
38
+
{{ end }}
39
+
{{ end }}
+6
appview/pages/templates/labels/fragments/labelDef.html
+6
appview/pages/templates/labels/fragments/labelDef.html
+16
-11
appview/pages/templates/layouts/base.html
+16
-11
appview/pages/templates/layouts/base.html
···
14
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
17
20
<!-- preload main font -->
18
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
22
···
21
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
25
{{ block "extrameta" . }}{{ end }}
23
26
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
25
28
{{ block "topbarLayout" . }}
26
-
<header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
29
+
<header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
27
30
28
31
{{ if .LoggedInUser }}
29
32
<div id="upgrade-banner"
···
37
40
{{ end }}
38
41
39
42
{{ block "mainLayout" . }}
40
-
<div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4">
41
-
{{ block "contentLayout" . }}
42
-
<main class="col-span-1 md:col-span-8">
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
43
47
{{ block "content" . }}{{ end }}
44
48
</main>
45
-
{{ end }}
46
-
47
-
{{ block "contentAfterLayout" . }}
48
-
<main class="col-span-1 md:col-span-8">
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
49
53
{{ block "contentAfter" . }}{{ end }}
50
54
</main>
51
-
{{ end }}
55
+
{{ end }}
56
+
</div>
52
57
</div>
53
58
{{ end }}
54
59
55
60
{{ block "footerLayout" . }}
56
-
<footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12">
61
+
<footer class="bg-white dark:bg-gray-800 mt-12">
57
62
{{ template "layouts/fragments/footer" . }}
58
63
</footer>
59
64
{{ end }}
+18
-6
appview/pages/templates/layouts/fragments/topbar.html
+18
-6
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a>
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
11
+
</a>
6
12
</div>
7
13
8
-
<div id="right-items" class="flex items-center gap-2">
14
+
<div id="right-items" class="flex items-center gap-4">
9
15
{{ with .LoggedInUser }}
10
16
{{ block "newButton" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
11
18
{{ block "dropDown" . }} {{ end }}
12
19
{{ else }}
13
20
<a href="/login">login</a>
···
24
31
{{ define "newButton" }}
25
32
<details class="relative inline-block text-left nav-dropdown">
26
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
27
-
{{ i "plus" "w-4 h-4" }} new
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
28
35
</summary>
29
36
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
30
37
<a href="/repo/new" class="flex items-center gap-2">
···
42
49
{{ define "dropDown" }}
43
50
<details class="relative inline-block text-left nav-dropdown">
44
51
<summary
45
-
class="cursor-pointer list-none flex items-center"
52
+
class="cursor-pointer list-none flex items-center gap-1"
46
53
>
47
54
{{ $user := didOrHandle .Did .Handle }}
48
-
{{ template "user/fragments/picHandle" $user }}
55
+
<img
56
+
src="{{ tinyAvatar $user }}"
57
+
alt=""
58
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
59
+
/>
60
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
49
61
</summary>
50
62
<div
51
63
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+8
-4
appview/pages/templates/layouts/profilebase.html
+8
-4
appview/pages/templates/layouts/profilebase.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
5
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
6
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
8
{{ end }}
9
9
10
10
{{ define "content" }}
11
11
{{ template "profileTabs" . }}
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
12
+
<section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm">
13
13
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
-
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ $style := "hidden md:block md:col-span-3" }}
15
+
{{ if eq $.Active "overview" }}
16
+
{{ $style = "md:col-span-3" }}
17
+
{{ end }}
18
+
<div class="{{ $style }} order-1 order-1">
15
19
<div class="flex flex-col gap-4">
16
20
{{ template "user/fragments/profileCard" .Card }}
17
21
{{ block "punchcard" .Card.Punchcard }} {{ end }}
18
22
</div>
19
23
</div>
24
+
20
25
{{ block "profileContent" . }} {{ end }}
21
26
</div>
22
27
</section>
···
101
106
{{ define "layouts/profilebase" }}
102
107
{{ template "layouts/base" . }}
103
108
{{ end }}
104
-
+6
-8
appview/pages/templates/layouts/repobase.html
+6
-8
appview/pages/templates/layouts/repobase.html
···
41
41
{{ template "repo/fragments/repoDescription" . }}
42
42
</section>
43
43
44
-
<section
45
-
class="w-full flex flex-col"
46
-
>
44
+
<section class="w-full flex flex-col" >
47
45
<nav class="w-full pl-4 overflow-auto">
48
46
<div class="flex z-60">
49
47
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
80
78
{{ end }}
81
79
</div>
82
80
</nav>
83
-
<section
84
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
85
-
>
81
+
{{ block "repoContentLayout" . }}
82
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
86
83
{{ block "repoContent" . }}{{ end }}
87
-
</section>
88
-
{{ block "repoAfter" . }}{{ end }}
84
+
</section>
85
+
{{ block "repoAfter" . }}{{ end }}
86
+
{{ end }}
89
87
</section>
90
88
{{ end }}
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
1
{{ define "title" }}privacy policy{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
1
{{ define "title" }}terms of service{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+11
appview/pages/templates/notifications/fragments/bell.html
···
1
+
{{define "notifications/fragments/bell"}}
2
+
<div class="relative"
3
+
hx-get="/notifications/count"
4
+
hx-target="#notification-count"
5
+
hx-trigger="load, every 30s">
6
+
<a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group">
7
+
{{ i "bell" "w-5 h-5" }}
8
+
<span id="notification-count"></span>
9
+
</a>
10
+
</div>
11
+
{{end}}
+7
appview/pages/templates/notifications/fragments/count.html
+7
appview/pages/templates/notifications/fragments/count.html
···
1
+
{{define "notifications/fragments/count"}}
2
+
{{if and .Count (gt .Count 0)}}
3
+
<span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center">
4
+
{{if gt .Count 99}}99+{{else}}{{.Count}}{{end}}
5
+
</span>
6
+
{{end}}
7
+
{{end}}
+81
appview/pages/templates/notifications/fragments/item.html
+81
appview/pages/templates/notifications/fragments/item.html
···
1
+
{{define "notifications/fragments/item"}}
2
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
+
<div
4
+
class="
5
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
6
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
+
flex gap-2 items-center
8
+
">
9
+
{{ template "notificationIcon" . }}
10
+
<div class="flex-1 w-full flex flex-col gap-1">
11
+
<span>{{ template "notificationHeader" . }}</span>
12
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
+
</div>
14
+
15
+
</div>
16
+
</a>
17
+
{{end}}
18
+
19
+
{{ define "notificationIcon" }}
20
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
23
+
{{ i .Icon "size-3 text-black dark:text-white" }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "notificationHeader" }}
29
+
{{ $actor := resolve .ActorDid }}
30
+
31
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
32
+
{{ if eq .Type "repo_starred" }}
33
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
34
+
{{ else if eq .Type "issue_created" }}
35
+
opened an issue
36
+
{{ else if eq .Type "issue_commented" }}
37
+
commented on an issue
38
+
{{ else if eq .Type "issue_closed" }}
39
+
closed an issue
40
+
{{ else if eq .Type "pull_created" }}
41
+
created a pull request
42
+
{{ else if eq .Type "pull_commented" }}
43
+
commented on a pull request
44
+
{{ else if eq .Type "pull_merged" }}
45
+
merged a pull request
46
+
{{ else if eq .Type "pull_closed" }}
47
+
closed a pull request
48
+
{{ else if eq .Type "followed" }}
49
+
followed you
50
+
{{ else }}
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
{{ define "notificationSummary" }}
55
+
{{ if eq .Type "repo_starred" }}
56
+
<!-- no summary -->
57
+
{{ else if .Issue }}
58
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
59
+
{{ else if .Pull }}
60
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
61
+
{{ else if eq .Type "followed" }}
62
+
<!-- no summary -->
63
+
{{ else }}
64
+
{{ end }}
65
+
{{ end }}
66
+
67
+
{{ define "notificationUrl" }}
68
+
{{ $url := "" }}
69
+
{{ if eq .Type "repo_starred" }}
70
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
71
+
{{ else if .Issue }}
72
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
73
+
{{ else if .Pull }}
74
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
75
+
{{ else if eq .Type "followed" }}
76
+
{{$url = printf "/%s" (resolve .ActorDid)}}
77
+
{{ else }}
78
+
{{ end }}
79
+
80
+
{{ $url }}
81
+
{{ end }}
+65
appview/pages/templates/notifications/list.html
+65
appview/pages/templates/notifications/list.html
···
1
+
{{ define "title" }}notifications{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex items-center justify-between">
6
+
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
+
<a href="/settings/notifications" class="flex items-center gap-2">
8
+
{{ i "settings" "w-4 h-4" }}
9
+
preferences
10
+
</a>
11
+
</div>
12
+
</div>
13
+
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
20
+
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
+
<div class="text-center py-12">
24
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
+
{{ i "bell-off" "w-16 h-16" }}
26
+
</div>
27
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
29
+
</div>
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
64
+
</div>
65
+
{{ end }}
+2
-1
appview/pages/templates/repo/blob.html
+2
-1
appview/pages/templates/repo/blob.html
···
4
4
{{ template "repo/fragments/meta" . }}
5
5
6
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
7
+
{{ $url := printf "https://tangled.org/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
8
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
···
78
78
{{ end }}
79
79
</div>
80
80
{{ end }}
81
+
{{ template "fragments/multiline-select" }}
81
82
{{ end }}
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/branches.html
···
4
4
5
5
{{ define "extrameta" }}
6
6
{{ $title := printf "branches · %s" .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.sh/%s/branches" .RepoInfo.FullName }}
8
-
7
+
{{ $url := printf "https://tangled.org/%s/branches" .RepoInfo.FullName }}
8
+
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
{{ end }}
11
11
+2
-2
appview/pages/templates/repo/commit.html
+2
-2
appview/pages/templates/repo/commit.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := printf "commit %s · %s" .Diff.Commit.This .RepoInfo.FullName }}
5
-
{{ $url := printf "https://tangled.sh/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
5
+
{{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
6
6
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
···
61
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
62
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
63
</div>
64
-
<div class="my-1 pt-2 text-xs border-t">
64
+
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
66
66
<div class="break-all">{{ .VerifiedCommit.Fingerprint $commit.This }}</div>
67
67
</div>
+7
appview/pages/templates/repo/fork.html
+7
appview/pages/templates/repo/fork.html
···
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
9
16
<fieldset class="space-y-3">
10
17
<legend class="dark:text-white">Select a knot to fork into</legend>
11
18
<div class="space-y-2">
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
1
{{ define "repo/fragments/cloneDropdown" }}
2
2
{{ $knot := .RepoInfo.Knot }}
3
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
4
+
{{ $knot = "tangled.org" }}
5
5
{{ end }}
6
6
7
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+6
appview/pages/templates/repo/fragments/colorBall.html
+6
appview/pages/templates/repo/fragments/colorBall.html
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
1
+
{{ define "repo/fragments/editLabelPanel" }}
2
+
<form
3
+
id="edit-label-panel"
4
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
5
+
hx-indicator="#spinner"
6
+
hx-disabled-elt="#save-btn,#cancel-btn"
7
+
hx-swap="none"
8
+
class="flex flex-col gap-6"
9
+
>
10
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
11
+
<input type="hidden" name="subject" value="{{ .Subject }}">
12
+
{{ template "editBasicLabels" . }}
13
+
{{ template "editKvLabels" . }}
14
+
{{ template "editLabelPanelActions" . }}
15
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
16
+
</form>
17
+
{{ end }}
18
+
19
+
{{ define "editBasicLabels" }}
20
+
{{ $defs := .Defs }}
21
+
{{ $subject := .Subject }}
22
+
{{ $state := .State }}
23
+
{{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }}
24
+
<div>
25
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
26
+
27
+
<div class="flex gap-1 items-center flex-wrap">
28
+
{{ range $k, $d := $defs }}
29
+
{{ $isChecked := $state.ContainsLabel $k }}
30
+
{{ if $d.ValueType.IsNull }}
31
+
{{ $fieldName := $d.AtUri }}
32
+
<label class="{{$labelStyle}}">
33
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
34
+
{{ template "labels/fragments/labelDef" $d }}
35
+
</label>
36
+
{{ end }}
37
+
{{ else }}
38
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
39
+
No labels defined yet. You can choose default labels or define custom
40
+
labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
41
+
</p>
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "editKvLabels" }}
48
+
{{ $defs := .Defs }}
49
+
{{ $subject := .Subject }}
50
+
{{ $state := .State }}
51
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }}
52
+
53
+
{{ range $k, $d := $defs }}
54
+
{{ if (not $d.ValueType.IsNull) }}
55
+
{{ $fieldName := $d.AtUri }}
56
+
{{ $valset := $state.GetValSet $k }}
57
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
58
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
59
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
60
+
<!-- checkbox -->
61
+
{{ range $variant := $d.ValueType.Enum }}
62
+
<label class="{{$labelStyle}}">
63
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
64
+
{{ $variant }}
65
+
</label>
66
+
{{ end }}
67
+
{{ else if $d.Multiple }}
68
+
<!-- dynamically growing input fields -->
69
+
{{ range $v, $s := $valset }}
70
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
71
+
{{ else }}
72
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
73
+
{{ end }}
74
+
{{ template "addFieldButton" $d }}
75
+
{{ else if $d.ValueType.IsEnum }}
76
+
<!-- radio buttons -->
77
+
{{ $isUsed := $state.ContainsLabel $k }}
78
+
{{ range $variant := $d.ValueType.Enum }}
79
+
<label class="{{$labelStyle}}">
80
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
81
+
{{ $variant }}
82
+
</label>
83
+
{{ end }}
84
+
<label class="{{$labelStyle}}">
85
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
86
+
None
87
+
</label>
88
+
{{ else }}
89
+
<!-- single input field based on value type -->
90
+
{{ range $v, $s := $valset }}
91
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
92
+
{{ else }}
93
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
94
+
{{ end }}
95
+
{{ end }}
96
+
</div>
97
+
{{ end }}
98
+
{{ end }}
99
+
{{ end }}
100
+
101
+
{{ define "multipleInputField" }}
102
+
<div class="flex gap-1 items-stretch">
103
+
{{ template "valueTypeInput" . }}
104
+
{{ template "removeFieldButton" }}
105
+
</div>
106
+
{{ end }}
107
+
108
+
{{ define "addFieldButton" }}
109
+
<div style="display:none" id="tpl-{{ .Id }}">
110
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
111
+
</div>
112
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
113
+
{{ i "plus" "size-4" }} add
114
+
</button>
115
+
{{ end }}
116
+
117
+
{{ define "removeFieldButton" }}
118
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
119
+
{{ i "trash-2" "size-4" }}
120
+
</button>
121
+
{{ end }}
122
+
123
+
{{ define "valueTypeInput" }}
124
+
{{ $def := .def }}
125
+
{{ $valueType := $def.ValueType }}
126
+
{{ $value := .value }}
127
+
{{ $key := .key }}
128
+
129
+
{{ if $valueType.IsBool }}
130
+
{{ template "boolTypeInput" $ }}
131
+
{{ else if $valueType.IsInt }}
132
+
{{ template "intTypeInput" $ }}
133
+
{{ else if $valueType.IsString }}
134
+
{{ template "stringTypeInput" $ }}
135
+
{{ else if $valueType.IsNull }}
136
+
{{ template "nullTypeInput" $ }}
137
+
{{ end }}
138
+
{{ end }}
139
+
140
+
{{ define "boolTypeInput" }}
141
+
{{ $def := .def }}
142
+
{{ $fieldName := $def.AtUri }}
143
+
{{ $value := .value }}
144
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
145
+
<div class="flex flex-col gap-1">
146
+
<label class="{{$labelStyle}}">
147
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
148
+
None
149
+
</label>
150
+
<label class="{{$labelStyle}}">
151
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
152
+
None
153
+
</label>
154
+
<label class="{{$labelStyle}}">
155
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
156
+
None
157
+
</label>
158
+
</div>
159
+
{{ end }}
160
+
161
+
{{ define "intTypeInput" }}
162
+
{{ $def := .def }}
163
+
{{ $fieldName := $def.AtUri }}
164
+
{{ $value := .value }}
165
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
166
+
{{ end }}
167
+
168
+
{{ define "stringTypeInput" }}
169
+
{{ $def := .def }}
170
+
{{ $fieldName := $def.AtUri }}
171
+
{{ $valueType := $def.ValueType }}
172
+
{{ $value := .value }}
173
+
{{ if $valueType.IsDidFormat }}
174
+
{{ $value = trimPrefix (resolve .value) "@" }}
175
+
{{ end }}
176
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
+
{{ end }}
178
+
179
+
{{ define "nullTypeInput" }}
180
+
{{ $def := .def }}
181
+
{{ $fieldName := $def.AtUri }}
182
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
183
+
{{ end }}
184
+
185
+
{{ define "editLabelPanelActions" }}
186
+
<div class="flex gap-2 pt-2">
187
+
<button
188
+
id="cancel-btn"
189
+
type="button"
190
+
hx-get="/{{ .RepoInfo.FullName }}/label"
191
+
hx-vals='{"subject": "{{.Subject}}"}'
192
+
hx-swap="outerHTML"
193
+
hx-target="#edit-label-panel"
194
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group">
195
+
{{ i "x" "size-4" }} cancel
196
+
</button>
197
+
198
+
<button
199
+
id="save-btn"
200
+
type="submit"
201
+
class="btn w-1/2 flex items-center">
202
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
203
+
<span id="spinner" class="group">
204
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
205
+
</span>
206
+
</button>
207
+
</div>
208
+
{{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
+43
appview/pages/templates/repo/fragments/labelPanel.html
···
1
+
{{ define "repo/fragments/labelPanel" }}
2
+
<div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0">
3
+
{{ template "basicLabels" . }}
4
+
{{ template "kvLabels" . }}
5
+
</div>
6
+
{{ end }}
7
+
8
+
{{ define "basicLabels" }}
9
+
<div>
10
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }}
11
+
12
+
{{ $hasLabel := false }}
13
+
<div class="flex gap-1 items-center flex-wrap">
14
+
{{ range $k, $d := .Defs }}
15
+
{{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }}
16
+
{{ $hasLabel = true }}
17
+
{{ template "labels/fragments/label" (dict "def" $d "val" "") }}
18
+
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ if not $hasLabel }}
22
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "kvLabels" }}
29
+
{{ range $k, $d := .Defs }}
30
+
{{ if (not $d.ValueType.IsNull) }}
31
+
<div id="label-{{$d.Id}}">
32
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }}
33
+
<div class="flex gap-1 items-center flex-wrap">
34
+
{{ range $v, $s := $.State.GetValSet $d.AtUri.String }}
35
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }}
36
+
{{ else }}
37
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
38
+
{{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
1
+
{{ define "repo/fragments/labelSectionHeader" }}
2
+
3
+
<div class="flex justify-between items-center gap-2">
4
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
5
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
6
+
<a
7
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
8
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
9
+
hx-vals='{"subject": "{{.Subject}}"}'
10
+
hx-swap="outerHTML"
11
+
hx-target="#label-panel">
12
+
{{ i "pencil" "size-3" }}
13
+
</a>
14
+
{{ end }}
15
+
</div>
16
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
-6
appview/pages/templates/repo/fragments/languageBall.html
-6
appview/pages/templates/repo/fragments/languageBall.html
···
1
-
{{ define "repo/fragments/languageBall" }}
2
-
<div
3
-
class="size-2 rounded-full"
4
-
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"
5
-
></div>
6
-
{{ end }}
+9
-5
appview/pages/templates/repo/fragments/meta.html
+9
-5
appview/pages/templates/repo/fragments/meta.html
···
1
1
{{ define "repo/fragments/meta" }}
2
2
<meta
3
3
name="vcs:clone"
4
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
4
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
5
5
/>
6
6
<meta
7
7
name="forge:summary"
8
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
8
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
9
9
/>
10
10
<meta
11
11
name="forge:dir"
12
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
12
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
13
13
/>
14
14
<meta
15
15
name="forge:file"
16
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
16
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
17
17
/>
18
18
<meta
19
19
name="forge:line"
20
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
20
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
21
21
/>
22
22
<meta
23
23
name="go-import"
24
24
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
25
+
/>
26
+
<meta
27
+
name="go-import"
28
+
content="tangled.org/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.org/{{ .RepoInfo.FullName }}"
25
29
/>
26
30
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/og.html
+1
-1
appview/pages/templates/repo/fragments/og.html
···
1
1
{{ define "repo/fragments/og" }}
2
2
{{ $title := or .Title .RepoInfo.FullName }}
3
3
{{ $description := or .Description .RepoInfo.Description }}
4
-
{{ $url := or .Url (printf "https://tangled.sh/%s" .RepoInfo.FullName) }}
4
+
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
5
6
6
7
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
+26
appview/pages/templates/repo/fragments/participants.html
+26
appview/pages/templates/repo/fragments/participants.html
···
1
+
{{ define "repo/fragments/participants" }}
2
+
{{ $all := . }}
3
+
{{ $ps := take $all 5 }}
4
+
<div class="px-6 md:px-0">
5
+
<div class="py-1 flex items-center text-sm">
6
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
+
</div>
9
+
<div class="flex items-center -space-x-3 mt-2">
10
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
+
{{ range $i, $p := $ps }}
12
+
<img
13
+
src="{{ tinyAvatar . }}"
14
+
alt=""
15
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
+
/>
17
+
{{ end }}
18
+
19
+
{{ if gt (len $all) 5 }}
20
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
+
+{{ sub (len $all) 5 }}
22
+
</span>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
+24
appview/pages/templates/repo/fragments/readme.html
+24
appview/pages/templates/repo/fragments/readme.html
···
1
+
{{ define "repo/fragments/readme" }}
2
+
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
+
{{- if .ReadmeFileName -}}
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
+
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
+
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
+
</div>
8
+
{{- end -}}
9
+
<section
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
14
+
{{ end }}"
15
+
>
16
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
17
+
{{- .Readme -}}
18
+
</pre>
19
+
{{- else -}}
20
+
{{ .HTMLReadme }}
21
+
{{- end -}}</article>
22
+
</section>
23
+
</div>
24
+
{{ end }}
+6
-1
appview/pages/templates/repo/fragments/shortTimeAgo.html
+6
-1
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
1
1
{{ define "repo/fragments/shortTimeAgo" }}
2
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
2
+
{{ $formatted := shortRelTimeFmt . }}
3
+
{{ $content := printf "%s ago" $formatted }}
4
+
{{ if eq $formatted "now" }}
5
+
{{ $content = "now" }}
6
+
{{ end }}
7
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }}
3
8
{{ end }}
4
9
+2
-23
appview/pages/templates/repo/index.html
+2
-23
appview/pages/templates/repo/index.html
···
49
49
<div
50
50
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
51
>
52
-
{{ template "repo/fragments/languageBall" $value.Name }}
52
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
53
<div>{{ or $value.Name "Other" }}
54
54
<span class="text-gray-500 dark:text-gray-400">
55
55
{{ if lt $value.Percentage 0.05 }}
···
340
340
341
341
{{ define "repoAfter" }}
342
342
{{- if or .HTMLReadme .Readme -}}
343
-
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
344
-
{{- if .ReadmeFileName -}}
345
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
346
-
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
347
-
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
348
-
</div>
349
-
{{- end -}}
350
-
<section
351
-
class="p-6 overflow-auto {{ if not .Raw }}
352
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
353
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
354
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
355
-
{{ end }}"
356
-
>
357
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
-
{{- .Readme -}}
359
-
</pre>
360
-
{{- else -}}
361
-
{{ .HTMLReadme }}
362
-
{{- end -}}</article>
363
-
</section>
364
-
</div>
343
+
{{ template "repo/fragments/readme" . }}
365
344
{{- end -}}
366
345
{{ end }}
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
···
3
3
{{ range $item := .CommentList }}
4
4
{{ template "commentListing" (list $ .) }}
5
5
{{ end }}
6
-
<div>
6
+
</div>
7
7
{{ end }}
8
8
9
9
{{ define "commentListing" }}
···
16
16
"Issue" $root.Issue
17
17
"Comment" $comment.Self) }}
18
18
19
-
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
19
+
<div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50">
20
20
{{ template "topLevelComment" $params }}
21
21
22
-
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
22
+
<div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700">
23
23
{{ range $index, $reply := $comment.Replies }}
24
24
<div class="relative ">
25
25
<!-- Horizontal connector -->
26
-
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
26
+
<div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div>
27
27
28
28
<div class="pl-2">
29
29
{{
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+26
-5
appview/pages/templates/repo/issues/issue.html
+26
-5
appview/pages/templates/repo/issues/issue.html
···
3
3
4
4
{{ define "extrameta" }}
5
5
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
6
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
7
8
8
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
9
{{ end }}
10
10
11
+
{{ define "repoContentLayout" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
13
+
<div class="col-span-1 md:col-span-8">
14
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
15
+
{{ block "repoContent" . }}{{ end }}
16
+
</section>
17
+
{{ block "repoAfter" . }}{{ end }}
18
+
</div>
19
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
20
+
{{ template "repo/fragments/labelPanel"
21
+
(dict "RepoInfo" $.RepoInfo
22
+
"Defs" $.LabelDefs
23
+
"Subject" $.Issue.AtUri
24
+
"State" $.Issue.Labels) }}
25
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
26
+
</div>
27
+
</div>
28
+
{{ end }}
29
+
11
30
{{ define "repoContent" }}
12
31
<section id="issue-{{ .Issue.IssueId }}">
13
32
{{ template "issueHeader" .Issue }}
···
15
34
{{ if .Issue.Body }}
16
35
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
17
36
{{ end }}
18
-
{{ template "issueReactions" . }}
37
+
<div class="flex flex-wrap gap-2 items-stretch mt-4">
38
+
{{ template "issueReactions" . }}
39
+
</div>
19
40
</section>
20
41
{{ end }}
21
42
···
86
107
{{ end }}
87
108
88
109
{{ define "issueReactions" }}
89
-
<div class="flex items-center gap-2 mt-2">
110
+
<div class="flex items-center gap-2">
90
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
91
112
{{ range $kind := .OrderedReactionKinds }}
92
113
{{
···
100
121
{{ end }}
101
122
</div>
102
123
{{ end }}
124
+
103
125
104
126
{{ define "repoAfter" }}
105
127
<div class="flex flex-col gap-4 mt-4">
···
113
135
}}
114
136
115
137
{{ template "repo/issues/fragments/newComment" . }}
116
-
<div>
138
+
</div>
117
139
{{ end }}
118
-
+3
-46
appview/pages/templates/repo/issues/issues.html
+3
-46
appview/pages/templates/repo/issues/issues.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "issues"}}
5
-
{{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/issues" .RepoInfo.FullName }}
6
6
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
···
37
37
{{ end }}
38
38
39
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
</p>
83
-
</div>
84
-
{{ end }}
40
+
<div class="mt-2">
41
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
85
42
</div>
86
43
{{ block "pagination" . }} {{ end }}
87
44
{{ end }}
+1
-1
appview/pages/templates/repo/log.html
+1
-1
appview/pages/templates/repo/log.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := printf "commits · %s" .RepoInfo.FullName }}
5
-
{{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/commits" .RepoInfo.FullName }}
6
6
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
1
{{ define "title" }}new repo{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
6
13
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
14
+
{{ end }}
19
15
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
29
21
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
37
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
38
37
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
41
52
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
58
55
</div>
59
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
-
</fieldset>
56
+
</div>
57
+
</div>
58
+
{{ end }}
61
59
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
71
64
</div>
72
-
</form>
73
-
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
74
176
{{ end }}
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pipelines"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
6
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
7
{{ end }}
8
8
···
60
60
<span class="inline-flex gap-2 items-center">
61
61
<span class="font-bold">{{ $target }}</span>
62
62
{{ i "arrow-left" "size-4" }}
63
-
{{ .Trigger.PRSourceBranch }}
63
+
{{ .Trigger.PRSourceBranch }}
64
64
<span class="text-sm font-mono">
65
65
@
66
66
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pipelines"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
6
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
7
{{ end }}
8
8
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
···
5
5
6
6
{{ define "extrameta" }}
7
7
{{ $title := printf "interdiff of %d and %d · %s · pull #%d · %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
-
8
+
{{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
11
11
{{ end }}
12
12
+2
-2
appview/pages/templates/repo/pulls/patch.html
+2
-2
appview/pages/templates/repo/pulls/patch.html
···
5
5
6
6
{{ define "extrameta" }}
7
7
{{ $title := printf "patch of %s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
-
8
+
{{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
11
11
{{ end }}
12
12
+31
-13
appview/pages/templates/repo/pulls/pull.html
+31
-13
appview/pages/templates/repo/pulls/pull.html
···
4
4
5
5
{{ define "extrameta" }}
6
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
7
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
8
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
{{ end }}
11
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
12
30
13
31
{{ define "repoContent" }}
14
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
57
{{ with $item }}
40
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
43
61
<!-- round number -->
44
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
64
</div>
47
65
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
67
<span class="gap-1 flex items-center">
50
68
{{ $owner := resolve $.Pull.OwnerDid }}
51
69
{{ $re := "re" }}
···
72
90
<span class="hidden md:inline">diff</span>
73
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
92
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
84
101
{{ end }}
102
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
85
103
</div>
86
104
</summary>
87
105
···
146
164
147
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
166
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
150
168
{{ if gt $cidx 0 }}
151
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
170
{{ end }}
+8
-1
appview/pages/templates/repo/pulls/pulls.html
+8
-1
appview/pages/templates/repo/pulls/pulls.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pulls"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/pulls" .RepoInfo.FullName }}
6
6
7
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
8
{{ end }}
···
107
107
{{ if and $pipeline $pipeline.Id }}
108
108
<span class="before:content-['·']"></span>
109
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
+
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
110
117
{{ end }}
111
118
</div>
112
119
</div>
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
1
+
{{ define "repo/settings/fragments/addLabelDefModal" }}
2
+
<div class="grid grid-cols-2">
3
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
4
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
5
+
6
+
<!-- Labels as direct siblings -->
7
+
{{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }}
8
+
<label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l">
9
+
Basic Labels
10
+
</label>
11
+
<label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r">
12
+
Key-value Labels
13
+
</label>
14
+
15
+
<!-- Basic Labels Content - direct sibling -->
16
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
17
+
{{ template "basicLabelDef" . }}
18
+
</div>
19
+
20
+
<!-- Key-value Labels Content - direct sibling -->
21
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
22
+
{{ template "kvLabelDef" . }}
23
+
</div>
24
+
25
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
26
+
</div>
27
+
{{ end }}
28
+
29
+
{{ define "basicLabelDef" }}
30
+
<form
31
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
32
+
hx-indicator="#spinner"
33
+
hx-swap="none"
34
+
hx-on::after-request="if(event.detail.successful) this.reset()"
35
+
class="flex flex-col space-y-4">
36
+
37
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
38
+
39
+
{{ template "nameInput" . }}
40
+
{{ template "scopeInput" . }}
41
+
{{ template "colorInput" . }}
42
+
43
+
<div class="flex gap-2 pt-2">
44
+
{{ template "cancelButton" . }}
45
+
{{ template "submitButton" . }}
46
+
</div>
47
+
</form>
48
+
{{ end }}
49
+
50
+
{{ define "kvLabelDef" }}
51
+
<form
52
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
53
+
hx-indicator="#spinner"
54
+
hx-swap="none"
55
+
hx-on::after-request="if(event.detail.successful) this.reset()"
56
+
class="flex flex-col space-y-4">
57
+
58
+
<p class="text-gray-500 dark:text-gray-400">
59
+
These labels are more detailed, they can have a key and an associated
60
+
value. You may define additional constraints on label values.
61
+
</p>
62
+
63
+
{{ template "nameInput" . }}
64
+
{{ template "valueInput" . }}
65
+
{{ template "multipleInput" . }}
66
+
{{ template "scopeInput" . }}
67
+
{{ template "colorInput" . }}
68
+
69
+
<div class="flex gap-2 pt-2">
70
+
{{ template "cancelButton" . }}
71
+
{{ template "submitButton" . }}
72
+
</div>
73
+
</form>
74
+
{{ end }}
75
+
76
+
{{ define "nameInput" }}
77
+
<div class="w-full">
78
+
<label for="name">Name</label>
79
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
80
+
</div>
81
+
{{ end }}
82
+
83
+
{{ define "colorInput" }}
84
+
<div class="w-full">
85
+
<label for="color">Color</label>
86
+
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
87
+
{{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }}
88
+
{{ range $i, $color := $colors }}
89
+
<label class="relative">
90
+
<input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}>
91
+
{{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }}
92
+
</label>
93
+
{{ end }}
94
+
</div>
95
+
</div>
96
+
{{ end }}
97
+
98
+
{{ define "scopeInput" }}
99
+
<div class="w-full">
100
+
<label>Scope</label>
101
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
102
+
<input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked />
103
+
Issues
104
+
</label>
105
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
106
+
<input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked />
107
+
Pull Requests
108
+
</label>
109
+
</div>
110
+
{{ end }}
111
+
112
+
{{ define "valueInput" }}
113
+
<div class="w-full">
114
+
<label for="valueType">Value Type</label>
115
+
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
116
+
<option value="string">String</option>
117
+
<option value="integer">Integer</option>
118
+
</select>
119
+
</div>
120
+
121
+
<div class="w-full">
122
+
<label for="enumValues">Permitted values</label>
123
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
124
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
125
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
126
+
</p>
127
+
</div>
128
+
129
+
<div class="w-full">
130
+
<label for="valueFormat">String format</label>
131
+
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
132
+
<option value="any" selected>Any</option>
133
+
<option value="did">DID</option>
134
+
</select>
135
+
</div>
136
+
{{ end }}
137
+
138
+
{{ define "multipleInput" }}
139
+
<div class="w-full flex flex-wrap gap-2">
140
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
141
+
<span>Allow multiple values</span>
142
+
</div>
143
+
{{ end }}
144
+
145
+
{{ define "cancelButton" }}
146
+
<button
147
+
type="button"
148
+
popovertarget="add-labeldef-modal"
149
+
popovertargetaction="hide"
150
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
151
+
>
152
+
{{ i "x" "size-4" }} cancel
153
+
</button>
154
+
{{ end }}
155
+
156
+
{{ define "submitButton" }}
157
+
<button type="submit" class="btn-create w-1/2 flex items-center">
158
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
159
+
<span id="spinner" class="group">
160
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
161
+
</span>
162
+
</button>
163
+
{{ end }}
164
+
165
+
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
···
1
+
{{ define "repo/settings/fragments/labelListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $label := index . 1 }}
4
+
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
5
+
{{ template "labels/fragments/labelDef" $label }}
6
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
7
+
{{ if $label.ValueType.IsNull }}
8
+
basic
9
+
{{ else }}
10
+
{{ $label.ValueType.Type }} type
11
+
{{ end }}
12
+
13
+
{{ if $label.ValueType.IsEnum }}
14
+
<span class="before:content-['·'] before:select-none"></span>
15
+
{{ join $label.ValueType.Enum ", " }}
16
+
{{ end }}
17
+
18
+
{{ if $label.ValueType.IsDidFormat }}
19
+
<span class="before:content-['·'] before:select-none"></span>
20
+
DID format
21
+
{{ end }}
22
+
23
+
{{ if $label.Multiple }}
24
+
<span class="before:content-['·'] before:select-none"></span>
25
+
multiple
26
+
{{ end }}
27
+
28
+
<span class="before:content-['·'] before:select-none"></span>
29
+
{{ join $label.Scope ", " }}
30
+
</div>
31
+
</div>
32
+
{{ end }}
+126
appview/pages/templates/repo/settings/general.html
+126
appview/pages/templates/repo/settings/general.html
···
7
7
</div>
8
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
9
{{ template "branchSettings" . }}
10
+
{{ template "defaultLabelSettings" . }}
11
+
{{ template "customLabelSettings" . }}
10
12
{{ template "deleteRepo" . }}
11
13
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
12
14
</div>
···
42
44
</div>
43
45
{{ end }}
44
46
47
+
{{ define "defaultLabelSettings" }}
48
+
<div class="flex flex-col gap-2">
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
85
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
86
+
{{ range .DefaultLabels }}
87
+
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
88
+
{{ template "repo/settings/fragments/labelListing" (list $ .) }}
89
+
{{ $action := "subscribe" }}
90
+
{{ $icon := "plus" }}
91
+
{{ if mapContains $.SubscribedLabels .AtUri.String }}
92
+
{{ $action = "unsubscribe" }}
93
+
{{ $icon = "minus" }}
94
+
{{ end }}
95
+
<button
96
+
class="btn gap-2 group"
97
+
title="{{$action}} from label"
98
+
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}
99
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
100
+
hx-swap="none"
101
+
hx-vals='{"label": "{{ .AtUri.String }}"}'>
102
+
{{ i $icon "size-4" }}
103
+
<span class="hidden md:inline">{{$action}}</span>
104
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</button>
106
+
</div>
107
+
{{ else }}
108
+
<div class="flex items-center justify-center p-2 text-gray-500">
109
+
no labels added yet
110
+
</div>
111
+
{{ end }}
112
+
</div>
113
+
<div id="default-label-operation" class="error"></div>
114
+
</div>
115
+
{{ end }}
116
+
117
+
{{ define "customLabelSettings" }}
118
+
<div class="flex flex-col gap-2">
119
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
120
+
<div class="col-span-1 md:col-span-2">
121
+
<h2 class="text-sm pb-2 uppercase font-bold">Custom Labels</h2>
122
+
</div>
123
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
124
+
<button
125
+
title="Add custom label"
126
+
class="btn flex items-center gap-2"
127
+
popovertarget="add-labeldef-modal"
128
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
129
+
popovertargetaction="toggle">
130
+
{{ i "plus" "size-4" }}
131
+
add label
132
+
</button>
133
+
<div
134
+
id="add-labeldef-modal"
135
+
popover
136
+
class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
137
+
{{ template "repo/settings/fragments/addLabelDefModal" . }}
138
+
</div>
139
+
</div>
140
+
</div>
141
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
142
+
{{ range .Labels }}
143
+
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
144
+
{{ template "repo/settings/fragments/labelListing" (list $ .) }}
145
+
{{ if $.RepoInfo.Roles.IsOwner }}
146
+
<button
147
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
148
+
title="Delete label"
149
+
hx-delete="/{{ $.RepoInfo.FullName }}/settings/label"
150
+
hx-swap="none"
151
+
hx-vals='{"label-id": "{{ .Id }}"}'
152
+
hx-confirm="Are you sure you want to delete the label `{{ .Name }}`?"
153
+
>
154
+
{{ i "trash-2" "w-5 h-5" }}
155
+
<span class="hidden md:inline">delete</span>
156
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
157
+
</button>
158
+
{{ end }}
159
+
</div>
160
+
{{ else }}
161
+
<div class="flex items-center justify-center p-2 text-gray-500">
162
+
no labels added yet
163
+
</div>
164
+
{{ end }}
165
+
</div>
166
+
<div id="label-operation" class="error"></div>
167
+
</div>
168
+
{{ end }}
169
+
45
170
{{ define "deleteRepo" }}
46
171
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
47
172
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
···
68
193
</div>
69
194
{{ end }}
70
195
{{ end }}
196
+
+2
-2
appview/pages/templates/repo/settings/pipelines.html
+2
-2
appview/pages/templates/repo/settings/pipelines.html
···
22
22
<p class="text-gray-500 dark:text-gray-400">
23
23
Choose a spindle to execute your workflows on. Only repository owners
24
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
26
26
click to learn more.
27
27
</a>
28
28
</p>
···
109
109
hx-swap="none"
110
110
class="flex flex-col gap-2"
111
111
>
112
-
<p class="uppercase p-0">ADD SECRET</p>
112
+
<p class="uppercase p-0 font-bold">ADD SECRET</p>
113
113
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
114
<input
115
115
type="text"
+7
-1
appview/pages/templates/repo/tree.html
+7
-1
appview/pages/templates/repo/tree.html
···
10
10
11
11
{{ template "repo/fragments/meta" . }}
12
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
-
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
13
+
{{ $url := printf "https://tangled.org/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
14
15
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
16
{{ end }}
···
88
88
</div>
89
89
</main>
90
90
{{end}}
91
+
92
+
{{ define "repoAfter" }}
93
+
{{- if or .HTMLReadme .Readme -}}
94
+
{{ template "repo/fragments/readme" . }}
95
+
{{- end -}}
96
+
{{ end }}
+1
-1
appview/pages/templates/spindles/index.html
+1
-1
appview/pages/templates/spindles/index.html
···
5
5
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
6
<span class="flex items-center gap-1">
7
7
{{ i "book" "w-3 h-3" }}
8
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
8
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a>
9
9
</span>
10
10
</div>
11
11
+1
-1
appview/pages/templates/strings/dashboard.html
+1
-1
appview/pages/templates/strings/dashboard.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
5
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
6
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" />
7
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
8
{{ end }}
9
9
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
3
{{ define "content" }}
4
4
<div class="px-6 py-2 mb-4">
5
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
8
{{ else }}
9
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
10
{{ end }}
+4
-3
appview/pages/templates/strings/string.html
+4
-3
appview/pages/templates/strings/string.html
···
4
4
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
5
<meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" />
6
6
<meta property="og:type" content="object" />
7
-
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
7
+
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
8
<meta property="og:description" content="{{ .String.Description }}" />
9
9
{{ end }}
10
10
···
23
23
hx-boost="true"
24
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
25
25
{{ i "pencil" "size-4" }}
26
-
<span class="hidden md:inline">edit</span>
26
+
<span class="hidden md:inline">edit</span>
27
27
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
28
28
</a>
29
29
<button
···
34
34
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
35
35
>
36
36
{{ i "trash-2" "size-4" }}
37
-
<span class="hidden md:inline">delete</span>
37
+
<span class="hidden md:inline">delete</span>
38
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
39
</button>
40
40
</div>
···
80
80
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
81
{{ end }}
82
82
</div>
83
+
{{ template "fragments/multiline-select" }}
83
84
</section>
84
85
{{ end }}
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
26
{{ end }}
27
27
28
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
29
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
32
35
</div>
33
36
{{ with .Description }}
34
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
45
43
46
{{ define "stringCardInfo" }}
44
47
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
50
<span class="select-none [&:before]:content-['·']"></span>
53
51
{{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
1
+
{{ define "timeline/fragments/goodfirstissues" }}
2
+
{{ if .GfiLabel }}
3
+
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
+
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
+
<div class="flex-1 flex flex-col gap-2">
6
+
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
+
<p>
8
+
Make your first contribution to an open-source project this October.
9
+
<em>good-first-issue</em> helps new contributors find easy ways to
10
+
start contributing to open-source projects.
11
+
</p>
12
+
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
13
+
Browse issues {{ i "arrow-right" "size-4" }}
14
+
</span>
15
+
</div>
16
+
<div class="hidden md:block relative px-16 scale-150">
17
+
<div class="relative opacity-60">
18
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
19
+
</div>
20
+
<div class="relative -mt-4 ml-2 opacity-80">
21
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
22
+
</div>
23
+
<div class="relative -mt-4 ml-4">
24
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
25
+
</div>
26
+
</div>
27
+
</div>
28
+
</a>
29
+
{{ end }}
30
+
{{ end }}
+1
-2
appview/pages/templates/timeline/fragments/hero.html
+1
-2
appview/pages/templates/timeline/fragments/hero.html
···
22
22
</div>
23
23
24
24
<figure class="w-full hidden md:block md:w-auto">
25
-
<a href="https://tangled.sh/@tangled.sh/core" class="block">
25
+
<a href="https://tangled.org/@tangled.org/core" class="block">
26
26
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
27
27
</a>
28
28
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
···
31
31
</figure>
32
32
</div>
33
33
{{ end }}
34
-
+23
-35
appview/pages/templates/timeline/fragments/timeline.html
+23
-35
appview/pages/templates/timeline/fragments/timeline.html
···
13
13
{{ with $e }}
14
14
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
15
{{ if .Repo }}
16
-
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
16
+
{{ template "timeline/fragments/repoEvent" (list $ .) }}
17
17
{{ else if .Star }}
18
-
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
18
+
{{ template "timeline/fragments/starEvent" (list $ .) }}
19
19
{{ else if .Follow }}
20
-
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
20
+
{{ template "timeline/fragments/followEvent" (list $ .) }}
21
21
{{ end }}
22
22
</div>
23
23
{{ end }}
···
29
29
30
30
{{ define "timeline/fragments/repoEvent" }}
31
31
{{ $root := index . 0 }}
32
-
{{ $repo := index . 1 }}
33
-
{{ $source := index . 2 }}
32
+
{{ $event := index . 1 }}
33
+
{{ $repo := $event.Repo }}
34
+
{{ $source := $event.Source }}
34
35
{{ $userHandle := resolve $repo.Did }}
35
36
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
36
37
{{ template "user/fragments/picHandleLink" $repo.Did }}
···
51
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
52
53
</div>
53
54
{{ with $repo }}
54
-
{{ template "user/fragments/repoCard" (list $root . true) }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
55
56
{{ end }}
56
57
{{ end }}
57
58
58
59
{{ define "timeline/fragments/starEvent" }}
59
60
{{ $root := index . 0 }}
60
-
{{ $star := index . 1 }}
61
+
{{ $event := index . 1 }}
62
+
{{ $star := $event.Star }}
61
63
{{ with $star }}
62
64
{{ $starrerHandle := resolve .StarredByDid }}
63
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
···
70
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
71
73
</div>
72
74
{{ with .Repo }}
73
-
{{ template "user/fragments/repoCard" (list $root . true) }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
74
76
{{ end }}
75
77
{{ end }}
76
78
{{ end }}
77
79
78
80
{{ define "timeline/fragments/followEvent" }}
79
81
{{ $root := index . 0 }}
80
-
{{ $follow := index . 1 }}
81
-
{{ $profile := index . 2 }}
82
-
{{ $stat := index . 3 }}
82
+
{{ $event := index . 1 }}
83
+
{{ $follow := $event.Follow }}
84
+
{{ $profile := $event.Profile }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
83
87
84
88
{{ $userHandle := resolve $follow.UserDid }}
85
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
89
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
90
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
91
95
</div>
92
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
93
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
94
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
95
-
</div>
96
-
97
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
98
-
<a href="/{{ $subjectHandle }}">
99
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
100
-
</a>
101
-
{{ with $profile }}
102
-
{{ with .Description }}
103
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
104
-
{{ end }}
105
-
{{ end }}
106
-
{{ with $stat }}
107
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
108
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
109
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
110
-
<span class="select-none after:content-['·']"></span>
111
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
112
-
</div>
113
-
{{ end }}
114
-
</div>
115
-
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
116
104
{{ end }}
+2
-2
appview/pages/templates/timeline/home.html
+2
-2
appview/pages/templates/timeline/home.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="timeline · tangled" />
5
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
6
+
<meta property="og:url" content="https://tangled.org" />
7
7
<meta property="og:description" content="tightly-knit social coding" />
8
8
{{ end }}
9
9
···
12
12
<div class="flex flex-col gap-4">
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
15
16
{{ template "timeline/fragments/trending" . }}
16
17
{{ template "timeline/fragments/timeline" . }}
17
18
<div class="flex justify-end">
···
87
88
) }}
88
89
</div>
89
90
{{ end }}
90
-
+2
-1
appview/pages/templates/timeline/timeline.html
+2
-1
appview/pages/templates/timeline/timeline.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="timeline · tangled" />
5
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
6
+
<meta property="og:url" content="https://tangled.org" />
7
7
<meta property="og:description" content="tightly-knit social coding" />
8
8
{{ end }}
9
9
···
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ end }}
15
15
16
+
{{ template "timeline/fragments/goodfirstissues" . }}
16
17
{{ template "timeline/fragments/trending" . }}
17
18
{{ template "timeline/fragments/timeline" . }}
18
19
{{ end }}
+2
-1
appview/pages/templates/user/completeSignup.html
+2
-1
appview/pages/templates/user/completeSignup.html
···
13
13
/>
14
14
<meta
15
15
property="og:url"
16
-
content="https://tangled.sh/complete-signup"
16
+
content="https://tangled.org/complete-signup"
17
17
/>
18
18
<meta
19
19
property="og:description"
20
20
content="complete your signup for tangled"
21
21
/>
22
22
<script src="/static/htmx.min.js"></script>
23
+
<link rel="manifest" href="/pwa-manifest.json" />
23
24
<link
24
25
rel="stylesheet"
25
26
href="/static/tw.css?{{ cssContentHash }}"
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 w-full flex gap-2 items-center group"
3
+
class="btn w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
6
hx-post="/follow?subject={{.UserDid}}"
···
12
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
13
hx-swap="outerHTML"
14
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
16
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
21
</button>
18
22
{{ end }}
+20
-17
appview/pages/templates/user/fragments/followCard.html
+20
-17
appview/pages/templates/user/fragments/followCard.html
···
1
1
{{ define "user/fragments/followCard" }}
2
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
7
</div>
8
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
19
23
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
24
26
{{ template "user/fragments/follow" . }}
25
27
</div>
26
-
{{ end }}
28
+
{{ end }}
29
+
</div>
27
30
</div>
28
31
</div>
29
-
{{ end }}
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
···
1
1
{{ define "user/fragments/picHandleLink" }}
2
-
{{ $resolved := resolve . }}
3
-
<a href="/{{ $resolved }}" class="flex items-center">
4
-
{{ template "user/fragments/picHandle" $resolved }}
2
+
<a href="/{{ resolve . }}" class="flex items-center gap-1">
3
+
{{ template "user/fragments/picHandle" . }}
5
4
</a>
6
5
{{ end }}
+27
-13
appview/pages/templates/user/fragments/repoCard.html
+27
-13
appview/pages/templates/user/fragments/repoCard.html
···
2
2
{{ $root := index . 0 }}
3
3
{{ $repo := index . 1 }}
4
4
{{ $fullName := index . 2 }}
5
+
{{ $starButton := false }}
6
+
{{ $starData := dict }}
7
+
{{ if gt (len .) 3 }}
8
+
{{ $starButton = index . 3 }}
9
+
{{ if gt (len .) 4 }}
10
+
{{ $starData = index . 4 }}
11
+
{{ end }}
12
+
{{ end }}
5
13
6
14
{{ with $repo }}
7
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
8
-
<div class="font-medium dark:text-white flex items-center">
9
-
{{ if .Source }}
10
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
11
-
{{ else }}
12
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
16
+
<div class="font-medium dark:text-white flex items-center justify-between">
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
23
+
{{ $repoOwner := resolve .Did }}
24
+
{{- if $fullName -}}
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
26
+
{{- else -}}
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
28
+
{{- end -}}
29
+
</div>
30
+
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
32
+
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
13
34
{{ end }}
14
-
15
-
{{ $repoOwner := resolve .Did }}
16
-
{{- if $fullName -}}
17
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
18
-
{{- else -}}
19
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
20
-
{{- end -}}
21
35
</div>
22
36
{{ with .Description }}
23
37
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
···
36
50
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
37
51
{{ with .Language }}
38
52
<div class="flex gap-2 items-center text-sm">
39
-
{{ template "repo/fragments/languageBall" . }}
53
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
40
54
<span>{{ . }}</span>
41
55
</div>
42
56
{{ end }}
+4
-3
appview/pages/templates/user/login.html
+4
-3
appview/pages/templates/user/login.html
···
5
5
<meta charset="UTF-8" />
6
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
<meta property="og:title" content="login · tangled" />
8
-
<meta property="og:url" content="https://tangled.sh/login" />
8
+
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>login · tangled</title>
13
14
</head>
14
15
<body class="flex items-center justify-center min-h-screen">
15
16
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
17
+
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
17
18
{{ template "fragments/logotype" }}
18
19
</h1>
19
20
<h2 class="text-center text-xl italic dark:text-white">
···
36
37
placeholder="akshay.tngl.sh"
37
38
/>
38
39
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
40
+
Use your <a href="https://atproto.com">AT Protocol</a>
40
41
handle to log in. If you're unsure, this is likely
41
42
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
43
</span>
+1
-1
appview/pages/templates/user/overview.html
+1
-1
appview/pages/templates/user/overview.html
···
73
73
{{ with .Repo.RepoStats }}
74
74
{{ with .Language }}
75
75
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
76
-
{{ template "repo/fragments/languageBall" . }}
76
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
77
77
<span>{{ . }}</span>
78
78
</div>
79
79
{{end }}
+173
appview/pages/templates/user/settings/notifications.html
+173
appview/pages/templates/user/settings/notifications.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "notificationSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "notificationSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
25
+
</p>
26
+
</div>
27
+
</div>
28
+
29
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
30
+
31
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
32
+
<div class="flex items-center justify-between p-2">
33
+
<div class="flex items-center gap-2">
34
+
<div class="flex flex-col gap-1">
35
+
<span class="font-bold">Repository starred</span>
36
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
37
+
<span>When someone stars your repository.</span>
38
+
</div>
39
+
</div>
40
+
</div>
41
+
<label class="flex items-center gap-2">
42
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
43
+
</label>
44
+
</div>
45
+
46
+
<div class="flex items-center justify-between p-2">
47
+
<div class="flex items-center gap-2">
48
+
<div class="flex flex-col gap-1">
49
+
<span class="font-bold">New issues</span>
50
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
51
+
<span>When someone creates an issue on your repository.</span>
52
+
</div>
53
+
</div>
54
+
</div>
55
+
<label class="flex items-center gap-2">
56
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
57
+
</label>
58
+
</div>
59
+
60
+
<div class="flex items-center justify-between p-2">
61
+
<div class="flex items-center gap-2">
62
+
<div class="flex flex-col gap-1">
63
+
<span class="font-bold">Issue comments</span>
64
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
65
+
<span>When someone comments on an issue you're involved with.</span>
66
+
</div>
67
+
</div>
68
+
</div>
69
+
<label class="flex items-center gap-2">
70
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
71
+
</label>
72
+
</div>
73
+
74
+
<div class="flex items-center justify-between p-2">
75
+
<div class="flex items-center gap-2">
76
+
<div class="flex flex-col gap-1">
77
+
<span class="font-bold">Issue closed</span>
78
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
79
+
<span>When an issue on your repository is closed.</span>
80
+
</div>
81
+
</div>
82
+
</div>
83
+
<label class="flex items-center gap-2">
84
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
85
+
</label>
86
+
</div>
87
+
88
+
<div class="flex items-center justify-between p-2">
89
+
<div class="flex items-center gap-2">
90
+
<div class="flex flex-col gap-1">
91
+
<span class="font-bold">New pull requests</span>
92
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
93
+
<span>When someone creates a pull request on your repository.</span>
94
+
</div>
95
+
</div>
96
+
</div>
97
+
<label class="flex items-center gap-2">
98
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
99
+
</label>
100
+
</div>
101
+
102
+
<div class="flex items-center justify-between p-2">
103
+
<div class="flex items-center gap-2">
104
+
<div class="flex flex-col gap-1">
105
+
<span class="font-bold">Pull request comments</span>
106
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
107
+
<span>When someone comments on a pull request you're involved with.</span>
108
+
</div>
109
+
</div>
110
+
</div>
111
+
<label class="flex items-center gap-2">
112
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
113
+
</label>
114
+
</div>
115
+
116
+
<div class="flex items-center justify-between p-2">
117
+
<div class="flex items-center gap-2">
118
+
<div class="flex flex-col gap-1">
119
+
<span class="font-bold">Pull request merged</span>
120
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
121
+
<span>When your pull request is merged.</span>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
<label class="flex items-center gap-2">
126
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
127
+
</label>
128
+
</div>
129
+
130
+
<div class="flex items-center justify-between p-2">
131
+
<div class="flex items-center gap-2">
132
+
<div class="flex flex-col gap-1">
133
+
<span class="font-bold">New followers</span>
134
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
135
+
<span>When someone follows you.</span>
136
+
</div>
137
+
</div>
138
+
</div>
139
+
<label class="flex items-center gap-2">
140
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
141
+
</label>
142
+
</div>
143
+
144
+
<div class="flex items-center justify-between p-2">
145
+
<div class="flex items-center gap-2">
146
+
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Email notifications</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>Receive notifications via email in addition to in-app notifications.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
</div>
158
+
159
+
<div class="flex justify-end pt-2">
160
+
<button
161
+
type="submit"
162
+
class="btn-create flex items-center gap-2 group"
163
+
>
164
+
{{ i "save" "w-4 h-4" }}
165
+
save
166
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
167
+
</button>
168
+
</div>
169
+
<div id="settings-notifications-success"></div>
170
+
171
+
<div id="settings-notifications-error" class="error"></div>
172
+
</form>
173
+
{{ end }}
+8
-3
appview/pages/templates/user/signup.html
+8
-3
appview/pages/templates/user/signup.html
···
5
5
<meta charset="UTF-8" />
6
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
<meta property="og:title" content="signup · tangled" />
8
-
<meta property="og:url" content="https://tangled.sh/signup" />
8
+
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
13
16
</head>
14
17
<body class="flex items-center justify-center min-h-screen">
15
18
<main class="max-w-md px-6 -mt-4">
···
39
42
invite code, desired username, and password in the next
40
43
page to complete your registration.
41
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
42
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
49
<span>join now</span>
44
50
</button>
45
51
</form>
46
52
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
48
54
</p>
49
55
50
56
<p id="signup-msg" class="error w-full"></p>
···
52
58
</body>
53
59
</html>
54
60
{{ end }}
55
-
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+10
-10
appview/pipelines/pipelines.go
+10
-10
appview/pipelines/pipelines.go
···
9
9
"strings"
10
10
"time"
11
11
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
-
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
-
"tangled.sh/tangled.sh/core/eventconsumer"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
12
+
"tangled.org/core/appview/config"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/oauth"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/appview/reporesolver"
17
+
"tangled.org/core/eventconsumer"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindlemodel "tangled.org/core/spindle/models"
22
22
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/gorilla/websocket"
+1
-1
appview/pipelines/router.go
+1
-1
appview/pipelines/router.go
-131
appview/posthog/notifier.go
-131
appview/posthog/notifier.go
···
1
-
package posthog_service
2
-
3
-
import (
4
-
"context"
5
-
"log"
6
-
7
-
"github.com/posthog/posthog-go"
8
-
"tangled.sh/tangled.sh/core/appview/db"
9
-
"tangled.sh/tangled.sh/core/appview/notify"
10
-
)
11
-
12
-
type posthogNotifier struct {
13
-
client posthog.Client
14
-
notify.BaseNotifier
15
-
}
16
-
17
-
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
-
return &posthogNotifier{
19
-
client,
20
-
notify.BaseNotifier{},
21
-
}
22
-
}
23
-
24
-
var _ notify.Notifier = &posthogNotifier{}
25
-
26
-
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
27
-
err := n.client.Enqueue(posthog.Capture{
28
-
DistinctId: repo.Did,
29
-
Event: "new_repo",
30
-
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
-
})
32
-
if err != nil {
33
-
log.Println("failed to enqueue posthog event:", err)
34
-
}
35
-
}
36
-
37
-
func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) {
38
-
err := n.client.Enqueue(posthog.Capture{
39
-
DistinctId: star.StarredByDid,
40
-
Event: "star",
41
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
-
})
43
-
if err != nil {
44
-
log.Println("failed to enqueue posthog event:", err)
45
-
}
46
-
}
47
-
48
-
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) {
49
-
err := n.client.Enqueue(posthog.Capture{
50
-
DistinctId: star.StarredByDid,
51
-
Event: "unstar",
52
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
-
})
54
-
if err != nil {
55
-
log.Println("failed to enqueue posthog event:", err)
56
-
}
57
-
}
58
-
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
-
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.Did,
62
-
Event: "new_issue",
63
-
Properties: posthog.Properties{
64
-
"repo_at": issue.RepoAt.String(),
65
-
"issue_id": issue.IssueId,
66
-
},
67
-
})
68
-
if err != nil {
69
-
log.Println("failed to enqueue posthog event:", err)
70
-
}
71
-
}
72
-
73
-
func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) {
74
-
err := n.client.Enqueue(posthog.Capture{
75
-
DistinctId: pull.OwnerDid,
76
-
Event: "new_pull",
77
-
Properties: posthog.Properties{
78
-
"repo_at": pull.RepoAt,
79
-
"pull_id": pull.PullId,
80
-
},
81
-
})
82
-
if err != nil {
83
-
log.Println("failed to enqueue posthog event:", err)
84
-
}
85
-
}
86
-
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
88
-
err := n.client.Enqueue(posthog.Capture{
89
-
DistinctId: comment.OwnerDid,
90
-
Event: "new_pull_comment",
91
-
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
-
},
95
-
})
96
-
if err != nil {
97
-
log.Println("failed to enqueue posthog event:", err)
98
-
}
99
-
}
100
-
101
-
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
102
-
err := n.client.Enqueue(posthog.Capture{
103
-
DistinctId: follow.UserDid,
104
-
Event: "follow",
105
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
-
})
107
-
if err != nil {
108
-
log.Println("failed to enqueue posthog event:", err)
109
-
}
110
-
}
111
-
112
-
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
113
-
err := n.client.Enqueue(posthog.Capture{
114
-
DistinctId: follow.UserDid,
115
-
Event: "unfollow",
116
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
-
})
118
-
if err != nil {
119
-
log.Println("failed to enqueue posthog event:", err)
120
-
}
121
-
}
122
-
123
-
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
124
-
err := n.client.Enqueue(posthog.Capture{
125
-
DistinctId: profile.Did,
126
-
Event: "edit_profile",
127
-
})
128
-
if err != nil {
129
-
log.Println("failed to enqueue posthog event:", err)
130
-
}
131
-
}
+130
-77
appview/pulls/pulls.go
+130
-77
appview/pulls/pulls.go
···
12
12
"strings"
13
13
"time"
14
14
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/notify"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/idresolver"
25
-
"tangled.sh/tangled.sh/core/patchutil"
26
-
"tangled.sh/tangled.sh/core/tid"
27
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.org/core/api/tangled"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/models"
19
+
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/markup"
23
+
"tangled.org/core/appview/reporesolver"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/idresolver"
26
+
"tangled.org/core/patchutil"
27
+
"tangled.org/core/tid"
28
+
"tangled.org/core/types"
28
29
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
75
76
return
76
77
}
77
78
78
-
pull, ok := r.Context().Value("pull").(*db.Pull)
79
+
pull, ok := r.Context().Value("pull").(*models.Pull)
79
80
if !ok {
80
81
log.Println("failed to get pull")
81
82
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
83
84
}
84
85
85
86
// can be nil if this pull is not stacked
86
-
stack, _ := r.Context().Value("stack").(db.Stack)
87
+
stack, _ := r.Context().Value("stack").(models.Stack)
87
88
88
89
roundNumberStr := chi.URLParam(r, "round")
89
90
roundNumber, err := strconv.Atoi(roundNumberStr)
···
123
124
return
124
125
}
125
126
126
-
pull, ok := r.Context().Value("pull").(*db.Pull)
127
+
pull, ok := r.Context().Value("pull").(*models.Pull)
127
128
if !ok {
128
129
log.Println("failed to get pull")
129
130
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
131
132
}
132
133
133
134
// can be nil if this pull is not stacked
134
-
stack, _ := r.Context().Value("stack").(db.Stack)
135
-
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
135
+
stack, _ := r.Context().Value("stack").(models.Stack)
136
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
136
137
137
138
totalIdents := 1
138
139
for _, submission := range pull.Submissions {
···
159
160
160
161
repoInfo := f.RepoInfo(user)
161
162
162
-
m := make(map[string]db.Pipeline)
163
+
m := make(map[string]models.Pipeline)
163
164
164
165
var shas []string
165
166
for _, s := range pull.Submissions {
···
194
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
195
196
}
196
197
197
-
userReactions := map[db.ReactionKind]bool{}
198
+
userReactions := map[models.ReactionKind]bool{}
198
199
if user != nil {
199
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
200
201
}
201
202
203
+
labelDefs, err := db.GetLabelDefinitions(
204
+
s.db,
205
+
db.FilterIn("at_uri", f.Repo.Labels),
206
+
db.FilterContains("scope", tangled.RepoPullNSID),
207
+
)
208
+
if err != nil {
209
+
log.Println("failed to fetch labels", err)
210
+
s.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defs := make(map[string]*models.LabelDefinition)
215
+
for _, l := range labelDefs {
216
+
defs[l.AtUri().String()] = &l
217
+
}
218
+
202
219
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
203
220
LoggedInUser: user,
204
221
RepoInfo: repoInfo,
···
209
226
ResubmitCheck: resubmitResult,
210
227
Pipelines: m,
211
228
212
-
OrderedReactionKinds: db.OrderedReactionKinds,
229
+
OrderedReactionKinds: models.OrderedReactionKinds,
213
230
Reactions: reactionCountMap,
214
231
UserReacted: userReactions,
232
+
233
+
LabelDefs: defs,
215
234
})
216
235
}
217
236
218
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
219
-
if pull.State == db.PullMerged {
237
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
238
+
if pull.State == models.PullMerged {
220
239
return types.MergeCheckResponse{}
221
240
}
222
241
···
282
301
return result
283
302
}
284
303
285
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
-
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
304
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
305
+
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
287
306
return pages.Unknown
288
307
}
289
308
···
356
375
diffOpts.Split = true
357
376
}
358
377
359
-
pull, ok := r.Context().Value("pull").(*db.Pull)
378
+
pull, ok := r.Context().Value("pull").(*models.Pull)
360
379
if !ok {
361
380
log.Println("failed to get pull")
362
381
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
363
382
return
364
383
}
365
384
366
-
stack, _ := r.Context().Value("stack").(db.Stack)
385
+
stack, _ := r.Context().Value("stack").(models.Stack)
367
386
368
387
roundId := chi.URLParam(r, "round")
369
388
roundIdInt, err := strconv.Atoi(roundId)
···
403
422
diffOpts.Split = true
404
423
}
405
424
406
-
pull, ok := r.Context().Value("pull").(*db.Pull)
425
+
pull, ok := r.Context().Value("pull").(*models.Pull)
407
426
if !ok {
408
427
log.Println("failed to get pull")
409
428
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
451
470
}
452
471
453
472
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
473
+
pull, ok := r.Context().Value("pull").(*models.Pull)
455
474
if !ok {
456
475
log.Println("failed to get pull")
457
476
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
474
493
user := s.oauth.GetUser(r)
475
494
params := r.URL.Query()
476
495
477
-
state := db.PullOpen
496
+
state := models.PullOpen
478
497
switch params.Get("state") {
479
498
case "closed":
480
-
state = db.PullClosed
499
+
state = models.PullClosed
481
500
case "merged":
482
-
state = db.PullMerged
501
+
state = models.PullMerged
483
502
}
484
503
485
504
f, err := s.repoResolver.Resolve(r)
···
500
519
}
501
520
502
521
for _, p := range pulls {
503
-
var pullSourceRepo *db.Repo
522
+
var pullSourceRepo *models.Repo
504
523
if p.PullSource != nil {
505
524
if p.PullSource.RepoAt != nil {
506
525
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
515
534
}
516
535
517
536
// we want to group all stacked PRs into just one list
518
-
stacks := make(map[string]db.Stack)
537
+
stacks := make(map[string]models.Stack)
519
538
var shas []string
520
539
n := 0
521
540
for _, p := range pulls {
···
551
570
log.Printf("failed to fetch pipeline statuses: %s", err)
552
571
// non-fatal
553
572
}
554
-
m := make(map[string]db.Pipeline)
573
+
m := make(map[string]models.Pipeline)
555
574
for _, p := range ps {
556
575
m[p.Sha] = p
557
576
}
558
577
578
+
labelDefs, err := db.GetLabelDefinitions(
579
+
s.db,
580
+
db.FilterIn("at_uri", f.Repo.Labels),
581
+
db.FilterContains("scope", tangled.RepoPullNSID),
582
+
)
583
+
if err != nil {
584
+
log.Println("failed to fetch labels", err)
585
+
s.pages.Error503(w)
586
+
return
587
+
}
588
+
589
+
defs := make(map[string]*models.LabelDefinition)
590
+
for _, l := range labelDefs {
591
+
defs[l.AtUri().String()] = &l
592
+
}
593
+
559
594
s.pages.RepoPulls(w, pages.RepoPullsParams{
560
595
LoggedInUser: s.oauth.GetUser(r),
561
596
RepoInfo: f.RepoInfo(user),
562
597
Pulls: pulls,
598
+
LabelDefs: defs,
563
599
FilteringBy: state,
564
600
Stacks: stacks,
565
601
Pipelines: m,
···
574
610
return
575
611
}
576
612
577
-
pull, ok := r.Context().Value("pull").(*db.Pull)
613
+
pull, ok := r.Context().Value("pull").(*models.Pull)
578
614
if !ok {
579
615
log.Println("failed to get pull")
580
616
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
647
683
return
648
684
}
649
685
650
-
comment := &db.PullComment{
686
+
comment := &models.PullComment{
651
687
OwnerDid: user.Did,
652
688
RepoAt: f.RepoAt().String(),
653
689
PullId: pull.PullId,
···
890
926
return
891
927
}
892
928
893
-
pullSource := &db.PullSource{
929
+
pullSource := &models.PullSource{
894
930
Branch: sourceBranch,
895
931
}
896
932
recordPullSource := &tangled.RepoPull_Source{
···
1000
1036
forkAtUri := fork.RepoAt()
1001
1037
forkAtUriStr := forkAtUri.String()
1002
1038
1003
-
pullSource := &db.PullSource{
1039
+
pullSource := &models.PullSource{
1004
1040
Branch: sourceBranch,
1005
1041
RepoAt: &forkAtUri,
1006
1042
}
···
1021
1057
title, body, targetBranch string,
1022
1058
patch string,
1023
1059
sourceRev string,
1024
-
pullSource *db.PullSource,
1060
+
pullSource *models.PullSource,
1025
1061
recordPullSource *tangled.RepoPull_Source,
1026
1062
isStacked bool,
1027
1063
) {
···
1057
1093
1058
1094
// We've already checked earlier if it's diff-based and title is empty,
1059
1095
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1060
-
if title == "" {
1096
+
if title == "" || body == "" {
1061
1097
formatPatches, err := patchutil.ExtractPatches(patch)
1062
1098
if err != nil {
1063
1099
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1068
1104
return
1069
1105
}
1070
1106
1071
-
title = formatPatches[0].Title
1072
-
body = formatPatches[0].Body
1107
+
if title == "" {
1108
+
title = formatPatches[0].Title
1109
+
}
1110
+
if body == "" {
1111
+
body = formatPatches[0].Body
1112
+
}
1073
1113
}
1074
1114
1075
1115
rkey := tid.TID()
1076
-
initialSubmission := db.PullSubmission{
1116
+
initialSubmission := models.PullSubmission{
1077
1117
Patch: patch,
1078
1118
SourceRev: sourceRev,
1079
1119
}
1080
-
pull := &db.Pull{
1120
+
pull := &models.Pull{
1081
1121
Title: title,
1082
1122
Body: body,
1083
1123
TargetBranch: targetBranch,
1084
1124
OwnerDid: user.Did,
1085
1125
RepoAt: f.RepoAt(),
1086
1126
Rkey: rkey,
1087
-
Submissions: []*db.PullSubmission{
1127
+
Submissions: []*models.PullSubmission{
1088
1128
&initialSubmission,
1089
1129
},
1090
1130
PullSource: pullSource,
···
1143
1183
targetBranch string,
1144
1184
patch string,
1145
1185
sourceRev string,
1146
-
pullSource *db.PullSource,
1186
+
pullSource *models.PullSource,
1147
1187
) {
1148
1188
// run some necessary checks for stacked-prs first
1149
1189
···
1364
1404
forkOwnerDid := repoString[0]
1365
1405
forkName := repoString[1]
1366
1406
// fork repo
1367
-
repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
1407
+
repo, err := db.GetRepo(
1408
+
s.db,
1409
+
db.FilterEq("did", forkOwnerDid),
1410
+
db.FilterEq("name", forkName),
1411
+
)
1368
1412
if err != nil {
1369
-
log.Println("failed to get repo", user.Did, forkVal)
1413
+
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
1370
1414
return
1371
1415
}
1372
1416
···
1447
1491
return
1448
1492
}
1449
1493
1450
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1494
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1451
1495
if !ok {
1452
1496
log.Println("failed to get pull")
1453
1497
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1478
1522
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1479
1523
user := s.oauth.GetUser(r)
1480
1524
1481
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1525
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1482
1526
if !ok {
1483
1527
log.Println("failed to get pull")
1484
1528
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1505
1549
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1506
1550
user := s.oauth.GetUser(r)
1507
1551
1508
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1552
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1509
1553
if !ok {
1510
1554
log.Println("failed to get pull")
1511
1555
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1568
1612
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1569
1613
user := s.oauth.GetUser(r)
1570
1614
1571
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1615
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1572
1616
if !ok {
1573
1617
log.Println("failed to get pull")
1574
1618
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1661
1705
}
1662
1706
1663
1707
// validate a resubmission against a pull request
1664
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1708
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1665
1709
if patch == "" {
1666
1710
return fmt.Errorf("Patch is empty.")
1667
1711
}
···
1682
1726
r *http.Request,
1683
1727
f *reporesolver.ResolvedRepo,
1684
1728
user *oauth.User,
1685
-
pull *db.Pull,
1729
+
pull *models.Pull,
1686
1730
patch string,
1687
1731
sourceRev string,
1688
1732
) {
···
1786
1830
r *http.Request,
1787
1831
f *reporesolver.ResolvedRepo,
1788
1832
user *oauth.User,
1789
-
pull *db.Pull,
1833
+
pull *models.Pull,
1790
1834
patch string,
1791
1835
stackId string,
1792
1836
) {
1793
1837
targetBranch := pull.TargetBranch
1794
1838
1795
-
origStack, _ := r.Context().Value("stack").(db.Stack)
1839
+
origStack, _ := r.Context().Value("stack").(models.Stack)
1796
1840
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1797
1841
if err != nil {
1798
1842
log.Println("failed to create resubmitted stack", err)
···
1801
1845
}
1802
1846
1803
1847
// find the diff between the stacks, first, map them by changeId
1804
-
origById := make(map[string]*db.Pull)
1805
-
newById := make(map[string]*db.Pull)
1848
+
origById := make(map[string]*models.Pull)
1849
+
newById := make(map[string]*models.Pull)
1806
1850
for _, p := range origStack {
1807
1851
origById[p.ChangeId] = p
1808
1852
}
···
1815
1859
// commits that got updated: corresponding pull is resubmitted & new round begins
1816
1860
//
1817
1861
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1818
-
additions := make(map[string]*db.Pull)
1819
-
deletions := make(map[string]*db.Pull)
1862
+
additions := make(map[string]*models.Pull)
1863
+
deletions := make(map[string]*models.Pull)
1820
1864
unchanged := make(map[string]struct{})
1821
1865
updated := make(map[string]struct{})
1822
1866
···
1876
1920
// deleted pulls are marked as deleted in the DB
1877
1921
for _, p := range deletions {
1878
1922
// do not do delete already merged PRs
1879
-
if p.State == db.PullMerged {
1923
+
if p.State == models.PullMerged {
1880
1924
continue
1881
1925
}
1882
1926
···
1921
1965
np, _ := newById[id]
1922
1966
1923
1967
// do not update already merged PRs
1924
-
if op.State == db.PullMerged {
1968
+
if op.State == models.PullMerged {
1925
1969
continue
1926
1970
}
1927
1971
···
2042
2086
return
2043
2087
}
2044
2088
2045
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2089
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2046
2090
if !ok {
2047
2091
log.Println("failed to get pull")
2048
2092
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2049
2093
return
2050
2094
}
2051
2095
2052
-
var pullsToMerge db.Stack
2096
+
var pullsToMerge models.Stack
2053
2097
pullsToMerge = append(pullsToMerge, pull)
2054
2098
if pull.IsStacked() {
2055
-
stack, ok := r.Context().Value("stack").(db.Stack)
2099
+
stack, ok := r.Context().Value("stack").(models.Stack)
2056
2100
if !ok {
2057
2101
log.Println("failed to get stack")
2058
2102
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2142
2186
return
2143
2187
}
2144
2188
2189
+
// notify about the pull merge
2190
+
for _, p := range pullsToMerge {
2191
+
s.notifier.NewPullMerged(r.Context(), p)
2192
+
}
2193
+
2145
2194
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2146
2195
}
2147
2196
···
2154
2203
return
2155
2204
}
2156
2205
2157
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2206
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2158
2207
if !ok {
2159
2208
log.Println("failed to get pull")
2160
2209
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2182
2231
}
2183
2232
defer tx.Rollback()
2184
2233
2185
-
var pullsToClose []*db.Pull
2234
+
var pullsToClose []*models.Pull
2186
2235
pullsToClose = append(pullsToClose, pull)
2187
2236
2188
2237
// if this PR is stacked, then we want to close all PRs below this one on the stack
2189
2238
if pull.IsStacked() {
2190
-
stack := r.Context().Value("stack").(db.Stack)
2239
+
stack := r.Context().Value("stack").(models.Stack)
2191
2240
subStack := stack.StrictlyBelow(pull)
2192
2241
pullsToClose = append(pullsToClose, subStack...)
2193
2242
}
···
2209
2258
return
2210
2259
}
2211
2260
2261
+
for _, p := range pullsToClose {
2262
+
s.notifier.NewPullClosed(r.Context(), p)
2263
+
}
2264
+
2212
2265
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2213
2266
}
2214
2267
···
2222
2275
return
2223
2276
}
2224
2277
2225
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2278
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2226
2279
if !ok {
2227
2280
log.Println("failed to get pull")
2228
2281
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2250
2303
}
2251
2304
defer tx.Rollback()
2252
2305
2253
-
var pullsToReopen []*db.Pull
2306
+
var pullsToReopen []*models.Pull
2254
2307
pullsToReopen = append(pullsToReopen, pull)
2255
2308
2256
2309
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2257
2310
if pull.IsStacked() {
2258
-
stack := r.Context().Value("stack").(db.Stack)
2311
+
stack := r.Context().Value("stack").(models.Stack)
2259
2312
subStack := stack.StrictlyAbove(pull)
2260
2313
pullsToReopen = append(pullsToReopen, subStack...)
2261
2314
}
···
2280
2333
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2281
2334
}
2282
2335
2283
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2336
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2284
2337
formatPatches, err := patchutil.ExtractPatches(patch)
2285
2338
if err != nil {
2286
2339
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2292
2345
}
2293
2346
2294
2347
// the stack is identified by a UUID
2295
-
var stack db.Stack
2348
+
var stack models.Stack
2296
2349
parentChangeId := ""
2297
2350
for _, fp := range formatPatches {
2298
2351
// all patches must have a jj change-id
···
2305
2358
body := fp.Body
2306
2359
rkey := tid.TID()
2307
2360
2308
-
initialSubmission := db.PullSubmission{
2361
+
initialSubmission := models.PullSubmission{
2309
2362
Patch: fp.Raw,
2310
2363
SourceRev: fp.SHA,
2311
2364
}
2312
-
pull := db.Pull{
2365
+
pull := models.Pull{
2313
2366
Title: title,
2314
2367
Body: body,
2315
2368
TargetBranch: targetBranch,
2316
2369
OwnerDid: user.Did,
2317
2370
RepoAt: f.RepoAt(),
2318
2371
Rkey: rkey,
2319
-
Submissions: []*db.PullSubmission{
2372
+
Submissions: []*models.PullSubmission{
2320
2373
&initialSubmission,
2321
2374
},
2322
2375
PullSource: pullSource,
+1
-1
appview/pulls/router.go
+1
-1
appview/pulls/router.go
+49
-22
appview/repo/artifact.go
+49
-22
appview/repo/artifact.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
+
"io"
7
8
"log"
8
9
"net/http"
9
10
"net/url"
···
16
17
"github.com/go-chi/chi/v5"
17
18
"github.com/go-git/go-git/v5/plumbing"
18
19
"github.com/ipfs/go-cid"
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/db"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/tid"
25
-
"tangled.sh/tangled.sh/core/types"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pages"
24
+
"tangled.org/core/appview/reporesolver"
25
+
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/tid"
27
+
"tangled.org/core/types"
26
28
)
27
29
28
30
// TODO: proper statuses here on early exit
···
100
102
}
101
103
defer tx.Rollback()
102
104
103
-
artifact := db.Artifact{
105
+
artifact := models.Artifact{
104
106
Did: user.Did,
105
107
Rkey: rkey,
106
108
RepoAt: f.RepoAt(),
···
133
135
})
134
136
}
135
137
136
-
// TODO: proper statuses here on early exit
137
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
138
-
tagParam := chi.URLParam(r, "tag")
139
-
filename := chi.URLParam(r, "file")
140
139
f, err := rp.repoResolver.Resolve(r)
141
140
if err != nil {
142
141
log.Println("failed to get repo and knot", err)
142
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
143
143
return
144
144
}
145
145
146
+
tagParam := chi.URLParam(r, "tag")
147
+
filename := chi.URLParam(r, "file")
148
+
146
149
tag, err := rp.resolveTag(r.Context(), f, tagParam)
147
150
if err != nil {
148
151
log.Println("failed to resolve tag", err)
···
150
153
return
151
154
}
152
155
153
-
client, err := rp.oauth.AuthorizedClient(r)
154
-
if err != nil {
155
-
log.Println("failed to get authorized client", err)
156
-
return
157
-
}
158
-
159
156
artifacts, err := db.GetArtifact(
160
157
rp.db,
161
158
db.FilterEq("repo_at", f.RepoAt()),
···
164
161
)
165
162
if err != nil {
166
163
log.Println("failed to get artifacts", err)
164
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
167
165
return
168
166
}
167
+
169
168
if len(artifacts) != 1 {
170
-
log.Printf("too many or too little artifacts found")
169
+
log.Printf("too many or too few artifacts found")
170
+
http.Error(w, "artifact not found", http.StatusNotFound)
171
171
return
172
172
}
173
173
174
174
artifact := artifacts[0]
175
175
176
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
176
+
ownerPds := f.OwnerId.PDSEndpoint()
177
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
178
+
q := url.Query()
179
+
q.Set("cid", artifact.BlobCid.String())
180
+
q.Set("did", artifact.Did)
181
+
url.RawQuery = q.Encode()
182
+
183
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
177
184
if err != nil {
178
-
log.Println("failed to get blob from pds", err)
185
+
log.Println("failed to create request", err)
186
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187
+
return
188
+
}
189
+
req.Header.Set("Content-Type", "application/json")
190
+
191
+
resp, err := http.DefaultClient.Do(req)
192
+
if err != nil {
193
+
log.Println("failed to make request", err)
194
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
179
195
return
180
196
}
197
+
defer resp.Body.Close()
181
198
182
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
183
-
w.Write(getBlobResp)
199
+
// copy status code and relevant headers from upstream response
200
+
w.WriteHeader(resp.StatusCode)
201
+
for key, values := range resp.Header {
202
+
for _, v := range values {
203
+
w.Header().Add(key, v)
204
+
}
205
+
}
206
+
207
+
// stream the body directly to the client
208
+
if _, err := io.Copy(w, resp.Body); err != nil {
209
+
log.Println("error streaming response to client:", err)
210
+
}
184
211
}
185
212
186
213
// TODO: proper statuses here on early exit
+10
-9
appview/repo/feed.go
+10
-9
appview/repo/feed.go
···
8
8
"slices"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/appview/reporesolver"
14
15
15
16
"github.com/bluesky-social/indigo/atproto/syntax"
16
17
"github.com/gorilla/feeds"
···
70
71
return feed, nil
71
72
}
72
73
73
-
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
75
76
if err != nil {
76
77
return nil, err
···
108
109
return items, nil
109
110
}
110
111
111
-
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
113
114
if err != nil {
114
115
return nil, err
···
128
129
}, nil
129
130
}
130
131
131
-
func (rp *Repo) getPullState(pull *db.Pull) string {
132
-
if pull.State == db.PullOpen {
132
+
func (rp *Repo) getPullState(pull *models.Pull) string {
133
+
if pull.State == models.PullOpen {
133
134
return "opened"
134
135
}
135
136
return pull.State.String()
136
137
}
137
138
138
-
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
139
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string {
139
140
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
140
141
141
-
if pull.State == db.PullMerged {
142
+
if pull.State == models.PullMerged {
142
143
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
143
144
}
144
145
+26
-30
appview/repo/index.go
+26
-30
appview/repo/index.go
···
17
17
18
18
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
19
"github.com/go-git/go-git/v5/plumbing"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview/commitverify"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
27
-
"tangled.sh/tangled.sh/core/types"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/commitverify"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/models"
24
+
"tangled.org/core/appview/pages"
25
+
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/xrpcclient"
27
+
"tangled.org/core/types"
28
28
29
29
"github.com/go-chi/chi/v5"
30
30
"github.com/go-enry/go-enry/v2"
···
191
191
}
192
192
193
193
for _, lang := range ls.Languages {
194
-
langs = append(langs, db.RepoLanguage{
194
+
langs = append(langs, models.RepoLanguage{
195
195
RepoAt: f.RepoAt(),
196
196
Ref: currentRef,
197
197
IsDefaultRef: isDefaultRef,
···
200
200
})
201
201
}
202
202
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
+
203
209
// update appview's cache
204
-
err = db.InsertRepoLanguages(rp.db, langs)
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
205
211
if err != nil {
206
212
// non-fatal
207
213
log.Println("failed to cache lang results", err)
208
214
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
+
}
209
220
}
210
221
211
222
var total int64
···
327
338
}
328
339
}()
329
340
330
-
// readme content
331
-
wg.Add(1)
332
-
go func() {
333
-
defer wg.Done()
334
-
for _, filename := range markup.ReadmeFilenames {
335
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
336
-
if err != nil {
337
-
continue
338
-
}
339
-
340
-
if blobResp == nil {
341
-
continue
342
-
}
343
-
344
-
readmeContent = blobResp.Content
345
-
readmeFileName = filename
346
-
break
347
-
}
348
-
}()
349
-
350
341
wg.Wait()
351
342
352
343
if errs != nil {
···
373
364
}
374
365
files = append(files, niceFile)
375
366
}
367
+
}
368
+
369
+
if treeResp != nil && treeResp.Readme != nil {
370
+
readmeFileName = treeResp.Readme.Filename
371
+
readmeContent = treeResp.Readme.Contents
376
372
}
377
373
378
374
result := &types.RepoIndexResponse{
+656
-78
appview/repo/repo.go
+656
-78
appview/repo/repo.go
···
20
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
-
"tangled.sh/tangled.sh/core/api/tangled"
24
-
"tangled.sh/tangled.sh/core/appview/commitverify"
25
-
"tangled.sh/tangled.sh/core/appview/config"
26
-
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/notify"
28
-
"tangled.sh/tangled.sh/core/appview/oauth"
29
-
"tangled.sh/tangled.sh/core/appview/pages"
30
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
32
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
33
-
"tangled.sh/tangled.sh/core/eventconsumer"
34
-
"tangled.sh/tangled.sh/core/idresolver"
35
-
"tangled.sh/tangled.sh/core/patchutil"
36
-
"tangled.sh/tangled.sh/core/rbac"
37
-
"tangled.sh/tangled.sh/core/tid"
38
-
"tangled.sh/tangled.sh/core/types"
39
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
23
+
"tangled.org/core/api/tangled"
24
+
"tangled.org/core/appview/commitverify"
25
+
"tangled.org/core/appview/config"
26
+
"tangled.org/core/appview/db"
27
+
"tangled.org/core/appview/models"
28
+
"tangled.org/core/appview/notify"
29
+
"tangled.org/core/appview/oauth"
30
+
"tangled.org/core/appview/pages"
31
+
"tangled.org/core/appview/pages/markup"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/patchutil"
38
+
"tangled.org/core/rbac"
39
+
"tangled.org/core/tid"
40
+
"tangled.org/core/types"
41
+
"tangled.org/core/xrpc/serviceauth"
40
42
41
43
securejoin "github.com/cyphar/filepath-securejoin"
42
44
"github.com/go-chi/chi/v5"
···
57
59
notifier notify.Notifier
58
60
logger *slog.Logger
59
61
serviceAuth *serviceauth.ServiceAuth
62
+
validator *validator.Validator
60
63
}
61
64
62
65
func New(
···
70
73
notifier notify.Notifier,
71
74
enforcer *rbac.Enforcer,
72
75
logger *slog.Logger,
76
+
validator *validator.Validator,
73
77
) *Repo {
74
78
return &Repo{oauth: oauth,
75
79
repoResolver: repoResolver,
···
81
85
notifier: notifier,
82
86
enforcer: enforcer,
83
87
logger: logger,
88
+
validator: validator,
84
89
}
85
90
}
86
91
···
295
300
return
296
301
}
297
302
303
+
newRepo := f.Repo
304
+
newRepo.Description = newDescription
305
+
record := newRepo.AsRecord()
306
+
298
307
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
299
308
//
300
309
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
301
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
310
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
302
311
if err != nil {
303
312
// failed to get record
304
313
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
···
306
315
}
307
316
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
308
317
Collection: tangled.RepoNSID,
309
-
Repo: user.Did,
310
-
Rkey: rkey,
318
+
Repo: newRepo.Did,
319
+
Rkey: newRepo.Rkey,
311
320
SwapRecord: ex.Cid,
312
321
Record: &lexutil.LexiconTypeDecoder{
313
-
Val: &tangled.Repo{
314
-
Knot: f.Knot,
315
-
Name: f.Name,
316
-
Owner: user.Did,
317
-
CreatedAt: f.Created.Format(time.RFC3339),
318
-
Description: &newDescription,
319
-
Spindle: &f.Spindle,
320
-
},
322
+
Val: &record,
321
323
},
322
324
})
323
325
···
398
400
log.Println(err)
399
401
// non-fatal
400
402
}
401
-
var pipeline *db.Pipeline
403
+
var pipeline *models.Pipeline
402
404
if p, ok := pipelines[result.Diff.Commit.This]; ok {
403
405
pipeline = &p
404
406
}
···
482
484
if xrpcResp.Dotdot != nil {
483
485
result.DotDot = *xrpcResp.Dotdot
484
486
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
485
491
486
492
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
487
493
// so we can safely redirect to the "parent" (which is the same file).
···
550
556
}
551
557
552
558
// convert artifacts to map for easy UI building
553
-
artifactMap := make(map[plumbing.Hash][]db.Artifact)
559
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
554
560
for _, a := range artifacts {
555
561
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
556
562
}
557
563
558
-
var danglingArtifacts []db.Artifact
564
+
var danglingArtifacts []models.Artifact
559
565
for _, a := range artifacts {
560
566
found := false
561
567
for _, t := range result.Tags {
···
871
877
return
872
878
}
873
879
874
-
repoAt := f.RepoAt()
875
-
rkey := repoAt.RecordKey().String()
876
-
if rkey == "" {
877
-
fail("Failed to resolve repo. Try again later", err)
878
-
return
879
-
}
880
-
881
880
newSpindle := r.FormValue("spindle")
882
881
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
883
882
client, err := rp.oauth.AuthorizedClient(r)
···
900
899
}
901
900
}
902
901
902
+
newRepo := f.Repo
903
+
newRepo.Spindle = newSpindle
904
+
record := newRepo.AsRecord()
905
+
903
906
spindlePtr := &newSpindle
904
907
if removingSpindle {
905
908
spindlePtr = nil
909
+
newRepo.Spindle = ""
906
910
}
907
911
908
912
// optimistic update
909
-
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
913
+
err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr)
910
914
if err != nil {
911
915
fail("Failed to update spindle. Try again later.", err)
912
916
return
913
917
}
914
918
915
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
919
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
916
920
if err != nil {
917
921
fail("Failed to update spindle, no record found on PDS.", err)
918
922
return
919
923
}
920
924
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
921
925
Collection: tangled.RepoNSID,
922
-
Repo: user.Did,
923
-
Rkey: rkey,
926
+
Repo: newRepo.Did,
927
+
Rkey: newRepo.Rkey,
924
928
SwapRecord: ex.Cid,
925
929
Record: &lexutil.LexiconTypeDecoder{
926
-
Val: &tangled.Repo{
927
-
Knot: f.Knot,
928
-
Name: f.Name,
929
-
Owner: user.Did,
930
-
CreatedAt: f.Created.Format(time.RFC3339),
931
-
Description: &f.Description,
932
-
Spindle: spindlePtr,
933
-
},
930
+
Val: &record,
934
931
},
935
932
})
936
933
···
950
947
rp.pages.HxRefresh(w)
951
948
}
952
949
950
+
func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
951
+
user := rp.oauth.GetUser(r)
952
+
l := rp.logger.With("handler", "AddLabel")
953
+
l = l.With("did", user.Did)
954
+
l = l.With("handle", user.Handle)
955
+
956
+
f, err := rp.repoResolver.Resolve(r)
957
+
if err != nil {
958
+
l.Error("failed to get repo and knot", "err", err)
959
+
return
960
+
}
961
+
962
+
errorId := "add-label-error"
963
+
fail := func(msg string, err error) {
964
+
l.Error(msg, "err", err)
965
+
rp.pages.Notice(w, errorId, msg)
966
+
}
967
+
968
+
// get form values for label definition
969
+
name := r.FormValue("name")
970
+
concreteType := r.FormValue("valueType")
971
+
valueFormat := r.FormValue("valueFormat")
972
+
enumValues := r.FormValue("enumValues")
973
+
scope := r.Form["scope"]
974
+
color := r.FormValue("color")
975
+
multiple := r.FormValue("multiple") == "true"
976
+
977
+
var variants []string
978
+
for part := range strings.SplitSeq(enumValues, ",") {
979
+
if part = strings.TrimSpace(part); part != "" {
980
+
variants = append(variants, part)
981
+
}
982
+
}
983
+
984
+
if concreteType == "" {
985
+
concreteType = "null"
986
+
}
987
+
988
+
format := models.ValueTypeFormatAny
989
+
if valueFormat == "did" {
990
+
format = models.ValueTypeFormatDid
991
+
}
992
+
993
+
valueType := models.ValueType{
994
+
Type: models.ConcreteType(concreteType),
995
+
Format: format,
996
+
Enum: variants,
997
+
}
998
+
999
+
label := models.LabelDefinition{
1000
+
Did: user.Did,
1001
+
Rkey: tid.TID(),
1002
+
Name: name,
1003
+
ValueType: valueType,
1004
+
Scope: scope,
1005
+
Color: &color,
1006
+
Multiple: multiple,
1007
+
Created: time.Now(),
1008
+
}
1009
+
if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
1010
+
fail(err.Error(), err)
1011
+
return
1012
+
}
1013
+
1014
+
// announce this relation into the firehose, store into owners' pds
1015
+
client, err := rp.oauth.AuthorizedClient(r)
1016
+
if err != nil {
1017
+
fail(err.Error(), err)
1018
+
return
1019
+
}
1020
+
1021
+
// emit a labelRecord
1022
+
labelRecord := label.AsRecord()
1023
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1024
+
Collection: tangled.LabelDefinitionNSID,
1025
+
Repo: label.Did,
1026
+
Rkey: label.Rkey,
1027
+
Record: &lexutil.LexiconTypeDecoder{
1028
+
Val: &labelRecord,
1029
+
},
1030
+
})
1031
+
// invalid record
1032
+
if err != nil {
1033
+
fail("Failed to write record to PDS.", err)
1034
+
return
1035
+
}
1036
+
1037
+
aturi := resp.Uri
1038
+
l = l.With("at-uri", aturi)
1039
+
l.Info("wrote label record to PDS")
1040
+
1041
+
// update the repo to subscribe to this label
1042
+
newRepo := f.Repo
1043
+
newRepo.Labels = append(newRepo.Labels, aturi)
1044
+
repoRecord := newRepo.AsRecord()
1045
+
1046
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1047
+
if err != nil {
1048
+
fail("Failed to update labels, no record found on PDS.", err)
1049
+
return
1050
+
}
1051
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1052
+
Collection: tangled.RepoNSID,
1053
+
Repo: newRepo.Did,
1054
+
Rkey: newRepo.Rkey,
1055
+
SwapRecord: ex.Cid,
1056
+
Record: &lexutil.LexiconTypeDecoder{
1057
+
Val: &repoRecord,
1058
+
},
1059
+
})
1060
+
if err != nil {
1061
+
fail("Failed to update labels for repo.", err)
1062
+
return
1063
+
}
1064
+
1065
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1066
+
if err != nil {
1067
+
fail("Failed to add label.", err)
1068
+
return
1069
+
}
1070
+
1071
+
rollback := func() {
1072
+
err1 := tx.Rollback()
1073
+
err2 := rollbackRecord(context.Background(), aturi, client)
1074
+
1075
+
// ignore txn complete errors, this is okay
1076
+
if errors.Is(err1, sql.ErrTxDone) {
1077
+
err1 = nil
1078
+
}
1079
+
1080
+
if errs := errors.Join(err1, err2); errs != nil {
1081
+
l.Error("failed to rollback changes", "errs", errs)
1082
+
return
1083
+
}
1084
+
}
1085
+
defer rollback()
1086
+
1087
+
_, err = db.AddLabelDefinition(tx, &label)
1088
+
if err != nil {
1089
+
fail("Failed to add label.", err)
1090
+
return
1091
+
}
1092
+
1093
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1094
+
RepoAt: f.RepoAt(),
1095
+
LabelAt: label.AtUri(),
1096
+
})
1097
+
1098
+
err = tx.Commit()
1099
+
if err != nil {
1100
+
fail("Failed to add label.", err)
1101
+
return
1102
+
}
1103
+
1104
+
// clear aturi when everything is successful
1105
+
aturi = ""
1106
+
1107
+
rp.pages.HxRefresh(w)
1108
+
}
1109
+
1110
+
func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1111
+
user := rp.oauth.GetUser(r)
1112
+
l := rp.logger.With("handler", "DeleteLabel")
1113
+
l = l.With("did", user.Did)
1114
+
l = l.With("handle", user.Handle)
1115
+
1116
+
f, err := rp.repoResolver.Resolve(r)
1117
+
if err != nil {
1118
+
l.Error("failed to get repo and knot", "err", err)
1119
+
return
1120
+
}
1121
+
1122
+
errorId := "label-operation"
1123
+
fail := func(msg string, err error) {
1124
+
l.Error(msg, "err", err)
1125
+
rp.pages.Notice(w, errorId, msg)
1126
+
}
1127
+
1128
+
// get form values
1129
+
labelId := r.FormValue("label-id")
1130
+
1131
+
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
1132
+
if err != nil {
1133
+
fail("Failed to find label definition.", err)
1134
+
return
1135
+
}
1136
+
1137
+
client, err := rp.oauth.AuthorizedClient(r)
1138
+
if err != nil {
1139
+
fail(err.Error(), err)
1140
+
return
1141
+
}
1142
+
1143
+
// delete label record from PDS
1144
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1145
+
Collection: tangled.LabelDefinitionNSID,
1146
+
Repo: label.Did,
1147
+
Rkey: label.Rkey,
1148
+
})
1149
+
if err != nil {
1150
+
fail("Failed to delete label record from PDS.", err)
1151
+
return
1152
+
}
1153
+
1154
+
// update repo record to remove the label reference
1155
+
newRepo := f.Repo
1156
+
var updated []string
1157
+
removedAt := label.AtUri().String()
1158
+
for _, l := range newRepo.Labels {
1159
+
if l != removedAt {
1160
+
updated = append(updated, l)
1161
+
}
1162
+
}
1163
+
newRepo.Labels = updated
1164
+
repoRecord := newRepo.AsRecord()
1165
+
1166
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1167
+
if err != nil {
1168
+
fail("Failed to update labels, no record found on PDS.", err)
1169
+
return
1170
+
}
1171
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1172
+
Collection: tangled.RepoNSID,
1173
+
Repo: newRepo.Did,
1174
+
Rkey: newRepo.Rkey,
1175
+
SwapRecord: ex.Cid,
1176
+
Record: &lexutil.LexiconTypeDecoder{
1177
+
Val: &repoRecord,
1178
+
},
1179
+
})
1180
+
if err != nil {
1181
+
fail("Failed to update repo record.", err)
1182
+
return
1183
+
}
1184
+
1185
+
// transaction for DB changes
1186
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1187
+
if err != nil {
1188
+
fail("Failed to delete label.", err)
1189
+
return
1190
+
}
1191
+
defer tx.Rollback()
1192
+
1193
+
err = db.UnsubscribeLabel(
1194
+
tx,
1195
+
db.FilterEq("repo_at", f.RepoAt()),
1196
+
db.FilterEq("label_at", removedAt),
1197
+
)
1198
+
if err != nil {
1199
+
fail("Failed to unsubscribe label.", err)
1200
+
return
1201
+
}
1202
+
1203
+
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
1204
+
if err != nil {
1205
+
fail("Failed to delete label definition.", err)
1206
+
return
1207
+
}
1208
+
1209
+
err = tx.Commit()
1210
+
if err != nil {
1211
+
fail("Failed to delete label.", err)
1212
+
return
1213
+
}
1214
+
1215
+
// everything succeeded
1216
+
rp.pages.HxRefresh(w)
1217
+
}
1218
+
1219
+
func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
1220
+
user := rp.oauth.GetUser(r)
1221
+
l := rp.logger.With("handler", "SubscribeLabel")
1222
+
l = l.With("did", user.Did)
1223
+
l = l.With("handle", user.Handle)
1224
+
1225
+
f, err := rp.repoResolver.Resolve(r)
1226
+
if err != nil {
1227
+
l.Error("failed to get repo and knot", "err", err)
1228
+
return
1229
+
}
1230
+
1231
+
if err := r.ParseForm(); err != nil {
1232
+
l.Error("invalid form", "err", err)
1233
+
return
1234
+
}
1235
+
1236
+
errorId := "default-label-operation"
1237
+
fail := func(msg string, err error) {
1238
+
l.Error(msg, "err", err)
1239
+
rp.pages.Notice(w, errorId, msg)
1240
+
}
1241
+
1242
+
labelAts := r.Form["label"]
1243
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1244
+
if err != nil {
1245
+
fail("Failed to subscribe to label.", err)
1246
+
return
1247
+
}
1248
+
1249
+
newRepo := f.Repo
1250
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
1251
+
1252
+
// dedup
1253
+
slices.Sort(newRepo.Labels)
1254
+
newRepo.Labels = slices.Compact(newRepo.Labels)
1255
+
1256
+
repoRecord := newRepo.AsRecord()
1257
+
1258
+
client, err := rp.oauth.AuthorizedClient(r)
1259
+
if err != nil {
1260
+
fail(err.Error(), err)
1261
+
return
1262
+
}
1263
+
1264
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1265
+
if err != nil {
1266
+
fail("Failed to update labels, no record found on PDS.", err)
1267
+
return
1268
+
}
1269
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1270
+
Collection: tangled.RepoNSID,
1271
+
Repo: newRepo.Did,
1272
+
Rkey: newRepo.Rkey,
1273
+
SwapRecord: ex.Cid,
1274
+
Record: &lexutil.LexiconTypeDecoder{
1275
+
Val: &repoRecord,
1276
+
},
1277
+
})
1278
+
1279
+
tx, err := rp.db.Begin()
1280
+
if err != nil {
1281
+
fail("Failed to subscribe to label.", err)
1282
+
return
1283
+
}
1284
+
defer tx.Rollback()
1285
+
1286
+
for _, l := range labelAts {
1287
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1288
+
RepoAt: f.RepoAt(),
1289
+
LabelAt: syntax.ATURI(l),
1290
+
})
1291
+
if err != nil {
1292
+
fail("Failed to subscribe to label.", err)
1293
+
return
1294
+
}
1295
+
}
1296
+
1297
+
if err := tx.Commit(); err != nil {
1298
+
fail("Failed to subscribe to label.", err)
1299
+
return
1300
+
}
1301
+
1302
+
// everything succeeded
1303
+
rp.pages.HxRefresh(w)
1304
+
}
1305
+
1306
+
func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
1307
+
user := rp.oauth.GetUser(r)
1308
+
l := rp.logger.With("handler", "UnsubscribeLabel")
1309
+
l = l.With("did", user.Did)
1310
+
l = l.With("handle", user.Handle)
1311
+
1312
+
f, err := rp.repoResolver.Resolve(r)
1313
+
if err != nil {
1314
+
l.Error("failed to get repo and knot", "err", err)
1315
+
return
1316
+
}
1317
+
1318
+
if err := r.ParseForm(); err != nil {
1319
+
l.Error("invalid form", "err", err)
1320
+
return
1321
+
}
1322
+
1323
+
errorId := "default-label-operation"
1324
+
fail := func(msg string, err error) {
1325
+
l.Error(msg, "err", err)
1326
+
rp.pages.Notice(w, errorId, msg)
1327
+
}
1328
+
1329
+
labelAts := r.Form["label"]
1330
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1331
+
if err != nil {
1332
+
fail("Failed to unsubscribe to label.", err)
1333
+
return
1334
+
}
1335
+
1336
+
// update repo record to remove the label reference
1337
+
newRepo := f.Repo
1338
+
var updated []string
1339
+
for _, l := range newRepo.Labels {
1340
+
if !slices.Contains(labelAts, l) {
1341
+
updated = append(updated, l)
1342
+
}
1343
+
}
1344
+
newRepo.Labels = updated
1345
+
repoRecord := newRepo.AsRecord()
1346
+
1347
+
client, err := rp.oauth.AuthorizedClient(r)
1348
+
if err != nil {
1349
+
fail(err.Error(), err)
1350
+
return
1351
+
}
1352
+
1353
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1354
+
if err != nil {
1355
+
fail("Failed to update labels, no record found on PDS.", err)
1356
+
return
1357
+
}
1358
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1359
+
Collection: tangled.RepoNSID,
1360
+
Repo: newRepo.Did,
1361
+
Rkey: newRepo.Rkey,
1362
+
SwapRecord: ex.Cid,
1363
+
Record: &lexutil.LexiconTypeDecoder{
1364
+
Val: &repoRecord,
1365
+
},
1366
+
})
1367
+
1368
+
err = db.UnsubscribeLabel(
1369
+
rp.db,
1370
+
db.FilterEq("repo_at", f.RepoAt()),
1371
+
db.FilterIn("label_at", labelAts),
1372
+
)
1373
+
if err != nil {
1374
+
fail("Failed to unsubscribe label.", err)
1375
+
return
1376
+
}
1377
+
1378
+
// everything succeeded
1379
+
rp.pages.HxRefresh(w)
1380
+
}
1381
+
1382
+
func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1383
+
l := rp.logger.With("handler", "LabelPanel")
1384
+
1385
+
f, err := rp.repoResolver.Resolve(r)
1386
+
if err != nil {
1387
+
l.Error("failed to get repo and knot", "err", err)
1388
+
return
1389
+
}
1390
+
1391
+
subjectStr := r.FormValue("subject")
1392
+
subject, err := syntax.ParseATURI(subjectStr)
1393
+
if err != nil {
1394
+
l.Error("failed to get repo and knot", "err", err)
1395
+
return
1396
+
}
1397
+
1398
+
labelDefs, err := db.GetLabelDefinitions(
1399
+
rp.db,
1400
+
db.FilterIn("at_uri", f.Repo.Labels),
1401
+
db.FilterContains("scope", subject.Collection().String()),
1402
+
)
1403
+
if err != nil {
1404
+
log.Println("failed to fetch label defs", err)
1405
+
return
1406
+
}
1407
+
1408
+
defs := make(map[string]*models.LabelDefinition)
1409
+
for _, l := range labelDefs {
1410
+
defs[l.AtUri().String()] = &l
1411
+
}
1412
+
1413
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1414
+
if err != nil {
1415
+
log.Println("failed to build label state", err)
1416
+
return
1417
+
}
1418
+
state := states[subject]
1419
+
1420
+
user := rp.oauth.GetUser(r)
1421
+
rp.pages.LabelPanel(w, pages.LabelPanelParams{
1422
+
LoggedInUser: user,
1423
+
RepoInfo: f.RepoInfo(user),
1424
+
Defs: defs,
1425
+
Subject: subject.String(),
1426
+
State: state,
1427
+
})
1428
+
}
1429
+
1430
+
func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1431
+
l := rp.logger.With("handler", "EditLabelPanel")
1432
+
1433
+
f, err := rp.repoResolver.Resolve(r)
1434
+
if err != nil {
1435
+
l.Error("failed to get repo and knot", "err", err)
1436
+
return
1437
+
}
1438
+
1439
+
subjectStr := r.FormValue("subject")
1440
+
subject, err := syntax.ParseATURI(subjectStr)
1441
+
if err != nil {
1442
+
l.Error("failed to get repo and knot", "err", err)
1443
+
return
1444
+
}
1445
+
1446
+
labelDefs, err := db.GetLabelDefinitions(
1447
+
rp.db,
1448
+
db.FilterIn("at_uri", f.Repo.Labels),
1449
+
db.FilterContains("scope", subject.Collection().String()),
1450
+
)
1451
+
if err != nil {
1452
+
log.Println("failed to fetch labels", err)
1453
+
return
1454
+
}
1455
+
1456
+
defs := make(map[string]*models.LabelDefinition)
1457
+
for _, l := range labelDefs {
1458
+
defs[l.AtUri().String()] = &l
1459
+
}
1460
+
1461
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1462
+
if err != nil {
1463
+
log.Println("failed to build label state", err)
1464
+
return
1465
+
}
1466
+
state := states[subject]
1467
+
1468
+
user := rp.oauth.GetUser(r)
1469
+
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1470
+
LoggedInUser: user,
1471
+
RepoInfo: f.RepoInfo(user),
1472
+
Defs: defs,
1473
+
Subject: subject.String(),
1474
+
State: state,
1475
+
})
1476
+
}
1477
+
953
1478
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
954
1479
user := rp.oauth.GetUser(r)
955
1480
l := rp.logger.With("handler", "AddCollaborator")
···
1051
1576
return
1052
1577
}
1053
1578
1054
-
err = db.AddCollaborator(rp.db, db.Collaborator{
1579
+
err = db.AddCollaborator(tx, models.Collaborator{
1055
1580
Did: syntax.DID(currentUser.Did),
1056
1581
Rkey: rkey,
1057
1582
SubjectDid: collaboratorIdent.DID,
···
1379
1904
return
1380
1905
}
1381
1906
1907
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1908
+
if err != nil {
1909
+
log.Println("failed to fetch labels", err)
1910
+
rp.pages.Error503(w)
1911
+
return
1912
+
}
1913
+
1914
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1915
+
if err != nil {
1916
+
log.Println("failed to fetch labels", err)
1917
+
rp.pages.Error503(w)
1918
+
return
1919
+
}
1920
+
// remove default labels from the labels list, if present
1921
+
defaultLabelMap := make(map[string]bool)
1922
+
for _, dl := range defaultLabels {
1923
+
defaultLabelMap[dl.AtUri().String()] = true
1924
+
}
1925
+
n := 0
1926
+
for _, l := range labels {
1927
+
if !defaultLabelMap[l.AtUri().String()] {
1928
+
labels[n] = l
1929
+
n++
1930
+
}
1931
+
}
1932
+
labels = labels[:n]
1933
+
1934
+
subscribedLabels := make(map[string]struct{})
1935
+
for _, l := range f.Repo.Labels {
1936
+
subscribedLabels[l] = struct{}{}
1937
+
}
1938
+
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1382
1950
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1383
-
LoggedInUser: user,
1384
-
RepoInfo: f.RepoInfo(user),
1385
-
Branches: result.Branches,
1386
-
Tabs: settingsTabs,
1387
-
Tab: "general",
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1388
1960
})
1389
1961
}
1390
1962
···
1557
2129
}
1558
2130
1559
2131
// choose a name for a fork
1560
-
forkName := f.Name
2132
+
forkName := r.FormValue("repo_name")
2133
+
if forkName == "" {
2134
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2135
+
return
2136
+
}
2137
+
1561
2138
// this check is *only* to see if the forked repo name already exists
1562
2139
// in the user's account.
1563
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
2140
+
existingRepo, err := db.GetRepo(
2141
+
rp.db,
2142
+
db.FilterEq("did", user.Did),
2143
+
db.FilterEq("name", forkName),
2144
+
)
1564
2145
if err != nil {
1565
-
if errors.Is(err, sql.ErrNoRows) {
1566
-
// no existing repo with this name found, we can use the name as is
1567
-
} else {
1568
-
log.Println("error fetching existing repo from db", err)
2146
+
if !errors.Is(err, sql.ErrNoRows) {
2147
+
log.Println("error fetching existing repo from db", "err", err)
1569
2148
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1570
2149
return
1571
2150
}
1572
2151
} else if existingRepo != nil {
1573
-
// repo with this name already exists, append random string
1574
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2152
+
// repo with this name already exists
2153
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2154
+
return
1575
2155
}
1576
2156
l = l.With("forkName", forkName)
1577
2157
···
1587
2167
1588
2168
// create an atproto record for this fork
1589
2169
rkey := tid.TID()
1590
-
repo := &db.Repo{
1591
-
Did: user.Did,
1592
-
Name: forkName,
1593
-
Knot: targetKnot,
1594
-
Rkey: rkey,
1595
-
Source: sourceAt,
2170
+
repo := &models.Repo{
2171
+
Did: user.Did,
2172
+
Name: forkName,
2173
+
Knot: targetKnot,
2174
+
Rkey: rkey,
2175
+
Source: sourceAt,
2176
+
Description: f.Repo.Description,
2177
+
Created: time.Now(),
2178
+
Labels: models.DefaultLabelDefs(),
1596
2179
}
2180
+
record := repo.AsRecord()
1597
2181
1598
2182
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1599
2183
if err != nil {
···
1602
2186
return
1603
2187
}
1604
2188
1605
-
createdAt := time.Now().Format(time.RFC3339)
1606
2189
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1607
2190
Collection: tangled.RepoNSID,
1608
2191
Repo: user.Did,
1609
2192
Rkey: rkey,
1610
2193
Record: &lexutil.LexiconTypeDecoder{
1611
-
Val: &tangled.Repo{
1612
-
Knot: repo.Knot,
1613
-
Name: repo.Name,
1614
-
CreatedAt: createdAt,
1615
-
Owner: user.Did,
1616
-
Source: &sourceAt,
1617
-
}},
2194
+
Val: &record,
2195
+
},
1618
2196
})
1619
2197
if err != nil {
1620
2198
l.Error("failed to write to PDS", "err", err)
+6
-5
appview/repo/repo_util.go
+6
-5
appview/repo/repo_util.go
···
9
9
"sort"
10
10
"strings"
11
11
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
-
"tangled.sh/tangled.sh/core/types"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages/repoinfo"
15
+
"tangled.org/core/types"
15
16
16
17
"github.com/go-git/go-git/v5/plumbing/object"
17
18
)
···
143
144
d *db.DB,
144
145
repoInfo repoinfo.RepoInfo,
145
146
shas []string,
146
-
) (map[string]db.Pipeline, error) {
147
-
m := make(map[string]db.Pipeline)
147
+
) (map[string]models.Pipeline, error) {
148
+
m := make(map[string]models.Pipeline)
148
149
149
150
if len(shas) == 0 {
150
151
return m, nil
+13
-4
appview/repo/router.go
+13
-4
appview/repo/router.go
···
4
4
"net/http"
5
5
6
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
7
+
"tangled.org/core/appview/middleware"
8
8
)
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
21
r.Route("/tags", func(r chi.Router) {
22
22
r.Get("/", rp.RepoTags)
23
23
r.Route("/{tag}", func(r chi.Router) {
24
-
r.Use(middleware.AuthMiddleware(rp.oauth))
25
-
// require auth to download for now
26
24
r.Get("/download/{file}", rp.DownloadArtifact)
27
25
28
26
// require repo:push to upload or delete artifacts
···
30
28
// additionally: only the uploader can truly delete an artifact
31
29
// (record+blob will live on their pds)
32
30
r.Group(func(r chi.Router) {
33
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
31
+
r.Use(middleware.AuthMiddleware(rp.oauth))
32
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
34
33
r.Post("/upload", rp.AttachArtifact)
35
34
r.Delete("/{file}", rp.DeleteArtifact)
36
35
})
···
64
63
r.Get("/*", rp.RepoCompare)
65
64
})
66
65
66
+
// label panel in issues/pulls/discussions/tasks
67
+
r.Route("/label", func(r chi.Router) {
68
+
r.Get("/", rp.LabelPanel)
69
+
r.Get("/edit", rp.EditLabelPanel)
70
+
})
71
+
67
72
// settings routes, needs auth
68
73
r.Group(func(r chi.Router) {
69
74
r.Use(middleware.AuthMiddleware(rp.oauth))
···
76
81
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
77
82
r.Get("/", rp.RepoSettings)
78
83
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
84
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
85
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
86
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel)
87
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel)
79
88
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
80
89
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
81
90
r.Put("/branches/default", rp.SetDefaultBranch)
+14
-12
appview/reporesolver/resolver.go
+14
-12
appview/reporesolver/resolver.go
···
14
14
"github.com/bluesky-social/indigo/atproto/identity"
15
15
securejoin "github.com/cyphar/filepath-securejoin"
16
16
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview/config"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
22
-
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/rbac"
17
+
"tangled.org/core/appview/config"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/repoinfo"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
24
25
)
25
26
26
27
type ResolvedRepo struct {
27
-
db.Repo
28
+
models.Repo
28
29
OwnerId identity.Identity
29
30
CurrentDir string
30
31
Ref string
···
44
45
}
45
46
46
47
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
47
-
repo, ok := r.Context().Value("repo").(*db.Repo)
48
+
repo, ok := r.Context().Value("repo").(*models.Repo)
48
49
if !ok {
49
50
log.Println("malformed middleware: `repo` not exist in context")
50
51
return nil, fmt.Errorf("malformed middleware")
···
162
163
log.Println("failed to get repo source for ", repoAt, err)
163
164
}
164
165
165
-
var sourceRepo *db.Repo
166
+
var sourceRepo *models.Repo
166
167
if source != "" {
167
168
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
168
169
if err != nil {
···
184
185
OwnerDid: f.OwnerDid(),
185
186
OwnerHandle: f.OwnerHandle(),
186
187
Name: f.Name,
188
+
Rkey: f.Repo.Rkey,
187
189
RepoAt: repoAt,
188
190
Description: f.Description,
189
191
IsStarred: isStarred,
190
192
Knot: knot,
191
193
Spindle: f.Spindle,
192
194
Roles: f.RolesInRepo(user),
193
-
Stats: db.RepoStats{
195
+
Stats: models.RepoStats{
194
196
StarCount: starCount,
195
197
IssueCount: issueCount,
196
198
PullCount: pullCount,
···
210
212
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
211
213
if u != nil {
212
214
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
213
-
return repoinfo.RolesInRepo{r}
215
+
return repoinfo.RolesInRepo{Roles: r}
214
216
} else {
215
217
return repoinfo.RolesInRepo{}
216
218
}
+4
-4
appview/serververify/verify.go
+4
-4
appview/serververify/verify.go
···
6
6
"fmt"
7
7
8
8
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
12
-
"tangled.sh/tangled.sh/core/rbac"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/rbac"
13
13
)
14
14
15
15
var (
+62
-10
appview/settings/settings.go
+62
-10
appview/settings/settings.go
···
11
11
"time"
12
12
13
13
"github.com/go-chi/chi/v5"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/email"
18
-
"tangled.sh/tangled.sh/core/appview/middleware"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/tid"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/config"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/email"
18
+
"tangled.org/core/appview/middleware"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/tid"
22
23
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
25
lexutil "github.com/bluesky-social/indigo/lex/util"
···
40
41
{"Name": "profile", "Icon": "user"},
41
42
{"Name": "keys", "Icon": "key"},
42
43
{"Name": "emails", "Icon": "mail"},
44
+
{"Name": "notifications", "Icon": "bell"},
43
45
}
44
46
)
45
47
···
67
69
r.Post("/primary", s.emailsPrimary)
68
70
})
69
71
72
+
r.Route("/notifications", func(r chi.Router) {
73
+
r.Get("/", s.notificationsSettings)
74
+
r.Put("/", s.updateNotificationPreferences)
75
+
})
76
+
70
77
return r
71
78
}
72
79
···
80
87
})
81
88
}
82
89
90
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
91
+
user := s.OAuth.GetUser(r)
92
+
did := s.OAuth.GetDid(r)
93
+
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
if err != nil {
96
+
log.Printf("failed to get notification preferences: %s", err)
97
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
98
+
return
99
+
}
100
+
101
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
102
+
LoggedInUser: user,
103
+
Preferences: prefs,
104
+
Tabs: settingsTabs,
105
+
Tab: "notifications",
106
+
})
107
+
}
108
+
109
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
110
+
did := s.OAuth.GetDid(r)
111
+
112
+
prefs := &models.NotificationPreferences{
113
+
UserDid: did,
114
+
RepoStarred: r.FormValue("repo_starred") == "on",
115
+
IssueCreated: r.FormValue("issue_created") == "on",
116
+
IssueCommented: r.FormValue("issue_commented") == "on",
117
+
IssueClosed: r.FormValue("issue_closed") == "on",
118
+
PullCreated: r.FormValue("pull_created") == "on",
119
+
PullCommented: r.FormValue("pull_commented") == "on",
120
+
PullMerged: r.FormValue("pull_merged") == "on",
121
+
Followed: r.FormValue("followed") == "on",
122
+
EmailNotifications: r.FormValue("email_notifications") == "on",
123
+
}
124
+
125
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
126
+
if err != nil {
127
+
log.Printf("failed to update notification preferences: %s", err)
128
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
129
+
return
130
+
}
131
+
132
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
133
+
}
134
+
83
135
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
84
136
user := s.OAuth.GetUser(r)
85
137
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
185
237
}
186
238
defer tx.Rollback()
187
239
188
-
if err := db.AddEmail(tx, db.Email{
240
+
if err := db.AddEmail(tx, models.Email{
189
241
Did: did,
190
242
Address: emAddr,
191
243
Verified: false,
···
246
298
if s.Config.Core.Dev {
247
299
appUrl = "http://" + s.Config.Core.ListenAddr
248
300
} else {
249
-
appUrl = "https://tangled.sh"
301
+
appUrl = s.Config.Core.AppviewHost
250
302
}
251
303
252
304
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76
-11
appview/signup/signup.go
+76
-11
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
5
7
"fmt"
6
8
"log/slog"
7
9
"net/http"
10
+
"net/url"
8
11
"os"
9
12
"strings"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/posthog/posthog-go"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/dns"
16
-
"tangled.sh/tangled.sh/core/appview/email"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/dns"
19
+
"tangled.org/core/appview/email"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/state/userutil"
23
+
"tangled.org/core/appview/xrpcclient"
24
+
"tangled.org/core/idresolver"
21
25
)
22
26
23
27
type Signup struct {
···
115
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
120
switch r.Method {
117
121
case http.MethodGet:
118
-
s.pages.Signup(w)
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
119
125
case http.MethodPost:
120
126
if s.cf == nil {
121
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
122
129
}
123
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
124
132
125
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
126
141
if !email.IsValidEmail(emailId) {
127
142
s.pages.Notice(w, noticeId, "Invalid email address.")
128
143
return
···
163
178
s.pages.Notice(w, noticeId, "Failed to send email.")
164
179
return
165
180
}
166
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
181
+
err = db.AddInflightSignup(s.db, models.InflightSignup{
167
182
Email: emailId,
168
183
InviteCode: code,
169
184
})
···
229
244
return
230
245
}
231
246
232
-
err = db.AddEmail(s.db, db.Email{
247
+
err = db.AddEmail(s.db, models.Email{
233
248
Did: did,
234
249
Address: email,
235
250
Verified: true,
···
254
269
return
255
270
}
256
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}
+15
-14
appview/spindles/spindles.go
+15
-14
appview/spindles/spindles.go
···
9
9
"time"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/idresolver"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/tid"
23
24
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
26
"github.com/bluesky-social/indigo/atproto/syntax"
···
115
116
}
116
117
117
118
// organize repos by did
118
-
repoMap := make(map[string][]db.Repo)
119
+
repoMap := make(map[string][]models.Repo)
119
120
for _, r := range repos {
120
121
repoMap[r.Did] = append(repoMap[r.Did], r)
121
122
}
···
163
164
s.Enforcer.E.LoadPolicy()
164
165
}()
165
166
166
-
err = db.AddSpindle(tx, db.Spindle{
167
+
err = db.AddSpindle(tx, models.Spindle{
167
168
Owner: syntax.DID(user.Did),
168
169
Instance: instance,
169
170
})
···
524
525
rkey := tid.TID()
525
526
526
527
// add member to db
527
-
if err = db.AddSpindleMember(tx, db.SpindleMember{
528
+
if err = db.AddSpindleMember(tx, models.SpindleMember{
528
529
Did: syntax.DID(user.Did),
529
530
Rkey: rkey,
530
531
Instance: instance,
+8
-7
appview/state/follow.go
+8
-7
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pages"
13
-
"tangled.sh/tangled.sh/core/tid"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/tid"
14
15
)
15
16
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
59
60
60
61
log.Println("created atproto record: ", resp.Uri)
61
62
62
-
follow := &db.Follow{
63
+
follow := &models.Follow{
63
64
UserDid: currentUser.Did,
64
65
SubjectDid: subjectIdent.DID.String(),
65
66
Rkey: rkey,
···
75
76
76
77
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
78
UserDid: subjectIdent.DID.String(),
78
-
FollowStatus: db.IsFollowing,
79
+
FollowStatus: models.IsFollowing,
79
80
})
80
81
81
82
return
···
106
107
107
108
s.pages.FollowFragment(w, pages.FollowFragmentParams{
108
109
UserDid: subjectIdent.DID.String(),
109
-
FollowStatus: db.IsNotFollowing,
110
+
FollowStatus: models.IsNotFollowing,
110
111
})
111
112
112
113
s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
+151
appview/state/gfi.go
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
"tangled.org/core/consts"
16
+
)
17
+
18
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
+
user := s.oauth.GetUser(r)
20
+
21
+
page, ok := r.Context().Value("page").(pagination.Page)
22
+
if !ok {
23
+
page = pagination.FirstPage()
24
+
}
25
+
26
+
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
+
28
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
+
if err != nil {
30
+
log.Println("failed to get repo labels", err)
31
+
s.pages.Error503(w)
32
+
return
33
+
}
34
+
35
+
if len(repoLabels) == 0 {
36
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
37
+
LoggedInUser: user,
38
+
RepoGroups: []*models.RepoGroup{},
39
+
LabelDefs: make(map[string]*models.LabelDefinition),
40
+
Page: page,
41
+
})
42
+
return
43
+
}
44
+
45
+
repoUris := make([]string, 0, len(repoLabels))
46
+
for _, rl := range repoLabels {
47
+
repoUris = append(repoUris, rl.RepoAt.String())
48
+
}
49
+
50
+
allIssues, err := db.GetIssuesPaginated(
51
+
s.db,
52
+
pagination.Page{
53
+
Limit: 500,
54
+
},
55
+
db.FilterIn("repo_at", repoUris),
56
+
db.FilterEq("open", 1),
57
+
)
58
+
if err != nil {
59
+
log.Println("failed to get issues", err)
60
+
s.pages.Error503(w)
61
+
return
62
+
}
63
+
64
+
var goodFirstIssues []models.Issue
65
+
for _, issue := range allIssues {
66
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
67
+
goodFirstIssues = append(goodFirstIssues, issue)
68
+
}
69
+
}
70
+
71
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
72
+
for _, issue := range goodFirstIssues {
73
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
74
+
group.Issues = append(group.Issues, issue)
75
+
} else {
76
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
77
+
Repo: issue.Repo,
78
+
Issues: []models.Issue{issue},
79
+
}
80
+
}
81
+
}
82
+
83
+
var sortedGroups []*models.RepoGroup
84
+
for _, group := range repoGroups {
85
+
sortedGroups = append(sortedGroups, group)
86
+
}
87
+
88
+
sort.Slice(sortedGroups, func(i, j int) bool {
89
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
90
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
91
+
92
+
// If one is tangled and the other isn't, non-tangled comes first
93
+
if iIsTangled != jIsTangled {
94
+
return jIsTangled // true if j is tangled (i should come first)
95
+
}
96
+
97
+
// Both tangled or both not tangled: sort by name
98
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
99
+
})
100
+
101
+
groupStart := page.Offset
102
+
groupEnd := page.Offset + page.Limit
103
+
if groupStart > len(sortedGroups) {
104
+
groupStart = len(sortedGroups)
105
+
}
106
+
if groupEnd > len(sortedGroups) {
107
+
groupEnd = len(sortedGroups)
108
+
}
109
+
110
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
111
+
112
+
var allIssuesFromGroups []models.Issue
113
+
for _, group := range paginatedGroups {
114
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
115
+
}
116
+
117
+
var allLabelDefs []models.LabelDefinition
118
+
if len(allIssuesFromGroups) > 0 {
119
+
labelDefUris := make(map[string]bool)
120
+
for _, issue := range allIssuesFromGroups {
121
+
for labelDefUri := range issue.Labels.Inner() {
122
+
labelDefUris[labelDefUri] = true
123
+
}
124
+
}
125
+
126
+
uriList := make([]string, 0, len(labelDefUris))
127
+
for uri := range labelDefUris {
128
+
uriList = append(uriList, uri)
129
+
}
130
+
131
+
if len(uriList) > 0 {
132
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
133
+
if err != nil {
134
+
log.Println("failed to fetch labels", err)
135
+
}
136
+
}
137
+
}
138
+
139
+
labelDefsMap := make(map[string]*models.LabelDefinition)
140
+
for i := range allLabelDefs {
141
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
142
+
}
143
+
144
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
145
+
LoggedInUser: user,
146
+
RepoGroups: paginatedGroups,
147
+
LabelDefs: labelDefsMap,
148
+
Page: page,
149
+
GfiLabel: labelDefsMap[goodFirstIssueLabel],
150
+
})
151
+
}
+4
-4
appview/state/git_http.go
+4
-4
appview/state/git_http.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/identity"
10
10
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.org/core/appview/models"
12
12
)
13
13
14
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
-
repo := r.Context().Value("repo").(*db.Repo)
16
+
repo := r.Context().Value("repo").(*models.Repo)
17
17
18
18
scheme := "https"
19
19
if s.config.Core.Dev {
···
31
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
32
return
33
33
}
34
-
repo := r.Context().Value("repo").(*db.Repo)
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
repo := r.Context().Value("repo").(*db.Repo)
51
+
repo := r.Context().Value("repo").(*models.Repo)
52
52
53
53
scheme := "https"
54
54
if s.config.Core.Dev {
+29
-15
appview/state/knotstream.go
+29
-15
appview/state/knotstream.go
···
8
8
"slices"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/cache"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
16
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/workflow"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/cache"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/models"
16
+
ec "tangled.org/core/eventconsumer"
17
+
"tangled.org/core/eventconsumer/cursor"
18
+
"tangled.org/core/log"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/workflow"
20
21
21
22
"github.com/bluesky-social/indigo/atproto/syntax"
22
23
"github.com/go-git/go-git/v5/plumbing"
···
124
125
}
125
126
}
126
127
127
-
punch := db.Punch{
128
+
punch := models.Punch{
128
129
Did: record.CommitterDid,
129
130
Date: time.Now(),
130
131
Count: count,
···
156
157
return fmt.Errorf("%s is not a valid reference name", ref)
157
158
}
158
159
159
-
var langs []db.RepoLanguage
160
+
var langs []models.RepoLanguage
160
161
for _, l := range record.Meta.LangBreakdown.Inputs {
161
162
if l == nil {
162
163
continue
163
164
}
164
165
165
-
langs = append(langs, db.RepoLanguage{
166
+
langs = append(langs, models.RepoLanguage{
166
167
RepoAt: repo.RepoAt(),
167
168
Ref: ref.Short(),
168
169
IsDefaultRef: record.Meta.IsDefaultRef,
···
171
172
})
172
173
}
173
174
174
-
return db.InsertRepoLanguages(d, langs)
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
175
189
}
176
190
177
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
207
221
}
208
222
209
223
// trigger info
210
-
var trigger db.Trigger
224
+
var trigger models.Trigger
211
225
var sha string
212
226
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
213
227
switch trigger.Kind {
···
234
248
return fmt.Errorf("failed to add trigger entry: %w", err)
235
249
}
236
250
237
-
pipeline := db.Pipeline{
251
+
pipeline := models.Pipeline{
238
252
Rkey: msg.Rkey,
239
253
Knot: source.Key(),
240
254
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+30
-37
appview/state/profile.go
+30
-37
appview/state/profile.go
···
15
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
16
"github.com/go-chi/chi/v5"
17
17
"github.com/gorilla/feeds"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
21
22
)
22
23
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
76
77
}
77
78
78
79
loggedInUser := s.oauth.GetUser(r)
79
-
followStatus := db.IsNotFollowing
80
+
followStatus := models.IsNotFollowing
80
81
if loggedInUser != nil {
81
82
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
82
83
}
···
130
131
}
131
132
132
133
// filter out ones that are pinned
133
-
pinnedRepos := []db.Repo{}
134
+
pinnedRepos := []models.Repo{}
134
135
for i, r := range repos {
135
136
// if this is a pinned repo, add it
136
137
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
148
149
l.Error("failed to fetch collaborating repos", "err", err)
149
150
}
150
151
151
-
pinnedCollaboratingRepos := []db.Repo{}
152
+
pinnedCollaboratingRepos := []models.Repo{}
152
153
for _, r := range collaboratingRepos {
153
154
// if this is a pinned repo, add it
154
155
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
216
217
s.pages.Error500(w)
217
218
return
218
219
}
219
-
var repoAts []string
220
+
var repos []models.Repo
220
221
for _, s := range stars {
221
-
repoAts = append(repoAts, string(s.RepoAt))
222
-
}
223
-
224
-
repos, err := db.GetRepos(
225
-
s.db,
226
-
0,
227
-
db.FilterIn("at_uri", repoAts),
228
-
)
229
-
if err != nil {
230
-
l.Error("failed to get repos", "err", err)
231
-
s.pages.Error500(w)
232
-
return
222
+
if s.Repo != nil {
223
+
repos = append(repos, *s.Repo)
224
+
}
233
225
}
234
226
235
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
271
263
272
264
func (s *State) followPage(
273
265
r *http.Request,
274
-
fetchFollows func(db.Execer, string) ([]db.Follow, error),
275
-
extractDid func(db.Follow) string,
266
+
fetchFollows func(db.Execer, string) ([]models.Follow, error),
267
+
extractDid func(models.Follow) string,
276
268
) (*FollowsPageParams, error) {
277
269
l := s.logger.With("handler", "reposPage")
278
270
···
329
321
followCards := make([]pages.FollowCard, len(follows))
330
322
for i, did := range followDids {
331
323
followStats := followStatsMap[did]
332
-
followStatus := db.IsNotFollowing
324
+
followStatus := models.IsNotFollowing
333
325
if _, exists := loggedInUserFollowing[did]; exists {
334
-
followStatus = db.IsFollowing
326
+
followStatus = models.IsFollowing
335
327
} else if loggedInUser != nil && loggedInUser.Did == did {
336
-
followStatus = db.IsSelf
328
+
followStatus = models.IsSelf
337
329
}
338
330
339
-
var profile *db.Profile
331
+
var profile *models.Profile
340
332
if p, exists := profiles[did]; exists {
341
333
profile = p
342
334
} else {
343
-
profile = &db.Profile{}
335
+
profile = &models.Profile{}
344
336
profile.Did = did
345
337
}
346
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
347
340
UserDid: did,
348
341
FollowStatus: followStatus,
349
342
FollowersCount: followStats.Followers,
···
358
351
}
359
352
360
353
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
361
-
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
354
+
followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
362
355
if err != nil {
363
356
s.pages.Notice(w, "all-followers", "Failed to load followers")
364
357
return
···
372
365
}
373
366
374
367
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
375
-
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
368
+
followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
376
369
if err != nil {
377
370
s.pages.Notice(w, "all-following", "Failed to load following")
378
371
return
···
453
446
return &feed, nil
454
447
}
455
448
456
-
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
449
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
457
450
for _, pull := range pulls {
458
451
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
459
452
if err != nil {
···
466
459
return nil
467
460
}
468
461
469
-
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
462
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
470
463
for _, issue := range issues {
471
464
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
472
465
if err != nil {
···
478
471
return nil
479
472
}
480
473
481
-
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
474
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
482
475
for _, repo := range repos {
483
476
item, err := s.createRepoItem(ctx, repo, author)
484
477
if err != nil {
···
489
482
return nil
490
483
}
491
484
492
-
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
485
+
func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
493
486
return &feeds.Item{
494
487
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
495
488
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
498
491
}
499
492
}
500
493
501
-
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
494
+
func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
502
495
return &feeds.Item{
503
496
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
504
497
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
507
500
}
508
501
}
509
502
510
-
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
503
+
func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
511
504
var title string
512
505
if repo.Source != nil {
513
506
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
558
551
stat1 := r.FormValue("stat1")
559
552
560
553
if stat0 != "" {
561
-
profile.Stats[0].Kind = db.VanityStatKind(stat0)
554
+
profile.Stats[0].Kind = models.VanityStatKind(stat0)
562
555
}
563
556
564
557
if stat1 != "" {
565
-
profile.Stats[1].Kind = db.VanityStatKind(stat1)
558
+
profile.Stats[1].Kind = models.VanityStatKind(stat1)
566
559
}
567
560
568
561
if err := db.ValidateProfile(s.db, profile); err != nil {
···
613
606
s.updateProfile(profile, w, r)
614
607
}
615
608
616
-
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
609
+
func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
617
610
user := s.oauth.GetUser(r)
618
611
tx, err := s.db.BeginTx(r.Context(), nil)
619
612
if err != nil {
+6
-5
appview/state/reaction.go
+6
-5
appview/state/reaction.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
11
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/pages"
15
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/tid"
16
17
)
17
18
18
19
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
30
31
return
31
32
}
32
33
33
-
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
+
reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind"))
34
35
if !ok {
35
36
log.Println("invalid reaction kind")
36
37
return
+53
-18
appview/state/router.go
+53
-18
appview/state/router.go
···
6
6
7
7
"github.com/go-chi/chi/v5"
8
8
"github.com/gorilla/sessions"
9
-
"tangled.sh/tangled.sh/core/appview/issues"
10
-
"tangled.sh/tangled.sh/core/appview/knots"
11
-
"tangled.sh/tangled.sh/core/appview/middleware"
12
-
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
13
-
"tangled.sh/tangled.sh/core/appview/pipelines"
14
-
"tangled.sh/tangled.sh/core/appview/pulls"
15
-
"tangled.sh/tangled.sh/core/appview/repo"
16
-
"tangled.sh/tangled.sh/core/appview/settings"
17
-
"tangled.sh/tangled.sh/core/appview/signup"
18
-
"tangled.sh/tangled.sh/core/appview/spindles"
19
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
-
avstrings "tangled.sh/tangled.sh/core/appview/strings"
21
-
"tangled.sh/tangled.sh/core/log"
9
+
"tangled.org/core/appview/issues"
10
+
"tangled.org/core/appview/knots"
11
+
"tangled.org/core/appview/labels"
12
+
"tangled.org/core/appview/middleware"
13
+
"tangled.org/core/appview/notifications"
14
+
oauthhandler "tangled.org/core/appview/oauth/handler"
15
+
"tangled.org/core/appview/pipelines"
16
+
"tangled.org/core/appview/pulls"
17
+
"tangled.org/core/appview/repo"
18
+
"tangled.org/core/appview/settings"
19
+
"tangled.org/core/appview/signup"
20
+
"tangled.org/core/appview/spindles"
21
+
"tangled.org/core/appview/state/userutil"
22
+
avstrings "tangled.org/core/appview/strings"
23
+
"tangled.org/core/log"
22
24
)
23
25
24
26
func (s *State) Router() http.Handler {
···
32
34
s.pages,
33
35
)
34
36
37
+
router.Use(middleware.TryRefreshSession())
35
38
router.Get("/favicon.svg", s.Favicon)
36
39
router.Get("/favicon.ico", s.Favicon)
40
+
router.Get("/pwa-manifest.json", s.PWAManifest)
37
41
38
42
userRouter := s.UserRouter(&middleware)
39
43
standardRouter := s.StandardRouter(&middleware)
···
90
94
r.Mount("/issues", s.IssuesRouter(mw))
91
95
r.Mount("/pulls", s.PullsRouter(mw))
92
96
r.Mount("/pipelines", s.PipelinesRouter(mw))
97
+
r.Mount("/labels", s.LabelsRouter(mw))
93
98
94
99
// These routes get proxied to the knot
95
100
r.Get("/info/refs", s.InfoRefs)
···
113
118
114
119
r.Get("/", s.HomeOrTimeline)
115
120
r.Get("/timeline", s.Timeline)
116
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
121
+
r.Get("/upgradeBanner", s.UpgradeBanner)
122
+
123
+
// special-case handler for serving tangled.org/core
124
+
r.Get("/core", s.Core())
117
125
118
126
r.Route("/repo", func(r chi.Router) {
119
127
r.Route("/new", func(r chi.Router) {
···
124
132
// r.Post("/import", s.ImportRepo)
125
133
})
126
134
135
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
136
+
127
137
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
128
138
r.Post("/", s.Follow)
129
139
r.Delete("/", s.Follow)
···
151
161
r.Mount("/strings", s.StringsRouter(mw))
152
162
r.Mount("/knots", s.KnotsRouter())
153
163
r.Mount("/spindles", s.SpindlesRouter())
164
+
r.Mount("/notifications", s.NotificationsRouter(mw))
165
+
154
166
r.Mount("/signup", s.SignupRouter())
155
167
r.Mount("/", s.OAuthRouter())
156
168
157
169
r.Get("/keys/{user}", s.Keys)
158
170
r.Get("/terms", s.TermsOfService)
159
171
r.Get("/privacy", s.PrivacyPolicy)
172
+
r.Get("/brand", s.Brand)
160
173
161
174
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
162
175
s.pages.Error404(w)
···
164
177
return r
165
178
}
166
179
180
+
// Core serves tangled.org/core go-import meta tags, and redirects
181
+
// to the core repository if accessed normally.
182
+
func (s *State) Core() http.HandlerFunc {
183
+
return func(w http.ResponseWriter, r *http.Request) {
184
+
if r.URL.Query().Get("go-get") == "1" {
185
+
w.Header().Set("Content-Type", "text/html")
186
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
187
+
return
188
+
}
189
+
190
+
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
191
+
}
192
+
}
193
+
167
194
func (s *State) OAuthRouter() http.Handler {
168
195
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
169
196
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
···
221
248
Db: s.db,
222
249
OAuth: s.oauth,
223
250
Pages: s.pages,
224
-
Config: s.config,
225
-
Enforcer: s.enforcer,
226
251
IdResolver: s.idResolver,
227
-
Knotstream: s.knotstream,
252
+
Notifier: s.notifier,
228
253
Logger: logger,
229
254
}
230
255
···
243
268
244
269
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
245
270
logger := log.New("repo")
246
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
271
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
247
272
return repo.Router(mw)
248
273
}
249
274
250
275
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
251
276
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
252
277
return pipes.Router(mw)
278
+
}
279
+
280
+
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
281
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
282
+
return ls.Router(mw)
283
+
}
284
+
285
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
286
+
notifs := notifications.New(s.db, s.oauth, s.pages)
287
+
return notifs.Router(mw)
253
288
}
254
289
255
290
func (s *State) SignupRouter() http.Handler {
+11
-10
appview/state/spindlestream.go
+11
-10
appview/state/spindlestream.go
···
9
9
"time"
10
10
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/cache"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
17
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
18
-
"tangled.sh/tangled.sh/core/log"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/cache"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/models"
17
+
ec "tangled.org/core/eventconsumer"
18
+
"tangled.org/core/eventconsumer/cursor"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindle "tangled.org/core/spindle/models"
21
22
)
22
23
23
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
89
90
created = t
90
91
}
91
92
92
-
status := db.PipelineStatus{
93
+
status := models.PipelineStatus{
93
94
Spindle: source.Key(),
94
95
Rkey: msg.Rkey,
95
96
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8
-7
appview/state/star.go
+8
-7
appview/state/star.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages"
14
-
"tangled.sh/tangled.sh/core/tid"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
"tangled.org/core/tid"
15
16
)
16
17
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
55
56
}
56
57
log.Println("created atproto record: ", resp.Uri)
57
58
58
-
star := &db.Star{
59
+
star := &models.Star{
59
60
StarredByDid: currentUser.Did,
60
61
RepoAt: subjectUri,
61
62
Rkey: rkey,
···
77
78
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
79
IsStarred: true,
79
80
RepoAt: subjectUri,
80
-
Stats: db.RepoStats{
81
+
Stats: models.RepoStats{
81
82
StarCount: starCount,
82
83
},
83
84
})
···
119
120
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
121
IsStarred: false,
121
122
RepoAt: subjectUri,
122
-
Stats: db.RepoStats{
123
+
Stats: models.RepoStats{
123
124
StarCount: starCount,
124
125
},
125
126
})
+121
-36
appview/state/state.go
+121
-36
appview/state/state.go
···
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"github.com/go-chi/chi/v5"
19
19
"github.com/posthog/posthog-go"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/cache"
23
-
"tangled.sh/tangled.sh/core/appview/cache/session"
24
-
"tangled.sh/tangled.sh/core/appview/config"
25
-
"tangled.sh/tangled.sh/core/appview/db"
26
-
"tangled.sh/tangled.sh/core/appview/notify"
27
-
"tangled.sh/tangled.sh/core/appview/oauth"
28
-
"tangled.sh/tangled.sh/core/appview/pages"
29
-
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
"tangled.sh/tangled.sh/core/appview/validator"
32
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
33
-
"tangled.sh/tangled.sh/core/eventconsumer"
34
-
"tangled.sh/tangled.sh/core/idresolver"
35
-
"tangled.sh/tangled.sh/core/jetstream"
36
-
tlog "tangled.sh/tangled.sh/core/log"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview"
22
+
"tangled.org/core/appview/cache"
23
+
"tangled.org/core/appview/cache/session"
24
+
"tangled.org/core/appview/config"
25
+
"tangled.org/core/appview/db"
26
+
"tangled.org/core/appview/models"
27
+
"tangled.org/core/appview/notify"
28
+
dbnotify "tangled.org/core/appview/notify/db"
29
+
phnotify "tangled.org/core/appview/notify/posthog"
30
+
"tangled.org/core/appview/oauth"
31
+
"tangled.org/core/appview/pages"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/jetstream"
38
+
tlog "tangled.org/core/log"
39
+
"tangled.org/core/rbac"
40
+
"tangled.org/core/tid"
40
41
)
41
42
42
43
type State struct {
···
78
79
cache := cache.New(config.Redis.Addr)
79
80
sess := session.New(cache)
80
81
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d)
82
+
validator := validator.New(d, res, enforcer)
82
83
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
85
if err != nil {
···
87
88
88
89
repoResolver := reporesolver.New(config, enforcer, res, d)
89
90
90
-
wrapper := db.DbWrapper{d}
91
+
wrapper := db.DbWrapper{Execer: d}
91
92
jc, err := jetstream.NewJetstreamClient(
92
93
config.Jetstream.Endpoint,
93
94
"appview",
···
102
103
tangled.StringNSID,
103
104
tangled.RepoIssueNSID,
104
105
tangled.RepoIssueCommentNSID,
106
+
tangled.LabelDefinitionNSID,
107
+
tangled.LabelOpNSID,
105
108
},
106
109
nil,
107
110
slog.Default(),
···
116
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
117
120
}
118
121
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
123
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
124
+
}
125
+
119
126
ingester := appview.Ingester{
120
127
Db: wrapper,
121
128
Enforcer: enforcer,
···
142
149
spindlestream.Start(ctx)
143
150
144
151
var notifiers []notify.Notifier
152
+
153
+
// Always add the database notifier
154
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
155
+
156
+
// Add other notifiers in production only
145
157
if !config.Core.Dev {
146
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
158
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
147
159
}
148
160
notifier := notify.NewMergedNotifier(notifiers...)
149
161
···
186
198
s.pages.Favicon(w)
187
199
}
188
200
201
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
202
+
const manifestJson = `{
203
+
"name": "tangled",
204
+
"description": "tightly-knit social coding.",
205
+
"icons": [
206
+
{
207
+
"src": "/favicon.svg",
208
+
"sizes": "144x144"
209
+
}
210
+
],
211
+
"start_url": "/",
212
+
"id": "org.tangled",
213
+
214
+
"display": "standalone",
215
+
"background_color": "#111827",
216
+
"theme_color": "#111827"
217
+
}`
218
+
219
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
220
+
w.Header().Set("Content-Type", "application/json")
221
+
w.Write([]byte(manifestJson))
222
+
}
223
+
189
224
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
190
225
user := s.oauth.GetUser(r)
191
226
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
200
235
})
201
236
}
202
237
238
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
239
+
user := s.oauth.GetUser(r)
240
+
s.pages.Brand(w, pages.BrandParams{
241
+
LoggedInUser: user,
242
+
})
243
+
}
244
+
203
245
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
204
246
if s.oauth.GetUser(r) != nil {
205
247
s.Timeline(w, r)
···
211
253
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
212
254
user := s.oauth.GetUser(r)
213
255
214
-
timeline, err := db.MakeTimeline(s.db, 50)
256
+
var userDid string
257
+
if user != nil {
258
+
userDid = user.Did
259
+
}
260
+
timeline, err := db.MakeTimeline(s.db, 50, userDid)
215
261
if err != nil {
216
262
log.Println(err)
217
263
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
224
270
return
225
271
}
226
272
227
-
s.pages.Timeline(w, pages.TimelineParams{
273
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
274
+
if err != nil {
275
+
// non-fatal
276
+
}
277
+
278
+
fmt.Println(s.pages.Timeline(w, pages.TimelineParams{
228
279
LoggedInUser: user,
229
280
Timeline: timeline,
230
281
Repos: repos,
231
-
})
282
+
GfiLabel: gfiLabel,
283
+
}))
232
284
}
233
285
234
286
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
235
287
user := s.oauth.GetUser(r)
288
+
if user == nil {
289
+
return
290
+
}
291
+
236
292
l := s.logger.With("handler", "UpgradeBanner")
237
293
l = l.With("did", user.Did)
238
294
l = l.With("handle", user.Handle)
···
266
322
}
267
323
268
324
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
269
-
timeline, err := db.MakeTimeline(s.db, 5)
325
+
timeline, err := db.MakeTimeline(s.db, 5, "")
270
326
if err != nil {
271
327
log.Println(err)
272
328
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
415
471
}
416
472
417
473
// Check for existing repos
418
-
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
474
+
existingRepo, err := db.GetRepo(
475
+
s.db,
476
+
db.FilterEq("did", user.Did),
477
+
db.FilterEq("name", repoName),
478
+
)
419
479
if err == nil && existingRepo != nil {
420
480
l.Info("repo exists")
421
481
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
···
424
484
425
485
// create atproto record for this repo
426
486
rkey := tid.TID()
427
-
repo := &db.Repo{
487
+
repo := &models.Repo{
428
488
Did: user.Did,
429
489
Name: repoName,
430
490
Knot: domain,
431
491
Rkey: rkey,
432
492
Description: description,
493
+
Created: time.Now(),
494
+
Labels: models.DefaultLabelDefs(),
433
495
}
496
+
record := repo.AsRecord()
434
497
435
498
xrpcClient, err := s.oauth.AuthorizedClient(r)
436
499
if err != nil {
···
439
502
return
440
503
}
441
504
442
-
createdAt := time.Now().Format(time.RFC3339)
443
505
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
444
506
Collection: tangled.RepoNSID,
445
507
Repo: user.Did,
446
508
Rkey: rkey,
447
509
Record: &lexutil.LexiconTypeDecoder{
448
-
Val: &tangled.Repo{
449
-
Knot: repo.Knot,
450
-
Name: repoName,
451
-
CreatedAt: createdAt,
452
-
Owner: user.Did,
453
-
}},
510
+
Val: &record,
511
+
},
454
512
})
455
513
if err != nil {
456
514
l.Info("PDS write failed", "err", err)
···
574
632
})
575
633
return err
576
634
}
635
+
636
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
637
+
defaults := models.DefaultLabelDefs()
638
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
639
+
if err != nil {
640
+
return err
641
+
}
642
+
// already present
643
+
if len(defaultLabels) == len(defaults) {
644
+
return nil
645
+
}
646
+
647
+
labelDefs, err := models.FetchDefaultDefs(r)
648
+
if err != nil {
649
+
return err
650
+
}
651
+
652
+
// Insert each label definition to the database
653
+
for _, labelDef := range labelDefs {
654
+
_, err = db.AddLabelDefinition(e, &labelDef)
655
+
if err != nil {
656
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
657
+
}
658
+
}
659
+
660
+
return nil
661
+
}
+19
-16
appview/strings/strings.go
+19
-16
appview/strings/strings.go
···
8
8
"strconv"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/middleware"
15
-
"tangled.sh/tangled.sh/core/appview/oauth"
16
-
"tangled.sh/tangled.sh/core/appview/pages"
17
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
-
"tangled.sh/tangled.sh/core/eventconsumer"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
"tangled.sh/tangled.sh/core/tid"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/middleware"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/notify"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pages/markup"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/tid"
22
21
23
22
"github.com/bluesky-social/indigo/api/atproto"
24
23
"github.com/bluesky-social/indigo/atproto/identity"
···
31
30
Db *db.DB
32
31
OAuth *oauth.OAuth
33
32
Pages *pages.Pages
34
-
Config *config.Config
35
-
Enforcer *rbac.Enforcer
36
33
IdResolver *idresolver.Resolver
37
34
Logger *slog.Logger
38
-
Knotstream *eventconsumer.Consumer
35
+
Notifier notify.Notifier
39
36
}
40
37
41
38
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
···
239
236
description := r.FormValue("description")
240
237
241
238
// construct new string from form values
242
-
entry := db.String{
239
+
entry := models.String{
243
240
Did: first.Did,
244
241
Rkey: first.Rkey,
245
242
Filename: filename,
···
284
281
return
285
282
}
286
283
284
+
s.Notifier.EditString(r.Context(), &entry)
285
+
287
286
// if that went okay, redir to the string
288
287
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
289
288
}
···
320
319
321
320
description := r.FormValue("description")
322
321
323
-
string := db.String{
322
+
string := models.String{
324
323
Did: syntax.DID(user.Did),
325
324
Rkey: tid.TID(),
326
325
Filename: filename,
···
357
356
fail("Failed to create string.", err)
358
357
return
359
358
}
359
+
360
+
s.Notifier.NewString(r.Context(), &string)
360
361
361
362
// successful
362
363
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
···
399
400
fail("Failed to delete string.", err)
400
401
return
401
402
}
403
+
404
+
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
402
405
403
406
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
404
407
}
+4
-3
appview/validator/issue.go
+4
-3
appview/validator/issue.go
···
4
4
"fmt"
5
5
"strings"
6
6
7
-
"tangled.sh/tangled.sh/core/appview/db"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
8
9
)
9
10
10
-
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
11
12
// if comments have parents, only ingest ones that are 1 level deep
12
13
if comment.ReplyTo != nil {
13
14
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
32
33
return nil
33
34
}
34
35
35
-
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
+
func (v *Validator) ValidateIssue(issue *models.Issue) error {
36
37
if issue.Title == "" {
37
38
return fmt.Errorf("issue title is empty")
38
39
}
+217
appview/validator/label.go
+217
appview/validator/label.go
···
1
+
package validator
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"regexp"
7
+
"strings"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"golang.org/x/exp/slices"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/models"
13
+
)
14
+
15
+
var (
16
+
// Label name should be alphanumeric with hyphens/underscores, but not start/end with them
17
+
labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
18
+
// Color should be a valid hex color
19
+
colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
20
+
// You can only label issues and pulls presently
21
+
validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22
+
)
23
+
24
+
func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error {
25
+
if label.Name == "" {
26
+
return fmt.Errorf("label name is empty")
27
+
}
28
+
if len(label.Name) > 40 {
29
+
return fmt.Errorf("label name too long (max 40 graphemes)")
30
+
}
31
+
if len(label.Name) < 1 {
32
+
return fmt.Errorf("label name too short (min 1 grapheme)")
33
+
}
34
+
if !labelNameRegex.MatchString(label.Name) {
35
+
return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)")
36
+
}
37
+
38
+
if !label.ValueType.IsConcreteType() {
39
+
return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type)
40
+
}
41
+
42
+
// null type checks: cannot be enums, multiple or explicit format
43
+
if label.ValueType.IsNull() && label.ValueType.IsEnum() {
44
+
return fmt.Errorf("null type cannot be used in conjunction with enum type")
45
+
}
46
+
if label.ValueType.IsNull() && label.Multiple {
47
+
return fmt.Errorf("null type labels cannot be multiple")
48
+
}
49
+
if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() {
50
+
return fmt.Errorf("format cannot be used in conjunction with null type")
51
+
}
52
+
53
+
// format checks: cannot be used with enum, or integers
54
+
if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() {
55
+
return fmt.Errorf("enum types cannot be used in conjunction with format specification")
56
+
}
57
+
58
+
if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() {
59
+
return fmt.Errorf("format specifications are only permitted on string types")
60
+
}
61
+
62
+
// validate scope (nsid format)
63
+
if label.Scope == nil {
64
+
return fmt.Errorf("scope is required")
65
+
}
66
+
for _, s := range label.Scope {
67
+
if _, err := syntax.ParseNSID(s); err != nil {
68
+
return fmt.Errorf("failed to parse scope: %w", err)
69
+
}
70
+
if !slices.Contains(validScopes, s) {
71
+
return fmt.Errorf("invalid scope: scope must be present in %q", validScopes)
72
+
}
73
+
}
74
+
75
+
// validate color if provided
76
+
if label.Color != nil {
77
+
color := strings.TrimSpace(*label.Color)
78
+
if color == "" {
79
+
// empty color is fine, set to nil
80
+
label.Color = nil
81
+
} else {
82
+
if !colorRegex.MatchString(color) {
83
+
return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
84
+
}
85
+
// expand 3-digit hex to 6-digit hex
86
+
if len(color) == 4 { // #ABC
87
+
color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
88
+
}
89
+
// convert to uppercase for consistency
90
+
color = strings.ToUpper(color)
91
+
label.Color = &color
92
+
}
93
+
}
94
+
95
+
return nil
96
+
}
97
+
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
+
if labelDef == nil {
100
+
return fmt.Errorf("label definition is required")
101
+
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
105
+
if labelOp == nil {
106
+
return fmt.Errorf("label operation is required")
107
+
}
108
+
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
118
+
}
119
+
120
+
expectedKey := labelDef.AtUri().String()
121
+
if labelOp.OperandKey != expectedKey {
122
+
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
123
+
}
124
+
125
+
if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel {
126
+
return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
127
+
}
128
+
129
+
if labelOp.Subject == "" {
130
+
return fmt.Errorf("subject URI is required")
131
+
}
132
+
if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil {
133
+
return fmt.Errorf("invalid subject URI: %w", err)
134
+
}
135
+
136
+
if err := v.validateOperandValue(labelDef, labelOp); err != nil {
137
+
return fmt.Errorf("invalid operand value: %w", err)
138
+
}
139
+
140
+
// Validate performed time is not zero/invalid
141
+
if labelOp.PerformedAt.IsZero() {
142
+
return fmt.Errorf("performed_at timestamp is required")
143
+
}
144
+
145
+
return nil
146
+
}
147
+
148
+
func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
149
+
valueType := labelDef.ValueType
150
+
151
+
// this is permitted, it "unsets" a label
152
+
if labelOp.OperandValue == "" {
153
+
labelOp.Operation = models.LabelOperationDel
154
+
return nil
155
+
}
156
+
157
+
switch valueType.Type {
158
+
case models.ConcreteTypeNull:
159
+
// For null type, value should be empty
160
+
if labelOp.OperandValue != "null" {
161
+
return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
162
+
}
163
+
164
+
case models.ConcreteTypeString:
165
+
// For string type, validate enum constraints if present
166
+
if valueType.IsEnum() {
167
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
168
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
169
+
}
170
+
}
171
+
172
+
switch valueType.Format {
173
+
case models.ValueTypeFormatDid:
174
+
id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
175
+
if err != nil {
176
+
return fmt.Errorf("failed to resolve did/handle: %w", err)
177
+
}
178
+
179
+
labelOp.OperandValue = id.DID.String()
180
+
181
+
case models.ValueTypeFormatAny, "":
182
+
default:
183
+
return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
184
+
}
185
+
186
+
case models.ConcreteTypeInt:
187
+
if labelOp.OperandValue == "" {
188
+
return fmt.Errorf("integer type requires non-empty value")
189
+
}
190
+
if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil {
191
+
return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
192
+
}
193
+
194
+
if valueType.IsEnum() {
195
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
196
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
197
+
}
198
+
}
199
+
200
+
case models.ConcreteTypeBool:
201
+
if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
202
+
return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
203
+
}
204
+
205
+
// validate enum constraints if present (though uncommon for booleans)
206
+
if valueType.IsEnum() {
207
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
208
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
209
+
}
210
+
}
211
+
212
+
default:
213
+
return fmt.Errorf("unsupported value type: %q", valueType.Type)
214
+
}
215
+
216
+
return nil
217
+
}
+27
appview/validator/string.go
+27
appview/validator/string.go
···
1
+
package validator
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"unicode/utf8"
7
+
8
+
"tangled.org/core/appview/models"
9
+
)
10
+
11
+
func (v *Validator) ValidateString(s *models.String) error {
12
+
var err error
13
+
14
+
if utf8.RuneCountInString(s.Filename) > 140 {
15
+
err = errors.Join(err, fmt.Errorf("filename too long"))
16
+
}
17
+
18
+
if utf8.RuneCountInString(s.Description) > 280 {
19
+
err = errors.Join(err, fmt.Errorf("description too long"))
20
+
}
21
+
22
+
if len(s.Contents) == 0 {
23
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
24
+
}
25
+
26
+
return err
27
+
}
+9
-3
appview/validator/validator.go
+9
-3
appview/validator/validator.go
···
1
1
package validator
2
2
3
3
import (
4
-
"tangled.sh/tangled.sh/core/appview/db"
5
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
4
+
"tangled.org/core/appview/db"
5
+
"tangled.org/core/appview/pages/markup"
6
+
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
6
8
)
7
9
8
10
type Validator struct {
9
11
db *db.DB
10
12
sanitizer markup.Sanitizer
13
+
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
11
15
}
12
16
13
-
func New(db *db.DB) *Validator {
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
14
18
return &Validator{
15
19
db: db,
16
20
sanitizer: markup.NewSanitizer(),
21
+
resolver: res,
22
+
enforcer: enforcer,
17
23
}
18
24
}
+2
-2
cmd/appview/main.go
+2
-2
cmd/appview/main.go
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/combinediff/main.go
+6
-2
cmd/gen.go
+6
-2
cmd/gen.go
···
2
2
3
3
import (
4
4
cbg "github.com/whyrusleeping/cbor-gen"
5
-
"tangled.sh/tangled.sh/core/api/tangled"
5
+
"tangled.org/core/api/tangled"
6
6
)
7
7
8
8
func main() {
···
20
20
tangled.GitRefUpdate{},
21
21
tangled.GitRefUpdate_CommitCountBreakdown{},
22
22
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
-
tangled.GitRefUpdate_LangBreakdown{},
24
23
tangled.GitRefUpdate_IndividualLanguageSize{},
24
+
tangled.GitRefUpdate_LangBreakdown{},
25
25
tangled.GitRefUpdate_Meta{},
26
26
tangled.GraphFollow{},
27
27
tangled.Knot{},
28
28
tangled.KnotMember{},
29
+
tangled.LabelDefinition{},
30
+
tangled.LabelDefinition_ValueType{},
31
+
tangled.LabelOp{},
32
+
tangled.LabelOp_Operand{},
29
33
tangled.Pipeline{},
30
34
tangled.Pipeline_CloneOpts{},
31
35
tangled.Pipeline_ManualTriggerData{},
+1
-1
cmd/interdiff/main.go
+1
-1
cmd/interdiff/main.go
+5
-5
cmd/knot/main.go
+5
-5
cmd/knot/main.go
···
5
5
"os"
6
6
7
7
"github.com/urfave/cli/v3"
8
-
"tangled.sh/tangled.sh/core/guard"
9
-
"tangled.sh/tangled.sh/core/hook"
10
-
"tangled.sh/tangled.sh/core/keyfetch"
11
-
"tangled.sh/tangled.sh/core/knotserver"
12
-
"tangled.sh/tangled.sh/core/log"
8
+
"tangled.org/core/guard"
9
+
"tangled.org/core/hook"
10
+
"tangled.org/core/keyfetch"
11
+
"tangled.org/core/knotserver"
12
+
"tangled.org/core/log"
13
13
)
14
14
15
15
func main() {
+3
-3
cmd/spindle/main.go
+3
-3
cmd/spindle/main.go
+1
-1
cmd/verifysig/main.go
+1
-1
cmd/verifysig/main.go
+9
consts/consts.go
+9
consts/consts.go
+44
contrib/Tiltfile
+44
contrib/Tiltfile
···
1
+
common_env = {
2
+
"TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""),
3
+
"TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""),
4
+
"TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"),
5
+
"TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"),
6
+
}
7
+
8
+
nix_globs = ["nix/**", "flake.nix", "flake.lock"]
9
+
10
+
local_resource(
11
+
name="appview",
12
+
serve_cmd="nix run .#watch-appview",
13
+
serve_dir="..",
14
+
deps=nix_globs,
15
+
env=common_env,
16
+
allow_parallel=True,
17
+
)
18
+
19
+
local_resource(
20
+
name="tailwind",
21
+
serve_cmd="nix run .#watch-tailwind",
22
+
serve_dir="..",
23
+
deps=nix_globs,
24
+
env=common_env,
25
+
allow_parallel=True,
26
+
)
27
+
28
+
local_resource(
29
+
name="redis",
30
+
serve_cmd="redis-server",
31
+
serve_dir="..",
32
+
deps=nix_globs,
33
+
env=common_env,
34
+
allow_parallel=True,
35
+
)
36
+
37
+
local_resource(
38
+
name="vm",
39
+
serve_cmd="nix run --impure .#vm",
40
+
serve_dir="..",
41
+
deps=nix_globs,
42
+
env=common_env,
43
+
allow_parallel=True,
44
+
)
+1
-1
crypto/verify.go
+1
-1
crypto/verify.go
+2
-2
docs/knot-hosting.md
+2
-2
docs/knot-hosting.md
···
19
19
First, clone this repository:
20
20
21
21
```
22
-
git clone https://tangled.sh/@tangled.sh/core
22
+
git clone https://tangled.org/@tangled.org/core
23
23
```
24
24
25
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
130
131
131
You should now have a running knot server! You can finalize
132
132
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
133
+
[/knots](https://tangled.org/knots) page. This simply creates
134
134
a record on your PDS to announce the existence of the knot.
135
135
136
136
### custom paths
+4
-5
docs/migrations.md
+4
-5
docs/migrations.md
···
14
14
For knots:
15
15
16
16
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
17
+
- Head to the [knot dashboard](https://tangled.org/knots) and
18
18
hit the "retry" button to verify your knot
19
19
20
20
For spindles:
21
21
22
22
- Upgrade to latest tag (v1.9.0 or above)
23
23
- Head to the [spindle
24
-
dashboard](https://tangled.sh/spindles) and hit the
24
+
dashboard](https://tangled.org/spindles) and hit the
25
25
"retry" button to verify your spindle
26
26
27
27
## Upgrading from v1.7.x
···
38
38
environment variable entirely
39
39
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
40
your DID. You can find your DID in the
41
-
[settings](https://tangled.sh/settings) page.
41
+
[settings](https://tangled.org/settings) page.
42
42
- Restart your knot once you have replaced the environment
43
43
variable
44
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
44
+
- Head to the [knot dashboard](https://tangled.org/knots) and
45
45
hit the "retry" button to verify your knot. This simply
46
46
writes a `sh.tangled.knot` record to your PDS.
47
47
···
57
57
};
58
58
};
59
59
```
60
-
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
···
44
44
### production
45
45
46
46
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
47
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
48
achieved using Nix.
49
49
50
50
Then, initialize the bao server:
+3
-3
docs/spindle/pipeline.md
+3
-3
docs/spindle/pipeline.md
···
21
21
- `manual`: The workflow can be triggered manually.
22
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
25
26
26
```yaml
27
27
when:
···
73
73
- nodejs
74
74
- go
75
75
# custom registry
76
-
git+https://tangled.sh/@example.com/my_pkg:
76
+
git+https://tangled.org/@example.com/my_pkg:
77
77
- my_pkg
78
78
```
79
79
···
141
141
- nodejs
142
142
- go
143
143
# custom registry
144
-
git+https://tangled.sh/@example.com/my_pkg:
144
+
git+https://tangled.org/@example.com/my_pkg:
145
145
- my_pkg
146
146
147
147
environment:
+2
-2
eventconsumer/consumer.go
+2
-2
eventconsumer/consumer.go
+1
-1
eventconsumer/cursor/redis.go
+1
-1
eventconsumer/cursor/redis.go
+2
-1
flake.nix
+2
-1
flake.nix
···
151
151
nativeBuildInputs = [
152
152
pkgs.go
153
153
pkgs.air
154
+
pkgs.tilt
154
155
pkgs.gopls
155
156
pkgs.httpie
156
157
pkgs.litecli
···
187
188
tailwind-watcher =
188
189
pkgs.writeShellScriptBin "run"
189
190
''
190
-
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
191
+
${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css
191
192
'';
192
193
in {
193
194
fmt = {
+2
-2
go.mod
+2
-2
go.mod
···
1
-
module tangled.sh/tangled.sh/core
1
+
module tangled.org/core
2
2
3
3
go 1.24.4
4
4
···
43
43
github.com/yuin/goldmark v1.7.12
44
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
45
golang.org/x/crypto v0.40.0
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
46
47
golang.org/x/net v0.42.0
47
48
golang.org/x/sync v0.16.0
48
49
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
168
169
go.uber.org/atomic v1.11.0 // indirect
169
170
go.uber.org/multierr v1.11.0 // indirect
170
171
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
172
golang.org/x/sys v0.34.0 // indirect
173
173
golang.org/x/text v0.27.0 // indirect
174
174
golang.org/x/time v0.12.0 // indirect
+2
-2
guard/guard.go
+2
-2
guard/guard.go
···
15
15
"github.com/bluesky-social/indigo/atproto/identity"
16
16
securejoin "github.com/cyphar/filepath-securejoin"
17
17
"github.com/urfave/cli/v3"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
20
)
21
21
22
22
func Command() *cli.Command {
+2
-5
input.css
+2
-5
input.css
···
228
228
}
229
229
/* LineHighlight */
230
230
.chroma .hl {
231
-
background-color: #bcc0cc;
231
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
232
}
233
+
233
234
/* LineNumbersTable */
234
235
.chroma .lnt {
235
236
white-space: pre;
···
864
865
text-decoration: underline;
865
866
}
866
867
}
867
-
868
-
.chroma .line:has(.ln:target) {
869
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
870
-
}
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
+1
-1
keyfetch/keyfetch.go
+1
-1
keyfetch/keyfetch.go
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
···
41
41
Repo Repo `env:",prefix=KNOT_REPO_"`
42
42
Server Server `env:",prefix=KNOT_SERVER_"`
43
43
Git Git `env:",prefix=KNOT_GIT_"`
44
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
44
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
45
45
}
46
46
47
47
func Load(ctx context.Context) (*Config, error) {
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/git/branch.go
+1
-1
knotserver/git/branch.go
+2
-2
knotserver/git/diff.go
+2
-2
knotserver/git/diff.go
···
12
12
"github.com/bluekeyes/go-gitdiff/gitdiff"
13
13
"github.com/go-git/go-git/v5/plumbing"
14
14
"github.com/go-git/go-git/v5/plumbing/object"
15
-
"tangled.sh/tangled.sh/core/patchutil"
16
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.org/core/patchutil"
16
+
"tangled.org/core/types"
17
17
)
18
18
19
19
func (g *GitRepo) Diff() (*types.NiceDiff, error) {
-103
knotserver/git/git.go
-103
knotserver/git/git.go
···
27
27
h plumbing.Hash
28
28
}
29
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
31
// to tar WriteHeader
45
32
type infoWrapper struct {
···
48
35
mode fs.FileMode
49
36
modTime time.Time
50
37
isDir bool
51
-
}
52
-
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
38
}
90
39
91
40
func Open(path string, ref string) (*GitRepo, error) {
···
171
120
return g.r.CommitObject(h)
172
121
}
173
122
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
123
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
124
c, err := g.r.CommitObject(g.h)
184
125
if err != nil {
···
211
152
}
212
153
213
154
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
155
}
240
156
241
157
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
326
func (i *infoWrapper) Sys() any {
411
327
return nil
412
328
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/post_receive.go
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
2
3
3
import (
4
4
"fmt"
5
-
"slices"
6
5
"strconv"
7
6
"strings"
8
7
"time"
···
35
34
outFormat.WriteString("")
36
35
outFormat.WriteString(recordSeparator)
37
36
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
39
38
if err != nil {
40
39
return nil, fmt.Errorf("failed to get tags: %w", err)
41
40
}
···
94
93
tags = append(tags, tag)
95
94
}
96
95
97
-
slices.Reverse(tags)
98
96
return tags, nil
99
97
}
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git.go
+1
-1
knotserver/git.go
-4
knotserver/http_util.go
-4
knotserver/http_util.go
+10
-10
knotserver/ingester.go
+10
-10
knotserver/ingester.go
···
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/knotserver/db"
21
-
"tangled.sh/tangled.sh/core/knotserver/git"
22
-
"tangled.sh/tangled.sh/core/log"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
-
"tangled.sh/tangled.sh/core/workflow"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/knotserver/db"
21
+
"tangled.org/core/knotserver/git"
22
+
"tangled.org/core/log"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/workflow"
25
25
)
26
26
27
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
141
141
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
142
142
}
143
143
144
-
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
144
+
didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
145
145
if err != nil {
146
146
return fmt.Errorf("failed to construct relative repo path: %w", err)
147
147
}
···
151
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
152
}
153
153
154
-
gr, err := git.Open(repoPath, record.Source.Branch)
154
+
gr, err := git.Open(repoPath, record.Source.Sha)
155
155
if err != nil {
156
156
return fmt.Errorf("failed to open git repository: %w", err)
157
157
}
···
191
191
Kind: string(workflow.TriggerKindPullRequest),
192
192
PullRequest: &trigger,
193
193
Repo: &tangled.Pipeline_TriggerRepo{
194
-
Did: repo.Owner,
194
+
Did: ident.DID.String(),
195
195
Knot: repo.Knot,
196
196
Repo: repo.Name,
197
197
},
+8
-8
knotserver/internal.go
+8
-8
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/config"
19
-
"tangled.sh/tangled.sh/core/knotserver/db"
20
-
"tangled.sh/tangled.sh/core/knotserver/git"
21
-
"tangled.sh/tangled.sh/core/notifier"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/workflow"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/config"
19
+
"tangled.org/core/knotserver/db"
20
+
"tangled.org/core/knotserver/git"
21
+
"tangled.org/core/notifier"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/workflow"
24
24
)
25
25
26
26
type InternalHandle struct {
+9
-9
knotserver/router.go
+9
-9
knotserver/router.go
···
7
7
"net/http"
8
8
9
9
"github.com/go-chi/chi/v5"
10
-
"tangled.sh/tangled.sh/core/idresolver"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
-
tlog "tangled.sh/tangled.sh/core/log"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
10
+
"tangled.org/core/idresolver"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/knotserver/xrpc"
15
+
tlog "tangled.org/core/log"
16
+
"tangled.org/core/notifier"
17
+
"tangled.org/core/rbac"
18
+
"tangled.org/core/xrpc/serviceauth"
19
19
)
20
20
21
21
type Knot struct {
+8
-8
knotserver/server.go
+8
-8
knotserver/server.go
···
6
6
"net/http"
7
7
8
8
"github.com/urfave/cli/v3"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/hook"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/log"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/hook"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/log"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
17
)
18
18
19
19
func Command() *cli.Command {
-36
knotserver/util.go
-36
knotserver/util.go
···
1
1
package knotserver
2
2
3
3
import (
4
-
"net/http"
5
-
"os"
6
-
"path/filepath"
7
-
8
4
"github.com/bluesky-social/indigo/atproto/syntax"
9
-
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"github.com/go-chi/chi/v5"
11
5
)
12
-
13
-
func didPath(r *http.Request) string {
14
-
did := chi.URLParam(r, "did")
15
-
name := chi.URLParam(r, "name")
16
-
path, _ := securejoin.SecureJoin(did, name)
17
-
filepath.Clean(path)
18
-
return path
19
-
}
20
-
21
-
func getDescription(path string) (desc string) {
22
-
db, err := os.ReadFile(filepath.Join(path, "description"))
23
-
if err == nil {
24
-
desc = string(db)
25
-
} else {
26
-
desc = ""
27
-
}
28
-
return
29
-
}
30
-
func setContentDisposition(w http.ResponseWriter, name string) {
31
-
h := "inline; filename=\"" + name + "\""
32
-
w.Header().Add("Content-Disposition", h)
33
-
}
34
-
35
-
func setGZipMIME(w http.ResponseWriter) {
36
-
setMIME(w, "application/gzip")
37
-
}
38
-
39
-
func setMIME(w http.ResponseWriter, mime string) {
40
-
w.Header().Add("Content-Type", mime)
41
-
}
42
6
43
7
var TIDClock = syntax.NewTIDClock(0)
44
8
+5
-5
knotserver/xrpc/create_repo.go
+5
-5
knotserver/xrpc/create_repo.go
···
13
13
"github.com/bluesky-social/indigo/xrpc"
14
14
securejoin "github.com/cyphar/filepath-securejoin"
15
15
gogit "github.com/go-git/go-git/v5"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/git"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/git"
19
+
"tangled.org/core/rbac"
20
+
xrpcerr "tangled.org/core/xrpc/errors"
21
21
)
22
22
23
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/delete_repo.go
+3
-3
knotserver/xrpc/delete_repo.go
···
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"github.com/bluesky-social/indigo/xrpc"
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/rbac"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5
-5
knotserver/xrpc/fork_status.go
+5
-5
knotserver/xrpc/fork_status.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/types"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/types"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
16
)
17
17
18
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/fork_sync.go
+4
-4
knotserver/xrpc/fork_sync.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
15
)
16
16
17
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/list_keys.go
+2
-2
knotserver/xrpc/list_keys.go
+6
-6
knotserver/xrpc/merge.go
+6
-6
knotserver/xrpc/merge.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/patchutil"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/types"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/merge_check.go
+3
-3
knotserver/xrpc/merge_check.go
···
7
7
"net/http"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/owner.go
+2
-2
knotserver/xrpc/owner.go
+2
-2
knotserver/xrpc/repo_archive.go
+2
-2
knotserver/xrpc/repo_archive.go
···
8
8
9
9
"github.com/go-git/go-git/v5/plumbing"
10
10
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/repo_blob.go
+4
-4
knotserver/xrpc/repo_blob.go
···
9
9
"slices"
10
10
"strings"
11
11
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
15
)
16
16
17
17
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
44
44
45
45
contents, err := gr.RawContent(treePath)
46
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
48
writeError(w, xrpcerr.NewXrpcError(
49
49
xrpcerr.WithTag("FileNotFound"),
50
50
xrpcerr.WithMessage("file not found at the specified path"),
+3
-3
knotserver/xrpc/repo_branch.go
+3
-3
knotserver/xrpc/repo_branch.go
···
5
5
"net/url"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/knotserver/git"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
11
)
12
12
13
13
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_branches.go
+3
-3
knotserver/xrpc/repo_branches.go
···
4
4
"net/http"
5
5
"strconv"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_compare.go
+3
-3
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_diff.go
+3
-3
knotserver/xrpc/repo_diff.go
···
3
3
import (
4
4
"net/http"
5
5
6
-
"tangled.sh/tangled.sh/core/knotserver/git"
7
-
"tangled.sh/tangled.sh/core/types"
8
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
6
+
"tangled.org/core/knotserver/git"
7
+
"tangled.org/core/types"
8
+
xrpcerr "tangled.org/core/xrpc/errors"
9
9
)
10
10
11
11
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_get_default_branch.go
+3
-3
knotserver/xrpc/repo_get_default_branch.go
···
4
4
"net/http"
5
5
"time"
6
6
7
-
"tangled.sh/tangled.sh/core/api/tangled"
8
-
"tangled.sh/tangled.sh/core/knotserver/git"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/knotserver/git"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_languages.go
+3
-3
knotserver/xrpc/repo_languages.go
···
6
6
"net/http"
7
7
"time"
8
8
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/knotserver/git"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/knotserver/git"
11
+
xrpcerr "tangled.org/core/xrpc/errors"
12
12
)
13
13
14
14
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_log.go
+3
-3
knotserver/xrpc/repo_log.go
···
4
4
"net/http"
5
5
"strconv"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+27
-3
knotserver/xrpc/repo_tree.go
+27
-3
knotserver/xrpc/repo_tree.go
···
4
4
"net/http"
5
5
"path/filepath"
6
6
"time"
7
+
"unicode/utf8"
7
8
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
11
13
)
12
14
13
15
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
43
45
return
44
46
}
45
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
46
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
47
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
48
68
for i, file := range files {
···
83
103
Parent: parentPtr,
84
104
Dotdot: dotdotPtr,
85
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
86
110
}
87
111
88
112
writeJson(w, response)
+4
-4
knotserver/xrpc/set_default_branch.go
+4
-4
knotserver/xrpc/set_default_branch.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"github.com/bluesky-social/indigo/xrpc"
11
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
"tangled.sh/tangled.sh/core/rbac"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
15
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
const ActorDid string = "ActorDid"
+2
-2
knotserver/xrpc/version.go
+2
-2
knotserver/xrpc/version.go
···
5
5
"net/http"
6
6
"runtime/debug"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.org/core/api/tangled"
9
9
)
10
10
11
11
// version is set during build time.
···
24
24
var modified bool
25
25
26
26
for _, mod := range info.Deps {
27
-
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
27
+
if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" {
28
28
modVer = mod.Version
29
29
break
30
30
}
+9
-9
knotserver/xrpc/xrpc.go
+9
-9
knotserver/xrpc/xrpc.go
···
7
7
"strings"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
14
-
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
17
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/idresolver"
12
+
"tangled.org/core/jetstream"
13
+
"tangled.org/core/knotserver/config"
14
+
"tangled.org/core/knotserver/db"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
+
xrpcerr "tangled.org/core/xrpc/errors"
18
+
"tangled.org/core/xrpc/serviceauth"
19
19
20
20
"github.com/go-chi/chi/v5"
21
21
)
-158
legal/privacy.md
-158
legal/privacy.md
···
1
-
# Privacy Policy
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
-
collects, uses, and shares your personal information when you use our
7
-
platform and services (the "Service").
8
-
9
-
## 1. Information We Collect
10
-
11
-
### Account Information
12
-
13
-
When you create an account, we collect:
14
-
15
-
- Your chosen username
16
-
- Email address
17
-
- Profile information you choose to provide
18
-
- Authentication data
19
-
20
-
### Content and Activity
21
-
22
-
We store:
23
-
24
-
- Code repositories and associated metadata
25
-
- Issues, pull requests, and comments
26
-
- Activity logs and usage patterns
27
-
- Public keys for authentication
28
-
29
-
## 2. Data Location and Hosting
30
-
31
-
### EU Data Hosting
32
-
33
-
**All Tangled service data is hosted within the European Union.**
34
-
Specifically:
35
-
36
-
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
-
(*.tngl.sh) are located in Finland
38
-
- **Application Data:** All other service data is stored on EU-based
39
-
servers
40
-
- **Data Processing:** All data processing occurs within EU
41
-
jurisdiction
42
-
43
-
### External PDS Notice
44
-
45
-
**Important:** If your account is hosted on Bluesky's PDS or other
46
-
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
-
that data. The data protection, storage location, and privacy
48
-
practices for such accounts are governed by the respective PDS
49
-
provider's policies, not this Privacy Policy. We only control data
50
-
processing within our own services and infrastructure.
51
-
52
-
## 3. Third-Party Data Processors
53
-
54
-
We only share your data with the following third-party processors:
55
-
56
-
### Resend (Email Services)
57
-
58
-
- **Purpose:** Sending transactional emails (account verification,
59
-
notifications)
60
-
- **Data Shared:** Email address and necessary message content
61
-
62
-
### Cloudflare (Image Caching)
63
-
64
-
- **Purpose:** Caching and optimizing image delivery
65
-
- **Data Shared:** Public images and associated metadata for caching
66
-
purposes
67
-
68
-
### Posthog (Usage Metrics Tracking)
69
-
70
-
- **Purpose:** Tracking usage and platform metrics
71
-
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
-
information
73
-
74
-
## 4. How We Use Your Information
75
-
76
-
We use your information to:
77
-
78
-
- Provide and maintain the Service
79
-
- Process your transactions and requests
80
-
- Send you technical notices and support messages
81
-
- Improve and develop new features
82
-
- Ensure security and prevent fraud
83
-
- Comply with legal obligations
84
-
85
-
## 5. Data Sharing and Disclosure
86
-
87
-
We do not sell, trade, or rent your personal information. We may share
88
-
your information only in the following circumstances:
89
-
90
-
- With the third-party processors listed above
91
-
- When required by law or legal process
92
-
- To protect our rights, property, or safety, or that of our users
93
-
- In connection with a merger, acquisition, or sale of assets (with
94
-
appropriate protections)
95
-
96
-
## 6. Data Security
97
-
98
-
We implement appropriate technical and organizational measures to
99
-
protect your personal information against unauthorized access,
100
-
alteration, disclosure, or destruction. However, no method of
101
-
transmission over the Internet is 100% secure.
102
-
103
-
## 7. Data Retention
104
-
105
-
We retain your personal information for as long as necessary to provide
106
-
the Service and fulfill the purposes outlined in this Privacy Policy,
107
-
unless a longer retention period is required by law.
108
-
109
-
## 8. Your Rights
110
-
111
-
Under applicable data protection laws, you have the right to:
112
-
113
-
- Access your personal information
114
-
- Correct inaccurate information
115
-
- Request deletion of your information
116
-
- Object to processing of your information
117
-
- Data portability
118
-
- Withdraw consent (where applicable)
119
-
120
-
## 9. Cookies and Tracking
121
-
122
-
We use cookies and similar technologies to:
123
-
124
-
- Maintain your login session
125
-
- Remember your preferences
126
-
- Analyze usage patterns to improve the Service
127
-
128
-
You can control cookie settings through your browser preferences.
129
-
130
-
## 10. Children's Privacy
131
-
132
-
The Service is not intended for children under 16 years of age. We do
133
-
not knowingly collect personal information from children under 16. If
134
-
we become aware that we have collected such information, we will take
135
-
steps to delete it.
136
-
137
-
## 11. International Data Transfers
138
-
139
-
While all our primary data processing occurs within the EU, some of our
140
-
third-party processors may process data outside the EU. When this
141
-
occurs, we ensure appropriate safeguards are in place, such as Standard
142
-
Contractual Clauses or adequacy decisions.
143
-
144
-
## 12. Changes to This Privacy Policy
145
-
146
-
We may update this Privacy Policy from time to time. We will notify you
147
-
of any changes by posting the new Privacy Policy on this page and
148
-
updating the "Last updated" date.
149
-
150
-
## 13. Contact Information
151
-
152
-
If you have any questions about this Privacy Policy or wish to exercise
153
-
your rights, please contact us through our platform or via email.
154
-
155
-
---
156
-
157
-
This Privacy Policy complies with the EU General Data Protection
158
-
Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
-109
legal/terms.md
···
1
-
# Terms of Service
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
-
to and use of the Tangled platform and services (the "Service")
7
-
operated by us ("Tangled," "we," "us," or "our").
8
-
9
-
## 1. Acceptance of Terms
10
-
11
-
By accessing or using our Service, you agree to be bound by these Terms.
12
-
If you disagree with any part of these terms, then you may not access
13
-
the Service.
14
-
15
-
## 2. Account Registration
16
-
17
-
To use certain features of the Service, you must register for an
18
-
account. You agree to provide accurate, current, and complete
19
-
information during the registration process and to update such
20
-
information to keep it accurate, current, and complete.
21
-
22
-
## 3. Account Termination
23
-
24
-
> **Important Notice**
25
-
>
26
-
> **We reserve the right to terminate, suspend, or restrict access to
27
-
> your account at any time, for any reason, or for no reason at all, at
28
-
> our sole discretion.** This includes, but is not limited to,
29
-
> termination for violation of these Terms, inappropriate conduct, spam,
30
-
> abuse, or any other behavior we deem harmful to the Service or other
31
-
> users.
32
-
>
33
-
> Account termination may result in the loss of access to your
34
-
> repositories, data, and other content associated with your account. We
35
-
> are not obligated to provide advance notice of termination, though we
36
-
> may do so in our discretion.
37
-
38
-
## 4. Acceptable Use
39
-
40
-
You agree not to use the Service to:
41
-
42
-
- Violate any applicable laws or regulations
43
-
- Infringe upon the rights of others
44
-
- Upload, store, or share content that is illegal, harmful, threatening,
45
-
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
-
objectionable
47
-
- Engage in spam, phishing, or other deceptive practices
48
-
- Attempt to gain unauthorized access to the Service or other users'
49
-
accounts
50
-
- Interfere with or disrupt the Service or servers connected to the
51
-
Service
52
-
53
-
## 5. Content and Intellectual Property
54
-
55
-
You retain ownership of the content you upload to the Service. By
56
-
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
-
license to use, reproduce, modify, and distribute your content as
58
-
necessary to provide the Service.
59
-
60
-
## 6. Privacy
61
-
62
-
Your privacy is important to us. Please review our [Privacy
63
-
Policy](/privacy), which also governs your use of the Service.
64
-
65
-
## 7. Disclaimers
66
-
67
-
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
-
no warranties, expressed or implied, and hereby disclaim and negate all
69
-
other warranties including without limitation, implied warranties or
70
-
conditions of merchantability, fitness for a particular purpose, or
71
-
non-infringement of intellectual property or other violation of rights.
72
-
73
-
## 8. Limitation of Liability
74
-
75
-
In no event shall Tangled, nor its directors, employees, partners,
76
-
agents, suppliers, or affiliates, be liable for any indirect,
77
-
incidental, special, consequential, or punitive damages, including
78
-
without limitation, loss of profits, data, use, goodwill, or other
79
-
intangible losses, resulting from your use of the Service.
80
-
81
-
## 9. Indemnification
82
-
83
-
You agree to defend, indemnify, and hold harmless Tangled and its
84
-
affiliates, officers, directors, employees, and agents from and against
85
-
any and all claims, damages, obligations, losses, liabilities, costs,
86
-
or debt, and expenses (including attorney's fees).
87
-
88
-
## 10. Governing Law
89
-
90
-
These Terms shall be interpreted and governed by the laws of Finland,
91
-
without regard to its conflict of law provisions.
92
-
93
-
## 11. Changes to Terms
94
-
95
-
We reserve the right to modify or replace these Terms at any time. If a
96
-
revision is material, we will try to provide at least 30 days notice
97
-
prior to any new terms taking effect.
98
-
99
-
## 12. Contact Information
100
-
101
-
If you have any questions about these Terms of Service, please contact
102
-
us through our platform or via email.
103
-
104
-
---
105
-
106
-
These terms are effective as of the last updated date shown above and
107
-
will remain in effect except with respect to any changes in their
108
-
provisions in the future, which will be in effect immediately after
109
-
being posted on this page.
+1
-1
lexicon-build-config.json
+1
-1
lexicon-build-config.json
+89
lexicons/label/definition.json
+89
lexicons/label/definition.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.definition",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"valueType",
15
+
"scope",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "The display name of this label.",
22
+
"minGraphemes": 1,
23
+
"maxGraphemes": 40
24
+
},
25
+
"valueType": {
26
+
"type": "ref",
27
+
"ref": "#valueType",
28
+
"description": "The type definition of this label. Appviews may allow sorting for certain types."
29
+
},
30
+
"scope": {
31
+
"type": "array",
32
+
"description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.",
33
+
"items": {
34
+
"type": "string",
35
+
"format": "nsid"
36
+
}
37
+
},
38
+
"color": {
39
+
"type": "string",
40
+
"description": "The hex value for the background color for the label. Appviews may choose to respect this."
41
+
},
42
+
"createdAt": {
43
+
"type": "string",
44
+
"format": "datetime"
45
+
},
46
+
"multiple": {
47
+
"type": "boolean",
48
+
"description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]"
49
+
}
50
+
}
51
+
}
52
+
},
53
+
"valueType": {
54
+
"type": "object",
55
+
"required": [
56
+
"type",
57
+
"format"
58
+
],
59
+
"properties": {
60
+
"type": {
61
+
"type": "string",
62
+
"enum": [
63
+
"null",
64
+
"boolean",
65
+
"integer",
66
+
"string"
67
+
],
68
+
"description": "The concrete type of this label's value."
69
+
},
70
+
"format": {
71
+
"type": "string",
72
+
"enum": [
73
+
"any",
74
+
"did",
75
+
"nsid"
76
+
],
77
+
"description": "An optional constraint that can be applied on string concrete types."
78
+
},
79
+
"enum": {
80
+
"type": "array",
81
+
"description": "Closed set of values that this label can take.",
82
+
"items": {
83
+
"type": "string"
84
+
}
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
+64
lexicons/label/op.json
+64
lexicons/label/op.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.op",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"add",
15
+
"delete",
16
+
"performedAt"
17
+
],
18
+
"properties": {
19
+
"subject": {
20
+
"type": "string",
21
+
"format": "at-uri",
22
+
"description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
23
+
},
24
+
"performedAt": {
25
+
"type": "string",
26
+
"format": "datetime"
27
+
},
28
+
"add": {
29
+
"type": "array",
30
+
"items": {
31
+
"type": "ref",
32
+
"ref": "#operand"
33
+
}
34
+
},
35
+
"delete": {
36
+
"type": "array",
37
+
"items": {
38
+
"type": "ref",
39
+
"ref": "#operand"
40
+
}
41
+
}
42
+
}
43
+
}
44
+
},
45
+
"operand": {
46
+
"type": "object",
47
+
"required": [
48
+
"key",
49
+
"value"
50
+
],
51
+
"properties": {
52
+
"key": {
53
+
"type": "string",
54
+
"format": "at-uri",
55
+
"description": "ATURI to the label definition"
56
+
},
57
+
"value": {
58
+
"type": "string",
59
+
"description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value."
60
+
}
61
+
}
62
+
}
63
+
}
64
+
}
+8
-5
lexicons/repo/repo.json
+8
-5
lexicons/repo/repo.json
···
12
12
"required": [
13
13
"name",
14
14
"knot",
15
-
"owner",
16
15
"createdAt"
17
16
],
18
17
"properties": {
19
18
"name": {
20
19
"type": "string",
21
20
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
21
},
27
22
"knot": {
28
23
"type": "string",
···
41
36
"type": "string",
42
37
"format": "uri",
43
38
"description": "source of the repo"
39
+
},
40
+
"labels": {
41
+
"type": "array",
42
+
"description": "List of labels that this repo subscribes to",
43
+
"items": {
44
+
"type": "string",
45
+
"format": "at-uri"
46
+
}
44
47
},
45
48
"createdAt": {
46
49
"type": "string",
+19
lexicons/repo/tree.json
+19
lexicons/repo/tree.json
···
41
41
"type": "string",
42
42
"description": "Parent directory path"
43
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
44
49
"files": {
45
50
"type": "array",
46
51
"items": {
···
69
74
"description": "Invalid request parameters"
70
75
}
71
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
72
91
},
73
92
"treeEntry": {
74
93
"type": "object",
+2
-2
nix/pkgs/knot-unwrapped.nix
+2
-2
nix/pkgs/knot-unwrapped.nix
···
4
4
sqlite-lib,
5
5
src,
6
6
}: let
7
-
version = "1.9.0-alpha";
7
+
version = "1.9.1-alpha";
8
8
in
9
9
buildGoApplication {
10
10
pname = "knot";
···
16
16
tags = ["libsqlite3"];
17
17
18
18
ldflags = [
19
-
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
19
+
"-X tangled.org/core/knotserver/xrpc.version=${version}"
20
20
];
21
21
22
22
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/patchutil.go
+1
-1
patchutil/patchutil.go
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+4
-4
readme.md
+4
-4
readme.md
···
1
1
# tangled
2
2
3
3
Hello Tanglers! This is the codebase for
4
-
[Tangled](https://tangled.sh)—a code collaboration platform built
4
+
[Tangled](https://tangled.org)—a code collaboration platform built
5
5
on the [AT Protocol](https://atproto.com).
6
6
7
-
Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the
8
-
[Discord](https://chat.tangled.sh) or IRC at [#tangled on
7
+
Read the introduction to Tangled [here](https://blog.tangled.org/intro). Join the
8
+
[Discord](https://chat.tangled.org) or IRC at [#tangled on
9
9
libera.chat](https://web.libera.chat/#tangled).
10
10
11
11
## docs
···
17
17
## security
18
18
19
19
If you've identified a security issue in Tangled, please email
20
-
[security@tangled.sh](mailto:security@tangled.sh) with details!
20
+
[security@tangled.org](mailto:security@tangled.org) with details!
+4
-4
spindle/db/events.go
+4
-4
spindle/db/events.go
···
5
5
"fmt"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/notifier"
10
-
"tangled.sh/tangled.sh/core/spindle/models"
11
-
"tangled.sh/tangled.sh/core/tid"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/notifier"
10
+
"tangled.org/core/spindle/models"
11
+
"tangled.org/core/tid"
12
12
)
13
13
14
14
type Event struct {
+5
-5
spindle/engine/engine.go
+5
-5
spindle/engine/engine.go
···
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"golang.org/x/sync/errgroup"
11
-
"tangled.sh/tangled.sh/core/notifier"
12
-
"tangled.sh/tangled.sh/core/spindle/config"
13
-
"tangled.sh/tangled.sh/core/spindle/db"
14
-
"tangled.sh/tangled.sh/core/spindle/models"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
11
+
"tangled.org/core/notifier"
12
+
"tangled.org/core/spindle/config"
13
+
"tangled.org/core/spindle/db"
14
+
"tangled.org/core/spindle/models"
15
+
"tangled.org/core/spindle/secrets"
16
16
)
17
17
18
18
var (
+6
-6
spindle/engines/nixery/engine.go
+6
-6
spindle/engines/nixery/engine.go
···
19
19
"github.com/docker/docker/client"
20
20
"github.com/docker/docker/pkg/stdcopy"
21
21
"gopkg.in/yaml.v3"
22
-
"tangled.sh/tangled.sh/core/api/tangled"
23
-
"tangled.sh/tangled.sh/core/log"
24
-
"tangled.sh/tangled.sh/core/spindle/config"
25
-
"tangled.sh/tangled.sh/core/spindle/engine"
26
-
"tangled.sh/tangled.sh/core/spindle/models"
27
-
"tangled.sh/tangled.sh/core/spindle/secrets"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/log"
24
+
"tangled.org/core/spindle/config"
25
+
"tangled.org/core/spindle/engine"
26
+
"tangled.org/core/spindle/models"
27
+
"tangled.org/core/spindle/secrets"
28
28
)
29
29
30
30
const (
+2
-2
spindle/engines/nixery/setup_steps.go
+2
-2
spindle/engines/nixery/setup_steps.go
+11
-11
spindle/ingester.go
+11
-11
spindle/ingester.go
···
7
7
"fmt"
8
8
"time"
9
9
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/eventconsumer"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/db"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/db"
15
15
16
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
17
"github.com/bluesky-social/indigo/atproto/identity"
···
146
146
147
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
148
149
-
l.Info("ingesting repo record")
149
+
l.Info("ingesting repo record", "did", did)
150
150
151
151
switch e.Commit.Operation {
152
152
case models.CommitOperationCreate, models.CommitOperationUpdate:
···
162
162
163
163
// no spindle configured for this repo
164
164
if record.Spindle == nil {
165
-
l.Info("no spindle configured", "did", record.Owner, "name", record.Name)
165
+
l.Info("no spindle configured", "name", record.Name)
166
166
return nil
167
167
}
168
168
169
169
// this repo did not want this spindle
170
170
if *record.Spindle != domain {
171
-
l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain)
171
+
l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain)
172
172
return nil
173
173
}
174
174
175
175
// add this repo to the watch list
176
-
if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil {
176
+
if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil {
177
177
l.Error("failed to add repo", "error", err)
178
178
return fmt.Errorf("failed to add repo: %w", err)
179
179
}
180
180
181
-
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
181
+
didSlashRepo, err := securejoin.SecureJoin(did, record.Name)
182
182
if err != nil {
183
183
return err
184
184
}
185
185
186
186
// add repo to rbac
187
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
187
+
if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil {
188
188
l.Error("failed to add repo to enforcer", "error", err)
189
189
return fmt.Errorf("failed to add repo: %w", err)
190
190
}
+2
-2
spindle/models/engine.go
+2
-2
spindle/models/engine.go
+1
-1
spindle/models/models.go
+1
-1
spindle/models/models.go
+17
-17
spindle/server.go
+17
-17
spindle/server.go
···
9
9
"net/http"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/eventconsumer"
14
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
-
"tangled.sh/tangled.sh/core/idresolver"
16
-
"tangled.sh/tangled.sh/core/jetstream"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/notifier"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
"tangled.sh/tangled.sh/core/spindle/config"
21
-
"tangled.sh/tangled.sh/core/spindle/db"
22
-
"tangled.sh/tangled.sh/core/spindle/engine"
23
-
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
24
-
"tangled.sh/tangled.sh/core/spindle/models"
25
-
"tangled.sh/tangled.sh/core/spindle/queue"
26
-
"tangled.sh/tangled.sh/core/spindle/secrets"
27
-
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/eventconsumer"
14
+
"tangled.org/core/eventconsumer/cursor"
15
+
"tangled.org/core/idresolver"
16
+
"tangled.org/core/jetstream"
17
+
"tangled.org/core/log"
18
+
"tangled.org/core/notifier"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/spindle/config"
21
+
"tangled.org/core/spindle/db"
22
+
"tangled.org/core/spindle/engine"
23
+
"tangled.org/core/spindle/engines/nixery"
24
+
"tangled.org/core/spindle/models"
25
+
"tangled.org/core/spindle/queue"
26
+
"tangled.org/core/spindle/secrets"
27
+
"tangled.org/core/spindle/xrpc"
28
+
"tangled.org/core/xrpc/serviceauth"
29
29
)
30
30
31
31
//go:embed motd
+1
-1
spindle/stream.go
+1
-1
spindle/stream.go
+5
-5
spindle/xrpc/add_secret.go
+5
-5
spindle/xrpc/add_secret.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/xrpc"
12
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
···
62
62
}
63
63
64
64
repo := resp.Value.Val.(*tangled.Repo)
65
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
65
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
66
66
if err != nil {
67
67
fail(xrpcerr.GenericError(err))
68
68
return
+5
-5
spindle/xrpc/list_secrets.go
+5
-5
spindle/xrpc/list_secrets.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/xrpc"
12
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
···
57
57
}
58
58
59
59
repo := resp.Value.Val.(*tangled.Repo)
60
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
61
61
if err != nil {
62
62
fail(xrpcerr.GenericError(err))
63
63
return
+2
-2
spindle/xrpc/owner.go
+2
-2
spindle/xrpc/owner.go
+5
-5
spindle/xrpc/remove_secret.go
+5
-5
spindle/xrpc/remove_secret.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"github.com/bluesky-social/indigo/xrpc"
11
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/secrets"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
16
)
17
17
18
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
···
56
56
}
57
57
58
58
repo := resp.Value.Val.(*tangled.Repo)
59
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
59
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
60
60
if err != nil {
61
61
fail(xrpcerr.GenericError(err))
62
62
return
+9
-9
spindle/xrpc/xrpc.go
+9
-9
spindle/xrpc/xrpc.go
···
8
8
9
9
"github.com/go-chi/chi/v5"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/config"
15
-
"tangled.sh/tangled.sh/core/spindle/db"
16
-
"tangled.sh/tangled.sh/core/spindle/models"
17
-
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/config"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/models"
17
+
"tangled.org/core/spindle/secrets"
18
+
xrpcerr "tangled.org/core/xrpc/errors"
19
+
"tangled.org/core/xrpc/serviceauth"
20
20
)
21
21
22
22
const ActorDid string = "ActorDid"
+7
-5
types/repo.go
+7
-5
types/repo.go
···
41
41
}
42
42
43
43
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
44
+
Ref string `json:"ref,omitempty"`
45
+
Parent string `json:"parent,omitempty"`
46
+
Description string `json:"description,omitempty"`
47
+
DotDot string `json:"dotdot,omitempty"`
48
+
Files []NiceTree `json:"files,omitempty"`
49
+
ReadmeFileName string `json:"readme_filename,omitempty"`
50
+
Readme string `json:"readme_contents,omitempty"`
49
51
}
50
52
51
53
type TagReference struct {
+1
-1
workflow/compile.go
+1
-1
workflow/compile.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/def.go
+1
-1
workflow/def.go
+2
-2
xrpc/serviceauth/service_auth.go
+2
-2
xrpc/serviceauth/service_auth.go
···
8
8
"strings"
9
9
10
10
"github.com/bluesky-social/indigo/atproto/auth"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/idresolver"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
const ActorDid string = "ActorDid"