Monorepo for Tangled tangled.org
2

Configure Feed

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

appview: refactor string page to accept multiple files

db schema is still using single file for non-blob file contents.

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jun 16, 2026, 11:57 PM +0900) commit 6b955521 parent 5129d588 change-id qxqyokot
+1020 -528
+58
appview/db/db.go
··· 2225 2225 return err 2226 2226 }) 2227 2227 2228 + orm.RunMigration(conn, logger, "multiple-files-for-a-string", func(tx *sql.Tx) error { 2229 + _, err := tx.Exec(` 2230 + create table strings_new ( 2231 + did text not null, 2232 + rkey text not null, 2233 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.string' || '/' || rkey) stored unique, 2234 + cid text, 2235 + 2236 + title text, 2237 + description text, 2238 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2239 + file_name text not null default '', 2240 + file_content text not null default '', 2241 + 2242 + -- appview-local information 2243 + edited text, 2244 + 2245 + primary key (did, rkey) 2246 + ); 2247 + `) 2248 + if err != nil { 2249 + return fmt.Errorf("failed to create tables: %w", err) 2250 + } 2251 + 2252 + _, err = tx.Exec(` 2253 + insert into strings_new ( 2254 + did, rkey, cid, 2255 + title, 2256 + description, 2257 + file_name, 2258 + file_content, 2259 + created, 2260 + edited 2261 + ) 2262 + select 2263 + did, rkey, cid, 2264 + filename, 2265 + description, 2266 + filename, 2267 + content, 2268 + created, 2269 + edited 2270 + from strings; 2271 + `) 2272 + if err != nil { 2273 + return fmt.Errorf("failed to insert strings: %w", err) 2274 + } 2275 + 2276 + _, err = tx.Exec(` 2277 + drop table strings; 2278 + alter table strings_new rename to strings; 2279 + `) 2280 + if err != nil { 2281 + return fmt.Errorf("failed to drop legacy table: %w", err) 2282 + } 2283 + return nil 2284 + }) 2285 + 2228 2286 return &DB{ 2229 2287 db, 2230 2288 logger,
+168 -21
appview/db/strings.go
··· 4 4 "database/sql" 5 5 "errors" 6 6 "fmt" 7 + "slices" 7 8 "strings" 8 9 "time" 9 10 ··· 12 13 "tangled.org/core/orm" 13 14 ) 14 15 15 - func AddString(e Execer, s models.String) error { 16 - _, err := e.Exec( 16 + func AddString(d *DB, s models.String) error { 17 + tx, err := d.Begin() 18 + if err != nil { 19 + return fmt.Errorf("starting transaction: %w", err) 20 + } 21 + defer tx.Rollback() 22 + res, err := tx.Exec( 17 23 `insert into strings ( 18 24 did, 19 25 rkey, 20 26 cid, 21 - filename, 27 + title, 22 28 description, 23 - content, 24 - created, 25 - edited 29 + file_name, 30 + file_content, 31 + created 26 32 ) 27 - values (?, ?, ?, ?, ?, ?, ?, null) 33 + values (?, ?, ?, ?, ?, ?, ?, ?) 28 34 on conflict(did, rkey) do update set 29 35 cid = excluded.cid, 30 - filename = excluded.filename, 36 + title = excluded.title, 31 37 description = excluded.description, 32 - content = excluded.content, 38 + file_name = excluded.file_name, 39 + file_content= excluded.file_content, 40 + created = excluded.created, 33 41 edited = case when strings.cid is not null then ? else strings.edited end 34 42 where strings.cid is not excluded.cid`, 35 43 s.Did, 36 44 s.Rkey, 37 45 s.Cid, 38 - s.Filename, 46 + s.Title, 39 47 s.Description, 40 - s.Contents, 48 + s.FileName, 49 + s.FileContent, 41 50 s.Created.Format(time.RFC3339), 42 51 time.Now().Format(time.RFC3339), 43 52 ) 44 - return err 53 + if err != nil { 54 + return fmt.Errorf("inserting string: %w", err) 55 + } 56 + 57 + num, err := res.RowsAffected() 58 + if err != nil { 59 + return fmt.Errorf("calculating affected rows: %w", err) 60 + } 61 + if num == 0 { 62 + return nil 63 + } 64 + 65 + if err := tx.Commit(); err != nil { 66 + return fmt.Errorf("commiting transaction: %w", err) 67 + } 68 + return nil 69 + } 70 + 71 + func GetString(e Execer, filters ...orm.Filter) (models.String, error) { 72 + strings, err := GetStrings(e, 0, filters...) 73 + if err != nil { 74 + return models.String{}, err 75 + } 76 + if len(strings) != 1 { 77 + return models.String{}, sql.ErrNoRows 78 + } 79 + return strings[0], nil 45 80 } 46 81 47 82 func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { 48 - var all []models.String 83 + stringMap := make(map[syntax.ATURI]*models.String) 49 84 50 85 var conditions []string 51 86 var args []any ··· 64 99 limitClause = fmt.Sprintf(" limit %d ", limit) 65 100 } 66 101 67 - query := fmt.Sprintf(`select 102 + query := fmt.Sprintf( 103 + `select 68 104 did, 69 105 rkey, 70 106 cid, 71 - filename, 107 + title, 72 108 description, 73 - content, 109 + file_name, 110 + file_content, 74 111 created, 75 112 edited 76 113 from strings ··· 91 128 for rows.Next() { 92 129 var s models.String 93 130 var createdAt string 94 - var cid, editedAt sql.Null[string] 131 + var cid, title, description, editedAt sql.Null[string] 95 132 96 133 if err := rows.Scan( 97 134 &s.Did, 98 135 &s.Rkey, 99 136 &cid, 100 - &s.Filename, 101 - &s.Description, 102 - &s.Contents, 137 + &title, 138 + &description, 139 + &s.FileName, 140 + &s.FileContent, 103 141 &createdAt, 104 142 &editedAt, 105 143 ); err != nil { ··· 111 149 *s.Cid = syntax.CID(cid.V) 112 150 } 113 151 152 + if title.Valid { 153 + s.Title = new(string) 154 + *s.Title = title.V 155 + } 156 + 157 + if description.Valid { 158 + s.Description = new(string) 159 + *s.Description = description.V 160 + } 161 + 114 162 s.Created, err = time.Parse(time.RFC3339, createdAt) 115 163 if err != nil { 116 164 s.Created = time.Now() ··· 124 172 s.Edited = &e 125 173 } 126 174 127 - all = append(all, s) 175 + s.Stats = &models.StringStats{} 176 + stringMap[s.AtUri()] = &s 128 177 } 129 178 130 179 if err := rows.Err(); err != nil { 131 180 return nil, err 132 181 } 182 + 183 + // if no strings, return early 184 + if len(stringMap) == 0 { 185 + return nil, nil 186 + } 187 + 188 + // build IN clause for related queries 189 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(stringMap)), ", ") 190 + args = make([]any, len(stringMap)) 191 + i := 0 192 + for _, s := range stringMap { 193 + args[i] = s.AtUri() 194 + i++ 195 + } 196 + 197 + // // get files 198 + // { 199 + // rows, err := e.Query( 200 + // fmt.Sprintf( 201 + // `select at_uri, name, blob from string_files where at_uri in (%s) order by at_uri, id`, 202 + // inClause, 203 + // ), 204 + // args..., 205 + // ) 206 + // if err != nil { 207 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 208 + // } 209 + // defer rows.Close() 210 + // 211 + // for rows.Next() { 212 + // var stringAt syntax.ATURI 213 + // var file models.String_File 214 + // file.Blob = &util.LexBlob{} 215 + // var gzipMimeType sql.Null[string] 216 + // var gzipSize sql.Null[int64] 217 + // if err := rows.Scan( 218 + // &stringAt, 219 + // &file.Name, 220 + // &blob, 221 + // ); err != nil { 222 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 223 + // } 224 + // if gzipMimeType.Valid && gzipSize.Valid { 225 + // file.Gzip = &models.GzipInfo{ 226 + // MimeType: gzipMimeType.V, 227 + // Size: gzipSize.V, 228 + // } 229 + // } 230 + // if s, ok := stringMap[stringAt]; ok { 231 + // s.Files = append(s.Files, file) 232 + // } 233 + // } 234 + // if err = rows.Err(); err != nil { 235 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 236 + // } 237 + // } 238 + 239 + // get star counts 240 + { 241 + rows, err := e.Query( 242 + fmt.Sprintf( 243 + `select subject, count(1) from stars where subject_type = 'string' and subject in (%s) group by subject`, 244 + inClause, 245 + ), 246 + args..., 247 + ) 248 + if err != nil { 249 + return nil, fmt.Errorf("failed to execute star-count query: %w", err) 250 + } 251 + defer rows.Close() 252 + 253 + for rows.Next() { 254 + var stringAt syntax.ATURI 255 + var count int 256 + if err := rows.Scan(&stringAt, &count); err != nil { 257 + continue 258 + } 259 + if s, ok := stringMap[stringAt]; ok { 260 + s.Stats.StarCount = count 261 + } 262 + } 263 + if err = rows.Err(); err != nil { 264 + return nil, fmt.Errorf("failed to execute star-count query: %w", err) 265 + } 266 + } 267 + 268 + var all []models.String 269 + for _, s := range stringMap { 270 + all = append(all, *s) 271 + } 272 + 273 + // sort by created timestamp (desc) 274 + slices.SortFunc(all, func(a, b models.String) int { 275 + if a.Created.After(b.Created) { 276 + return -1 277 + } 278 + return 1 279 + }) 133 280 134 281 return all, nil 135 282 }
+5 -3
appview/ingester.go
··· 931 931 return err 932 932 } 933 933 934 - string := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) 935 - 936 - if err = i.Validator.ValidateString(&string); err != nil { 934 + string, err := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) 935 + if err != nil { 936 + return fmt.Errorf("failed to parse string record: %w", err) 937 + } 938 + if err = string.Validate(); err != nil { 937 939 l.Error("invalid record", "err", err) 938 940 return err 939 941 }
+11 -9
appview/ingester_string_test.go
··· 103 103 if !ok { 104 104 t.Fatal("row not inserted") 105 105 } 106 - if s.Filename != "hello.txt" { 107 - t.Errorf("filename = %q, want hello.txt", s.Filename) 106 + if s.FileName != "hello.txt" { 107 + t.Errorf("filename = %q, want hello.txt", s.FileName) 108 108 } 109 - if s.Description != "a greeting" { 110 - t.Errorf("description = %q", s.Description) 109 + if s.Description == nil { 110 + t.Errorf("description = nil") 111 + } 112 + if *s.Description != "a greeting" { 113 + t.Errorf("description = %q", *s.Description) 111 114 } 112 - if s.Contents != "hello world\n" { 113 - t.Errorf("contents = %q", s.Contents) 115 + if s.FileContent != "hello world\n" { 116 + t.Errorf("contents = %q", s.FileContent) 114 117 } 115 118 if !s.Created.Equal(created) { 116 119 t.Errorf("created = %v, want %v (record CreatedAt must round-trip)", s.Created, created) ··· 144 147 if !ok { 145 148 t.Fatal("row missing after update") 146 149 } 147 - if s.Contents != "hello, world!\n" { 148 - t.Errorf("contents = %q, want updated value", s.Contents) 150 + if s.FileContent != "hello, world!\n" { 151 + t.Errorf("contents = %q, want updated value", s.FileContent) 149 152 } 150 153 if !s.Created.Equal(created) { 151 154 t.Errorf("update overwrote created: got %v, want %v", s.Created, created) ··· 215 218 name string 216 219 rec tangled.String 217 220 }{ 218 - {"empty contents", tangled.String{Filename: "x", Contents: "", CreatedAt: now}}, 219 221 {"long filename", tangled.String{Filename: strings.Repeat("a", 141), Contents: "x", CreatedAt: now}}, 220 222 {"long description", tangled.String{Filename: "x", Description: strings.Repeat("d", 281), Contents: "x", CreatedAt: now}}, 221 223 }
+104 -54
appview/models/string.go
··· 1 1 package models 2 2 3 3 import ( 4 - "bytes" 4 + "errors" 5 5 "fmt" 6 - "io" 7 - "strings" 8 6 "time" 7 + "unicode/utf8" 9 8 10 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 11 "tangled.org/core/api/tangled" 12 12 ) 13 13 ··· 16 16 Rkey syntax.RecordKey 17 17 Cid *syntax.CID 18 18 19 - Filename string 20 - Description string 21 - Contents string 19 + // String_File will remain, and we will still use it. 20 + // We just use `FileContent` when fetching the file. 21 + 22 + // after that, change lexicon, start migrating to blobs 23 + // when string is migrated, clear FileName and FileContent. 24 + // when they are cleared, fetch the blob on page load. 25 + 26 + Title *string 27 + Description *string 28 + Files []String_File 22 29 Created time.Time 23 30 Edited *time.Time 31 + 32 + // legacy string data 33 + FileName string 34 + FileContent string 35 + 36 + // optionally, populate this when querying for reverse mappings 37 + Stats *StringStats 38 + } 39 + 40 + // TODO: replace this with [tangled.String_File] 41 + type String_File struct { 42 + Name string 43 + Blob *lexutil.LexBlob 44 + Gzip *GzipInfo 45 + } 46 + type GzipInfo struct { 47 + MimeType string 48 + Size int64 24 49 } 25 50 26 51 func (s *String) AtUri() syntax.ATURI { ··· 28 53 } 29 54 30 55 func (s *String) AsRecord() *tangled.String { 56 + var description string 57 + if s.Description != nil { 58 + description = *s.Description 59 + } 31 60 return &tangled.String{ 32 - Filename: s.Filename, 33 - Description: s.Description, 34 - Contents: s.Contents, 61 + Filename: s.FileName, 62 + Description: description, 63 + Contents: s.FileContent, 35 64 CreatedAt: s.Created.Format(time.RFC3339), 36 65 } 37 66 } 38 67 39 - func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) String { 68 + func (s *String) Validate() error { 69 + var err error 70 + if s.FileName == "" && len(s.Files) == 0 { 71 + err = errors.Join(err, fmt.Errorf("string should have more than one files")) 72 + } 73 + // legacy record check 74 + if utf8.RuneCountInString(s.FileName) > 140 { 75 + err = errors.Join(err, fmt.Errorf("filename too long")) 76 + } 77 + for i, file := range s.Files { 78 + if utf8.RuneCountInString(file.Name) > 140 { 79 + err = errors.Join(err, fmt.Errorf("filename too long at files[%d]", i)) 80 + } 81 + } 82 + if s.Title != nil { 83 + if utf8.RuneCountInString(*s.Title) > 140 { 84 + err = errors.Join(err, fmt.Errorf("title too long")) 85 + } 86 + } 87 + if s.Description != nil { 88 + if utf8.RuneCountInString(*s.Description) > 280 { 89 + err = errors.Join(err, fmt.Errorf("description too long")) 90 + } 91 + } 92 + return err 93 + } 94 + 95 + func (s String) RenderTitle() string { 96 + if s.Title != nil { 97 + return *s.Title 98 + } 99 + if len(s.Files) > 0 { 100 + return s.Files[0].Name 101 + } 102 + return s.FileName 103 + } 104 + 105 + // FileByName returns first item in files with given filename 106 + func (s *String) FileByName(name string) (String_File, bool) { 107 + for _, file := range s.Files { 108 + if file.Name == name { 109 + return file, true 110 + } 111 + } 112 + return String_File{}, false 113 + } 114 + 115 + func (s String) IsLegacySingleFile() bool { 116 + return len(s.Files) == 0 117 + } 118 + 119 + func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) (String, error) { 40 120 created, err := time.Parse(time.RFC3339, record.CreatedAt) 41 121 if err != nil { 42 - created = time.Now() 122 + return String{}, fmt.Errorf("invalid createdAt: %w", err) 123 + } 124 + var description *string 125 + if record.Description != "" { 126 + description = &record.Description 43 127 } 44 128 return String{ 45 129 Did: did, 46 130 Rkey: rkey, 47 131 Cid: &cid, 48 - Filename: record.Filename, 49 - Description: record.Description, 50 - Contents: record.Contents, 132 + Description: description, 51 133 Created: created, 52 - } 134 + FileName: record.Filename, 135 + FileContent: record.Contents, 136 + }, nil 53 137 } 54 138 55 139 type StringStats struct { 56 - LineCount uint64 57 - ByteCount uint64 140 + StarCount int 141 + // CommentCount int 58 142 } 59 143 60 - func (s String) Stats() StringStats { 61 - lineCount, err := countLines(strings.NewReader(s.Contents)) 62 - if err != nil { 63 - // non-fatal 64 - // TODO: log this? 65 - } 66 - 67 - return StringStats{ 68 - LineCount: uint64(lineCount), 69 - ByteCount: uint64(len(s.Contents)), 70 - } 71 - } 72 - 73 - func countLines(r io.Reader) (int, error) { 74 - buf := make([]byte, 32*1024) 75 - bufLen := 0 76 - count := 0 77 - nl := []byte{'\n'} 78 - 79 - for { 80 - c, err := r.Read(buf) 81 - if c > 0 { 82 - bufLen += c 83 - } 84 - count += bytes.Count(buf[:c], nl) 85 - 86 - switch { 87 - case err == io.EOF: 88 - /* handle last line not having a newline at the end */ 89 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 90 - count++ 91 - } 92 - return count, nil 93 - case err != nil: 94 - return 0, err 95 - } 96 - } 144 + type StringFileStats struct { 145 + LineCount int 146 + ByteCount int 97 147 }
+44 -22
appview/pages/pages.go
··· 30 30 "tangled.org/core/idresolver" 31 31 "tangled.org/core/types" 32 32 33 - "github.com/bluesky-social/indigo/atproto/identity" 34 33 "github.com/bluesky-social/indigo/atproto/syntax" 35 34 "github.com/go-git/go-git/v5/plumbing" 36 35 ) ··· 1666 1665 return p.executeRepo("repo/pipelines/workflow", w, params) 1667 1666 } 1668 1667 1669 - type PutStringParams struct { 1668 + type NewStringParams struct { 1670 1669 BaseParams 1671 - Action string 1672 - 1673 - // this is supplied in the case of editing an existing string 1674 - String models.String 1670 + String models.String 1671 + FileParams []StringFileEditFragmentParams 1675 1672 } 1676 1673 1677 - func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1678 - return p.execute("strings/put", w, params) 1674 + func (p *Pages) NewString(w io.Writer, params NewStringParams) error { 1675 + // use default string value to render template 1676 + params.String = models.String{Files: make([]models.String_File, 1)} 1677 + params.FileParams = make([]StringFileEditFragmentParams, 1) 1678 + return p.execute("strings/new", w, params) 1679 1679 } 1680 1680 1681 - type StringsDashboardParams struct { 1681 + type EditStringParams struct { 1682 1682 BaseParams 1683 - Card ProfileCard 1684 - Strings []models.String 1683 + String models.String 1684 + FileParams []StringFileEditFragmentParams 1685 1685 } 1686 1686 1687 - func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1688 - return p.execute("strings/dashboard", w, params) 1687 + func (p *Pages) EditString(w io.Writer, params EditStringParams) error { 1688 + return p.execute("strings/edit", w, params) 1689 1689 } 1690 1690 1691 1691 type StringTimelineParams struct { ··· 1699 1699 1700 1700 type SingleStringParams struct { 1701 1701 BaseParams 1702 - ShowRendered bool 1703 - RenderToggle bool 1704 - RenderedContents template.HTML 1705 - String *models.String 1706 - Stats models.StringStats 1707 - IsStarred bool 1708 - StarCount int 1709 - Owner identity.Identity 1710 - CommentList []models.CommentListItem 1702 + String *models.String 1703 + FileParams []StringFileFragmentParams 1704 + IsStarred bool 1705 + StarCount int 1706 + CommentList []models.CommentListItem 1711 1707 1712 1708 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1713 1709 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool ··· 1716 1712 1717 1713 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1718 1714 return p.execute("strings/string", w, params) 1715 + } 1716 + 1717 + type StringFileEditFragmentParams struct { 1718 + Name string 1719 + Content string 1720 + Size uint64 1721 + } 1722 + 1723 + type StringFileFragmentParams struct { 1724 + String *models.String 1725 + Name string 1726 + Content string 1727 + 1728 + LineCount int 1729 + Size uint64 1730 + HasNoTrailingEOL bool 1731 + HasRenderedToggle bool 1732 + ShowingRendered bool 1733 + } 1734 + 1735 + func (p *Pages) StringFileFragment(w io.Writer, params StringFileFragmentParams) error { 1736 + return p.executePlain("strings/fragments/file", w, params) 1737 + } 1738 + 1739 + func (p *Pages) StringFileEditFragment(w io.Writer) error { 1740 + return p.executePlain("strings/fragments/fileEdit", w, StringFileEditFragmentParams{}) 1719 1741 } 1720 1742 1721 1743 type SearchReposParams struct {
+3 -2
appview/pages/ratchet_test.go
··· 42 42 } 43 43 44 44 var bareDidAllowlist = map[string]bool{ 45 + "templates/spindles/dashboard.html": true, 46 + "templates/strings/edit.html": true, 47 + "templates/strings/fragments/file.html": true, 45 48 "templates/strings/string.html": true, 46 - "templates/strings/fragments/form.html": true, 47 - "templates/spindles/dashboard.html": true, 48 49 } 49 50 50 51 var didCloseAsUrlSegment = regexp.MustCompile(`\.(?:Did|OwnerDid)\s*\}\}\s*/`)
-58
appview/pages/templates/strings/dashboard.html
··· 1 - {{ define "title" }}Strings by {{ resolve .Card.UserDid }} &middot; Tangled{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - {{ $handle := resolve .Card.UserDid }} 5 - <meta property="og:title" content="{{ $handle }}" /> 6 - <meta property="og:type" content="profile" /> 7 - <meta property="og:url" content="https://tangled.org/{{ $handle }}" /> 8 - <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 9 - {{ end }} 10 - 11 - 12 - {{ define "content" }} 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"> 15 - {{ template "user/fragments/profileCard" .Card }} 16 - </div> 17 - <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 18 - {{ block "allStrings" . }}{{ end }} 19 - </div> 20 - </div> 21 - {{ end }} 22 - 23 - {{ define "allStrings" }} 24 - <p class="text-base font-medium p-2 dark:text-white">All strings</p> 25 - <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 26 - {{ range .Strings }} 27 - {{ template "singleString" (list $ .) }} 28 - {{ else }} 29 - <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 30 - {{ end }} 31 - </div> 32 - {{ end }} 33 - 34 - {{ define "singleString" }} 35 - {{ $root := index . 0 }} 36 - {{ $s := index . 1 }} 37 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 38 - <div class="font-medium dark:text-white flex gap-2 items-center"> 39 - <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 40 - </div> 41 - {{ with $s.Description }} 42 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 43 - {{ . }} 44 - </div> 45 - {{ end }} 46 - 47 - {{ $stat := $s.Stats }} 48 - <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 49 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 - {{ with $s.Edited }} 52 - <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 53 - {{ else }} 54 - {{ template "repo/fragments/shortTimeAgo" $s.Created }} 55 - {{ end }} 56 - </div> 57 - </div> 58 - {{ end }}
+16
appview/pages/templates/strings/edit.html
··· 1 + {{ define "title" }}edit string{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + <p class="text-xl font-bold dark:text-white">Edit string</p> 6 + </div> 7 + <form 8 + id="string-form" 9 + hx-post="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit" 10 + hx-indicator="find button[type='submit']" 11 + hx-swap="none" 12 + hx-trigger="submit, ctrlenter" 13 + > 14 + {{ template "strings/fragments/formBody" . }} 15 + </form> 16 + {{ end }}
+31
appview/pages/templates/strings/fragments/file.html
··· 1 + {{ define "strings/fragments/file" }} 2 + <section 3 + class="bg-white dark:bg-gray-800 px-6 py-4 mb-2 rounded relative w-full dark:text-white" 4 + hx-target="this" 5 + hx-swap="outerHTML" 6 + > 7 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 8 + <span>{{ .Name }}</span> 9 + <div> 10 + <span>{{ .LineCount }} line{{if ne .LineCount 1}}s{{end}}</span> 11 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 12 + <span>{{ .Size }}</span> 13 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 14 + <a href="/strings/{{ resolve .String.Did.String }}/{{ .String.Rkey }}/{{ .String.Cid }}/{{ .Name }}/raw">View raw</a> 15 + {{ if .HasRenderedToggle }} 16 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 17 + <button hx-get="/strings/{{ .String.Did }}/{{ .String.Rkey }}/{{ .String.Cid }}/{{ .Name }}?code={{ .ShowingRendered }}"> 18 + View {{ if .ShowingRendered }}code{{ else }}rendered{{ end }} 19 + </button> 20 + {{ end }} 21 + </div> 22 + </div> 23 + <div class="overflow-x-auto overflow-y-hidden relative"> 24 + {{ if .ShowingRendered }} 25 + <div class="prose dark:prose-invert">{{ .Content | readme }}</div> 26 + {{ else }} 27 + <div class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .Content .Name | escapeHtml }}</div> 28 + {{ end }} 29 + </div> 30 + </section> 31 + {{ end }}
+37
appview/pages/templates/strings/fragments/fileEdit.html
··· 1 + {{ define "strings/fragments/fileEdit" }} 2 + <div id="file-editor" class="p-6 mb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 3 + <div class="flex justify-between items-center md:flex-row md:items-center gap-2"> 4 + <div class="flex gap-2"> 5 + <input 6 + type="text" 7 + name="filename" 8 + placeholder="Filename" 9 + required 10 + value="{{ .Name }}" 11 + class="md:max-w-64" 12 + > 13 + <button 14 + id="remove-file-btn" 15 + class="remove-file-btn btn flex items-center text-red-400 dark:text-red-500" 16 + onclick="this.closest('#file-editor').remove()" 17 + > 18 + {{ i "trash-2" "size-4" }} 19 + </button> 20 + </div> 21 + <div class="text-sm text-gray-500 dark:text-gray-400"> 22 + <span id="line-count">0 lines</span> 23 + <span class="select-none px-1 [&:before]:content-['·']"></span> 24 + <span id="byte-count">0 bytes</span> 25 + </div> 26 + </div> 27 + <textarea 28 + name="content" 29 + wrap="off" 30 + class="w-full font-mono" 31 + rows="20" 32 + spellcheck="false" 33 + placeholder="Paste your string here!" 34 + required 35 + >{{ .Content }}</textarea> 36 + </div> 37 + {{ end }}
-90
appview/pages/templates/strings/fragments/form.html
··· 1 - {{ define "strings/fragments/form" }} 2 - <form 3 - {{ if eq .Action "new" }} 4 - hx-post="/strings/new" 5 - {{ else }} 6 - hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 - {{ end }} 8 - hx-indicator="#new-button" 9 - class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 - hx-swap="none"> 11 - <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 - <input 13 - type="text" 14 - id="filename" 15 - name="filename" 16 - placeholder="Filename" 17 - required 18 - value="{{ .String.Filename }}" 19 - class="md:max-w-64" 20 - > 21 - <input 22 - type="text" 23 - id="description" 24 - name="description" 25 - value="{{ .String.Description }}" 26 - placeholder="Description ..." 27 - class="flex-1" 28 - > 29 - </div> 30 - <textarea 31 - name="content" 32 - id="content-textarea" 33 - wrap="off" 34 - class="w-full font-mono" 35 - rows="20" 36 - spellcheck="false" 37 - placeholder="Paste your string here!" 38 - required>{{ .String.Contents }}</textarea> 39 - <div class="flex justify-between items-center"> 40 - <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 - <span id="line-count">0 lines</span> 42 - <span class="select-none px-1 [&:before]:content-['·']"></span> 43 - <span id="byte-count">0 bytes</span> 44 - </div> 45 - <div id="actions" class="flex gap-2 items-center"> 46 - {{ if eq .Action "edit" }} 47 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 - href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 - {{ i "x" "size-4" }} 50 - <span class="hidden md:inline">Cancel</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </a> 53 - {{ end }} 54 - <button 55 - type="submit" 56 - id="new-button" 57 - class="w-fit btn-create group" 58 - > 59 - <span class="inline-flex items-center gap-2"> 60 - {{ i "arrow-up" "w-4 h-4" }} 61 - Publish 62 - </span> 63 - <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 - </span> 66 - </button> 67 - </div> 68 - </div> 69 - <script> 70 - (function() { 71 - const textarea = document.getElementById('content-textarea'); 72 - const lineCount = document.getElementById('line-count'); 73 - const byteCount = document.getElementById('byte-count'); 74 - function updateStats() { 75 - const content = textarea.value; 76 - const lines = content === '' ? 0 : content.split('\n').length; 77 - const bytes = new TextEncoder().encode(content).length; 78 - lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 - byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 - } 81 - textarea.addEventListener('input', updateStats); 82 - textarea.addEventListener('paste', () => { 83 - setTimeout(updateStats, 0); 84 - }); 85 - updateStats(); 86 - })(); 87 - </script> 88 - <div id="error" class="error dark:text-red-400"></div> 89 - </form> 90 - {{ end }}
+125
appview/pages/templates/strings/fragments/formBody.html
··· 1 + {{ define "strings/fragments/formBody" }} 2 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 3 + <input 4 + type="text" 5 + name="title" 6 + placeholder="Title..." 7 + value="{{ with .String.Title }}{{ . }}{{ end }}" 8 + class="md:max-w-64" 9 + > 10 + <input 11 + type="text" 12 + name="description" 13 + placeholder="Description ..." 14 + value="{{ with .String.Description }}{{ . }}{{ end }}" 15 + class="flex-1" 16 + > 17 + </div> 18 + <div id="string-files"> 19 + {{ range .FileParams }} 20 + {{ template "strings/fragments/fileEdit" . }} 21 + {{ end }} 22 + </div> 23 + <div class="flex justify-between items-center"> 24 + <div id="error"></div> 25 + <div class="flex gap-2"> 26 + <!-- TODO: accept multiple files in a string 27 + <button 28 + type="button" 29 + class="w-fit btn rounded py-0" 30 + hx-get="/strings/fileEdit" 31 + hx-swap="beforeend" 32 + hx-target="#string-files" 33 + hx-indicator="this" 34 + > 35 + <span class="inline-flex items-center gap-2"> 36 + {{ i "file-plus" "w-4 h-4" }} 37 + Add file 38 + </span> 39 + </button> 40 + --> 41 + <button 42 + type="submit" 43 + class="w-fit btn-create group" 44 + > 45 + <span class="inline-flex items-center gap-2"> 46 + {{ i "arrow-up" "w-4 h-4" }} 47 + Publish 48 + </span> 49 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + <script> 56 + (() => { 57 + const container = document.getElementById('string-files'); 58 + 59 + // update file stats 60 + function updateStats(editor) { 61 + console.log('update stats'); 62 + const textarea = editor.querySelector('textarea'); 63 + const lineCount = editor.querySelector('#line-count'); 64 + const byteCount = editor.querySelector('#byte-count'); 65 + 66 + if (!textarea || !lineCount || !byteCount) return; 67 + 68 + const content = textarea.value; 69 + const lines = content === '' ? 0 : content.split('\n').length; 70 + const bytes = new TextEncoder().encode(content).length; 71 + 72 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 73 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 74 + } 75 + 76 + function findEditorFromEvent(evt) { 77 + const textarea = evt.target.closest('#file-editor textarea'); 78 + if (!textarea) return null; 79 + return textarea.closest('#file-editor'); 80 + } 81 + container.addEventListener('input', (evt) => { 82 + const editor = findEditorFromEvent(evt); 83 + if (!editor) return; 84 + updateStats(editor); 85 + }); 86 + container.addEventListener('paste', (evt) => { 87 + const editor = findEditorFromEvent(evt); 88 + if (!editor) return; 89 + setTimeout(() => updateStats(editor), 0); 90 + }); 91 + 92 + container.querySelectorAll('#file-editor').forEach((el) => { 93 + updateStats(el); 94 + }); 95 + 96 + // disable remove-file-btn 97 + function updateRemoveButtons() { 98 + const editors = container.querySelectorAll('#file-editor'); 99 + const buttons = container.querySelectorAll('#file-editor #remove-file-btn'); 100 + if (editors.length == 1) { 101 + buttons.forEach(btn => { btn.disabled = true; }); 102 + } else { 103 + buttons.forEach(btn => { btn.disabled = false; }); 104 + } 105 + } 106 + 107 + (new MutationObserver(() => updateRemoveButtons())).observe(container, { 108 + childList: true, 109 + subtree: false, 110 + }); 111 + updateRemoveButtons(); 112 + 113 + // Because textarea triggering the keydown event can be added to DOM 114 + // after the initial load, we manually trigger the event from js. 115 + const form = document.querySelector('string-form'); 116 + container.addEventListener('keydown', (evt) => { 117 + const textarea = evt.target.closest('#file-editor textarea'); 118 + if (!textarea) return; 119 + if ((evt.ctrlKey || evt.metaKey) && evt.key === 'Enter') { 120 + htmx.trigger('#string-form', "ctrlenter"); 121 + } 122 + }); 123 + })(); 124 + </script> 125 + {{ end }}
+17
appview/pages/templates/strings/new.html
··· 1 + {{ define "title" }}Publish a new string &middot; Tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 6 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 7 + </div> 8 + <form 9 + id="string-form" 10 + hx-post="/strings/new" 11 + hx-indicator="find button[type='submit']" 12 + hx-swap="none" 13 + hx-trigger="submit, ctrlenter" 14 + > 15 + {{ template "strings/fragments/formBody" . }} 16 + </form> 17 + {{ end }}
-13
appview/pages/templates/strings/put.html
··· 1 - {{ define "title" }}Publish a new string &middot; Tangled{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="px-6 py-2 mb-4"> 5 - {{ if eq .Action "new" }} 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 - {{ else }} 9 - <p class="text-xl font-bold dark:text-white">Edit string</p> 10 - {{ end }} 11 - </div> 12 - {{ template "strings/fragments/form" . }} 13 - {{ end }}
+48 -71
appview/pages/templates/strings/string.html
··· 1 - {{ define "title" }}{{ .String.Filename }} &middot; by {{ resolve .Owner.DID.String }} &middot; Tangled{{ end }} 1 + {{ define "title" }}{{ .String.RenderTitle }} &middot; by {{ resolve .String.Did.String }} &middot; Tangled{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $ownerId := resolve .Owner.DID.String }} 5 - <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 4 + {{ $ownerId := resolve .String.Did.String }} 5 + <meta property="og:title" content="{{ .String.RenderTitle }} · by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 - <meta property="og:description" content="{{ .String.Description }}" /> 8 + <meta property="og:description" content="{{ with .String.Description }}{{ . }}{{ end }}" /> 9 9 {{ end }} 10 10 11 11 {{ define "content" }} 12 - {{ $ownerId := resolve .Owner.DID.String }} 12 + {{ $ownerId := resolve .String.Did.String }} 13 13 <section id="string-header" class="mb-2 py-2 px-4 dark:text-white"> 14 14 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 15 15 <!-- left items --> 16 16 <div class="flex flex-col gap-2"> 17 17 <!-- string owner / string name --> 18 18 <div class="flex items-center gap-2 flex-wrap"> 19 - {{ template "user/fragments/picHandleLink" .Owner.DID.String }} 19 + <a href="/{{ resolve .String.Did.String }}?tab=strings" class="flex items-center gap-1"> 20 + {{ template "user/fragments/picHandle" .String.Did.String }} 21 + </a> 20 22 <span class="select-none">/</span> 21 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.RenderTitle }}</a> 22 24 </div> 23 25 24 26 <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 25 - {{ if .String.Description }} 26 - {{ .String.Description }} 27 + {{ with .String.Description }} 28 + {{ . }} 27 29 {{ else }} 28 30 <span class="italic">This string has no description</span> 29 31 {{ end }} 30 32 </span> 31 - </div> 32 33 33 - {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 34 - <div class="w-fit grid grid-flow-col grid-cols-3 gap-2"> 35 - {{ if $isOwner }} 36 - <a class="btn no-underline hover:no-underline group" 37 - hx-boost="true" 38 - href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 39 - {{ i "pencil" "w-4 h-4" }} 40 - <span class="hidden md:inline">Edit</span> 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - <button 44 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group" 45 - title="Delete string" 46 - hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 47 - hx-swap="none" 48 - hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 49 - > 50 - {{ i "trash-2" "w-4 h-4" }} 51 - <span class="hidden md:inline">Delete</span> 52 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 - </button> 34 + <div class="text-gray-500 dark:text-gray-400 text-sm text-base"> 35 + <span> 36 + {{ if .String.Edited }} 37 + edited {{ template "repo/fragments/time" .String.Edited }} 38 + {{ else }} 39 + {{ template "repo/fragments/time" .String.Created }} 54 40 {{ end }} 55 - <div class="{{ if not $isOwner }}col-span-3 justify-self-end{{ end }}"> 56 - {{ template "fragments/starBtn" 57 - (dict "SubjectAt" .String.AtUri 58 - "IsStarred" .IsStarred 59 - "StarCount" .StarCount) }} 60 - </div> 41 + </span> 42 + </div> 61 43 </div> 62 - </div> 63 - </section> 64 - <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 65 - <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 66 - <span> 67 - {{ .String.Filename }} 68 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 69 - <span> 70 - {{ with .String.Edited }} 71 - edited {{ template "repo/fragments/shortTimeAgo" . }} 72 - {{ else }} 73 - {{ template "repo/fragments/shortTimeAgo" .String.Created }} 74 - {{ end }} 75 - </span> 76 - </span> 77 - <div> 78 - <span>{{ .Stats.LineCount }} lines</span> 79 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 80 - <span>{{ byteFmt .Stats.ByteCount }}</span> 81 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 82 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">View raw</a> 83 - {{ if .RenderToggle }} 84 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 85 - <a href="?code={{ .ShowRendered }}" hx-boost="true"> 86 - View {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 87 - </a> 44 + 45 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 46 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 47 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 48 + hx-boost="true" 49 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 50 + {{ i "pencil" "w-4 h-4" }} 51 + <span class="hidden md:inline">edit</span> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </a> 54 + <button 55 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-2 group" 56 + title="Delete string" 57 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 58 + hx-swap="none" 59 + hx-confirm="Are you sure you want to delete the string?" 60 + > 61 + {{ i "trash-2" "w-4 h-4" }} 62 + <span class="hidden md:inline">delete</span> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 64 + </button> 88 65 {{ end }} 66 + {{ template "fragments/starBtn" 67 + (dict "SubjectAt" .String.AtUri 68 + "IsStarred" .IsStarred 69 + "StarCount" .StarCount) }} 89 70 </div> 90 71 </div> 91 - <div class="overflow-x-auto overflow-y-hidden relative"> 92 - {{ if .ShowRendered }} 93 - <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 94 - {{ else }} 95 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div> 96 - {{ end }} 97 - </div> 98 - {{ template "fragments/multiline-select" }} 99 72 </section> 73 + {{ range .FileParams }} 74 + {{ template "strings/fragments/file" . }} 75 + {{ end }} 76 + {{ template "fragments/multiline-select" }} 100 77 <div class="flex flex-col gap-4 mt-4"> 101 78 {{ 102 79 template "fragments/comment/commentList"
+2 -4
appview/pages/templates/strings/timeline.html
··· 31 31 <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 32 <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 33 <span class="select-none">/</span> 34 - <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .RenderTitle }}</a> 35 35 </div> 36 36 {{ with .Description }} 37 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 46 46 {{ define "stringCardInfo" }} 47 47 {{ $stat := .Stats }} 48 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span>{{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}}</span> 50 50 <span class="select-none [&:before]:content-['·']"></span> 51 51 {{ with .Edited }} 52 52 <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> ··· 55 55 {{ end }} 56 56 </div> 57 57 {{ end }} 58 - 59 -
+2 -2
appview/pages/templates/user/strings.html
··· 25 25 {{ $s := index . 1 }} 26 26 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 27 27 <div class="font-medium dark:text-white flex gap-2 items-center"> 28 - <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 28 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.RenderTitle }}</a> 29 29 </div> 30 30 {{ with $s.Description }} 31 31 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 35 35 36 36 {{ $stat := $s.Stats }} 37 37 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 38 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 38 + <span>{{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}}</span> 39 39 <span class="select-none [&:before]:content-['·']"></span> 40 40 {{ with $s.Edited }} 41 41 <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+3
appview/state/profile.go
··· 409 409 Strings: strings, 410 410 Card: profile, 411 411 }) 412 + if err != nil { 413 + l.Error("failed to render", "err", err) 414 + } 412 415 } 413 416 414 417 func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) {
+6 -6
appview/state/router.go
··· 334 334 logger := log.SubLogger(s.logger, "strings") 335 335 336 336 strs := &avstrings.Strings{ 337 - Db: s.db, 338 - OAuth: s.oauth, 339 - Pages: s.pages, 340 - IdResolver: s.idResolver, 341 - Notifier: s.notifier, 342 - Logger: logger, 337 + Db: s.db, 338 + OAuth: s.oauth, 339 + Pages: s.pages, 340 + Dir: s.idResolver.Directory(), 341 + Notifier: s.notifier, 342 + Logger: logger, 343 343 } 344 344 345 345 return strs.Router(mw)
+340 -146
appview/strings/strings.go
··· 1 - package strings 1 + package stringn 2 2 3 3 import ( 4 + "bytes" 5 + "context" 6 + "database/sql" 7 + "errors" 4 8 "fmt" 9 + "io" 5 10 "log/slog" 6 11 "net/http" 7 - "path" 8 12 "strconv" 13 + "strings" 9 14 "time" 10 15 11 16 "tangled.org/core/api/tangled" ··· 16 21 "tangled.org/core/appview/oauth" 17 22 "tangled.org/core/appview/pages" 18 23 "tangled.org/core/appview/pages/markup" 19 - "tangled.org/core/idresolver" 20 24 "tangled.org/core/orm" 21 25 "tangled.org/core/tid" 22 26 27 + "github.com/bluesky-social/indigo/api/agnostic" 23 28 "github.com/bluesky-social/indigo/api/atproto" 24 29 "github.com/bluesky-social/indigo/atproto/identity" 25 30 "github.com/bluesky-social/indigo/atproto/syntax" 31 + "github.com/bluesky-social/indigo/xrpc" 26 32 "github.com/go-chi/chi/v5" 27 33 28 34 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 30 36 ) 31 37 32 38 type Strings struct { 33 - Db *db.DB 34 - OAuth *oauth.OAuth 35 - Pages *pages.Pages 36 - IdResolver *idresolver.Resolver 37 - Logger *slog.Logger 38 - Notifier notify.Notifier 39 + Db *db.DB 40 + OAuth *oauth.OAuth 41 + Pages *pages.Pages 42 + Dir identity.Directory 43 + Logger *slog.Logger 44 + Notifier notify.Notifier 39 45 } 40 46 41 47 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 43 49 44 50 r. 45 51 Get("/", s.timeline) 52 + r. 53 + Get("/fileEdit", s.FileEditFragment) 46 54 47 55 r. 48 - With(mw.ResolveIdent()). 49 56 Route("/{user}", func(r chi.Router) { 50 57 r.Get("/", s.dashboard) 51 58 52 59 r.Route("/{rkey}", func(r chi.Router) { 53 - r.Get("/", s.contents) 60 + r.Use(mw.ResolveIdent()) 61 + r.Use(s.resolveString) 62 + 63 + r.Get("/", s.SingleString) 54 64 r.Delete("/", s.delete) 55 - r.Get("/raw", s.contents) 56 65 r.Get("/edit", s.edit) 57 66 r.Post("/edit", s.edit) 67 + 68 + r.Get("/{cid}/{filename}", s.FileFragment) 69 + r.Get("/{cid}/{filename}/raw", s.FileRaw) 70 + 71 + // legacy endpoint 72 + r.Get("/raw", s.redirectToFirstFileRaw) 58 73 }) 59 74 }) 60 75 ··· 68 83 return r 69 84 } 70 85 86 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 87 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 88 + } 89 + 71 90 func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 72 91 l := s.Logger.With("handler", "timeline") 73 92 ··· 84 103 }) 85 104 } 86 105 87 - func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 88 - l := s.Logger.With("handler", "contents") 106 + type stringCtxKey struct{} 89 107 90 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 91 - if !ok { 92 - l.Error("malformed middleware") 93 - w.WriteHeader(http.StatusInternalServerError) 94 - return 95 - } 96 - l = l.With("did", id.DID, "handle", id.Handle) 108 + func (s *Strings) resolveString(next http.Handler) http.Handler { 109 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 + l := s.Logger.With("middleware", "resolveString") 111 + rkey := chi.URLParam(r, "rkey") 97 112 98 - rkey := chi.URLParam(r, "rkey") 99 - if rkey == "" { 100 - l.Error("malformed url, empty rkey") 101 - w.WriteHeader(http.StatusBadRequest) 102 - return 103 - } 104 - l = l.With("rkey", rkey) 113 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 114 + if !ok { 115 + l.Error("malformed middleware") 116 + w.WriteHeader(http.StatusInternalServerError) 117 + return 118 + } 119 + 120 + string, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey)) 121 + if errors.Is(err, sql.ErrNoRows) { 122 + s.Pages.Error404(w) 123 + return 124 + } else if err != nil { 125 + l.Error("failed to fetch string", "err", err) 126 + w.WriteHeader(http.StatusInternalServerError) 127 + return 128 + } 129 + 130 + ctx := context.WithValue(r.Context(), stringCtxKey{}, string) 131 + next.ServeHTTP(w, r.WithContext(ctx)) 132 + }) 133 + } 134 + 135 + func stringFromContext(ctx context.Context) (models.String, bool) { 136 + str, ok := ctx.Value(stringCtxKey{}).(models.String) 137 + return str, ok 138 + } 105 139 106 - strings, err := db.GetStrings( 107 - s.Db, 108 - 0, 109 - orm.FilterEq("did", id.DID), 110 - orm.FilterEq("rkey", rkey), 111 - ) 112 - if err != nil { 113 - l.Error("failed to fetch string", "err", err) 114 - w.WriteHeader(http.StatusInternalServerError) 115 - return 116 - } 117 - if len(strings) < 1 { 118 - l.Error("string not found") 140 + // redirectToFirstFileRaw is a handle for legacy endpoint. 141 + // It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw 142 + func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) { 143 + str, ok := stringFromContext(r.Context()) 144 + if !ok { 145 + s.Logger.Error("malformed middleware. string missing") 119 146 s.Pages.Error404(w) 120 147 return 121 148 } 122 - if len(strings) != 1 { 123 - l.Error("incorrect number of records returned", "len(strings)", len(strings)) 124 - w.WriteHeader(http.StatusInternalServerError) 125 - return 149 + var cid syntax.CID 150 + var filename string 151 + if str.Cid != nil { 152 + cid = *str.Cid 153 + } else { 154 + var err error 155 + cid, err = s.getRecordCid(r.Context(), str.AtUri()) 156 + if err != nil { 157 + s.Pages.Error404(w) 158 + return 159 + } 160 + } 161 + if str.IsLegacySingleFile() { 162 + filename = str.FileName 163 + } else { 164 + filename = str.Files[0].Name 126 165 } 127 - string := strings[0] 166 + http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound) 167 + } 128 168 129 - if path.Base(r.URL.Path) == "raw" { 130 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 131 - if string.Filename != "" { 132 - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 133 - } 134 - w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 169 + func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) { 170 + l := s.Logger.With("handler", "SingleString") 171 + ctx := r.Context() 135 172 136 - _, err = w.Write([]byte(string.Contents)) 137 - if err != nil { 138 - l.Error("failed to write raw response", "err", err) 139 - } 173 + string, ok := stringFromContext(ctx) 174 + if !ok { 175 + l.Error("malformed middleware. string missing") 176 + s.Pages.Error404(w) 140 177 return 141 178 } 142 179 143 - var showRendered, renderToggle bool 144 - if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 145 - renderToggle = true 146 - showRendered = r.URL.Query().Get("code") != "true" 147 - } 148 - 149 - stringUri := string.AtUri().String() 150 - starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, stringUri) 180 + starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, string.AtUri().String()) 151 181 if err != nil { 152 182 l.Error("failed to get star count", "err", err) 153 183 } 154 184 user := s.OAuth.GetMultiAccountUser(r) 155 185 isStarred := false 156 186 if user != nil { 157 - isStarred = db.GetStarStatus(s.Db, user.Did, stringUri) 187 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri().String()) 158 188 } 159 189 160 190 comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) ··· 191 221 } 192 222 } 193 223 224 + var files []pages.StringFileFragmentParams 225 + 226 + if string.IsLegacySingleFile() { 227 + files = []pages.StringFileFragmentParams{ 228 + s.makeFileFragmentParams(&string, string.FileName, string.FileContent, false), 229 + } 230 + } else { 231 + files = make([]pages.StringFileFragmentParams, len(string.Files)) 232 + for i, file := range string.Files { 233 + // TODO: read blob 234 + content := "" 235 + files[i] = s.makeFileFragmentParams(&string, file.Name, content, false) 236 + } 237 + } 238 + 194 239 err = s.Pages.SingleString(w, pages.SingleStringParams{ 195 240 BaseParams: pages.BaseParamsFromContext(r.Context()), 196 - RenderToggle: renderToggle, 197 - ShowRendered: showRendered, 198 241 String: &string, 199 - Stats: string.Stats(), 242 + FileParams: files, 200 243 IsStarred: isStarred, 201 244 StarCount: starCount, 202 - Owner: id, 203 245 CommentList: models.NewCommentList(comments), 204 246 205 247 Reactions: reactions, 206 248 UserReacted: userReactions, 207 249 VouchRelationships: vouchRelationships, 208 250 }) 209 - } 210 - 211 - func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 212 - http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 251 + if err != nil { 252 + l.Error("failed to render", "err", err) 253 + } 213 254 } 214 255 215 256 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 216 257 l := s.Logger.With("handler", "edit") 258 + ctx := r.Context() 217 259 218 260 user := s.OAuth.GetMultiAccountUser(r) 219 261 220 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 262 + oldString, ok := stringFromContext(ctx) 221 263 if !ok { 222 - l.Error("malformed middleware") 223 - w.WriteHeader(http.StatusInternalServerError) 224 - return 225 - } 226 - l = l.With("did", id.DID, "handle", id.Handle) 227 - 228 - rkey := chi.URLParam(r, "rkey") 229 - if rkey == "" { 230 - l.Error("malformed url, empty rkey") 231 - w.WriteHeader(http.StatusBadRequest) 232 - return 233 - } 234 - l = l.With("rkey", rkey) 235 - 236 - // get the string currently being edited 237 - all, err := db.GetStrings( 238 - s.Db, 239 - 0, 240 - orm.FilterEq("did", id.DID), 241 - orm.FilterEq("rkey", rkey), 242 - ) 243 - if err != nil { 244 - l.Error("failed to fetch string", "err", err) 245 - w.WriteHeader(http.StatusInternalServerError) 246 - return 247 - } 248 - if len(all) != 1 { 249 - l.Error("incorrect number of records returned", "len(strings)", len(all)) 250 - w.WriteHeader(http.StatusInternalServerError) 264 + l.Error("malformed middleware. string missing") 265 + s.Pages.Error404(w) 251 266 return 252 267 } 253 - first := all[0] 254 268 255 269 // verify that the logged in user owns this string 256 - if user.Did != id.DID.String() { 257 - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 270 + if user.Did != oldString.Did.String() { 271 + l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) 258 272 w.WriteHeader(http.StatusUnauthorized) 259 273 return 260 274 } ··· 262 276 switch r.Method { 263 277 case http.MethodGet: 264 278 // return the form with prefilled fields 265 - s.Pages.PutString(w, pages.PutStringParams{ 279 + var files []pages.StringFileEditFragmentParams 280 + if oldString.IsLegacySingleFile() { 281 + files = []pages.StringFileEditFragmentParams{ 282 + { 283 + Name: oldString.FileName, 284 + Content: oldString.FileContent, 285 + Size: uint64(len(oldString.FileContent)), 286 + }, 287 + } 288 + } else { 289 + files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) 290 + for i, file := range oldString.Files { 291 + // TODO: read blob 292 + content := "" 293 + files[i] = pages.StringFileEditFragmentParams{ 294 + Name: file.Name, 295 + Content: content, 296 + Size: uint64(file.Blob.Size), 297 + } 298 + } 299 + } 300 + err := s.Pages.EditString(w, pages.EditStringParams{ 266 301 BaseParams: pages.BaseParamsFromContext(r.Context()), 267 - Action: "edit", 268 - String: first, 302 + String: oldString, 303 + FileParams: files, 269 304 }) 305 + if err != nil { 306 + l.Error("failed to render", "err", err) 307 + } 270 308 case http.MethodPost: 271 309 fail := func(msg string, err error) { 272 310 l.Error(msg, "err", err) 273 311 s.Pages.Notice(w, "error", msg) 274 312 } 275 313 314 + var title *string 315 + if val := r.FormValue("title"); val != "" { 316 + title = &val 317 + } 318 + 319 + var description *string 320 + if val := r.FormValue("description"); val != "" { 321 + description = &val 322 + } 323 + 276 324 filename := r.FormValue("filename") 277 325 if filename == "" { 278 326 fail("Empty filename.", nil) ··· 281 329 282 330 content := r.FormValue("content") 283 331 if content == "" { 284 - fail("Empty contents.", nil) 332 + fail("Empty content.", nil) 285 333 return 286 334 } 287 335 288 - description := r.FormValue("description") 289 - 290 - // construct new string from form values 291 - entry := models.String{ 292 - Did: first.Did, 293 - Rkey: first.Rkey, 294 - Filename: filename, 295 - Description: description, 296 - Contents: content, 297 - Created: first.Created, 298 - } 336 + newString := oldString 337 + newString.Title = title 338 + newString.Description = description 339 + newString.FileName = filename 340 + newString.FileContent = content 299 341 300 342 client, err := s.OAuth.AuthorizedClient(r) 301 343 if err != nil { ··· 305 347 306 348 // first replace the existing record in the PDS 307 349 var exCid string 308 - if entry.Cid != nil { 309 - exCid = entry.Cid.String() 350 + if newString.Cid != nil { 351 + exCid = oldString.Cid.String() 310 352 } else { 311 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey.String()) 353 + cid, err := s.getRecordCid(ctx, oldString.AtUri()) 312 354 if err != nil { 313 - fail("Failed to get existing record.", err) 314 - return 315 - } 316 - if ex.Cid == nil { 317 - fail("Failed to get existing record.", err) 355 + s.Pages.Error404(w) 318 356 return 319 357 } 320 - exCid = *ex.Cid 358 + exCid = cid.String() 321 359 } 322 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 360 + resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 323 361 Collection: tangled.StringNSID, 324 - Repo: entry.Did.String(), 325 - Rkey: entry.Rkey.String(), 362 + Repo: newString.Did.String(), 363 + Rkey: newString.Rkey.String(), 326 364 SwapRecord: &exCid, 327 - Record: &lexutil.LexiconTypeDecoder{Val: entry.AsRecord()}, 365 + Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 328 366 }) 329 367 if err != nil { 330 368 fail("Failed to updated existing record.", err) 331 369 return 332 370 } 333 - l := l.With("aturi", resp.Uri) 371 + l = l.With("aturi", resp.Uri) 334 372 l.Info("edited string") 335 373 374 + newString.Cid = new(syntax.CID) 375 + *newString.Cid = syntax.CID(resp.Cid) 376 + 336 377 // if that went okay, updated the db 337 - if err = db.AddString(s.Db, entry); err != nil { 378 + if err = db.AddString(s.Db, newString); err != nil { 338 379 fail("Failed to update string.", err) 339 380 return 340 381 } 341 382 342 - s.Notifier.EditString(r.Context(), &entry) 383 + s.Notifier.EditString(ctx, &newString) 343 384 344 385 // if that went okay, redir to the string 345 - s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", entry.Did, entry.Rkey)) 386 + s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 346 387 } 347 388 348 389 } ··· 353 394 354 395 switch r.Method { 355 396 case http.MethodGet: 356 - s.Pages.PutString(w, pages.PutStringParams{ 397 + err := s.Pages.NewString(w, pages.NewStringParams{ 357 398 BaseParams: pages.BaseParamsFromContext(r.Context()), 358 - Action: "new", 359 399 }) 400 + if err != nil { 401 + l.Error("failed to render", "err", err) 402 + } 360 403 case http.MethodPost: 361 404 fail := func(msg string, err error) { 362 405 l.Error(msg, "err", err) 363 406 s.Pages.Notice(w, "error", msg) 364 407 } 365 408 409 + var title *string 410 + if val := r.FormValue("title"); val != "" { 411 + title = &val 412 + } 413 + 414 + var description *string 415 + if val := r.FormValue("description"); val != "" { 416 + description = &val 417 + } 418 + 366 419 filename := r.FormValue("filename") 367 420 if filename == "" { 368 421 fail("Empty filename.", nil) ··· 371 424 372 425 content := r.FormValue("content") 373 426 if content == "" { 374 - fail("Empty contents.", nil) 427 + fail("Empty content.", nil) 375 428 return 376 429 } 377 430 378 - description := r.FormValue("description") 379 - 380 431 string := models.String{ 381 432 Did: syntax.DID(user.Did), 382 433 Rkey: syntax.RecordKey(tid.TID()), 383 - Filename: filename, 434 + Title: title, 384 435 Description: description, 385 - Contents: content, 436 + FileName: filename, 437 + FileContent: content, 386 438 Created: time.Now(), 387 439 } 388 440 ··· 419 471 } 420 472 421 473 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 422 - l := s.Logger.With("handler", "create") 474 + l := s.Logger.With("handler", "delete") 423 475 user := s.OAuth.GetMultiAccountUser(r) 424 476 fail := func(msg string, err error) { 425 477 l.Error(msg, "err", err) ··· 475 527 476 528 s.Pages.HxRedirect(w, "/strings/"+user.Did) 477 529 } 530 + 531 + // FileRaw renders raw file in that specific CID. (strong cache policy) 532 + func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { 533 + l := s.Logger.With("handler", "FileRaw") 534 + ctx := r.Context() 535 + 536 + string, ok := stringFromContext(ctx) 537 + if !ok { 538 + l.Error("malformed middleware. string missing") 539 + s.Pages.Error404(w) 540 + return 541 + } 542 + filename := chi.URLParam(r, "filename") 543 + 544 + if string.IsLegacySingleFile() { 545 + if filename != string.FileName { 546 + http.NotFound(w, r) 547 + return 548 + } 549 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 550 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 551 + w.Header().Set("Content-Length", strconv.Itoa(len(string.FileContent))) 552 + _, err := w.Write([]byte(string.FileContent)) 553 + if err != nil { 554 + l.Error("failed to write raw response", "err", err) 555 + } 556 + } else { 557 + file, ok := string.FileByName(filename) 558 + if !ok { 559 + http.NotFound(w, r) 560 + return 561 + } 562 + 563 + content := "" 564 + 565 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 566 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 567 + w.Header().Set("Content-Length", strconv.FormatInt(file.Blob.Size, 10)) 568 + _, err := w.Write([]byte(content)) 569 + if err != nil { 570 + l.Error("failed to write raw response", "err", err) 571 + } 572 + } 573 + } 574 + 575 + func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { 576 + size := len(content) 577 + if size > 8*1024*1024 { // 8 MB 578 + // TODO: show "file too big" page 579 + } 580 + 581 + buf, _ := io.ReadAll(strings.NewReader(content)) 582 + 583 + var lineCount int 584 + var hasNoTrailingEOL bool 585 + if size > 0 { 586 + hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) 587 + lineCount = bytes.Count(buf, []byte{'\n'}) 588 + if hasNoTrailingEOL { 589 + lineCount++ 590 + } 591 + } 592 + 593 + format := markup.GetFormat(filename) 594 + isMarkup := format == markup.FormatMarkdown 595 + 596 + return pages.StringFileFragmentParams{ 597 + String: string, 598 + Name: filename, 599 + Content: content, 600 + 601 + LineCount: lineCount, 602 + Size: uint64(size), 603 + HasNoTrailingEOL: hasNoTrailingEOL, 604 + HasRenderedToggle: isMarkup, 605 + ShowingRendered: isMarkup, 606 + } 607 + } 608 + 609 + // render each string "file" html fragment 610 + func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { 611 + l := s.Logger.With("handler", "FileFragment") 612 + ctx := r.Context() 613 + 614 + string, ok := stringFromContext(ctx) 615 + if !ok { 616 + l.Error("malformed middleware. string missing") 617 + http.NotFound(w, r) 618 + return 619 + } 620 + filename := chi.URLParam(r, "filename") 621 + forceCode := r.URL.Query().Get("code") == "true" 622 + 623 + var params pages.StringFileFragmentParams 624 + if string.IsLegacySingleFile() { 625 + if filename != string.FileName { 626 + http.NotFound(w, r) 627 + return 628 + } 629 + params = s.makeFileFragmentParams(&string, string.FileName, string.FileContent, forceCode) 630 + } else { 631 + file, ok := string.FileByName(filename) 632 + if !ok { 633 + l.Error("malformed middleware. string missing") 634 + http.NotFound(w, r) 635 + return 636 + } 637 + 638 + // TODO: read blob 639 + content := "" 640 + 641 + params = s.makeFileFragmentParams(&string, file.Name, content, forceCode) 642 + } 643 + s.Pages.StringFileFragment(w, params) 644 + } 645 + 646 + func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { 647 + s.Pages.StringFileEditFragment(w) 648 + } 649 + 650 + func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { 651 + ident, err := s.Dir.Lookup(ctx, uri.Authority()) 652 + if err != nil { 653 + return "", err 654 + } 655 + 656 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 657 + out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 658 + if err != nil { 659 + return "", err 660 + } 661 + if out.Cid == nil { 662 + return "", fmt.Errorf("record CID is empty") 663 + } 664 + 665 + cid, err := syntax.ParseCID(*out.Cid) 666 + if err != nil { 667 + return "", err 668 + } 669 + 670 + return cid, nil 671 + }
-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 - }