gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

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 }