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 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
68func (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
77func 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
92func AddString(e Execer, s String) error {
93 _, err := e.Exec(
94 `insert into strings (
95 did,
96 rkey,
97 filename,
98 description,
99 content,
100 created,
101 edited
102 )
103 values (?, ?, ?, ?, ?, ?, null)
104 on conflict(did, rkey) do update set
105 filename = excluded.filename,
106 description = excluded.description,
107 content = excluded.content,
108 edited = case
109 when
110 strings.content != excluded.content
111 or strings.filename != excluded.filename
112 or strings.description != excluded.description then ?
113 else strings.edited
114 end`,
115 s.Did,
116 s.Rkey,
117 s.Filename,
118 s.Description,
119 s.Contents,
120 s.Created.Format(time.RFC3339),
121 time.Now().Format(time.RFC3339),
122 )
123 return err
124}
125
126func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127 var all []String
128
129 var conditions []string
130 var args []any
131 for _, filter := range filters {
132 conditions = append(conditions, filter.Condition())
133 args = append(args, filter.Arg()...)
134 }
135
136 whereClause := ""
137 if conditions != nil {
138 whereClause = " where " + strings.Join(conditions, " and ")
139 }
140
141 limitClause := ""
142 if limit != 0 {
143 limitClause = fmt.Sprintf(" limit %d ", limit)
144 }
145
146 query := fmt.Sprintf(`select
147 did,
148 rkey,
149 filename,
150 description,
151 content,
152 created,
153 edited
154 from strings
155 %s
156 order by created desc
157 %s`,
158 whereClause,
159 limitClause,
160 )
161
162 rows, err := e.Query(query, args...)
163
164 if err != nil {
165 return nil, err
166 }
167 defer rows.Close()
168
169 for rows.Next() {
170 var s String
171 var createdAt string
172 var editedAt sql.NullString
173
174 if err := rows.Scan(
175 &s.Did,
176 &s.Rkey,
177 &s.Filename,
178 &s.Description,
179 &s.Contents,
180 &createdAt,
181 &editedAt,
182 ); err != nil {
183 return nil, err
184 }
185
186 s.Created, err = time.Parse(time.RFC3339, createdAt)
187 if err != nil {
188 s.Created = time.Now()
189 }
190
191 if editedAt.Valid {
192 e, err := time.Parse(time.RFC3339, editedAt.String)
193 if err != nil {
194 e = time.Now()
195 }
196 s.Edited = &e
197 }
198
199 all = append(all, s)
200 }
201
202 if err := rows.Err(); err != nil {
203 return nil, err
204 }
205
206 return all, nil
207}
208
209func CountStrings(e Execer, filters ...filter) (int64, error) {
210 var conditions []string
211 var args []any
212 for _, filter := range filters {
213 conditions = append(conditions, filter.Condition())
214 args = append(args, filter.Arg()...)
215 }
216
217 whereClause := ""
218 if conditions != nil {
219 whereClause = " where " + strings.Join(conditions, " and ")
220 }
221
222 repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
223 var count int64
224 err := e.QueryRow(repoQuery, args...).Scan(&count)
225
226 if !errors.Is(err, sql.ErrNoRows) && err != nil {
227 return 0, err
228 }
229
230 return count, nil
231}
232
233func DeleteString(e Execer, filters ...filter) error {
234 var conditions []string
235 var args []any
236 for _, filter := range filters {
237 conditions = append(conditions, filter.Condition())
238 args = append(args, filter.Arg()...)
239 }
240
241 whereClause := ""
242 if conditions != nil {
243 whereClause = " where " + strings.Join(conditions, " and ")
244 }
245
246 query := fmt.Sprintf(`delete from strings %s`, whereClause)
247
248 _, err := e.Exec(query, args...)
249 return err
250}
251
252func 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}