1package db
2
3import (
4 "bytes"
5 "database/sql"
6 "errors"
7 "fmt"
8 "io"
9 "strings"
10 "time"
11 "unicode/utf8"
12
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 "tangled.sh/tangled.sh/core/api/tangled"
15)
16
17type 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
28func (s *String) StringAt() syntax.ATURI {
29 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30}
31
32type StringStats struct {
33 LineCount uint64
34 ByteCount uint64
35}
36
37func (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
50func (s String) Validate() error {
51 var err error
52
53 if !strings.Contains(s.Filename, ".") {
54 err = errors.Join(err, fmt.Errorf("missing filename extension"))
55 }
56
57 if strings.HasSuffix(s.Filename, ".") {
58 err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59 }
60
61 if utf8.RuneCountInString(s.Filename) > 140 {
62 err = errors.Join(err, fmt.Errorf("filename too long"))
63 }
64
65 if utf8.RuneCountInString(s.Description) > 280 {
66 err = errors.Join(err, fmt.Errorf("description too long"))
67 }
68
69 if len(s.Contents) == 0 {
70 err = errors.Join(err, fmt.Errorf("contents is empty"))
71 }
72
73 return err
74}
75
76func (s *String) AsRecord() tangled.String {
77 return tangled.String{
78 Filename: s.Filename,
79 Description: s.Description,
80 Contents: s.Contents,
81 CreatedAt: s.Created.Format(time.RFC3339),
82 }
83}
84
85func StringFromRecord(did, rkey string, record tangled.String) String {
86 created, err := time.Parse(record.CreatedAt, time.RFC3339)
87 if err != nil {
88 created = time.Now()
89 }
90 return String{
91 Did: syntax.DID(did),
92 Rkey: rkey,
93 Filename: record.Filename,
94 Description: record.Description,
95 Contents: record.Contents,
96 Created: created,
97 }
98}
99
100func AddString(e Execer, s String) error {
101 _, err := e.Exec(
102 `insert into strings (
103 did,
104 rkey,
105 filename,
106 description,
107 content,
108 created,
109 edited
110 )
111 values (?, ?, ?, ?, ?, ?, null)
112 on conflict(did, rkey) do update set
113 filename = excluded.filename,
114 description = excluded.description,
115 content = excluded.content,
116 edited = case
117 when
118 strings.content != excluded.content
119 or strings.filename != excluded.filename
120 or strings.description != excluded.description then ?
121 else strings.edited
122 end`,
123 s.Did,
124 s.Rkey,
125 s.Filename,
126 s.Description,
127 s.Contents,
128 s.Created.Format(time.RFC3339),
129 time.Now().Format(time.RFC3339),
130 )
131 return err
132}
133
134func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
135 var all []String
136
137 var conditions []string
138 var args []any
139 for _, filter := range filters {
140 conditions = append(conditions, filter.Condition())
141 args = append(args, filter.Arg()...)
142 }
143
144 whereClause := ""
145 if conditions != nil {
146 whereClause = " where " + strings.Join(conditions, " and ")
147 }
148
149 limitClause := ""
150 if limit != 0 {
151 limitClause = fmt.Sprintf(" limit %d ", limit)
152 }
153
154 query := fmt.Sprintf(`select
155 did,
156 rkey,
157 filename,
158 description,
159 content,
160 created,
161 edited
162 from strings
163 %s
164 order by created desc
165 %s`,
166 whereClause,
167 limitClause,
168 )
169
170 rows, err := e.Query(query, args...)
171
172 if err != nil {
173 return nil, err
174 }
175 defer rows.Close()
176
177 for rows.Next() {
178 var s String
179 var createdAt string
180 var editedAt sql.NullString
181
182 if err := rows.Scan(
183 &s.Did,
184 &s.Rkey,
185 &s.Filename,
186 &s.Description,
187 &s.Contents,
188 &createdAt,
189 &editedAt,
190 ); err != nil {
191 return nil, err
192 }
193
194 s.Created, err = time.Parse(time.RFC3339, createdAt)
195 if err != nil {
196 s.Created = time.Now()
197 }
198
199 if editedAt.Valid {
200 e, err := time.Parse(time.RFC3339, editedAt.String)
201 if err != nil {
202 e = time.Now()
203 }
204 s.Edited = &e
205 }
206
207 all = append(all, s)
208 }
209
210 if err := rows.Err(); err != nil {
211 return nil, err
212 }
213
214 return all, nil
215}
216
217func DeleteString(e Execer, filters ...filter) error {
218 var conditions []string
219 var args []any
220 for _, filter := range filters {
221 conditions = append(conditions, filter.Condition())
222 args = append(args, filter.Arg()...)
223 }
224
225 whereClause := ""
226 if conditions != nil {
227 whereClause = " where " + strings.Join(conditions, " and ")
228 }
229
230 query := fmt.Sprintf(`delete from strings %s`, whereClause)
231
232 _, err := e.Exec(query, args...)
233 return err
234}
235
236func countLines(r io.Reader) (int, error) {
237 buf := make([]byte, 32*1024)
238 bufLen := 0
239 count := 0
240 nl := []byte{'\n'}
241
242 for {
243 c, err := r.Read(buf)
244 if c > 0 {
245 bufLen += c
246 }
247 count += bytes.Count(buf[:c], nl)
248
249 switch {
250 case err == io.EOF:
251 /* handle last line not having a newline at the end */
252 if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
253 count++
254 }
255 return count, nil
256 case err != nil:
257 return 0, err
258 }
259 }
260}