gotextlog

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

commit ec965c776c93720048a0cca1b9620e8b314a5324
parent 823a0659215cc8f1b1277dab06161462457fdc31
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 25 Aug 2026 21:32:28 -0700

Implement configuration and domain helpers

Diffstat:
Ainternal/config/config.go | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/config/config_test.go | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/domain/domain.go | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/domain/domain_test.go | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 813 insertions(+), 0 deletions(-)

diff --git a/internal/config/config.go b/internal/config/config.go @@ -0,0 +1,178 @@ +package config + +import ( + "encoding/json" + "errors" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" +) + +const DefaultURL = "https://textlog.cc" + +type Theme string + +const ( + ThemeAuto Theme = "auto" + ThemeLight Theme = "light" + ThemeDark Theme = "dark" +) + +type FeedKind string + +const ( + FeedForYou FeedKind = "for-you" + FeedToMe FeedKind = "to-me" + FeedHot FeedKind = "hot" + FeedLatest FeedKind = "latest" +) + +type Config struct { + BaseURL string `json:"baseUrl"` + Theme Theme `json:"theme"` + Token string `json:"token,omitempty"` + LastTab FeedKind `json:"lastTab,omitempty"` +} + +func NormalizeBaseURL(value string) (string, error) { + value = strings.TrimSpace(value) + u, err := url.Parse(value) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", errors.New("Textlog URL must be a valid HTTP(S) URL") + } + u.Scheme = strings.ToLower(u.Scheme) + if u.Scheme != "http" && u.Scheme != "https" { + return "", errors.New("Textlog URL must use HTTP or HTTPS") + } + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" { + return "", errors.New("Textlog URL cannot include a query or fragment") + } + u.Host = strings.ToLower(u.Host) + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = strings.TrimRight(u.RawPath, "/") + return strings.TrimSuffix(u.String(), "/"), nil +} + +func ConfigPath(env map[string]string) string { + if env == nil { + env = Environment() + } + home := env["HOME"] + if home == "" { + home, _ = os.UserHomeDir() + } + return configPathFor(runtime.GOOS, home, env) +} +func configPathFor(goos, home string, env map[string]string) string { + switch goos { + case "windows": + root := env["APPDATA"] + if root == "" { + root = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(root, "textlog", "config.json") + case "darwin": + return filepath.Join(home, "Library", "Application Support", "textlog", "config.json") + default: + root := env["XDG_CONFIG_HOME"] + if root == "" { + root = filepath.Join(home, ".config") + } + return filepath.Join(root, "textlog", "config.json") + } +} +func validTheme(t Theme) bool { return t == ThemeAuto || t == ThemeLight || t == ThemeDark } +func validTab(t FeedKind) bool { + return t == FeedForYou || t == FeedToMe || t == FeedHot || t == FeedLatest +} +func Load(env map[string]string) (Config, error) { + if env == nil { + env = Environment() + } + var stored Config + if data, err := os.ReadFile(ConfigPath(env)); err == nil { + var decoded Config + if json.Unmarshal(data, &decoded) == nil { + stored = decoded + } + } + base := stored.BaseURL + if base == "" { + base = DefaultURL + } + if env["TEXTLOG_URL"] != "" { + base = env["TEXTLOG_URL"] + } + var err error + base, err = NormalizeBaseURL(base) + if err != nil { + return Config{}, err + } + theme := stored.Theme + if theme == "" { + theme = ThemeAuto + } + if env["TEXTLOG_THEME"] != "" { + theme = Theme(env["TEXTLOG_THEME"]) + } + if _, ok := env["NO_COLOR"]; ok { + theme = ThemeLight + } + if !validTheme(theme) { + theme = ThemeAuto + } + token := stored.Token + if env["TEXTLOG_TOKEN"] != "" { + token = env["TEXTLOG_TOKEN"] + } + last := stored.LastTab + if !validTab(last) { + last = "" + } + return Config{BaseURL: base, Theme: theme, Token: token, LastTab: last}, nil +} +func Save(cfg Config, env map[string]string) error { + path := ConfigPath(env) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp, err := os.CreateTemp(filepath.Dir(path), "config-*.tmp") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err = tmp.Write(data); err == nil { + err = tmp.Sync() + } + if closeErr := tmp.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err = os.Rename(name, path); err != nil { + return err + } + return os.Chmod(path, 0600) +} +func Environment() map[string]string { + out := map[string]string{} + for _, item := range os.Environ() { + if k, v, ok := strings.Cut(item, "="); ok { + out[k] = v + } + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go @@ -0,0 +1,197 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestConfigLifecycleAndEnvironmentPrecedence(t *testing.T) { + home := t.TempDir() + env := map[string]string{"HOME": home} + if runtime.GOOS == "windows" { + env["APPDATA"] = filepath.Join(home, "roaming") + } else if runtime.GOOS != "darwin" { + env["XDG_CONFIG_HOME"] = filepath.Join(home, "xdg") + } + + stored := Config{ + BaseURL: "https://stored.example", + Theme: ThemeDark, + Token: "stored-secret", + LastTab: FeedToMe, + } + if err := Save(stored, env); err != nil { + t.Fatalf("Save: %v", err) + } + + path := ConfigPath(env) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%q): %v", path, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("config mode = %04o, want 0600", info.Mode().Perm()) + } + + loaded, err := Load(map[string]string{ + "HOME": home, + "APPDATA": env["APPDATA"], + "XDG_CONFIG_HOME": env["XDG_CONFIG_HOME"], + "TEXTLOG_URL": "https://override.example/", + "TEXTLOG_TOKEN": "environment-secret", + "TEXTLOG_THEME": "light", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := Config{ + BaseURL: "https://override.example", + Theme: ThemeLight, + Token: "environment-secret", + LastTab: FeedToMe, + } + if loaded != want { + t.Fatalf("Load = %#v, want %#v", loaded, want) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var unchanged Config + if err := json.Unmarshal(data, &unchanged); err != nil { + t.Fatalf("saved JSON: %v", err) + } + if unchanged != stored { + t.Fatalf("environment changed stored config to %#v", unchanged) + } + if !strings.HasSuffix(string(data), "\n") { + t.Fatalf("saved JSON has no trailing newline: %q", data) + } +} + +func TestLoadDefaultsAndSanitizesStoredSelections(t *testing.T) { + home := t.TempDir() + env := isolatedEnv(home) + path := ConfigPath(env) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"baseUrl":"https://stored.example/","theme":"sepia","token":"saved","lastTab":"live"}`), 0o600); err != nil { + t.Fatal(err) + } + + got, err := Load(env) + if err != nil { + t.Fatal(err) + } + if got.BaseURL != "https://stored.example" || got.Theme != ThemeAuto || got.Token != "saved" || got.LastTab != "" { + t.Fatalf("Load invalid selections = %#v", got) + } + + env["NO_COLOR"] = "" + env["TEXTLOG_THEME"] = "dark" + got, err = Load(env) + if err != nil { + t.Fatal(err) + } + if got.Theme != ThemeLight { + t.Fatalf("NO_COLOR theme = %q, want %q", got.Theme, ThemeLight) + } + + missing, err := Load(isolatedEnv(t.TempDir())) + if err != nil { + t.Fatal(err) + } + if missing != (Config{BaseURL: DefaultURL, Theme: ThemeAuto}) { + t.Fatalf("missing config = %#v", missing) + } + + malformedEnv := isolatedEnv(t.TempDir()) + malformedPath := ConfigPath(malformedEnv) + if err := os.MkdirAll(filepath.Dir(malformedPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(malformedPath, []byte(`{"baseUrl":"https://partial.example",`), 0o600); err != nil { + t.Fatal(err) + } + malformed, err := Load(malformedEnv) + if err != nil { + t.Fatal(err) + } + if malformed != (Config{BaseURL: DefaultURL, Theme: ThemeAuto}) { + t.Fatalf("partially decoded malformed config = %#v", malformed) + } +} + +func TestSavePersistsTheProvidedConfigWithoutApplyingEnvironment(t *testing.T) { + env := isolatedEnv(t.TempDir()) + env["TEXTLOG_URL"] = "https://override.example" + env["TEXTLOG_THEME"] = "light" + want := Config{BaseURL: "https://stored.example/", Theme: ThemeDark, Token: "stored"} + if err := Save(want, env); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(ConfigPath(env)) + if err != nil { + t.Fatal(err) + } + var got Config + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("saved config = %#v, want %#v", got, want) + } +} + +func TestNormalizeBaseURL(t *testing.T) { + valid := map[string]string{ + "https://textlog.cc/": "https://textlog.cc", + "http://localhost:3000/api///": "http://localhost:3000/api", + " https://EXAMPLE.test/a/ ": "https://example.test/a", + } + for input, want := range valid { + got, err := NormalizeBaseURL(input) + if err != nil || got != want { + t.Errorf("NormalizeBaseURL(%q) = %q, %v; want %q", input, got, err, want) + } + } + + for _, input := range []string{"wat", "file:///tmp/no", "https://example.test?q=one", "https://example.test/#frag", "https:///missing-host"} { + if _, err := NormalizeBaseURL(input); err == nil { + t.Errorf("NormalizeBaseURL(%q) unexpectedly succeeded", input) + } + } +} + +func TestPlatformConfigPaths(t *testing.T) { + env := map[string]string{ + "APPDATA": filepath.FromSlash("/roaming"), + "XDG_CONFIG_HOME": filepath.FromSlash("/xdg"), + } + tests := map[string]string{ + "windows": filepath.Join(env["APPDATA"], "textlog", "config.json"), + "darwin": filepath.Join("/home/alice", "Library", "Application Support", "textlog", "config.json"), + "linux": filepath.Join(env["XDG_CONFIG_HOME"], "textlog", "config.json"), + } + for goos, want := range tests { + if got := configPathFor(goos, "/home/alice", env); got != want { + t.Errorf("configPathFor(%q) = %q, want %q", goos, got, want) + } + } +} + +func isolatedEnv(home string) map[string]string { + env := map[string]string{"HOME": home} + if runtime.GOOS == "windows" { + env["APPDATA"] = filepath.Join(home, "roaming") + } else if runtime.GOOS != "darwin" { + env["XDG_CONFIG_HOME"] = filepath.Join(home, "xdg") + } + return env +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go @@ -0,0 +1,287 @@ +// Package domain contains pure application behavior shared by the TUI screens. +package domain + +import ( + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/ryan/gotextlog/internal/textlog" +) + +const ( + MaxPostCharacters = 280 + MaxPostLines = 10 +) + +// CodePointLength follows JavaScript's Array.from string length semantics for +// valid UTF-8: a Unicode code point counts as one character. +func CodePointLength(value string) int { + return utf8.RuneCountInString(value) +} + +// ValidatePost returns a user-facing problem, or an empty string when valid. +func ValidatePost(value string) string { + if strings.TrimFunc(value, func(character rune) bool { + return unicode.IsSpace(character) || character == '\uFEFF' + }) == "" { + return "Write something first" + } + if CodePointLength(value) > MaxPostCharacters { + return "Notes can be at most 280 characters" + } + if strings.Count(value, "\n")+1 > MaxPostLines { + return "Notes can be at most 10 lines" + } + return "" +} + +// RelativeTime formats an RFC 3339 timestamp relative to now. +func RelativeTime(value string, now time.Time) string { + created, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return "" + } + seconds := int64(now.Sub(created).Seconds()) + if seconds < 0 { + seconds = 0 + } + switch { + case seconds < 60: + return formatInteger(seconds) + "s" + case seconds < 60*60: + return formatInteger(seconds/60) + "m" + case seconds < 24*60*60: + return formatInteger(seconds/(60*60)) + "h" + case seconds < 30*24*60*60: + return formatInteger(seconds/(24*60*60)) + "d" + default: + return created.UTC().Format("2006-01-02") + } +} + +func formatInteger(value int64) string { + if value == 0 { + return "0" + } + var digits [20]byte + index := len(digits) + for value > 0 { + index-- + digits[index] = byte(value%10) + '0' + value /= 10 + } + return string(digits[index:]) +} + +// Redact replaces every occurrence of token without changing tokenless text. +func Redact(value, token string) string { + if token == "" { + return value + } + return strings.ReplaceAll(value, token, "[redacted]") +} + +type TokenKind string + +const ( + TokenText TokenKind = "text" + TokenCode TokenKind = "code" + TokenLink TokenKind = "link" + TokenReference TokenKind = "reference" +) + +type RichToken struct { + Kind TokenKind + Text string + URL string +} + +const ( + inlineCodePattern = "`[^`\n]+`" + 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}` + markdownPattern = `\[(?:\\[^\n]|[^\]\\\n])+\]\((?:https?://)?[^` + javascriptWhitespace + `)]+\)` + webURLPattern = `https?://[^` + javascriptWhitespace + `]+` + domainPattern = `(?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z]{2,})(?:/[^` + javascriptWhitespace + `]*)?` + referencePattern = `[#@][A-Za-z0-9_]+` +) + +var ( + richTextPattern = regexp.MustCompile("(?i)(" + strings.Join([]string{ + inlineCodePattern, + markdownPattern, + webURLPattern, + domainPattern, + referencePattern, + }, "|") + ")") + markdownLinkPattern = regexp.MustCompile(`^\[((?:\\[^\n]|[^\]\\\n])+)\]\(([^)]+)\)$`) + asciiArtBodyPattern = regexp.MustCompile(`(?i)(^|[` + javascriptWhitespace + `])#(?:ascii|ascii_art)\b`) + markdownUnescaper = strings.NewReplacer(`\\`, `\`, `\[`, `[`, `\]`, `]`) +) + +// TokenizeRichText recognizes inline code, markdown links, bare web URLs, +// domain names, mentions, and hashtags. Inline code wins over link parsing. +func TokenizeRichText(body string) []RichToken { + matches := richTextPattern.FindAllStringIndex(body, -1) + if len(matches) == 0 { + if body == "" { + return []RichToken{} + } + return []RichToken{{Kind: TokenText, Text: body}} + } + + tokens := make([]RichToken, 0, len(matches)*2+1) + offset := 0 + for _, match := range matches { + if match[0] > offset { + tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:match[0]]}) + } + value := body[match[0]:match[1]] + switch { + case strings.HasPrefix(value, "`"): + tokens = append(tokens, RichToken{Kind: TokenCode, Text: value}) + case strings.HasPrefix(value, "["): + parts := markdownLinkPattern.FindStringSubmatch(value) + if len(parts) != 3 { + tokens = append(tokens, RichToken{Kind: TokenText, Text: value}) + break + } + tokens = append(tokens, RichToken{ + Kind: TokenLink, + Text: markdownUnescaper.Replace(parts[1]), + URL: absoluteWebURL(parts[2]), + }) + case value[0] == '#' || value[0] == '@': + tokens = append(tokens, RichToken{Kind: TokenReference, Text: value}) + default: + tokens = append(tokens, RichToken{Kind: TokenLink, Text: value, URL: absoluteWebURL(value)}) + } + offset = match[1] + } + if offset < len(body) { + tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:]}) + } + return tokens +} + +func absoluteWebURL(value string) string { + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return value + } + return "https://" + value +} + +// TerminalSafeText normalizes line endings and tab stops and removes emoji +// skin-tone modifiers that some terminals incorrectly render as wide glyphs. +func TerminalSafeText(value string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + + const tabWidth = 4 + var safe strings.Builder + safe.Grow(len(value)) + column := 0 + for _, character := range value { + switch { + case character == '\n': + safe.WriteRune(character) + column = 0 + case character >= '\U0001F3FB' && character <= '\U0001F3FF': + // Intentionally omitted from terminal output. + case character == '\t': + spaces := tabWidth - column%tabWidth + safe.WriteString(strings.Repeat(" ", spaces)) + column += spaces + default: + safe.WriteRune(character) + column++ + } + } + return safe.String() +} + +// IsASCIIArt reports whether tags or an in-body hashtag request literal mode. +func IsASCIIArt(body string, tags []string) bool { + for _, tag := range tags { + tag = strings.ToLower(strings.TrimPrefix(tag, "#")) + if tag == "ascii" || tag == "ascii_art" { + return true + } + } + return asciiArtBodyPattern.MatchString(body) +} + +func IsASCIIArtPost(post textlog.Post) bool { + return IsASCIIArt(post.Body, post.Tags) +} + +// ClampSelection keeps a list selection in range, with zero representing an +// empty list as well as its first item. +func ClampSelection(current, length int) int { + if current < 0 || length <= 0 { + return 0 + } + if current >= length { + return length - 1 + } + return current +} + +type ThreadItem struct { + Post textlog.Post + Depth int + MoreCount int +} + +// BuildReplyThread orders replies depth-first. Orphans remain visible, while +// duplicate IDs and cycles cannot duplicate posts or recurse indefinitely. +func BuildReplyThread(root textlog.Post, replies []textlog.Reply) []ThreadItem { + children := make(map[int][]textlog.Reply) + for _, reply := range replies { + if reply.ParentID != nil { + children[*reply.ParentID] = append(children[*reply.ParentID], reply) + } + } + + thread := make([]ThreadItem, 0, len(replies)+1) + thread = append(thread, ThreadItem{Post: root}) + visited := map[int]bool{root.ID: true} + + var appendChildren func(parentID, depth int) + appendChildren = func(parentID, depth int) { + for _, reply := range children[parentID] { + if visited[reply.ID] { + continue + } + visited[reply.ID] = true + moreCount := 0 + if len(children[reply.ID]) == 0 { + moreCount = reply.ReplyCount + } + thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount}) + appendChildren(reply.ID, depth+1) + } + } + + appendChildren(root.ID, 1) + for _, reply := range replies { + if visited[reply.ID] { + continue + } + visited[reply.ID] = true + depth := reply.Depth + if depth < 1 { + depth = 1 + } + moreCount := 0 + if len(children[reply.ID]) == 0 { + moreCount = reply.ReplyCount + } + thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount}) + appendChildren(reply.ID, depth+1) + } + return thread +} diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go @@ -0,0 +1,151 @@ +package domain + +import ( + "strings" + "testing" + "time" + + "github.com/ryan/gotextlog/internal/textlog" +) + +func TestNoteValidationTimeAndRedaction(t *testing.T) { + if got := CodePointLength("🌱"); got != 1 { + t.Fatalf("CodePointLength = %d, want 1", got) + } + tests := []struct { + body string + want string + }{ + {"", "Write something first"}, + {" \t\n", "Write something first"}, + {"\uFEFF", "Write something first"}, + {strings.Repeat("🌱", 281), "Notes can be at most 280 characters"}, + {strings.Repeat("x\n", 10) + "x", "Notes can be at most 10 lines"}, + {"hello", ""}, + } + for _, test := range tests { + if got := ValidatePost(test.body); got != test.want { + t.Errorf("ValidatePost(%q) = %q, want %q", test.body, got, test.want) + } + } + + now := time.Date(2026, 1, 31, 0, 2, 0, 0, time.UTC) + for input, want := range map[string]string{ + "2026-01-31T00:01:45Z": "15s", + "2026-01-30T23:00:00Z": "1h", + "2026-01-29T00:02:00Z": "2d", + "2026-01-01T00:00:00Z": "2026-01-01", + "2026-02-01T00:00:00Z": "0s", + } { + if got := RelativeTime(input, now); got != want { + t.Errorf("RelativeTime(%q) = %q, want %q", input, got, want) + } + } + if got := Redact("secret + secret", "secret"); got != "[redacted] + [redacted]" { + t.Fatalf("Redact = %q", got) + } + if got := Redact("unchanged", ""); got != "unchanged" { + t.Fatalf("Redact without token = %q", got) + } +} + +func TestRichTextAndTerminalSafety(t *testing.T) { + body := "See [small PRs](getsmall.xyz/post/one), [Anthropic Risk August 2026 \\[pdf\\]](www-cdn.anthropic.com/report.pdf), https://textlog.cc and `example.com` with @david and #small_web." + tokens := TokenizeRichText(body) + wantInteractive := []RichToken{ + {Kind: TokenLink, Text: "small PRs", URL: "https://getsmall.xyz/post/one"}, + {Kind: TokenLink, Text: "Anthropic Risk August 2026 [pdf]", URL: "https://www-cdn.anthropic.com/report.pdf"}, + {Kind: TokenLink, Text: "https://textlog.cc", URL: "https://textlog.cc"}, + {Kind: TokenCode, Text: "`example.com`"}, + {Kind: TokenReference, Text: "@david"}, + {Kind: TokenReference, Text: "#small_web"}, + } + var interactive []RichToken + for _, token := range tokens { + if token.Kind != TokenText { + interactive = append(interactive, token) + } + } + if len(interactive) != len(wantInteractive) { + t.Fatalf("tokens = %#v", tokens) + } + for i := range wantInteractive { + if interactive[i] != wantInteractive[i] { + t.Errorf("interactive token %d = %#v, want %#v", i, interactive[i], wantInteractive[i]) + } + } + var rebuilt strings.Builder + for _, token := range tokens { + rebuilt.WriteString(token.Text) + } + if strings.Contains(rebuilt.String(), "\\[") || !strings.Contains(rebuilt.String(), "[pdf]") { + t.Fatalf("rendered token text did not unescape markdown label: %q", rebuilt.String()) + } + + if got, want := TerminalSafeText("A\tB\r\n /\\\r | |\nyes 🙌🏻!"), "A B\n /\\\n | |\nyes 🙌!"; got != want { + t.Fatalf("TerminalSafeText = %q, want %q", got, want) + } + if got, want := TerminalSafeText("123\tX\n1234\tX"), "123 X\n1234 X"; got != want { + t.Fatalf("TerminalSafeText tab stops = %q, want %q", got, want) + } + if got := TokenizeRichText("https://one.example\u00a0https://two.example"); len(got) != 3 || got[1].Kind != TokenText { + t.Fatalf("TokenizeRichText did not stop links at JavaScript whitespace: %#v", got) + } + + if !IsASCIIArt("`literal` example.com", []string{"#ASCII"}) || + !IsASCIIArt("art\n#ascii_art", nil) || + IsASCIIArt("ordinary text", []string{"design"}) { + t.Fatal("IsASCIIArt did not recognize supported tags") + } + + for _, test := range []struct{ current, length, want int }{ + {-2, 4, 0}, {2, 4, 2}, {8, 4, 3}, {1, 0, 0}, + } { + if got := ClampSelection(test.current, test.length); got != test.want { + t.Errorf("ClampSelection(%d, %d) = %d, want %d", test.current, test.length, got, test.want) + } + } +} + +func TestBuildReplyThreadDepthFirstOrphansAndCycles(t *testing.T) { + root := post(1, nil, 0) + replies := []textlog.Reply{ + reply(2, intPtr(1), 3, 1), + reply(3, intPtr(1), 0, 1), + reply(4, intPtr(2), 2, 2), + } + got := BuildReplyThread(root, replies) + assertThread(t, got, []int{1, 2, 4, 3}, []int{0, 1, 2, 1}, []int{0, 0, 2, 0}) + + malformed := []textlog.Reply{ + reply(7, intPtr(8), 0, 0), + reply(8, intPtr(7), 0, 2), + reply(9, intPtr(99), 4, 0), + reply(7, intPtr(1), 0, 1), // duplicate ID must not be emitted again. + } + got = BuildReplyThread(root, malformed) + assertThread(t, got, []int{1, 7, 8, 9}, []int{0, 1, 2, 1}, []int{0, 0, 0, 4}) +} + +func post(id int, parentID *int, replyCount int) textlog.Post { + return textlog.Post{ID: id, ParentID: parentID, Body: string(rune('0' + id)), ReplyCount: replyCount} +} + +func reply(id int, parentID *int, replyCount, depth int) textlog.Reply { + return textlog.Reply{Post: post(id, parentID, replyCount), Depth: depth} +} + +func intPtr(value int) *int { return &value } + +func assertThread(t *testing.T, got []ThreadItem, ids, depths, more []int) { + t.Helper() + if len(got) != len(ids) { + t.Fatalf("thread length = %d, want %d: %#v", len(got), len(ids), got) + } + for i := range got { + if got[i].Post.ID != ids[i] || got[i].Depth != depths[i] || got[i].MoreCount != more[i] { + t.Errorf("thread[%d] = {id:%d depth:%d more:%d}, want {id:%d depth:%d more:%d}", + i, got[i].Post.ID, got[i].Depth, got[i].MoreCount, ids[i], depths[i], more[i]) + } + } +}