domain.go (8620B)
1 // Package domain contains pure application behavior shared by the TUI screens. 2 package domain 3 4 import ( 5 "regexp" 6 "sort" 7 "strings" 8 "time" 9 "unicode" 10 "unicode/utf8" 11 12 "git.ryansepassi.com/git/gotextlog.git/internal/textlog" 13 ) 14 15 const ( 16 MaxPostCharacters = 280 17 MaxPostLines = 10 18 ) 19 20 // CodePointLength follows JavaScript's Array.from string length semantics for 21 // valid UTF-8: a Unicode code point counts as one character. 22 func CodePointLength(value string) int { 23 return utf8.RuneCountInString(value) 24 } 25 26 // ValidatePost returns a user-facing problem, or an empty string when valid. 27 func ValidatePost(value string) string { 28 if strings.TrimFunc(value, func(character rune) bool { 29 return unicode.IsSpace(character) || character == '\uFEFF' 30 }) == "" { 31 return "Write something first" 32 } 33 if CodePointLength(value) > MaxPostCharacters { 34 return "Notes can be at most 280 characters" 35 } 36 if strings.Count(value, "\n")+1 > MaxPostLines { 37 return "Notes can be at most 10 lines" 38 } 39 return "" 40 } 41 42 // RelativeTime formats an RFC 3339 timestamp relative to now. 43 func RelativeTime(value string, now time.Time) string { 44 created, err := time.Parse(time.RFC3339Nano, value) 45 if err != nil { 46 return "" 47 } 48 seconds := int64(now.Sub(created).Seconds()) 49 if seconds < 0 { 50 seconds = 0 51 } 52 switch { 53 case seconds < 60: 54 return formatInteger(seconds) + "s" 55 case seconds < 60*60: 56 return formatInteger(seconds/60) + "m" 57 case seconds < 24*60*60: 58 return formatInteger(seconds/(60*60)) + "h" 59 case seconds < 30*24*60*60: 60 return formatInteger(seconds/(24*60*60)) + "d" 61 default: 62 return created.UTC().Format("2006-01-02") 63 } 64 } 65 66 func formatInteger(value int64) string { 67 if value == 0 { 68 return "0" 69 } 70 var digits [20]byte 71 index := len(digits) 72 for value > 0 { 73 index-- 74 digits[index] = byte(value%10) + '0' 75 value /= 10 76 } 77 return string(digits[index:]) 78 } 79 80 // Redact replaces every occurrence of token without changing tokenless text. 81 func Redact(value, token string) string { 82 if token == "" { 83 return value 84 } 85 return strings.ReplaceAll(value, token, "[redacted]") 86 } 87 88 type TokenKind string 89 90 const ( 91 TokenText TokenKind = "text" 92 TokenCode TokenKind = "code" 93 TokenLink TokenKind = "link" 94 TokenReference TokenKind = "reference" 95 ) 96 97 type RichToken struct { 98 Kind TokenKind 99 Text string 100 URL string 101 } 102 103 const ( 104 inlineCodePattern = "`[^`\n]+`" 105 javascriptWhitespace = `\t\n\v\f\r \x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}` 106 markdownPattern = `\[(?:\\[^\n]|[^\]\\\n])+\]\((?:https?://)?[^` + javascriptWhitespace + `)]+\)` 107 webURLPattern = `https?://[^` + javascriptWhitespace + `]+` 108 domainPattern = `(?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z]{2,})(?:/[^` + javascriptWhitespace + `]*)?` 109 referencePattern = `[#@][A-Za-z0-9_]+` 110 ) 111 112 var ( 113 richTextPattern = regexp.MustCompile("(?i)(" + strings.Join([]string{ 114 inlineCodePattern, 115 markdownPattern, 116 webURLPattern, 117 domainPattern, 118 referencePattern, 119 }, "|") + ")") 120 markdownLinkPattern = regexp.MustCompile(`^\[((?:\\[^\n]|[^\]\\\n])+)\]\(([^)]+)\)$`) 121 asciiArtBodyPattern = regexp.MustCompile(`(?i)(^|[` + javascriptWhitespace + `])#(?:ascii|ascii_art)\b`) 122 markdownUnescaper = strings.NewReplacer(`\\`, `\`, `\[`, `[`, `\]`, `]`) 123 ) 124 125 // TokenizeRichText recognizes inline code, markdown links, bare web URLs, 126 // domain names, mentions, and hashtags. Inline code wins over link parsing. 127 func TokenizeRichText(body string) []RichToken { 128 matches := richTextPattern.FindAllStringIndex(body, -1) 129 if len(matches) == 0 { 130 if body == "" { 131 return []RichToken{} 132 } 133 return []RichToken{{Kind: TokenText, Text: body}} 134 } 135 136 tokens := make([]RichToken, 0, len(matches)*2+1) 137 offset := 0 138 for _, match := range matches { 139 if match[0] > offset { 140 tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:match[0]]}) 141 } 142 value := body[match[0]:match[1]] 143 switch { 144 case strings.HasPrefix(value, "`"): 145 tokens = append(tokens, RichToken{Kind: TokenCode, Text: value}) 146 case strings.HasPrefix(value, "["): 147 parts := markdownLinkPattern.FindStringSubmatch(value) 148 if len(parts) != 3 { 149 tokens = append(tokens, RichToken{Kind: TokenText, Text: value}) 150 break 151 } 152 tokens = append(tokens, RichToken{ 153 Kind: TokenLink, 154 Text: markdownUnescaper.Replace(parts[1]), 155 URL: absoluteWebURL(parts[2]), 156 }) 157 case value[0] == '#' || value[0] == '@': 158 tokens = append(tokens, RichToken{Kind: TokenReference, Text: value}) 159 default: 160 tokens = append(tokens, RichToken{Kind: TokenLink, Text: value, URL: absoluteWebURL(value)}) 161 } 162 offset = match[1] 163 } 164 if offset < len(body) { 165 tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:]}) 166 } 167 return tokens 168 } 169 170 func absoluteWebURL(value string) string { 171 lower := strings.ToLower(value) 172 if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { 173 return value 174 } 175 return "https://" + value 176 } 177 178 // TerminalSafeText normalizes line endings and tab stops and removes emoji 179 // skin-tone modifiers that some terminals incorrectly render as wide glyphs. 180 func TerminalSafeText(value string) string { 181 value = strings.ReplaceAll(value, "\r\n", "\n") 182 value = strings.ReplaceAll(value, "\r", "\n") 183 184 const tabWidth = 4 185 var safe strings.Builder 186 safe.Grow(len(value)) 187 column := 0 188 for _, character := range value { 189 switch { 190 case character == '\n': 191 safe.WriteRune(character) 192 column = 0 193 case character >= '\U0001F3FB' && character <= '\U0001F3FF': 194 // Intentionally omitted from terminal output. 195 case character == '\t': 196 spaces := tabWidth - column%tabWidth 197 safe.WriteString(strings.Repeat(" ", spaces)) 198 column += spaces 199 default: 200 safe.WriteRune(character) 201 column++ 202 } 203 } 204 return safe.String() 205 } 206 207 // IsASCIIArt reports whether tags or an in-body hashtag request literal mode. 208 func IsASCIIArt(body string, tags []string) bool { 209 for _, tag := range tags { 210 tag = strings.ToLower(strings.TrimPrefix(tag, "#")) 211 if tag == "ascii" || tag == "ascii_art" { 212 return true 213 } 214 } 215 return asciiArtBodyPattern.MatchString(body) 216 } 217 218 func IsASCIIArtPost(post textlog.Post) bool { 219 return IsASCIIArt(post.Body, post.Tags) 220 } 221 222 // ClampSelection keeps a list selection in range, with zero representing an 223 // empty list as well as its first item. 224 func ClampSelection(current, length int) int { 225 if current < 0 || length <= 0 { 226 return 0 227 } 228 if current >= length { 229 return length - 1 230 } 231 return current 232 } 233 234 type ThreadItem struct { 235 Post textlog.Post 236 Depth int 237 MoreCount int 238 } 239 240 // BuildReplyThread orders each set of siblings from oldest to newest and 241 // places a reply's descendants immediately after it. Orphans remain visible, 242 // and duplicate IDs and cycles cannot duplicate posts or recurse indefinitely. 243 func BuildReplyThread(root textlog.Post, replies []textlog.Reply) []ThreadItem { 244 type candidate struct { 245 reply textlog.Reply 246 index int 247 } 248 byParent := make(map[int][]candidate) 249 unique := make([]candidate, 0, len(replies)) 250 known := map[int]bool{root.ID: true} 251 for i, reply := range replies { 252 if known[reply.ID] { 253 continue 254 } 255 known[reply.ID] = true 256 item := candidate{reply: reply, index: i} 257 unique = append(unique, item) 258 if reply.ParentID != nil { 259 byParent[*reply.ParentID] = append(byParent[*reply.ParentID], item) 260 } 261 } 262 older := func(left, right candidate) bool { 263 leftTime, rightTime := left.reply.CreatedAt, right.reply.CreatedAt 264 if leftTime.Equal(rightTime) { 265 return left.index < right.index 266 } 267 return leftTime.Before(rightTime) 268 } 269 sort.SliceStable(unique, func(i, j int) bool { return older(unique[i], unique[j]) }) 270 for parentID := range byParent { 271 children := byParent[parentID] 272 sort.SliceStable(children, func(i, j int) bool { return older(children[i], children[j]) }) 273 byParent[parentID] = children 274 } 275 276 thread := []ThreadItem{{Post: root}} 277 emitted := map[int]bool{root.ID: true} 278 var appendReply func(candidate, int) 279 appendReply = func(item candidate, depth int) { 280 if emitted[item.reply.ID] { 281 return 282 } 283 emitted[item.reply.ID] = true 284 if depth < 1 { 285 depth = 1 286 } 287 moreCount := 0 288 if len(byParent[item.reply.ID]) == 0 { 289 moreCount = item.reply.ReplyCount 290 } 291 thread = append(thread, ThreadItem{Post: item.reply.Post, Depth: depth, MoreCount: moreCount}) 292 for _, child := range byParent[item.reply.ID] { 293 appendReply(child, depth+1) 294 } 295 } 296 for _, child := range byParent[root.ID] { 297 appendReply(child, 1) 298 } 299 for _, item := range unique { 300 depth := item.reply.Depth 301 if depth < 1 { 302 depth = 1 303 } 304 appendReply(item, depth) 305 } 306 return thread 307 }