[
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build reddittui\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: '1.23'\n\n      - name: Build reddittui\n        run: go build -v ./...\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release reddittui\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: actions/setup-go@v4\n        with:\n          go-version: stable\n      - name: GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          version: \"~> v2\"\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GH_KEY }}\n"
  },
  {
    "path": ".gitignore",
    "content": "samples\nbuild/\n# Added by goreleaser init:\ndist/\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "# This is an example .goreleaser.yml file with some sensible defaults.\n# Make sure to check the documentation at https://goreleaser.com\n\n# The lines below are called `modelines`. See `:help modeline`\n# Feel free to remove those if you don't want/need to use them.\n# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n# vim: set ts=2 sw=2 tw=0 fo=cnqoj\n\nversion: 2\n\nbefore:\n  hooks:\n    # You may remove this if you don't use go modules.\n    - go mod tidy\n    # you may remove this if you don't need go generate\n    - go generate ./...\n\nbuilds:\n  - binary: reddittui\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n\narchives:\n  - formats: \n      - tar.gz\n    # this name template makes the OS and Arch compatible with the results of `uname`.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- title .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    # use zip for windows archives\n    format_overrides:\n      - goos: windows\n        formats: \n          - zip\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n\nrelease:\n  footer: >-\n\n    ---\n\n    Released by [GoReleaser](https://github.com/goreleaser/goreleaser).\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "MIT License\n\nCopyright (c) 2025 Anthony Majestro\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Reddittui\nA lightweight terminal application for browsing Reddit from your command line. Powered by [bubbletea](https://github.com/charmbracelet/bubbletea)\n\n## Features\n- **Subreddit Browsing:** Navigate through your favorite subreddits.\n- **Post Viewing:** Read text posts and comments.\n- **Keyboard Navigation:** Scroll and select posts using vim/standard keyboard shortcuts.\n- **Configurable**: Customize caching behavior and define subreddit filters using a configuration file\n\n## Demo\nhttps://github.com/user-attachments/assets/40d61ef3-3a95-4a26-8c49-bec616f6ae1c\n\n## Installation\n\n### Git\n#### Prerequisites\n- **Git**\n- **Go:** Version 1.16 or newer\n- **Terminal:** A Unix-like terminal (Linux, macOS, or similar).\n- **POSIX Utilities:** The `install` command is used for installation, which is available on both Linux and macOS.\n\nClone the repository and run the install script: \n\n```bash\ngit clone https://github.com/tonymajestro/reddit-tui.git reddittui\ncd reddittui\n./install.sh\n```\n\nTo remove reddittui run the uninstall script:\n\n```bash\n./uninstall.sh\n```\n\n### Arch\nArch users can install reddittui from the AUR using yay or other AUR helpers.\n\n[Pre-compiled](https://aur.archlinux.org/packages/reddit-tui-bin) and [source packages](https://aur.archlinux.org/packages/reddit-tui) are available.\n\n```bash\nyay -S reddit-tui-bin\n```\n\n```bash\nyay -S reddit-tui\n```\n\n### Nix\nNix users can try it in a shell or add it to their system config like this.\n```bash\nnix-shell -p reddit-tui\n```\n```nix\n  environment.systemPackages = [\n      pkgs.reddit-tui\n    ];\n```\n\n## Usage\nRun the installed binary from your preferred terminal:\n\n```bash\n# Open reddittui, navigating to the home page\nreddittui\n\n# Open reddittui, navigating to a specific subreddit\nreddittui --subreddit dogs\n\n# Open reddittui, navigating to a specific post by its ID\nreddittui --post 1iyuce4\n```\n\n## Keybindings\n- Navigation\n  - **h, j, k, l:** Vim movement\n  - **left, right, up, down:** Normal movement\n  - **g**: Go to top of page\n  - **G**: Go to bottom of page\n  - **s**: Switch subreddits\n- Posts page\n  - **L**: Load more posts\n- Comments page\n  - **o**: Open post link in browser\n  - **c**: Collapse comments\n- Misc\n  - **H:** Go to home page\n  - **backspace**: Go back\n  - **q, esc**: Exit reddittui\n\n## Configuration files\nAfter running the reddittui binary, the following files will be initialized:\n- Configuration file:\n  - `~/.config/reddittui/reddittui.toml`\n- Log file:\n  - `~/.local/state/reddittui.log`\n- Cache\n  - `~/.cache/reddittui/`\n\nSample configuration:\n```toml\n# Core configuration\n[core]\nbypassCache = false\nlogLevel = \"Warn\"\n\n# Filter out posts containing keywords or belonging to certain subreddits\n[filter]\nsubreddits = [\"news\", \"politics\"]\nkeywords = [\"pizza\", \"pineapple\"]\n\n# Configure client timeout and cache TTL. By default, subreddit posts and comments are cached for 1 hour.\n[client]\ntimeoutSeconds = 10\ncacheTtlSeconds = 3600\n\n# Configure which reddit server to use. Default is old.reddit.com but redlib servers are also supported\n[server]\ndomain = \"old.reddit.com\"\ntype = \"old\"\n```\n\n## Redlib\nFor enhanced privacy, private [Redlib backends](https://github.com/redlib-org/redlib) are supported. A list of Redlib servers can be found [here](https://github.com/redlib-org/redlib-instances/blob/main/instances.json). Use the following configuration to use a Redlib server instead of old.reddit.com:\n\n```toml\n[server]\ndomain = \"safereddit.com\"\ntype = \"redlib\"\n```\n\n## Acknowledgments\nReddittui is based on the [bubbletea](https://github.com/charmbracelet/bubbletea) framework. It also takes inspiration from [circumflex](https://github.com/bensadeh/circumflex), a hackernews terminal browser.\n"
  },
  {
    "path": "client/cache/commentsCache.go",
    "content": "package cache\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype CommentsCache interface {\n\tGet(path string) (model.Comments, error)\n\tPut(comments model.Comments, path string) error\n\tClean()\n}\n\ntype FileCommentsCache struct {\n\tBaseUrl      string\n\tCacheBaseDir string\n}\n\nfunc NewFileCommentsCache(baseUrl, cacheDir string) FileCommentsCache {\n\treturn FileCommentsCache{\n\t\tBaseUrl:      baseUrl,\n\t\tCacheBaseDir: cacheDir,\n\t}\n}\n\n// Get comments stored in cached file.\n// Returns comments if they are present and not expired\nfunc (f FileCommentsCache) Get(filename string) (comments model.Comments, err error) {\n\tsubreddit := f.GetSubredditFromUrl(filename)\n\tif len(subreddit) == 0 {\n\t\treturn comments, common.ErrNotFound\n\t}\n\n\tsanitizedFilename := url.QueryEscape(filename) + \".json\"\n\tcacheFilePath := filepath.Join(f.CacheBaseDir, subreddit, sanitizedFilename)\n\n\tcacheFile, err := os.Open(cacheFilePath)\n\tif os.IsNotExist(err) {\n\t\tslog.Info(\"not found: \" + cacheFilePath)\n\t\treturn comments, common.ErrNotFound\n\t} else if err != nil {\n\t\tslog.Warn(\"Could not open cache file.\", \"error\", err)\n\t\treturn comments, common.ErrCannotOpenCacheFile\n\t}\n\n\tdefer cacheFile.Close()\n\n\tdecoder := json.NewDecoder(cacheFile)\n\terr = decoder.Decode(&comments)\n\tif err != nil {\n\t\tslog.Warn(\"Could not decode cached comments.\", \"error\", err)\n\t\treturn comments, common.ErrCannotDecodeCacheFile\n\t}\n\n\tif time.Now().After(comments.Expiry) {\n\t\treturn comments, common.ErrCacheEntryExpired\n\t}\n\n\treturn comments, nil\n}\n\n// Cache the comments, writing the contents to the given cache file\nfunc (f FileCommentsCache) Put(comments model.Comments, filename string) error {\n\tsubreddit := f.GetSubredditFromUrl(filename)\n\n\tcacheDir := filepath.Join(f.CacheBaseDir, subreddit)\n\tif err := os.MkdirAll(cacheDir, 0755); err != nil {\n\t\tslog.Warn(\"Could not create subreddit comments cache directory\", \"error\", err)\n\t\treturn err\n\t}\n\n\tsanitizedFilename := url.QueryEscape(filename) + \".json\"\n\tcacheFilePath := filepath.Join(cacheDir, sanitizedFilename)\n\tcacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)\n\tif err != nil {\n\t\tslog.Warn(\"Could not open cache file for encoding\", \"error\", err)\n\t\treturn common.ErrCannotOpenCacheFile\n\t}\n\n\tdefer cacheFile.Close()\n\n\tcommentsJson, err := json.MarshalIndent(comments, \"\", \" \")\n\tif err != nil {\n\t\tslog.Warn(\"Could not encode comments for caching\", \"error\", err)\n\t\treturn common.ErrCannotEncodeCacheFile\n\t}\n\n\t_, err = cacheFile.Write(commentsJson)\n\tif err != nil {\n\t\tslog.Warn(\"Could not encode comments for caching\", \"error\", err)\n\t\treturn common.ErrCannotEncodeCacheFile\n\t}\n\n\treturn nil\n}\n\nfunc (f FileCommentsCache) GetSubredditFromUrl(commentsUrl string) string {\n\tpart := fmt.Sprintf(\"%s/r/\", f.BaseUrl)\n\tif !strings.Contains(commentsUrl, part) {\n\t\treturn \"\"\n\t}\n\n\tsubreddit := commentsUrl[len(part):]\n\tif strings.Contains(subreddit, \"/\") {\n\t\tsubreddit = subreddit[:strings.Index(subreddit, \"/\")]\n\t}\n\n\treturn subreddit\n}\n\nfunc (f FileCommentsCache) Clean() {\n\t// First delete expired comments files in each subreddit directory\n\tfilepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Ignore directories, only look at cache comment files\n\t\tif d.IsDir() || filepath.Ext(path) != \".json\" {\n\t\t\treturn nil\n\t\t}\n\n\t\tcacheFile, err := os.Open(path)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"Could not open cache file.\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tdefer cacheFile.Close()\n\n\t\tvar comments model.Comments\n\t\tdecoder := json.NewDecoder(cacheFile)\n\t\terr = decoder.Decode(&comments)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"Could not decode cached comments.\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// Delete cached comments file if it is expired\n\t\tif time.Now().After(comments.Expiry) {\n\t\t\terr = os.Remove(path)\n\t\t\tif err != nil {\n\t\t\t\tslog.Warn(\"Could not delete expired cache file\")\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n\n\t// Wait for OS to process deleted files\n\ttime.Sleep(500 * time.Millisecond)\n\n\t// Delete subreddit directories that are empty\n\tfilepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\tslog.Error(\"cache error\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif !d.IsDir() || path == f.CacheBaseDir {\n\t\t\treturn nil\n\t\t}\n\n\t\tdir, err := os.Open(path)\n\t\tif err != nil {\n\t\t\tslog.Warn(\"Could not open cache dir\", \"error\", err)\n\t\t\treturn filepath.SkipDir\n\t\t}\n\n\t\t// Get list of filenames in subreddit directory\n\t\tfiles, err := dir.Readdirnames(0)\n\t\tif err != nil {\n\t\t\tslog.Warn(\"Could not read contests of subreddit cache dir\", \"error\", err)\n\t\t\treturn filepath.SkipDir\n\t\t}\n\n\t\t// Delete subreddit directory if it is empty\n\t\tif len(files) == 0 {\n\t\t\terr = os.Remove(path)\n\t\t\tif err != nil {\n\t\t\t\tslog.Warn(\"Could not delete empty cache dir\")\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\ntype NoOpCommentsCache struct{}\n\nfunc NewNoOpCommentsCache() NoOpCommentsCache {\n\treturn NoOpCommentsCache{}\n}\n\nfunc (n NoOpCommentsCache) Get(cacheFilePath string) (comments model.Comments, err error) {\n\treturn comments, common.ErrNotFound\n}\n\nfunc (n NoOpCommentsCache) Put(comments model.Comments, cacheFilePath string) error {\n\treturn nil\n}\n\nfunc (n NoOpCommentsCache) Clean() {\n}\n"
  },
  {
    "path": "client/cache/comments_cache_test.go",
    "content": "package cache\n\nimport (\n\t\"fmt\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\ttestPostPoints       = \"5 points\"\n\ttestPostText         = \"text\"\n\ttestPostTimestamp    = \"5 mins ago\"\n\ttestCommentAuthor    = \"author\"\n\ttestCommentText      = \"comments\"\n\ttestCommentPoints    = \"5 points\"\n\ttestCommentTimestamp = \"5 mins ago\"\n\ttestBaseUrl          = \"old.reddit.com\"\n\ttestCommentDepth     = 0\n)\n\nfunc TestCommentsCacheHappyPath(t *testing.T) {\n\tcache := NewFileCommentsCache(testBaseUrl, t.TempDir())\n\n\texpiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond)\n\texpected := createTestComments(expiry)\n\n\tcommentsUrl := generateCommentsFileUrl(testSubreddit, \"happy\")\n\terr := cache.Put(expected, commentsUrl)\n\tif err != nil {\n\t\tt.Fatalf(\"could not put comments in comments cache: %v\", err)\n\t}\n\n\tgot, err := cache.Get(commentsUrl)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no errors getting comments from cache: %v\", err)\n\t}\n\n\tassertComments(expected, got, t)\n}\n\nfunc TestCommentsCacheCacheNotFound(t *testing.T) {\n\tcache := NewFileCommentsCache(testBaseUrl, t.TempDir())\n\n\tif _, err := cache.Get(\"notfound.json\"); err != common.ErrNotFound {\n\t\tt.Fatalf(\"expected to not find comments in cache\")\n\t}\n}\n\nfunc assertComments(expected, got model.Comments, t *testing.T) {\n\tassertVal(\"PostTitle\", expected.PostTitle, got.PostTitle, t)\n\tassertVal(\"PostAuthor\", expected.PostAuthor, got.PostAuthor, t)\n\tassertVal(\"Subreddit\", expected.Subreddit, got.Subreddit, t)\n\tassertVal(\"PostPoints\", expected.PostPoints, got.PostPoints, t)\n\tassertVal(\"PostText\", expected.PostText, got.PostText, t)\n\tassertVal(\"PostTimestamp\", expected.PostTimestamp, got.PostTimestamp, t)\n\tassertVal(\"Expiry\", expected.Expiry, got.Expiry, t)\n\n\tif len(expected.Comments) != len(got.Comments) {\n\t\tt.Fatalf(\"expected %d comments but got %d:\", len(expected.Comments), len(got.Comments))\n\t}\n\n\tfor i, expectedComment := range expected.Comments {\n\t\tgotComment := got.Comments[i]\n\t\tassertComment(expectedComment, gotComment, t)\n\t}\n\n\tif t.Failed() {\n\t\tt.FailNow()\n\t}\n}\n\nfunc assertComment(expectedComment, gotComment model.Comment, t *testing.T) {\n\tassertVal(\"Author\", expectedComment.Author, gotComment.Author, t)\n\tassertVal(\"Text\", expectedComment.Text, gotComment.Text, t)\n\tassertVal(\"Points\", expectedComment.Points, gotComment.Points, t)\n\tassertVal(\"Timestamp\", expectedComment.Timestamp, gotComment.Timestamp, t)\n\tassertVal(\"Depth\", expectedComment.Depth, gotComment.Depth, t)\n}\n\nfunc createTestComment() model.Comment {\n\treturn model.Comment{\n\t\tAuthor:    testCommentAuthor,\n\t\tText:      testCommentText,\n\t\tPoints:    testCommentPoints,\n\t\tTimestamp: testCommentTimestamp,\n\t\tDepth:     testCommentDepth,\n\t}\n}\n\nfunc createTestComments(expiry time.Time) model.Comments {\n\treturn model.Comments{\n\t\tPostTitle:     testTitle,\n\t\tPostAuthor:    testAuthor,\n\t\tSubreddit:     testSubreddit,\n\t\tPostPoints:    testPostPoints,\n\t\tPostText:      testPostUrl,\n\t\tPostTimestamp: testPostTimestamp,\n\t\tExpiry:        expiry,\n\t\tComments:      []model.Comment{createTestComment()},\n\t}\n}\n\nfunc generateCommentsFileUrl(subreddit, filename string) string {\n\treturn fmt.Sprintf(\"%s/r/%s/%s\", testBaseUrl, subreddit, filename)\n}\n"
  },
  {
    "path": "client/cache/postsCache.go",
    "content": "package cache\n\nimport (\n\t\"encoding/json\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"time\"\n)\n\ntype PostsCache interface {\n\tGet(path string) (model.Posts, error)\n\tPut(posts model.Posts, cacheFilePath string) error\n\tClean()\n}\n\ntype FilePostsCache struct {\n\tCacheBaseDir string\n}\n\nfunc NewFilePostsCache(cacheDir string) FilePostsCache {\n\treturn FilePostsCache{CacheBaseDir: cacheDir}\n}\n\n// Get posts stored in cached file.\n// Returns posts if they are present and not expired\nfunc (f FilePostsCache) Get(filename string) (posts model.Posts, err error) {\n\tsanitizedFilename := url.QueryEscape(filename) + \".json\"\n\tcacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename)\n\n\tcacheFile, err := os.Open(cacheFilePath)\n\tif os.IsNotExist(err) {\n\t\treturn posts, common.ErrNotFound\n\t} else if err != nil {\n\t\tslog.Warn(\"Could not open cache file.\", \"error\", err)\n\t\treturn posts, common.ErrCannotOpenCacheFile\n\t}\n\n\tdefer cacheFile.Close()\n\n\tdecoder := json.NewDecoder(cacheFile)\n\terr = decoder.Decode(&posts)\n\tif err != nil {\n\t\tslog.Warn(\"Could not decode cached posts.\", \"error\", err)\n\t\treturn posts, common.ErrCannotDecodeCacheFile\n\t}\n\n\tif time.Now().After(posts.Expiry) {\n\t\treturn posts, common.ErrCacheEntryExpired\n\t}\n\n\treturn posts, nil\n}\n\n// Cache the posts, writing the contents to the given cache file\nfunc (f FilePostsCache) Put(posts model.Posts, filename string) error {\n\tsanitizedFilename := url.QueryEscape(filename) + \".json\"\n\tcacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename)\n\n\tcacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)\n\tif err != nil {\n\t\tslog.Warn(\"Could not open cache file for encoding\", \"error\", err)\n\t\treturn common.ErrCannotOpenCacheFile\n\t}\n\n\tdefer cacheFile.Close()\n\n\tpostsJson, err := json.MarshalIndent(posts, \"\", \" \")\n\tif err != nil {\n\t\tslog.Warn(\"Could not encode posts for caching\", \"error\", err)\n\t\treturn common.ErrCannotEncodeCacheFile\n\t}\n\n\t_, err = cacheFile.Write(postsJson)\n\tif err != nil {\n\t\tslog.Warn(\"Could not encode posts for caching\", \"error\", err)\n\t\treturn common.ErrCannotEncodeCacheFile\n\t}\n\n\treturn nil\n}\n\nfunc (f FilePostsCache) Clean() {\n\tfilepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\tslog.Error(\"cache error\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// Only clean up posts which are stored in root cache directory, skip everything else.\n\t\tif d.IsDir() {\n\t\t\tif path == f.CacheBaseDir {\n\t\t\t\treturn nil\n\t\t\t} else {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t} else if filepath.Ext(path) != \".json\" {\n\t\t\treturn nil\n\t\t}\n\n\t\tcacheFile, err := os.Open(path)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"Could not open cache file.\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\tdefer cacheFile.Close()\n\n\t\tvar posts model.Posts\n\t\tdecoder := json.NewDecoder(cacheFile)\n\t\terr = decoder.Decode(&posts)\n\t\tif err != nil {\n\t\t\tslog.Debug(\"Could not decode cached posts.\", \"error\", err)\n\t\t\treturn nil\n\t\t}\n\n\t\t// Delete cached posts file if it is expired\n\t\tif time.Now().After(posts.Expiry) {\n\t\t\tslog.Debug(\"Removing expired cache posts\", \"path\", path)\n\t\t\terr = os.Remove(path)\n\t\t\tif err != nil {\n\t\t\t\tslog.Debug(\"Could not delete expired cache file\", \"error\", err)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\ntype NoOpPostsCache struct{}\n\nfunc NewNoOpPostsCache() NoOpPostsCache {\n\treturn NoOpPostsCache{}\n}\n\nfunc (n NoOpPostsCache) Get(cacheFilePath string) (posts model.Posts, err error) {\n\treturn posts, common.ErrNotFound\n}\n\nfunc (n NoOpPostsCache) Put(posts model.Posts, cacheFilePath string) error {\n\treturn nil\n}\n\nfunc (f NoOpPostsCache) Clean() {\n}\n"
  },
  {
    "path": "client/cache/posts_cache_test.go",
    "content": "package cache\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nconst (\n\ttestTitle         = \"title\"\n\ttestDescription   = \"description\"\n\ttestAuthor        = \"author\"\n\ttestSubreddit     = \"subreddit\"\n\ttestFriendlyDate  = \"5 mins ago\"\n\ttestPostUrl       = \"post.url\"\n\ttestCommentsUrl   = \"comments.url\"\n\ttestTotalComments = \"5 comments\"\n\ttestTotalLikes    = \"10 likes\"\n\ttestIsHome        = false\n\ttestAfter         = \"after\"\n)\n\nfunc TestPostsCacheHappyPath(t *testing.T) {\n\tcache := NewFilePostsCache(t.TempDir())\n\n\texpiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond)\n\texpected := createTestPosts(expiry)\n\terr := cache.Put(expected, \"happy\")\n\tif err != nil {\n\t\tt.Fatalf(\"could not put posts in posts cache: %v\", err)\n\t}\n\n\tgot, err := cache.Get(\"happy\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected no errors getting posts from cache: %v\", err)\n\t}\n\n\tassertPosts(expected, got, t)\n}\n\nfunc TestPostsCacheNotFound(t *testing.T) {\n\tcache := NewFilePostsCache(t.TempDir())\n\n\tif _, err := cache.Get(\"notfound.json\"); err != common.ErrNotFound {\n\t\tt.Fatalf(\"expected to not find posts in cache\")\n\t}\n}\n\nfunc TestPostsCacheCannotDecodePosts(t *testing.T) {\n\tcache := NewFilePostsCache(t.TempDir())\n\n\tfile, err := os.CreateTemp(cache.CacheBaseDir, \"cannotdecode*.json\")\n\tif err != nil {\n\t\tt.Fatalf(\"could not create test posts file\")\n\t}\n\tdefer file.Close()\n\n\tfile.WriteString(\"not valid json\")\n\n\t// Cache adds the .json extension when fetching the file from the cache\n\t// Strip it here so we don't add it twice\n\tfilename := filepath.Base(file.Name())\n\tcacheEntryName := strings.TrimSuffix(filename, filepath.Ext(filename))\n\n\tif _, err = cache.Get(cacheEntryName); err != common.ErrCannotDecodeCacheFile {\n\t\tt.Fatalf(\"expected cannot decode cache entry %s, got %v\", filename, err)\n\t}\n}\n\nfunc TestPostsCacheCacheExpired(t *testing.T) {\n\tcache := NewFilePostsCache(t.TempDir())\n\n\texpiry := time.Now().Round(time.Millisecond)\n\texpected := createTestPosts(expiry)\n\terr := cache.Put(expected, \"expired\")\n\tif err != nil {\n\t\tt.Fatalf(\"could not put posts in posts cache: %v\", err)\n\t}\n\n\t// Posts should be already expired by time we fetch them\n\ttime.Sleep(100 * time.Millisecond)\n\n\t_, err = cache.Get(\"happy\")\n\tif err == nil {\n\t\tt.Fatalf(\"expected no errors getting posts from cache: %v\", err)\n\t}\n}\n\nfunc TestPostsCacheCleanCache(t *testing.T) {\n\tcache := NewFilePostsCache(t.TempDir())\n\n\tposts1 := createTestPosts(time.Now().Round(time.Millisecond))\n\tposts2 := createTestPosts(time.Now().Add(200 * time.Millisecond).Round(time.Millisecond))\n\n\tposts1.Subreddit = \"subreddit1\"\n\tposts2.Subreddit = \"subreddit2\"\n\n\tcache.Put(posts1, \"subreddit1\")\n\tcache.Put(posts2, \"subreddit2\")\n\n\tcache.Clean()\n\n\tif _, err := cache.Get(\"subreddit1\"); err != common.ErrNotFound {\n\t\tt.Fatal(\"expected expired posts subreddit1 to be cleaned from cache\")\n\t}\n\n\tgotPosts2, err := cache.Get(\"subreddit2\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error fetching subreddit2 from cache: %v\", err)\n\t}\n\n\tassertPosts(posts2, gotPosts2, t)\n\n\ttime.Sleep(200 * time.Millisecond)\n\tcache.Clean()\n\n\tif _, err := cache.Get(\"subreddit2\"); err != common.ErrNotFound {\n\t\tt.Fatal(\"expected expired posts subreddit1 to be cleaned from cache\")\n\t}\n}\n\nfunc assertPosts(expected, got model.Posts, t *testing.T) {\n\tassertVal(\"After\", expected.After, got.After, t)\n\tassertVal(\"Description\", expected.Description, got.Description, t)\n\tassertVal(\"Subreddit\", expected.Subreddit, got.Subreddit, t)\n\tassertVal(\"IsHome\", expected.IsHome, got.IsHome, t)\n\tassertVal(\"Expiry\", expected.Expiry, got.Expiry, t)\n\n\tif len(expected.Posts) != len(got.Posts) {\n\t\tt.Fatalf(\"expected %d posts but got %d:\", len(expected.Posts), len(got.Posts))\n\t}\n\n\tfor i, expectedPost := range expected.Posts {\n\t\tgotPost := got.Posts[i]\n\t\tassertPost(expectedPost, gotPost, t)\n\t}\n\n\tif t.Failed() {\n\t\tt.FailNow()\n\t}\n}\n\nfunc assertPost(expected, got model.Post, t *testing.T) {\n\tassertVal(\"PostTitle\", expected.PostTitle, got.PostTitle, t)\n\tassertVal(\"Author\", expected.Author, got.Author, t)\n\tassertVal(\"Subreddit\", expected.Author, got.Author, t)\n\tassertVal(\"FriendlyDate\", expected.FriendlyDate, got.FriendlyDate, t)\n\tassertVal(\"Expiry\", expected.Expiry, got.Expiry, t)\n\tassertVal(\"PostUrl\", expected.PostUrl, got.PostUrl, t)\n\tassertVal(\"CommentsUrl\", expected.CommentsUrl, got.CommentsUrl, t)\n\tassertVal(\"TotalComments\", expected.TotalComments, got.TotalComments, t)\n\tassertVal(\"TotalLikes\", expected.TotalLikes, got.TotalLikes, t)\n}\n\nfunc assertVal[K comparable](context string, expected, got K, t *testing.T) {\n\tif expected != got {\n\t\tt.Errorf(\"assertion failed %s: for expected %v but got %v\", context, expected, got)\n\t}\n}\n\nfunc createTestPost() model.Post {\n\treturn model.Post{\n\t\tPostTitle:     testTitle,\n\t\tAuthor:        testAuthor,\n\t\tSubreddit:     testSubreddit,\n\t\tFriendlyDate:  testFriendlyDate,\n\t\tPostUrl:       testPostUrl,\n\t\tCommentsUrl:   testCommentsUrl,\n\t\tTotalComments: testTotalComments,\n\t\tTotalLikes:    testTotalLikes,\n\t}\n}\n\nfunc createTestPosts(expiry time.Time) model.Posts {\n\tpost := createTestPost()\n\tposts := []model.Post{post}\n\treturn model.Posts{\n\t\tDescription: testDescription,\n\t\tSubreddit:   testSubreddit,\n\t\tIsHome:      false,\n\t\tPosts:       posts,\n\t\tAfter:       testAfter,\n\t\tExpiry:      expiry,\n\t}\n}\n"
  },
  {
    "path": "client/client.go",
    "content": "package client\n\nimport (\n\t\"log\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/client/comments\"\n\t\"reddittui/client/common\"\n\t\"reddittui/client/posts\"\n\t\"reddittui/config\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"time\"\n)\n\ntype RedditClient struct {\n\tBaseUrl        string\n\tpostsClient    posts.RedditPostsClient\n\tcommentsClient comments.RedditCommentsClient\n}\n\nfunc NewRedditClient(configuration config.Config) RedditClient {\n\tbaseUrl, err := NormalizeBaseUrl(configuration.Server.Domain)\n\tif err != nil {\n\t\tlog.Fatalf(\"Could not parse reddit server url: %s\", configuration.Server.Domain)\n\t}\n\n\t// Support legacy core.ClientTimeout configuration value, use the greater of the two\n\ttimeoutSeconds := max(configuration.Core.ClientTimeout, configuration.Client.TimeoutSeconds)\n\thttpClient := &http.Client{\n\t\tTimeout: time.Duration(timeoutSeconds) * time.Second,\n\t}\n\n\tpostsCache, commentsCache := InitializeCaches(baseUrl, configuration.Core.BypassCache)\n\tpostsClient := posts.NewRedditPostsClient(baseUrl, httpClient, postsCache, configuration)\n\tcommentsClient := comments.NewRedditCommentsClient(baseUrl, configuration.Server.Type, httpClient, commentsCache)\n\n\treturn RedditClient{\n\t\tbaseUrl,\n\t\tpostsClient,\n\t\tcommentsClient,\n\t}\n}\n\nfunc (r RedditClient) GetHomePosts(after string) (model.Posts, error) {\n\treturn r.postsClient.GetHomePosts(after)\n}\n\nfunc (r RedditClient) GetSubredditPosts(subreddit, after string) (model.Posts, error) {\n\treturn r.postsClient.GetSubredditPosts(subreddit, after)\n}\n\nfunc (r RedditClient) GetComments(url string) (model.Comments, error) {\n\treturn r.commentsClient.GetComments(url)\n}\n\nfunc (r RedditClient) CleanCache() {\n\tr.postsClient.Cache.Clean()\n\tr.commentsClient.Cache.Clean()\n}\n\nfunc InitializeCaches(baseUrl string, bypassCache bool) (cache.PostsCache, cache.CommentsCache) {\n\tif bypassCache {\n\t\treturn cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()\n\t}\n\n\t// read cache dir from env var\n\tcacheDir, err := utils.GetCacheDir()\n\tif err != nil {\n\t\tslog.Warn(\"Cannot open cache dir, skipping cache\")\n\t\treturn cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()\n\t}\n\n\t// ensure root cache dir exists\n\terr = os.MkdirAll(cacheDir, 0755)\n\tif err != nil {\n\t\tslog.Warn(\"Cannot create root cache dir, skipping cache\")\n\t\treturn cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache()\n\t}\n\n\t// use root cache dir for posts\n\tpostsCache := cache.NewFilePostsCache(cacheDir)\n\n\t// ensure comments cache dir exists\n\tcommentsCacheDir := filepath.Join(cacheDir, common.CommentsCacheDirName)\n\terr = os.MkdirAll(commentsCacheDir, 0755)\n\tif err != nil {\n\t\tslog.Warn(\"Cannot create comments cache dir, skipping comments cache\")\n\t\treturn postsCache, cache.NewNoOpCommentsCache()\n\t}\n\n\tcommentsCache := cache.NewFileCommentsCache(baseUrl, commentsCacheDir)\n\treturn postsCache, commentsCache\n}\n"
  },
  {
    "path": "client/comments/commentsClient.go",
    "content": "package comments\n\nimport (\n\t\"log/slog\"\n\t\"net/http\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"regexp\"\n\t\"time\"\n\n\t\"golang.org/x/net/html\"\n)\n\nconst defaultTtl = 1 * time.Hour\n\nvar postTextTrimRegex = regexp.MustCompile(\"\\n\\n\\n+\")\n\ntype RedditCommentsClient struct {\n\tBaseUrl string\n\tClient  *http.Client\n\tCache   cache.CommentsCache\n\tParser  CommentsParser\n}\n\nfunc NewRedditCommentsClient(baseUrl, serverType string, httpClient *http.Client, commentsCache cache.CommentsCache) RedditCommentsClient {\n\tvar parser CommentsParser\n\n\tswitch serverType {\n\tcase \"old\":\n\t\tparser = OldRedditCommentsParser{}\n\tcase \"redlib\":\n\t\tparser = RedlibCommentsParser{}\n\tdefault:\n\t\tpanic(\"Unrecognized server type in configuration: \" + serverType)\n\t}\n\n\treturn RedditCommentsClient{\n\t\tBaseUrl: baseUrl,\n\t\tClient:  httpClient,\n\t\tCache:   commentsCache,\n\t\tParser:  parser,\n\t}\n}\n\nfunc (r RedditCommentsClient) GetComments(url string) (comments model.Comments, err error) {\n\ttotalTimer := utils.NewTimer(\"total time to retrieve comments\")\n\tdefer totalTimer.StopAndLog()\n\n\ttimer := utils.NewTimer(\"fetching comments from cache\")\n\tcomments, err = r.Cache.Get(url)\n\tif err == nil {\n\t\t// return cached data\n\t\ttimer.StopAndLog()\n\t\treturn comments, nil\n\t}\n\ttimer.StopAndLog()\n\n\turlWithLimit := common.AddQueryParameter(url, common.LimitQueryParameter)\n\treq, err := http.NewRequest(\"GET\", urlWithLimit, nil)\n\tif err != nil {\n\t\treturn comments, err\n\t}\n\treq.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue)\n\n\ttimer = utils.NewTimer(\"fetching comments from server\")\n\tres, err := r.Client.Do(req)\n\ttimer.StopAndLog(\"url\", url)\n\tif err != nil {\n\t\treturn comments, err\n\t}\n\n\tif res.StatusCode != http.StatusOK {\n\t\tslog.Error(\"Error fetching comments from server\", \"StatusCode\", res.StatusCode)\n\t\treturn comments, common.ErrNotFound\n\t}\n\n\tdefer res.Body.Close()\n\n\ttimer = utils.NewTimer(\"parsing comments html\")\n\tdoc, err := html.Parse(res.Body)\n\ttimer.StopAndLog()\n\tif err != nil {\n\t\treturn comments, err\n\t}\n\n\ttimer = utils.NewTimer(\"converting comments html\")\n\tcomments = r.Parser.ParseComments(common.HtmlNode{Node: doc}, url)\n\tcomments.Expiry = time.Now().Add(defaultTtl)\n\ttimer.StopAndLog()\n\n\ttimer = utils.NewTimer(\"putting comments in cache\")\n\tr.Cache.Put(comments, url)\n\ttimer.StopAndLog()\n\n\treturn comments, nil\n}\n"
  },
  {
    "path": "client/comments/commentsParser.go",
    "content": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"strings\"\n\n\t\"golang.org/x/net/html\"\n)\n\ntype CommentsParser interface {\n\tParseComments(common.HtmlNode, string) model.Comments\n}\n\ntype OldRedditCommentsParser struct{}\n\nfunc (p OldRedditCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments {\n\tvar commentsData model.Comments\n\tvar commentsList []model.Comment\n\n\tcommentsData.PostTitle = p.getTitle(root)\n\tcommentsData.PostAuthor = p.getPostAuthor(root)\n\tcommentsData.PostTimestamp = p.getPostTimestamp(root)\n\tcommentsData.Subreddit = p.getSubreddit(root)\n\tcommentsData.PostPoints = p.getPostPoints(root)\n\tcommentsData.Comments = p.parseCommentsList(root, 0, commentsList)\n\n\tpostText, postUrl := p.getPostContent(root)\n\tif postUrl == \"\" {\n\t\t// Self post\n\t\tpostUrl = url\n\t}\n\tcommentsData.PostText = postText\n\tcommentsData.PostUrl = postUrl\n\n\treturn commentsData\n}\n\nfunc (p OldRedditCommentsParser) parseCommentsList(node common.HtmlNode, depth int, comments []model.Comment) []model.Comment {\n\tvar commentsNode common.HtmlNode\n\n\tcommentsNode, ok := node.FindDescendant(\"div\", \"sitetable\", \"nestedlisting\")\n\tif !ok {\n\t\tcommentsNode, ok = node.FindDescendant(\"div\", \"sitetable\", \"listing\")\n\t\tif !ok {\n\t\t\treturn comments\n\t\t}\n\t}\n\n\tfor c := range commentsNode.FindChildren(\"div\", \"thing\", \"comment\") {\n\t\tif c.ClassContains(\"deleted\") {\n\t\t\t// Skip deleted comments and their children\n\t\t\t// todo: figure out how to render these properly\n\t\t\tcontinue\n\t\t}\n\n\t\tentryNode, ok := c.FindChild(\"div\", \"entry\")\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcomment := p.parseCommentNode(entryNode, depth)\n\t\tcomments = append(comments, comment)\n\n\t\tif n, ok := c.FindChild(\"div\", \"child\"); ok {\n\t\t\tcomments = p.parseCommentsList(n, depth+1, comments)\n\t\t}\n\t}\n\n\treturn comments\n}\n\nfunc (p OldRedditCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment {\n\tvar comment model.Comment\n\tcomment.Depth = depth\n\n\tif taglineNode, ok := node.FindChild(\"p\", \"tagline\"); ok {\n\t\tif authorNode, ok := taglineNode.FindChild(\"a\", \"author\"); ok {\n\t\t\tcomment.Author = authorNode.Text()\n\t\t}\n\n\t\t// Default to 1 point if the comment is too new to show points\n\t\tpoints := \"1 point\"\n\t\tif likesNode, ok := taglineNode.FindChild(\"span\", \"score\", \"likes\"); ok {\n\t\t\tpoints = likesNode.Text()\n\t\t}\n\t\tcomment.Points = points\n\n\t\tif timestampNode, ok := taglineNode.FindChild(\"time\", \"live-timestamp\"); ok {\n\t\t\tcomment.Timestamp = timestampNode.Text()\n\t\t}\n\t}\n\n\tif usertextNode, ok := node.FindChild(\"form\", \"usertext\"); ok {\n\t\tcomment.Text = strings.TrimSpace(renderHtmlNode(usertextNode))\n\t}\n\n\treturn comment\n}\n\nfunc (p OldRedditCommentsParser) getTitle(root common.HtmlNode) string {\n\tfor n := range root.FindDescendants(\"meta\") {\n\t\tif n.GetAttr(\"property\") == \"og:title\" {\n\t\t\treturn n.GetAttr(\"content\")\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (p OldRedditCommentsParser) getPostContent(root common.HtmlNode) (content, url string) {\n\tif linkListingNode, ok := root.FindDescendant(\"div\", \"sitetable\", \"linklisting\"); ok {\n\t\t// self post\n\t\tif mdNode, ok := linkListingNode.FindDescendant(\"div\", \"md\"); ok {\n\t\t\tpostText := renderHtmlNode(mdNode)\n\n\t\t\t// skip alb.reddit.com urls\n\t\t\tif strings.Contains(postText, \"alb.reddit.com\") {\n\t\t\t\treturn \"\", \"\"\n\t\t\t}\n\n\t\t\tcontent = postTextTrimRegex.ReplaceAllString(postText, \"\\n\\n\")\n\t\t\treturn content, \"\"\n\t\t}\n\t}\n\n\tif entry, ok := root.FindDescendant(\"div\", \"entry\", \"unvoted\"); ok {\n\t\t// link post\n\t\tif linkNode, ok := entry.FindDescendant(\"a\", \"title\"); ok {\n\t\t\turl = linkNode.GetAttr(\"href\")\n\n\t\t\t// skip alb.reddit.com urls\n\t\t\tif strings.Contains(url, \"alb.reddit.com\") {\n\t\t\t\treturn \"\", \"\"\n\t\t\t}\n\n\t\t\tcontent := fmt.Sprintf(\"%s\\n\\n\", common.HyperLinkStyle.Render(url))\n\t\t\treturn content, url\n\n\t\t}\n\t}\n\n\treturn \"\", \"\"\n}\n\nfunc (p OldRedditCommentsParser) getPostAuthor(root common.HtmlNode) string {\n\tif linkListingNode, ok := root.FindDescendant(\"div\", \"sitetable\", \"linklisting\"); ok {\n\t\tif authorNode, ok := linkListingNode.FindDescendant(\"a\", \"author\"); ok {\n\t\t\treturn authorNode.Text()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (p OldRedditCommentsParser) getPostTimestamp(root common.HtmlNode) string {\n\tif linkListingNode, ok := root.FindDescendant(\"div\", \"sitetable\", \"linklisting\"); ok {\n\t\tif timestampNode, ok := linkListingNode.FindDescendant(\"time\", \"live-timestamp\"); ok {\n\t\t\treturn timestampNode.Text()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (p OldRedditCommentsParser) getSubreddit(root common.HtmlNode) string {\n\tif spanNode, ok := root.FindDescendant(\"span\", \"pagename\", \"redditname\"); ok {\n\t\tif subredditNode, ok := spanNode.FindDescendant(\"a\"); ok {\n\t\t\treturn subredditNode.Text()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (p OldRedditCommentsParser) getPostPoints(root common.HtmlNode) string {\n\tif linkListingNode, ok := root.FindDescendant(\"div\", \"sitetable\", \"linklisting\"); ok {\n\t\tif likesNode, ok := linkListingNode.FindDescendant(\"div\", \"score\", \"likes\"); ok {\n\t\t\treturn likesNode.Text()\n\t\t}\n\n\t\tif unvotedNode, ok := linkListingNode.FindDescendant(\"div\", \"score\", \"unvoted\"); ok {\n\t\t\treturn unvotedNode.Text()\n\t\t}\n\n\t\t// Fallback to any score node\n\t\tif pointsNode, ok := linkListingNode.FindDescendant(\"div\", \"score\"); ok {\n\t\t\treturn pointsNode.Text()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\ntype RedlibCommentsParser struct{}\n\nfunc (p RedlibCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments {\n\tvar (\n\t\tcommentsData model.Comments\n\t\tcommentsList []model.Comment\n\t)\n\n\tmainNode, ok := root.FindDescendant(\"main\")\n\tif !ok {\n\t\treturn commentsData\n\t}\n\n\tif headerNode, ok := mainNode.FindDescendant(\"div\", \"post\", \"highlighted\"); ok {\n\t\tcommentsData.PostAuthor = p.getPostAuthor(headerNode)\n\t\tcommentsData.PostTimestamp = p.getPostTimestamp(headerNode)\n\t\tcommentsData.Subreddit = p.getSubreddit(headerNode)\n\t}\n\n\tcommentsData.PostTitle = p.getTitle(root)\n\tcommentsData.PostPoints = p.getPostPoints(mainNode)\n\tcommentsData.Comments = p.parseCommentsList(mainNode, 0, commentsList)\n\n\tpostText, postUrl := p.getPostContent(mainNode)\n\tif postUrl == \"\" {\n\t\t// Self post\n\t\tpostUrl = url\n\t}\n\tcommentsData.PostText = postText\n\tcommentsData.PostUrl = postUrl\n\n\treturn commentsData\n}\n\nfunc (p RedlibCommentsParser) getTitle(root common.HtmlNode) string {\n\ttitleNode, ok := root.FindDescendant(\"title\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\t// Strip subreddit from title\n\ttitle := titleNode.Text()\n\tindex := strings.Index(title, \"- r/\")\n\tif index < 0 {\n\t\treturn title\n\t}\n\treturn strings.TrimSpace(title[:index])\n}\n\nfunc (p RedlibCommentsParser) getPostAuthor(root common.HtmlNode) string {\n\tauthorNode, ok := root.FindDescendant(\"a\", \"post_author\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tauthor := authorNode.Text()\n\tif len(author) > 2 && author[:2] == \"u/\" {\n\t\tauthor = author[2:]\n\t}\n\n\treturn author\n}\n\nfunc (p RedlibCommentsParser) getPostTimestamp(root common.HtmlNode) string {\n\ttimestampNode, ok := root.FindDescendant(\"span\", \"created\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\treturn timestampNode.Text()\n}\n\nfunc (p RedlibCommentsParser) getSubreddit(root common.HtmlNode) string {\n\tsubredditNode, ok := root.FindDescendant(\"a\", \"post_subreddit\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\treturn subredditNode.Text()\n}\n\nfunc (p RedlibCommentsParser) getPostPoints(root common.HtmlNode) string {\n\tpointsNode, ok := root.FindDescendant(\"div\", \"post_score\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSpace(pointsNode.Text())\n}\n\nfunc (p RedlibCommentsParser) getPostContent(root common.HtmlNode) (content, url string) {\n\t// self post\n\tif postBodyNode, ok := root.FindDescendant(\"div\", \"post_body\"); ok {\n\t\tif mdNode, ok := postBodyNode.FindDescendant(\"div\", \"md\"); ok {\n\t\t\tpostText := renderHtmlNode(mdNode)\n\t\t\tcontent = postTextTrimRegex.ReplaceAllString(postText, \"\\n\\n\")\n\t\t\treturn content, \"\"\n\t\t}\n\t}\n\n\t// link post\n\tfor linkNode := range root.FindChildren(\"a\") {\n\t\tif linkNode.GetAttr(\"id\") == \"post_url\" {\n\t\t\turl = linkNode.GetAttr(\"href\")\n\t\t\tcontent := fmt.Sprintf(\"%s\\n\\n\", common.HyperLinkStyle.Render(url))\n\t\t\treturn content, url\n\t\t}\n\t}\n\n\treturn \"\", \"\"\n}\n\nfunc (p RedlibCommentsParser) parseCommentsList(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment {\n\tfor threadNode := range root.FindDescendants(\"div\", \"thread\") {\n\t\tcomments = p.parseThread(threadNode, depth, comments)\n\t}\n\n\treturn comments\n}\n\nfunc (p RedlibCommentsParser) parseThread(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment {\n\tcommentNode, ok := root.FindChild(\"div\", \"comment\")\n\tif !ok {\n\t\treturn comments\n\t}\n\n\tcomment := p.parseCommentNode(commentNode, depth)\n\tcomments = append(comments, comment)\n\n\tif n, ok := commentNode.FindDescendant(\"blockquote\", \"replies\"); ok {\n\t\tcomments = p.parseThread(n, depth+1, comments)\n\t}\n\n\treturn comments\n}\n\nfunc (p RedlibCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment {\n\tvar comment model.Comment\n\tcomment.Depth = depth\n\n\tif leftNode, ok := node.FindDescendant(\"div\", \"comment_left\"); ok {\n\t\tif scoreNode, ok := leftNode.FindChild(\"p\", \"comment_score\"); ok {\n\t\t\tpoints := \"1 point\"\n\t\t\tif scoreNode.GetAttr(\"title\") != \"Hidden\" {\n\t\t\t\tpoints = utils.GetSingularPlural(strings.TrimSpace(scoreNode.Text()), \"point\", \"points\")\n\t\t\t}\n\t\t\tcomment.Points = strings.TrimSpace(points)\n\t\t}\n\t}\n\n\tif rightNode, ok := node.FindDescendant(\"details\", \"comment_right\"); ok {\n\t\tif authorNode, ok := rightNode.FindDescendant(\"a\", \"comment_author\"); ok {\n\t\t\tauthor := authorNode.Text()\n\t\t\tif len(author) > 2 && author[:2] == \"u/\" {\n\t\t\t\tauthor = author[2:]\n\t\t\t}\n\t\t\tcomment.Author = author\n\t\t}\n\n\t\tif timestampNode, ok := rightNode.FindDescendant(\"a\", \"created\"); ok {\n\t\t\tcomment.Timestamp = timestampNode.Text()\n\t\t}\n\n\t\tif commentBodyNode, ok := node.FindDescendant(\"div\", \"md\"); ok {\n\t\t\tcommentText := strings.TrimSpace(renderHtmlNode(commentBodyNode))\n\t\t\tcomment.Text = postTextTrimRegex.ReplaceAllString(commentText, \"\\n\\n\")\n\t\t}\n\t}\n\n\treturn comment\n}\n\nfunc renderHtmlNode(node common.HtmlNode) string {\n\tvar content strings.Builder\n\tfor child := range node.ChildNodes() {\n\t\tcNode := common.HtmlNode{Node: child}\n\n\t\tvar nodeResults strings.Builder\n\t\trenderHtmlNodeHelper(cNode, &nodeResults)\n\t\tcontent.WriteString(nodeResults.String())\n\t\tcontent.WriteString(\"\\n\")\n\t}\n\n\treturn content.String()\n}\n\nfunc renderHtmlNodeHelper(node common.HtmlNode, results *strings.Builder) {\n\tif node.Type == html.TextNode {\n\t\tresults.WriteString(node.Data)\n\t} else if node.Tag() == \"a\" {\n\t\tresults.WriteString(common.RenderAnchor(node))\n\t\treturn\n\t} else if node.Tag() == \"li\" {\n\t\tresults.WriteString(node.Text())\n\t\treturn\n\t}\n\n\tfor child := range node.ChildNodes() {\n\t\trenderHtmlNodeHelper(common.HtmlNode{Node: child}, results)\n\t}\n}\n"
  },
  {
    "path": "client/common/errors.go",
    "content": "package common\n\nimport \"errors\"\n\nvar (\n\tErrCacheEntryExpired     = errors.New(\"entry is expired\")\n\tErrCannotLoadPosts       = errors.New(\"cannot load posts\")\n\tErrNotFound              = errors.New(\"not found\")\n\tErrCannotOpenCacheFile   = errors.New(\"cannot open cache file\")\n\tErrCannotEncodeCacheFile = errors.New(\"cannot encode cache file\")\n\tErrCannotDecodeCacheFile = errors.New(\"cannot decode cache file\")\n)\n"
  },
  {
    "path": "client/common/html.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"iter\"\n\t\"reddittui/components/colors\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"golang.org/x/net/html\"\n)\n\nconst (\n\tUserAgentHeaderKey   = \"User-Agent\"\n\tUserAgentHeaderValue = \"Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0\"\n\tCacheControlHeader   = \"Cache-Control\"\n\tCommentsCacheDirName = \"comments\"\n)\n\nvar (\n\tHyperLinkStyle     = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Italic(true)\n\tLinkPostTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colors.AdaptiveColor(colors.Text))\n)\n\nconst LimitQueryParameter = \"limit=500\"\n\ntype HtmlNode struct {\n\t*html.Node\n}\n\nfunc (n HtmlNode) GetAttr(key string) string {\n\tfor _, attr := range n.Attr {\n\t\tif attr.Key != key {\n\t\t\tcontinue\n\t\t}\n\n\t\treturn attr.Val\n\t}\n\n\treturn \"\"\n}\n\nfunc (n HtmlNode) Classes() []string {\n\tvar classes []string\n\n\tclass := n.GetAttr(\"class\")\n\tfor _, c := range strings.Fields(class) {\n\t\tclasses = append(classes, strings.TrimSpace(c))\n\t}\n\n\treturn classes\n}\n\nfunc (n HtmlNode) Class() string {\n\treturn n.GetAttr(\"class\")\n}\n\nfunc (n HtmlNode) Id() string {\n\treturn n.GetAttr(\"id\")\n}\n\nfunc (n HtmlNode) ClassContains(classesToFind ...string) bool {\n\tfor _, c := range classesToFind {\n\t\tif !slices.Contains(n.Classes(), strings.TrimSpace(c)) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc (n HtmlNode) Text() string {\n\tfor c := range n.ChildNodes() {\n\t\tif c.Type == html.TextNode {\n\t\t\treturn c.Data\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (n HtmlNode) Tag() string {\n\treturn n.Data\n}\n\nfunc (n HtmlNode) TagEquals(tag string) bool {\n\treturn n.Type == html.ElementNode && n.Data == tag\n}\n\nfunc (n HtmlNode) NodeEquals(tag string, classes ...string) bool {\n\treturn n.TagEquals(tag) && n.ClassContains(classes...)\n}\n\nfunc (n HtmlNode) NodeEqualsById(tag string, id string) bool {\n\treturn n.TagEquals(tag) && n.Id() == id\n}\n\nfunc (n HtmlNode) FindDescendant(tag string, classes ...string) (HtmlNode, bool) {\n\tvar descendant HtmlNode\n\n\tfor c := range n.Descendants() {\n\t\tdescendant = HtmlNode{c}\n\t\tif len(classes) == 0 && descendant.TagEquals(tag) {\n\t\t\treturn descendant, true\n\t\t} else if descendant.NodeEquals(tag, classes...) {\n\t\t\treturn descendant, true\n\t\t}\n\t}\n\n\treturn descendant, false\n}\n\nfunc (n HtmlNode) FindDescendantById(tag string, id string) (HtmlNode, bool) {\n\tvar descendant HtmlNode\n\n\tfor c := range n.Descendants() {\n\t\tdescendant = HtmlNode{c}\n\t\tif id == \"\" && descendant.TagEquals(tag) {\n\t\t\treturn descendant, true\n\t\t} else if descendant.NodeEqualsById(tag, id) {\n\t\t\treturn descendant, true\n\t\t}\n\t}\n\n\treturn descendant, false\n}\n\nfunc (n HtmlNode) FindDescendants(tag string, classes ...string) iter.Seq[HtmlNode] {\n\treturn func(yield func(HtmlNode) bool) {\n\t\tfor c := range n.Descendants() {\n\t\t\tchildNode := HtmlNode{c}\n\n\t\t\tif len(classes) == 0 && childNode.TagEquals(tag) {\n\t\t\t\tif !yield(childNode) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else if childNode.NodeEquals(tag, classes...) {\n\t\t\t\tif !yield(childNode) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (n HtmlNode) FindChild(tag string, classes ...string) (HtmlNode, bool) {\n\tvar child HtmlNode\n\n\tfor c := range n.ChildNodes() {\n\t\tchild = HtmlNode{c}\n\t\tif len(classes) == 0 && child.TagEquals(tag) {\n\t\t\treturn child, true\n\t\t} else if child.NodeEquals(tag, classes...) {\n\t\t\treturn child, true\n\t\t}\n\t}\n\n\treturn child, false\n}\n\nfunc (n HtmlNode) FindChildren(tag string, classes ...string) iter.Seq[HtmlNode] {\n\treturn func(yield func(HtmlNode) bool) {\n\t\tfor c := range n.ChildNodes() {\n\t\t\tchildNode := HtmlNode{c}\n\n\t\t\tif len(classes) == 0 && childNode.TagEquals(tag) {\n\t\t\t\tif !yield(childNode) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else if childNode.NodeEquals(tag, classes...) {\n\t\t\t\tif !yield(childNode) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc RenderAnchor(node HtmlNode) string {\n\tvar (\n\t\turl      = node.GetAttr(\"href\")\n\t\tlinkText = node.Text()\n\t)\n\n\tif !strings.HasPrefix(url, \"http\") && !strings.HasPrefix(url, \"www\") {\n\t\treturn HyperLinkStyle.Render(linkText)\n\t} else if url == linkText {\n\t\treturn HyperLinkStyle.Render(linkText)\n\t}\n\n\treturn fmt.Sprintf(\n\t\t\"%s %s\",\n\t\tlinkText,\n\t\tHyperLinkStyle.Render(url))\n}\n\nfunc AddQueryParameter(url, query string) string {\n\tif strings.Contains(url, \"?\") {\n\t\treturn fmt.Sprintf(\"%s&%s\", url, query)\n\t}\n\n\treturn fmt.Sprintf(\"%s?%s\", url, query)\n}\n"
  },
  {
    "path": "client/posts/postsClient.go",
    "content": "package posts\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"reddittui/client/cache\"\n\t\"reddittui/client/common\"\n\t\"reddittui/config\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"strings\"\n\t\"time\"\n\n\t\"golang.org/x/net/html\"\n)\n\ntype RedditPostsClient struct {\n\tBaseUrl          string\n\tCacheTtl         time.Duration\n\tClient           *http.Client\n\tCache            cache.PostsCache\n\tParser           PostsParser\n\tKeywordFilters   []string\n\tSubredditFilters []string\n}\n\nfunc NewRedditPostsClient(\n\tbaseUrl string,\n\thttpClient *http.Client,\n\tpostsCache cache.PostsCache,\n\tconfiguration config.Config,\n) RedditPostsClient {\n\tvar parser PostsParser\n\n\tswitch strings.ToLower(configuration.Server.Type) {\n\tcase \"old\":\n\t\tparser = OldRedditPostsParser{}\n\tcase \"redlib\":\n\t\tparser = RedlibParser{baseUrl}\n\tdefault:\n\t\tpanic(\"Unrecognized server type in configuration: \" + configuration.Server.Type)\n\t}\n\n\treturn RedditPostsClient{\n\t\tBaseUrl:          baseUrl,\n\t\tCacheTtl:         time.Duration(configuration.Client.CacheTtlSeconds) * time.Second,\n\t\tClient:           httpClient,\n\t\tCache:            postsCache,\n\t\tParser:           parser,\n\t\tKeywordFilters:   configuration.Filter.Keywords,\n\t\tSubredditFilters: configuration.Filter.Subreddits,\n\t}\n}\n\nfunc (r RedditPostsClient) GetHomePosts(after string) (model.Posts, error) {\n\ttimer := utils.NewTimer(\"total time to retrieve home posts\")\n\tdefer timer.StopAndLog()\n\n\tpostsUrl := r.BuildPostsUrl(\"\", after)\n\tposts, err := r.tryGetCachedPosts(postsUrl)\n\tposts.IsHome = true\n\n\treturn posts, err\n}\n\nfunc (r RedditPostsClient) GetSubredditPosts(subreddit string, after string) (model.Posts, error) {\n\ttimer := utils.NewTimer(\"total time to retrieve subreddit posts\")\n\tdefer timer.StopAndLog()\n\n\tpostsUrl := r.BuildPostsUrl(subreddit, after)\n\tposts, err := r.tryGetCachedPosts(postsUrl)\n\tposts.Subreddit = subreddit\n\n\treturn posts, err\n}\n\n// Try to get posts from cache. If they are not present, fetch them and cache the results\nfunc (r RedditPostsClient) tryGetCachedPosts(postsUrl string) (posts model.Posts, err error) {\n\ttimer := utils.NewTimer(\"fetching posts from cache\")\n\tposts, err = r.Cache.Get(postsUrl)\n\tif err == nil {\n\t\t// return cached data\n\t\ttimer.StopAndLog()\n\t\treturn r.filterPosts(posts), nil\n\t}\n\ttimer.StopAndLog()\n\n\ttimer = utils.NewTimer(\"getting posts from server\")\n\tposts, err = r.getPosts(postsUrl)\n\tif err != nil {\n\t\ttimer.StopAndLog()\n\t\treturn posts, err\n\t}\n\ttimer.StopAndLog()\n\n\ttimer = utils.NewTimer(\"filtering posts\")\n\tposts = r.filterPosts(posts)\n\ttimer.StopAndLog()\n\n\ttimer = utils.NewTimer(\"putting posts in cache\")\n\tr.Cache.Put(posts, postsUrl)\n\ttimer.StopAndLog()\n\treturn posts, nil\n}\n\nfunc (r RedditPostsClient) getPosts(url string) (posts model.Posts, err error) {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn posts, err\n\t}\n\n\treq.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue)\n\n\ttimer := utils.NewTimer(\"fetching posts from server\")\n\tres, err := r.Client.Do(req)\n\ttimer.StopAndLog(\"url\", url)\n\n\tif err != nil {\n\t\treturn posts, err\n\t} else if res.StatusCode != http.StatusOK {\n\t\t// Treat all non-200s as 404s\n\t\treturn posts, common.ErrCannotLoadPosts\n\t}\n\n\tdefer res.Body.Close()\n\n\ttimer = utils.NewTimer(\"parsing posts html\")\n\tdoc, err := html.Parse(res.Body)\n\ttimer.StopAndLog()\n\tif err != nil {\n\t\treturn posts, err\n\t}\n\n\ttimer = utils.NewTimer(\"converting posts html\")\n\tposts = r.Parser.ParsePosts(common.HtmlNode{Node: doc})\n\ttimer.StopAndLog()\n\tif len(posts.Posts) == 0 {\n\t\t// if there are no posts, assume 404.\n\t\t// reddit redirect invalid subreddits requests to some search page instead of doing 404\n\t\tslog.Warn(\"Subreddit not found\")\n\t\treturn posts, common.ErrNotFound\n\t}\n\n\tposts.Expiry = time.Now().Add(r.CacheTtl)\n\treturn posts, nil\n}\n\nfunc (r RedditPostsClient) filterPosts(posts model.Posts) model.Posts {\n\tvar filteredPosts []model.Post\n\nouter:\n\tfor _, post := range posts.Posts {\n\t\tfor _, keyword := range r.KeywordFilters {\n\t\t\tif strings.Contains(strings.ToLower(post.PostTitle), strings.ToLower(keyword)) {\n\t\t\t\tslog.Debug(\"filtering post\", \"title\", post.PostTitle)\n\t\t\t\tcontinue outer\n\t\t\t}\n\t\t}\n\n\t\tfor _, subreddit := range r.SubredditFilters {\n\t\t\tsubreddit = utils.NormalizeSubreddit(subreddit)\n\t\t\tif strings.EqualFold(post.Subreddit, subreddit) {\n\t\t\t\tslog.Debug(\"filtering post\", \"title\", post.PostTitle)\n\t\t\t\tcontinue outer\n\t\t\t}\n\t\t}\n\n\t\tfilteredPosts = append(filteredPosts, post)\n\t}\n\n\tposts.Posts = filteredPosts\n\treturn posts\n}\n\nfunc (r RedditPostsClient) BuildPostsUrl(subreddit, after string) string {\n\tafterParam := \"\"\n\tif len(after) > 0 {\n\t\tafterParam = fmt.Sprintf(\"?after=%s\", after)\n\t}\n\n\tif len(subreddit) > 0 {\n\t\treturn fmt.Sprintf(\"%s/r/%s%s\", r.BaseUrl, subreddit, afterParam)\n\t}\n\n\treturn fmt.Sprintf(\"%s%s\", r.BaseUrl, afterParam)\n}\n"
  },
  {
    "path": "client/posts/postsParser.go",
    "content": "package posts\n\nimport (\n\t\"log/slog\"\n\t\"net/url\"\n\t\"reddittui/client/common\"\n\t\"reddittui/model\"\n\t\"strings\"\n)\n\ntype PostsParser interface {\n\tParsePosts(common.HtmlNode) model.Posts\n}\n\ntype OldRedditPostsParser struct{}\n\nfunc (p OldRedditPostsParser) ParsePosts(root common.HtmlNode) model.Posts {\n\tvar (\n\t\tposts       []model.Post\n\t\tdescription string\n\t)\n\n\tfor d := range root.FindDescendants(\"div\", \"thing\") {\n\t\tif d.ClassContains(\"promoted\", \"promotedlink\") {\n\t\t\t// Skip ads and promotional content\n\t\t\tcontinue\n\t\t}\n\n\t\tpost := p.parsePost(d)\n\t\tposts = append(posts, post)\n\t}\n\n\t// Parse description\n\tfor d := range root.FindDescendants(\"meta\") {\n\t\tif d.GetAttr(\"name\") == \"description\" {\n\t\t\tdescription = d.GetAttr(\"content\")\n\t\t}\n\t}\n\n\t// Parse url for next page of posts\n\tafter := \"\"\n\tfor d := range root.FindDescendants(\"div\", \"nav-buttons\") {\n\t\tfor a := range d.FindDescendants(\"a\") {\n\t\t\tif strings.Contains(a.Text(), \"next\") {\n\t\t\t\tif parsed, err := url.Parse(a.GetAttr(\"href\")); err == nil {\n\t\t\t\t\tafter = parsed.Query().Get(\"after\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tmodelPosts := model.Posts{\n\t\tPosts:       posts,\n\t\tDescription: description,\n\t\tAfter:       after,\n\t}\n\n\treturn modelPosts\n}\n\nfunc (p OldRedditPostsParser) parsePost(n common.HtmlNode) model.Post {\n\tvar post model.Post\n\tfor c := range n.Descendants() {\n\t\tcNode := common.HtmlNode{Node: c}\n\n\t\tif cNode.NodeEquals(\"a\", \"title\") {\n\t\t\tpost.PostTitle = cNode.Text()\n\t\t\tpost.PostUrl = cNode.GetAttr(\"href\")\n\t\t} else if cNode.NodeEquals(\"a\", \"author\") {\n\t\t\tpost.Author = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"a\", \"subreddit\") {\n\t\t\tpost.Subreddit = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"time\", \"live-timestamp\") {\n\t\t\tpost.FriendlyDate = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"a\", \"comments\") {\n\t\t\tpost.CommentsUrl = cNode.GetAttr(\"href\")\n\t\t\tpost.TotalComments = strings.Fields(cNode.Text())[0]\n\t\t} else if cNode.NodeEquals(\"div\", \"likes\") {\n\t\t\tpost.TotalLikes = cNode.Text()\n\t\t}\n\t}\n\n\treturn post\n}\n\ntype RedlibParser struct {\n\tBaseUrl string\n}\n\nfunc (p RedlibParser) ParsePosts(root common.HtmlNode) model.Posts {\n\tvar posts model.Posts\n\n\tfor d := range root.FindDescendants(\"div\", \"post\") {\n\t\tpost := p.parsePost(d)\n\t\tposts.Posts = append(posts.Posts, post)\n\t}\n\n\tif descriptionNode, ok := root.FindDescendantById(\"p\", \"sub_description\"); ok {\n\t\tposts.Description = descriptionNode.Text()\n\t}\n\n\treturn posts\n}\n\nfunc (p RedlibParser) parsePost(n common.HtmlNode) model.Post {\n\tvar post model.Post\n\tfor c := range n.Descendants() {\n\t\tcNode := common.HtmlNode{Node: c}\n\n\t\tif cNode.NodeEquals(\"h2\", \"post_title\") {\n\t\t\tfor postTitleSubNode := range cNode.FindChildren(\"a\") {\n\t\t\t\tpost.PostTitle = postTitleSubNode.Text()\n\t\t\t\tcommentsUrl, err := p.buildUrl(postTitleSubNode.GetAttr(\"href\"))\n\t\t\t\tif err != nil {\n\t\t\t\t\tslog.Debug(\"Error parsing comments url\", \"error\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tpost.CommentsUrl = commentsUrl\n\t\t\t}\n\t\t} else if cNode.NodeEquals(\"a\", \"post_author\") {\n\t\t\tpost.Author = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"a\", \"post_subreddit\") {\n\t\t\tpost.Subreddit = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"span\", \"created\") {\n\t\t\tpost.FriendlyDate = cNode.Text()\n\t\t} else if cNode.NodeEquals(\"a\", \"post_comments\") {\n\t\t\tcommentsUrl, err := p.buildUrl(cNode.GetAttr(\"href\"))\n\t\t\tif err != nil {\n\t\t\t\tslog.Debug(\"Error parsing comments url\", \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpost.CommentsUrl = commentsUrl\n\t\t\tpost.TotalComments = cNode.GetAttr(\"title\")\n\t\t} else if cNode.NodeEquals(\"div\", \"post_score\") {\n\t\t\tpost.TotalLikes = strings.TrimSpace(cNode.Text())\n\t\t}\n\t}\n\n\treturn post\n}\n\nfunc (p RedlibParser) buildUrl(part string) (string, error) {\n\treturn url.JoinPath(p.BaseUrl, part)\n}\n"
  },
  {
    "path": "client/url.go",
    "content": "package client\n\nimport (\n\t\"net/url\"\n)\n\nfunc NormalizeBaseUrl(baseUrl string) (string, error) {\n\tparsed, err := url.Parse(baseUrl)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif parsed.Scheme != \"https\" {\n\t\tparsed.Scheme = \"https\"\n\t}\n\n\turl := parsed.String()\n\tif url[len(url)-1] == '/' {\n\t\turl = url[:len(url)-1]\n\t}\n\n\treturn url, nil\n}\n\nfunc GetPostUrl(baseUrl, post string) (string, error) {\n\tparsed, err := url.Parse(post)\n\tif err != nil {\n\t\t// User passed in post ID, build URL from ID\n\t\treturn url.JoinPath(baseUrl, post)\n\t}\n\n\t// User passed in url, use base URL instead of the one passed in\n\treturn url.JoinPath(baseUrl, parsed.Path)\n}\n"
  },
  {
    "path": "components/colors/colors.go",
    "content": "package colors\n\nimport \"github.com/charmbracelet/lipgloss\"\n\n// https://github.com/catppuccin/catppuccin\n\ntype Color int\n\nconst (\n\tRed = iota\n\tMaroon\n\tPink\n\tOrange\n\tYellow\n\tGreen\n\tBlue\n\tPurple\n\tIndigo\n\tLavender\n\tText\n\tSubtext\n\tSand\n\tWhite\n)\n\ntype Palette struct {\n\tRed      string\n\tMaroon   string\n\tPink     string\n\tOrange   string\n\tYellow   string\n\tGreen    string\n\tBlue     string\n\tPurple   string\n\tIndigo   string\n\tLavender string\n\tText     string\n\tSubtext  string\n\tSand     string\n\tWhite    string\n}\n\nfunc (p Palette) ToHex(color Color) string {\n\tswitch color {\n\tcase Red:\n\t\treturn p.Red\n\tcase Maroon:\n\t\treturn p.Maroon\n\tcase Pink:\n\t\treturn p.Pink\n\tcase Orange:\n\t\treturn p.Orange\n\tcase Yellow:\n\t\treturn p.Yellow\n\tcase Green:\n\t\treturn p.Green\n\tcase Blue:\n\t\treturn p.Blue\n\tcase Purple:\n\t\treturn p.Purple\n\tcase Indigo:\n\t\treturn p.Indigo\n\tcase Lavender:\n\t\treturn p.Lavender\n\tcase Text:\n\t\treturn p.Text\n\tcase Subtext:\n\t\treturn p.Subtext\n\tcase Sand:\n\t\treturn p.Sand\n\tcase White:\n\t\treturn p.White\n\tdefault:\n\t\treturn p.Text\n\t}\n}\n\n// catppuccin-macchiato\nvar Dark = Palette{\n\tRed:      \"#ed8796\",\n\tMaroon:   \"#ee99a0\",\n\tPink:     \"#f5bde6\",\n\tOrange:   \"#f5a97f\",\n\tYellow:   \"#eed49f\",\n\tGreen:    \"#a6da95\",\n\tBlue:     \"#8aadf4\",\n\tPurple:   \"#c6a0f6\",\n\tIndigo:   \"#5f5fd7 \",\n\tLavender: \"#b7bdf8\",\n\tText:     \"#cad3f5\",\n\tSubtext:  \"#b8c0e0\",\n\tSand:     \"#dddddd\",\n\tWhite:    \"#ffffff\",\n}\n\n// catppuccin-latte\nvar Light = Palette{\n\tRed:      \"#d20f39\",\n\tMaroon:   \"#e64553\",\n\tPink:     \"#ea76cb\",\n\tOrange:   \"#fe640b\",\n\tYellow:   \"#df8e1d\",\n\tGreen:    \"#40a02b\",\n\tBlue:     \"#1e66f5\",\n\tPurple:   \"#8839ef\",\n\tIndigo:   \"#5f5fd7 \",\n\tLavender: \"#7287fd\",\n\tText:     \"#4c4f69\",\n\tSubtext:  \"#5c5f77\",\n\tSand:     \"#dddddd\",\n\tWhite:    \"#ffffff\",\n}\n\nfunc AdaptiveColors(light, dark Color) lipgloss.AdaptiveColor {\n\treturn lipgloss.AdaptiveColor{\n\t\tLight: Light.ToHex(light),\n\t\tDark:  Dark.ToHex(dark),\n\t}\n}\n\nfunc AdaptiveColor(color Color) lipgloss.AdaptiveColor {\n\treturn lipgloss.AdaptiveColor{\n\t\tLight: Light.ToHex(color),\n\t\tDark:  Dark.ToHex(color),\n\t}\n}\n"
  },
  {
    "path": "components/comments/commentsPage.go",
    "content": "package comments\n\nimport (\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/components/messages\"\n\t\"reddittui/components/styles\"\n\t\"reddittui/model\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar commentsErrorText = \"Could not load comments. Please try again in a few moments.\"\n\ntype CommentsPage struct {\n\tredditClient   client.RedditClient\n\theader         CommentsHeader\n\tpager          CommentsViewport\n\tcontainerStyle lipgloss.Style\n\tpostUrl        string\n\tfocus          bool\n}\n\nfunc NewCommentsPage(redditClient client.RedditClient) CommentsPage {\n\theader := NewCommentsHeader()\n\tvp := NewCommentsViewport()\n\n\treturn CommentsPage{\n\t\tredditClient:   redditClient,\n\t\theader:         header,\n\t\tpager:          vp,\n\t\tcontainerStyle: styles.GlobalStyle,\n\t}\n}\n\nfunc (c CommentsPage) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (c CommentsPage) Update(msg tea.Msg) (CommentsPage, tea.Cmd) {\n\tvar cmd tea.Cmd\n\tvar cmds []tea.Cmd\n\n\tif c.focus {\n\t\tc, cmd = c.handleFocusedMessages(msg)\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\tc, cmd = c.handleGlobalMessages(msg)\n\tcmds = append(cmds, cmd)\n\n\treturn c, tea.Batch(cmds...)\n}\n\nfunc (c CommentsPage) handleGlobalMessages(msg tea.Msg) (CommentsPage, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase messages.LoadCommentsMsg:\n\t\turl := string(msg)\n\t\treturn c, c.loadComments(url)\n\tcase messages.UpdateCommentsMsg:\n\t\tc.updateComments(model.Comments(msg))\n\t\treturn c, messages.LoadingComplete\n\t}\n\n\treturn c, nil\n}\n\nfunc (c CommentsPage) handleFocusedMessages(msg tea.Msg) (CommentsPage, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch keypress := msg.String(); keypress {\n\t\tcase \"H\":\n\t\t\treturn c, messages.LoadHome\n\n\t\tcase \"escape\", \"backspace\", \"left\", \"h\":\n\t\t\treturn c, messages.GoBack\n\n\t\tcase \"o\", \"O\":\n\t\t\treturn c, messages.OpenUrl(c.postUrl)\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tc.pager, cmd = c.pager.Update(msg)\n\treturn c, cmd\n}\n\nfunc (c CommentsPage) View() string {\n\theaderView := c.header.View()\n\tpagerView := c.pager.View()\n\tjoined := lipgloss.JoinVertical(lipgloss.Center, headerView, pagerView)\n\treturn c.containerStyle.Render(joined)\n}\n\nfunc (c *CommentsPage) SetSize(w, h int) {\n\tc.containerStyle = c.containerStyle.Width(w).Height(h)\n\tc.resizeComponents()\n}\n\nfunc (c *CommentsPage) Focus() {\n\tc.focus = true\n}\n\nfunc (c *CommentsPage) Blur() {\n\tc.focus = false\n}\n\nfunc (c *CommentsPage) resizeComponents() {\n\tvar (\n\t\tw            = c.containerStyle.GetWidth() - c.containerStyle.GetHorizontalFrameSize()\n\t\th            = c.containerStyle.GetHeight() - c.containerStyle.GetVerticalFrameSize()\n\t\theaderHeight = lipgloss.Height(c.header.View())\n\t\tpagerHeight  = h - headerHeight\n\t)\n\n\tc.header.SetSize(w, h)\n\tc.pager.SetSize(w, pagerHeight)\n}\n\nfunc (c *CommentsPage) loadComments(url string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tcomments, err := c.redditClient.GetComments(url)\n\t\tif err != nil {\n\t\t\tslog.Error(commentsErrorText, \"error\", err)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: commentsErrorText}\n\t\t}\n\n\t\treturn messages.UpdateCommentsMsg(comments)\n\t}\n}\n\nfunc (c *CommentsPage) updateComments(comments model.Comments) {\n\tc.header.SetContent(comments)\n\tc.pager.SetContent(comments)\n\tc.postUrl = comments.PostUrl\n\n\t// Need to resize components when content loads so padding and margins are correct\n\tc.resizeComponents()\n}\n"
  },
  {
    "path": "components/comments/header.go",
    "content": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/model\"\n\t\"reddittui/utils\"\n\t\"strconv\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar (\n\theaderContainerStyle = lipgloss.NewStyle().MarginBottom(2)\n\ttitleStyle           = lipgloss.NewStyle().\n\t\t\t\tMarginBottom(1).\n\t\t\t\tPadding(0, 2).\n\t\t\t\tHeight(1).\n\t\t\t\tBackground(colors.AdaptiveColors(colors.Blue, colors.Indigo)).\n\t\t\t\tForeground(colors.AdaptiveColors(colors.White, colors.Sand))\n\n\tdefaultDescriptionStyle = lipgloss.NewStyle().\n\t\t\t\tBold(true).\n\t\t\t\tForeground(colors.AdaptiveColor(colors.Text))\n)\n\ntype CommentsHeader struct {\n\tDescriptionStyle lipgloss.Style\n\tTitle            string\n\tDescription      string\n\tAuthor           string\n\tTimestamp        string\n\tPoints           string\n\tTotalComments    int\n\tW                int\n}\n\nfunc NewCommentsHeader() CommentsHeader {\n\treturn CommentsHeader{DescriptionStyle: defaultDescriptionStyle}\n}\n\nfunc (h *CommentsHeader) SetSize(width, height int) {\n\th.W = width - headerContainerStyle.GetHorizontalFrameSize()\n\th.DescriptionStyle = h.DescriptionStyle.Width(h.W)\n}\n\nfunc (h CommentsHeader) View() string {\n\ttitleView := titleStyle.Render(utils.TruncateString(h.Title, h.W))\n\tdescriptionView := h.DescriptionStyle.Render(h.Description)\n\n\tauthorView := postAuthorStyle.Render(h.Author)\n\ttimestampView := postTimestampStyle.Render(fmt.Sprintf(\"submitted %s by\", h.Timestamp))\n\tauthorTimestampView := fmt.Sprintf(\"%s %s\", timestampView, authorView)\n\n\tpostPointsView := postPointsStyle.Render(utils.GetSingularPlural(h.Points, \"point\", \"points\"))\n\ttotalCommentsView := totalCommentsStyle.Render(utils.GetSingularPlural(strconv.Itoa(h.TotalComments), \"comment\", \"comments\"))\n\tpointsAndCommentsView := fmt.Sprintf(\"%s • %s\", postPointsView, totalCommentsView)\n\n\tjoinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView, authorTimestampView, pointsAndCommentsView)\n\n\treturn headerContainerStyle.Render(joinedView)\n}\n\nfunc (h *CommentsHeader) SetContent(comments model.Comments) {\n\th.Title = utils.NormalizeSubreddit(comments.Subreddit)\n\th.Description = comments.PostTitle\n\th.Author = comments.PostAuthor\n\th.TotalComments = len(comments.Comments)\n\th.Timestamp = comments.PostTimestamp\n\th.Points = comments.PostPoints\n}\n"
  },
  {
    "path": "components/comments/keys.go",
    "content": "package comments\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\ntype viewportKeyMap struct {\n\tCursorUp         key.Binding\n\tCursorDown       key.Binding\n\tGoToStart        key.Binding\n\tGoToEnd          key.Binding\n\tOpenPost         key.Binding\n\tGoHome           key.Binding\n\tCollapseComments key.Binding\n\tShowFullHelp     key.Binding\n\tCloseFullHelp    key.Binding\n\tQuit             key.Binding\n\tForceQuit        key.Binding\n}\n\nvar commentsKeys = viewportKeyMap{\n\tCursorUp: key.NewBinding(\n\t\tkey.WithKeys(\"up\", \"k\"),\n\t\tkey.WithHelp(\"↑/k\", \"up\"),\n\t),\n\tCursorDown: key.NewBinding(\n\t\tkey.WithKeys(\"down\", \"j\"),\n\t\tkey.WithHelp(\"↓/j\", \"down\"),\n\t),\n\tGoToStart: key.NewBinding(\n\t\tkey.WithKeys(\"home\", \"g\"),\n\t\tkey.WithHelp(\"g/home\", \"go to start\"),\n\t),\n\tGoToEnd: key.NewBinding(\n\t\tkey.WithKeys(\"end\", \"G\"),\n\t\tkey.WithHelp(\"G/end\", \"go to end\"),\n\t),\n\tOpenPost: key.NewBinding(\n\t\tkey.WithKeys(\"o\", \"O\"),\n\t\tkey.WithHelp(\"o\", \"open post\"),\n\t),\n\tGoHome: key.NewBinding(\n\t\tkey.WithKeys(\"H\"),\n\t\tkey.WithHelp(\"H\", \"go home\"),\n\t),\n\tCollapseComments: key.NewBinding(\n\t\tkey.WithKeys(\"c\"),\n\t\tkey.WithHelp(\"c\", \"collapse comments\"),\n\t),\n\tShowFullHelp: key.NewBinding(\n\t\tkey.WithKeys(\"?\"),\n\t\tkey.WithHelp(\"?\", \"more\"),\n\t),\n\tCloseFullHelp: key.NewBinding(\n\t\tkey.WithKeys(\"?\"),\n\t\tkey.WithHelp(\"?\", \"close help\"),\n\t),\n\tQuit: key.NewBinding(\n\t\tkey.WithKeys(\"q\", \"esc\"),\n\t\tkey.WithHelp(\"q\", \"quit\"),\n\t),\n\tForceQuit: key.NewBinding(key.WithKeys(\"ctrl+c\")),\n}\n\nfunc (k viewportKeyMap) ShortHelp() []key.Binding {\n\treturn []key.Binding{k.CursorUp, k.CursorDown, k.OpenPost, k.GoHome, k.ShowFullHelp}\n}\n\nfunc (k viewportKeyMap) FullHelp() [][]key.Binding {\n\treturn [][]key.Binding{\n\t\t{k.CursorUp, k.CursorDown, k.GoToStart, k.GoToEnd, k.OpenPost},\n\t\t{k.GoHome, k.CollapseComments, k.Quit, k.CloseFullHelp},\n\t}\n}\n"
  },
  {
    "path": "components/comments/pager.go",
    "content": "package comments\n\nimport (\n\t\"fmt\"\n\t\"reddittui/model\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/help\"\n\t\"github.com/charmbracelet/bubbles/key\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\ntype CommentsViewport struct {\n\tviewport      viewport.Model\n\tpostText      string\n\tpostUrl       string\n\tcomments      []model.Comment\n\tkeyMap        viewportKeyMap\n\thelp          help.Model\n\tcollapsed     bool\n\tviewportLines []string\n\tw, h          int\n}\n\nfunc NewCommentsViewport() CommentsViewport {\n\treturn CommentsViewport{\n\t\tviewport:  viewport.New(0, 0),\n\t\tkeyMap:    commentsKeys,\n\t\thelp:      help.New(),\n\t\tcollapsed: false,\n\t}\n}\n\nfunc (c CommentsViewport) Update(msg tea.Msg) (CommentsViewport, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, c.keyMap.GoToStart):\n\t\t\tc.viewport.GotoTop()\n\t\tcase key.Matches(msg, c.keyMap.GoToEnd):\n\t\t\tc.viewport.GotoBottom()\n\t\tcase key.Matches(msg, c.keyMap.CollapseComments):\n\t\t\tc.toggleCollapseComments()\n\t\tcase key.Matches(msg, c.keyMap.ShowFullHelp),\n\t\t\tkey.Matches(msg, c.keyMap.CloseFullHelp):\n\t\t\tc.help.ShowAll = !c.help.ShowAll\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tc.viewport, cmd = c.viewport.Update(msg)\n\treturn c, cmd\n}\n\nfunc (c CommentsViewport) View() string {\n\tviewportView := viewportStyle.Render(c.viewport.View())\n\thelpView := c.help.View(c.keyMap)\n\treturn lipgloss.JoinVertical(lipgloss.Left, viewportView, helpView)\n}\n\nfunc (c *CommentsViewport) SetSize(w, h int) {\n\tc.w = w - viewportStyle.GetHorizontalFrameSize()\n\tc.h = h\n\n\tc.ResizeComponents()\n\tc.SetViewportContent()\n}\n\nfunc (c *CommentsViewport) SetContent(comments model.Comments) {\n\tc.postText = comments.PostText\n\tc.postUrl = comments.PostUrl\n\tc.comments = comments.Comments\n\n\tc.collapsed = false\n\tc.viewport.SetYOffset(0)\n\tc.ResizeComponents()\n\tc.SetViewportContent()\n}\n\nfunc (c *CommentsViewport) ResizeComponents() {\n\thelpHeight := lipgloss.Height(c.help.View(c.keyMap))\n\n\tc.viewport.Width = c.w\n\tc.viewport.Height = c.h - helpHeight - 1\n}\n\nfunc (c *CommentsViewport) GetViewportView() string {\n\tvar content strings.Builder\n\n\tif len(c.postText) > 0 {\n\t\tcontent.WriteString(c.postText)\n\t\tcontent.WriteString(\"\\n\")\n\t} else {\n\t\tcontent.WriteString(c.postUrl)\n\t\tcontent.WriteString(\"\\n\\n\")\n\t}\n\n\tfor i := range len(c.comments) {\n\t\tcomment := c.comments[i]\n\t\tcommentView := c.formatComment(comment, i)\n\t\tif len(commentView) > 0 {\n\t\t\tcontent.WriteString(commentView)\n\t\t\tcontent.WriteString(\"\\n\\n\")\n\t\t}\n\t}\n\n\treturn content.String()\n}\n\nfunc (c *CommentsViewport) SetViewportContent() {\n\tcontent := c.GetViewportView()\n\tc.viewport.SetContent(content)\n\tc.viewportLines = strings.Split(content, \"\\n\")\n}\n\n// Format comment, adding padding to the entry according to the comment's depth\nfunc (c *CommentsViewport) formatComment(comment model.Comment, i int) string {\n\tvar (\n\t\tauthorAndDateView          string\n\t\tpointsView                 string\n\t\tpointsAndCollapsedHintView string\n\t\tpaddingW                   = comment.Depth * 2\n\t\tcontainerStyle             = lipgloss.NewStyle().PaddingLeft(paddingW).Width(c.w - paddingW)\n\t)\n\n\tif c.collapsed && comment.Depth > 0 {\n\t\treturn \"\"\n\t}\n\n\tauthorView := commentAuthorStyle.Render(comment.Author)\n\tdateView := commentDateStyle.Render(comment.Timestamp)\n\tauthorAndDateView = fmt.Sprintf(\"%s • %s\", authorView, dateView)\n\tpointsView = renderPoints(comment.Points)\n\tpointsAndCollapsedHintView = pointsView\n\n\tif c.collapsed {\n\t\tchildren := 0\n\t\tfor j := i + 1; j < len(c.comments); j++ {\n\t\t\tnextComment := c.comments[j]\n\t\t\tif nextComment.Depth == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tchildren++\n\t\t}\n\n\t\tif children == 1 {\n\t\t\tcollapsedHintView := collapsedStyle.Render(\"(1 comment hidden)\")\n\t\t\tpointsAndCollapsedHintView = fmt.Sprintf(\"%s  %s\", pointsView, collapsedHintView)\n\t\t} else if children > 1 {\n\t\t\tcollapsedView := collapsedStyle.Render(fmt.Sprintf(\"(%d comments hidden)\", children))\n\t\t\tpointsAndCollapsedHintView = fmt.Sprintf(\"%s  %s\", pointsView, collapsedView)\n\t\t}\n\t}\n\n\tjoined := lipgloss.JoinVertical(lipgloss.Left, authorAndDateView, comment.Text, pointsAndCollapsedHintView)\n\treturn containerStyle.Render(joined)\n}\n\nfunc renderPoints(pointsString string) string {\n\tparts := strings.Fields(pointsString)\n\tif len(parts) != 2 {\n\t\treturn defaultPointsStyle.Render(pointsString)\n\t}\n\n\tif strings.Contains(parts[0], \"-\") {\n\t\treturn negativePointsStyle.Render(pointsString)\n\t} else if strings.Contains(parts[0], \"k\") {\n\t\treturn popularPointsStyle.Render(pointsString)\n\t}\n\n\tpoints, err := strconv.Atoi(parts[0])\n\tif err != nil {\n\t\treturn defaultPointsStyle.Render(pointsString)\n\t} else if points >= 1000 {\n\t\treturn popularPointsStyle.Render(pointsString)\n\t}\n\n\treturn defaultPointsStyle.Render(pointsString)\n}\n\nfunc (c *CommentsViewport) toggleCollapseComments() {\n\tpos, title, text := c.findAnchorComment()\n\tif pos < 0 {\n\t\treturn\n\t}\n\n\toffset := pos - c.viewport.YOffset\n\n\tc.collapsed = !c.collapsed\n\tc.SetViewportContent()\n\n\tnewPos := c.findComment(title, text)\n\tc.viewport.SetYOffset(newPos - offset)\n}\n\n// Find comment closest to the center of the screen to act as an anchor when toggling\n// child comments.\nfunc (c *CommentsViewport) findAnchorComment() (pos int, title string, text string) {\n\tfindAnchorHelper := func(start, offset int) int {\n\t\tfor i := start; i >= 0 && i < len(c.viewportLines); i += offset {\n\t\t\tline := c.viewportLines[i]\n\t\t\tif len(line) > 0 && line[0] == ' ' {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsplit := strings.Split(line, \"•\")\n\t\t\tif len(split) == 2 && strings.Contains(split[1], \"ago\") {\n\t\t\t\treturn i\n\t\t\t}\n\t\t}\n\n\t\treturn -1\n\t}\n\n\t// Don't use actual center of viewport since the header takes up some amount of space and\n\t// users probably look closer to the top of the screen rather than the bottom\n\tsearchStart := c.viewport.YOffset + int(float64(c.viewport.Height)*0.4)\n\n\tif searchStart >= len(c.viewportLines) {\n\t\tsearchStart = 0\n\t}\n\n\t// Look for the comment above and below the center of the screen. Calculate which comment is closer to\n\t// the center of the screen\n\tupPos := findAnchorHelper(searchStart, -1)\n\tdownPos := findAnchorHelper(searchStart, 1)\n\n\tif upPos < 0 && downPos < 0 {\n\t\treturn -1, \"\", \"\"\n\t} else if upPos >= 0 && downPos < 0 {\n\t\treturn upPos, c.viewportLines[upPos], c.viewportLines[upPos+1]\n\t} else if upPos < 0 && downPos >= 0 {\n\t\treturn downPos, c.viewportLines[downPos], c.viewportLines[downPos+1]\n\t}\n\n\tupDiff, downDiff := searchStart-upPos, downPos-searchStart\n\tif upDiff < downDiff {\n\t\treturn upPos, c.viewportLines[upPos], c.viewportLines[upPos+1]\n\t}\n\treturn downPos, c.viewportLines[downPos], c.viewportLines[downPos+1]\n}\n\nfunc (c *CommentsViewport) findComment(title, text string) int {\n\tfor i := range len(c.viewportLines) - 1 {\n\t\tcurrTitle := c.viewportLines[i]\n\t\tcurrText := c.viewportLines[i+1]\n\n\t\tif currTitle == title && currText == text {\n\t\t\treturn i\n\t\t}\n\t}\n\n\treturn -1\n}\n"
  },
  {
    "path": "components/comments/styles.go",
    "content": "package comments\n\nimport (\n\t\"reddittui/components/colors\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar viewportStyle = lipgloss.NewStyle().Margin(0, 2, 1, 2)\n\nvar (\n\tcommentAuthorStyle  = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Bold(true)\n\tcommentDateStyle    = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Lavender)).Italic(true)\n\tcommentTextStyle    = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text))\n\tpopularPointsStyle  = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))\n\tdefaultPointsStyle  = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))\n\tnegativePointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red))\n\tcollapsedStyle      = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Yellow))\n)\n\nvar (\n\tpostAuthorStyle    = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue))\n\tpostPointsStyle    = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))\n\ttotalCommentsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Orange))\n\tpostTextStyle      = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Sand))\n\tpostTimestampStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Faint(true)\n)\n"
  },
  {
    "path": "components/messages/messages.go",
    "content": "package messages\n\nimport (\n\t\"reddittui/model\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\ntype ErrorModalMsg struct {\n\tErrorMsg string\n\tOnClose  tea.Cmd\n}\n\ntype (\n\tCleanCacheMsg      struct{}\n\tGoBackMsg          struct{}\n\tLoadCommentsMsg    string\n\tLoadHomeMsg        struct{}\n\tLoadMorePostsMsg   bool\n\tLoadSubredditMsg   string\n\tUpdateCommentsMsg  model.Comments\n\tUpdatePostsMsg     model.Posts\n\tAddMorePostsMsg    model.Posts\n\tLoadingCompleteMsg struct{}\n\n\tOpenModalMsg        struct{}\n\tExitModalMsg        struct{}\n\tShowSpinnerModalMsg string\n\n\tShowErrorModalMsg ErrorModalMsg\n\n\tOpenUrlMsg string\n)\n\nfunc CleanCache() tea.Msg {\n\treturn CleanCacheMsg{}\n}\n\nfunc GoBack() tea.Msg {\n\treturn GoBackMsg{}\n}\n\nfunc LoadHome() tea.Msg {\n\treturn LoadHomeMsg{}\n}\n\nfunc LoadMorePosts(home bool) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn LoadMorePostsMsg(home)\n\t}\n}\n\nfunc LoadSubreddit(subreddit string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn LoadSubredditMsg(subreddit)\n\t}\n}\n\nfunc LoadComments(url string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn LoadCommentsMsg(url)\n\t}\n}\n\nfunc LoadingComplete() tea.Msg {\n\treturn LoadingCompleteMsg{}\n}\n\nfunc OpenModal() tea.Msg {\n\treturn OpenModalMsg{}\n}\n\nfunc ExitModal() tea.Msg {\n\treturn ExitModalMsg{}\n}\n\nfunc ShowSpinnerModal(loadingMsg string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn ShowSpinnerModalMsg(loadingMsg)\n\t}\n}\n\nfunc ShowErrorModal(errorMsg string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn ShowErrorModalMsg{ErrorMsg: errorMsg}\n\t}\n}\n\nfunc ShowErrorModalWithCallback(errorMsg string, callback tea.Cmd) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn ShowErrorModalMsg{ErrorMsg: errorMsg, OnClose: callback}\n\t}\n}\n\nfunc HideSpinnerModal() tea.Msg {\n\treturn ExitModalMsg{}\n}\n\nfunc OpenUrl(url string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn OpenUrlMsg(url)\n\t}\n}\n"
  },
  {
    "path": "components/modal/error.go",
    "content": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar (\n\tdefaultErrorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red)).Bold(true)\n\terrorMsgStyle     = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text))\n)\n\ntype ErrorModal struct {\n\tErrorMsg string\n}\n\nfunc NewErrorModal() ErrorModal {\n\treturn ErrorModal{}\n}\n\nfunc (e ErrorModal) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (e ErrorModal) Update(msg tea.Msg) (ErrorModal, tea.Cmd) {\n\tswitch msg.(type) {\n\tcase tea.KeyMsg:\n\t\t// Press any key to exit modal\n\t\treturn e, messages.ExitModal\n\t}\n\n\treturn e, nil\n}\n\nfunc (e ErrorModal) View() string {\n\tdefaultErrorView := defaultErrorStyle.Render(\"Error:\")\n\terrorMsgView := errorMsgStyle.Render(e.ErrorMsg)\n\treturn fmt.Sprintf(\"%s %s\", defaultErrorView, errorMsgView)\n}\n"
  },
  {
    "path": "components/modal/modal.go",
    "content": "package modal\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\ntype SessionState int\n\nconst (\n\tdefaultState SessionState = iota\n\tloading\n\tsearching\n\tquitting\n\tshowingError\n)\n\nvar modalStyle = lipgloss.NewStyle().\n\tBorder(lipgloss.RoundedBorder(), true).\n\tBorderForeground(colors.AdaptiveColor(colors.Blue)).\n\tPadding(1, 2).\n\tMargin(1, 1)\n\ntype ModalManager struct {\n\tquit       QuitModal\n\tsearch     SubredditSearchModal\n\tspinner    SpinnerModal\n\terrorModal ErrorModal\n\tstate      SessionState\n\tstyle      lipgloss.Style\n\tonClose    tea.Cmd\n}\n\nfunc NewModalManager() ModalManager {\n\treturn ModalManager{\n\t\tquit:       NewQuitModal(),\n\t\tsearch:     NewSubredditSearchModal(),\n\t\tspinner:    NewSpinnerModal(),\n\t\terrorModal: NewErrorModal(),\n\t\tstyle:      modalStyle,\n\t}\n}\n\nfunc (m ModalManager) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m ModalManager) Update(msg tea.Msg) (ModalManager, tea.Cmd) {\n\tvar cmds []tea.Cmd\n\tvar cmd tea.Cmd\n\n\tif m.state != defaultState {\n\t\tm, cmd = m.handleFocusedMessages(msg)\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\tm, cmd = m.handleGlobalMessages(msg)\n\tcmds = append(cmds, cmd)\n\n\treturn m, tea.Batch(cmds...)\n}\n\nfunc (m ModalManager) handleGlobalMessages(msg tea.Msg) (ModalManager, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase messages.ShowSpinnerModalMsg:\n\t\tloadingMsg := string(msg)\n\t\treturn m, m.SetLoading(loadingMsg)\n\n\tcase messages.ShowErrorModalMsg:\n\t\treturn m, m.SetErrorWithCallback(msg.ErrorMsg, msg.OnClose)\n\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"esc\", \"q\":\n\t\t\tif m.state == defaultState {\n\t\t\t\treturn m, m.SetQuitting()\n\t\t\t}\n\t\tcase \"s\", \"S\":\n\t\t\treturn m, m.SetSearching()\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m ModalManager) handleFocusedMessages(msg tea.Msg) (ModalManager, tea.Cmd) {\n\tvar cmd tea.Cmd\n\n\tswitch m.state {\n\tcase loading:\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\tcase quitting:\n\t\tm.quit, cmd = m.quit.Update(msg)\n\t\treturn m, cmd\n\tcase searching:\n\t\tm.search, cmd = m.search.Update(msg)\n\t\treturn m, cmd\n\tcase showingError:\n\t\tm.errorModal, cmd = m.errorModal.Update(msg)\n\t\treturn m, cmd\n\tdefault:\n\t\treturn m, nil\n\t}\n}\n\nfunc (m ModalManager) View(background Viewer) string {\n\tswitch m.state {\n\tcase loading:\n\t\treturn PlaceModal(m.spinner, background, lipgloss.Center, lipgloss.Center, m.style)\n\tcase quitting:\n\t\treturn PlaceModal(m.quit, background, lipgloss.Center, lipgloss.Center, m.style)\n\tcase searching:\n\t\treturn PlaceModal(m.search, background, lipgloss.Center, lipgloss.Center, m.style)\n\tcase showingError:\n\t\treturn PlaceModal(m.errorModal, background, lipgloss.Center, lipgloss.Center, m.style)\n\tdefault:\n\t\t// This sometimes happens when loading completes before the loading modal finishes rendering\n\t\treturn \"\"\n\t}\n}\n\nfunc (m *ModalManager) SetSize(w, h int) {\n\tm.search.SetSize(w, h)\n\n\tmodalSize := int((float64(w) * (2)) / 3.0)\n\tm.style = m.style.MaxWidth(modalSize)\n}\n\nfunc (m *ModalManager) Blur() tea.Cmd {\n\tm.state = defaultState\n\tm.search.Blur()\n\n\tonClose := m.onClose\n\tm.onClose = nil\n\treturn onClose\n}\n\nfunc (m *ModalManager) SetLoading(message string) tea.Cmd {\n\tm.state = loading\n\tm.spinner.SetLoading(message)\n\treturn m.spinner.Tick\n}\n\nfunc (m *ModalManager) SetSearching() tea.Cmd {\n\tm.state = searching\n\tm.search.Focus()\n\treturn messages.OpenModal\n}\n\nfunc (m *ModalManager) SetQuitting() tea.Cmd {\n\tm.state = quitting\n\treturn messages.OpenModal\n}\n\nfunc (m *ModalManager) SetError(errorMsg string) tea.Cmd {\n\tm.state = showingError\n\tm.errorModal.ErrorMsg = errorMsg\n\treturn messages.OpenModal\n}\n\nfunc (m *ModalManager) SetErrorWithCallback(errorMsg string, onClose tea.Cmd) tea.Cmd {\n\tm.state = showingError\n\tm.onClose = onClose\n\tm.errorModal.ErrorMsg = errorMsg\n\treturn messages.OpenModal\n}\n"
  },
  {
    "path": "components/modal/quit.go",
    "content": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nconst (\n\tquitMsg  = \"Are you sure you want to quit?\"\n\tyesNoMsg = \"(y/n)\"\n)\n\nvar (\n\tquitTitleStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)\n\tquitYesNoStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Bold(true)\n)\n\ntype QuitModal struct{}\n\nfunc NewQuitModal() QuitModal {\n\treturn QuitModal{}\n}\n\nfunc (q QuitModal) View() string {\n\ttitleView := quitTitleStyle.Render(quitMsg)\n\tyesNoView := quitYesNoStyle.Render(yesNoMsg)\n\treturn fmt.Sprintf(\"%s  %s\", titleView, yesNoView)\n}\n\nfunc (q QuitModal) Update(msg tea.Msg) (QuitModal, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"y\", \"Y\", \"q\", \"Q\", \"esc\":\n\t\t\treturn q, tea.Quit\n\n\t\tdefault:\n\t\t\treturn q, messages.ExitModal\n\t\t}\n\t}\n\n\treturn q, nil\n}\n"
  },
  {
    "path": "components/modal/render.go",
    "content": "package modal\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mattn/go-runewidth\"\n\t\"github.com/muesli/ansi\"\n\t\"github.com/muesli/reflow/truncate\"\n\t\"github.com/muesli/termenv\"\n)\n\nconst maxModalWidthPercentage = 0.66\n\n// Most code in this file is taken from the following codebases (modals are not currently supported in charmbracelet)\n//\n// https://github.com/mrusme/neonmodem/blob/a237769cbfc526bec380d90e4a3b873fbb47e77c/ui/helpers/overlay.go#L37\n// https://github.com/charmbracelet/lipgloss/pull/102\ntype Viewer interface {\n\tView() string\n}\n\n// func Place(fg, bg string, xPos, yPos lipgloss.Position) string {\nfunc PlaceModal(foreground, background Viewer, xPos, yPos lipgloss.Position, modalStyle lipgloss.Style) string {\n\tvar (\n\t\tx int\n\t\ty int\n\n\t\tinitFg      = modalStyle.Render(foreground.View())\n\t\tinitFgWidth = lipgloss.Width(initFg)\n\n\t\tfgWidth  = min(initFgWidth, int(float64(modalStyle.GetMaxWidth())*maxModalWidthPercentage))\n\t\tfg       = modalStyle.Width(fgWidth).Render(foreground.View())\n\t\tfgHeight = lipgloss.Height(fg)\n\n\t\tbg       = background.View()\n\t\tbgWidth  = lipgloss.Width(bg)\n\t\tbgHeight = lipgloss.Height(bg)\n\t)\n\n\tswitch xPos {\n\tcase lipgloss.Left:\n\t\tx = 0\n\tcase lipgloss.Center:\n\t\tx = (bgWidth / 2) - (fgWidth / 2) - 1\n\tcase lipgloss.Right:\n\t\tx = bgWidth - fgWidth\n\t}\n\n\tswitch yPos {\n\tcase lipgloss.Top:\n\t\ty = 0\n\tcase lipgloss.Center:\n\t\t// 45% looks more pleasing than 50% for center aligned modals\n\t\ty = int((float64(bgHeight) * 0.45)) - (fgHeight / 2) - 1\n\tcase lipgloss.Bottom:\n\t\tx = bgHeight - fgHeight\n\t}\n\n\treturn Place(x, y, fg, bg, false)\n}\n\n// PlaceOverlay places fg on top of bg.\nfunc Place(\n\tx, y int,\n\tfg, bg string,\n\tshadow bool, opts ...WhitespaceOption,\n) string {\n\tfgLines, fgWidth := getLines(fg)\n\tbgLines, bgWidth := getLines(bg)\n\tbgHeight := len(bgLines)\n\tfgHeight := len(fgLines)\n\n\tif shadow {\n\t\tvar shadowbg string = \"\"\n\t\tshadowchar := lipgloss.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"#333333\")).\n\t\t\tRender(\"░\")\n\t\tfor i := 0; i <= fgHeight; i++ {\n\t\t\tif i == 0 {\n\t\t\t\tshadowbg += \" \" + strings.Repeat(\" \", fgWidth) + \"\\n\"\n\t\t\t} else {\n\t\t\t\tshadowbg += \" \" + strings.Repeat(shadowchar, fgWidth) + \"\\n\"\n\t\t\t}\n\t\t}\n\n\t\tfg = Place(0, 0, fg, shadowbg, false, opts...)\n\t\tfgLines, fgWidth = getLines(fg)\n\t\tfgHeight = len(fgLines)\n\t}\n\n\tif fgWidth >= bgWidth && fgHeight >= bgHeight {\n\t\t// FIXME: return fg or bg?\n\t\treturn fg\n\t}\n\t// TODO: allow placement outside of the bg box?\n\tx = clamp(x, 0, bgWidth-fgWidth)\n\ty = clamp(y, 0, bgHeight-fgHeight)\n\n\tws := &whitespace{}\n\tfor _, opt := range opts {\n\t\topt(ws)\n\t}\n\n\tvar b strings.Builder\n\tfor i, bgLine := range bgLines {\n\t\tif i > 0 {\n\t\t\tb.WriteByte('\\n')\n\t\t}\n\t\tif i < y || i >= y+fgHeight {\n\t\t\tb.WriteString(bgLine)\n\t\t\tcontinue\n\t\t}\n\n\t\tpos := 0\n\t\tif x > 0 {\n\t\t\tleft := truncate.String(bgLine, uint(x))\n\t\t\tpos = ansi.PrintableRuneWidth(left)\n\t\t\tb.WriteString(left)\n\t\t\tif pos < x {\n\t\t\t\tb.WriteString(ws.render(x - pos))\n\t\t\t\tpos = x\n\t\t\t}\n\t\t}\n\n\t\tfgLine := fgLines[i-y]\n\t\tb.WriteString(fgLine)\n\t\tpos += ansi.PrintableRuneWidth(fgLine)\n\n\t\tright := cutLeft(bgLine, pos)\n\t\tbgWidth := ansi.PrintableRuneWidth(bgLine)\n\t\trightWidth := ansi.PrintableRuneWidth(right)\n\t\tif rightWidth <= bgWidth-pos {\n\t\t\tb.WriteString(ws.render(bgWidth - rightWidth - pos))\n\t\t}\n\n\t\tb.WriteString(right)\n\t}\n\n\treturn b.String()\n}\n\n// Split a string into lines, additionally returning the size of the widest\n// line.\nfunc getLines(s string) (lines []string, widest int) {\n\tlines = strings.Split(s, \"\\n\")\n\n\tfor _, l := range lines {\n\t\tw := ansi.PrintableRuneWidth(l)\n\t\tif widest < w {\n\t\t\twidest = w\n\t\t}\n\t}\n\n\treturn lines, widest\n}\n\n// cutLeft cuts printable characters from the left.\n// This function is heavily based on muesli's ansi and truncate packages.\nfunc cutLeft(s string, cutWidth int) string {\n\tvar (\n\t\tpos    int\n\t\tisAnsi bool\n\t\tab     bytes.Buffer\n\t\tb      bytes.Buffer\n\t)\n\tfor _, c := range s {\n\t\tvar w int\n\t\tif c == ansi.Marker || isAnsi {\n\t\t\tisAnsi = true\n\t\t\tab.WriteRune(c)\n\t\t\tif ansi.IsTerminator(c) {\n\t\t\t\tisAnsi = false\n\t\t\t\tif bytes.HasSuffix(ab.Bytes(), []byte(\"[0m\")) {\n\t\t\t\t\tab.Reset()\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tw = runewidth.RuneWidth(c)\n\t\t}\n\n\t\tif pos >= cutWidth {\n\t\t\tif b.Len() == 0 {\n\t\t\t\tif ab.Len() > 0 {\n\t\t\t\t\tb.Write(ab.Bytes())\n\t\t\t\t}\n\t\t\t\tif pos-cutWidth > 1 {\n\t\t\t\t\tb.WriteByte(' ')\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tb.WriteRune(c)\n\t\t}\n\t\tpos += w\n\t}\n\treturn b.String()\n}\n\nfunc clamp(v, lower, upper int) int {\n\treturn min(max(v, lower), upper)\n}\n\ntype whitespace struct {\n\tstyle termenv.Style\n\tchars string\n}\n\n// Render whitespaces.\nfunc (w whitespace) render(width int) string {\n\tif w.chars == \"\" {\n\t\tw.chars = \" \"\n\t}\n\n\tr := []rune(w.chars)\n\tj := 0\n\tb := strings.Builder{}\n\n\t// Cycle through runes and print them into the whitespace.\n\tfor i := 0; i < width; {\n\t\tb.WriteRune(r[j])\n\t\tj++\n\t\tif j >= len(r) {\n\t\t\tj = 0\n\t\t}\n\t\ti += ansi.PrintableRuneWidth(string(r[j]))\n\t}\n\n\t// Fill any extra gaps white spaces. This might be necessary if any runes\n\t// are more than one cell wide, which could leave a one-rune gap.\n\tshort := width - ansi.PrintableRuneWidth(b.String())\n\tif short > 0 {\n\t\tb.WriteString(strings.Repeat(\" \", short))\n\t}\n\n\treturn w.style.Styled(b.String())\n}\n\n// WhitespaceOption sets a styling rule for rendering whitespace.\ntype WhitespaceOption func(*whitespace)\n"
  },
  {
    "path": "components/modal/search.go",
    "content": "package modal\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/components/messages\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nconst (\n\tsearchHelpText     = \"Choose a subreddit:\"\n\tsearchPlaceholder  = \"subreddit\"\n\tdefaultSearchWidth = 35\n)\n\nvar (\n\tsearchHelpStyle  = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)\n\tsearchModelStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))\n)\n\ntype SubredditSearchModal struct {\n\ttextinput.Model\n\tstyle lipgloss.Style\n}\n\nfunc NewSubredditSearchModal() SubredditSearchModal {\n\tsearchTextInput := textinput.New()\n\tsearchTextInput.Placeholder = searchPlaceholder\n\tsearchTextInput.ShowSuggestions = true\n\tsearchTextInput.SetSuggestions(subredditSuggestions)\n\tsearchTextInput.CharLimit = 30\n\n\treturn SubredditSearchModal{\n\t\tModel: searchTextInput,\n\t\tstyle: lipgloss.NewStyle(),\n\t}\n}\n\nfunc (s SubredditSearchModal) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (s SubredditSearchModal) Update(msg tea.Msg) (SubredditSearchModal, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"enter\":\n\t\t\treturn s, messages.LoadSubreddit(s.Value())\n\t\tcase \"esc\":\n\t\t\treturn s, messages.ExitModal\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\ts.Model, cmd = s.Model.Update(msg)\n\treturn s, cmd\n}\n\nfunc (s SubredditSearchModal) View() string {\n\ttitleView := searchHelpStyle.Render(searchHelpText)\n\tmodelView := searchModelStyle.Render(s.Model.View())\n\tjoined := lipgloss.JoinVertical(lipgloss.Left, titleView, modelView)\n\treturn s.style.Render(joined)\n}\n\nfunc (s *SubredditSearchModal) SetSize(w, h int) {\n\tsearchW := min(w-s.style.GetHorizontalFrameSize(), defaultSearchWidth)\n\ts.style = s.style.Width(searchW)\n}\n\nfunc (s *SubredditSearchModal) Blur() {\n\ts.Model.Blur()\n\ts.Reset()\n}\n"
  },
  {
    "path": "components/modal/spinner.go",
    "content": "package modal\n\nimport (\n\t\"fmt\"\n\t\"reddittui/components/colors\"\n\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar (\n\tspinnerStyle     = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple))\n\tspinnerTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true)\n)\n\ntype SpinnerModal struct {\n\tspinner.Model\n\tLoadingMessage string\n}\n\nfunc NewSpinnerModal() SpinnerModal {\n\tmodel := spinner.New()\n\tmodel.Spinner = spinner.Dot\n\tmodel.Style = spinnerStyle\n\n\treturn SpinnerModal{\n\t\tModel: model,\n\t}\n}\n\nfunc (s SpinnerModal) Init() tea.Cmd {\n\treturn s.Tick\n}\n\nfunc (s SpinnerModal) Update(msg tea.Msg) (SpinnerModal, tea.Cmd) {\n\tvar cmd tea.Cmd\n\ts.Model, cmd = s.Model.Update(msg)\n\treturn s, cmd\n}\n\nfunc (s SpinnerModal) View() string {\n\tloadingTextView := spinnerTextStyle.Render(s.LoadingMessage)\n\treturn fmt.Sprintf(\"%s %s\", s.Model.View(), loadingTextView)\n}\n\nfunc (s *SpinnerModal) SetLoading(message string) {\n\tmodel := spinner.New()\n\tmodel.Spinner = spinner.Dot\n\tmodel.Style = spinnerStyle\n\ts.Model = model\n\ts.LoadingMessage = message\n}\n"
  },
  {
    "path": "components/modal/subredditList.go",
    "content": "package modal\n\nvar subredditSuggestions = []string{\n\t\"15minutefood\",\n\t\"adviceanimals\",\n\t\"all\",\n\t\"animalsbeingbros\",\n\t\"animalsbeingderps\",\n\t\"animalsbeingjerks\",\n\t\"anime\",\n\t\"anime_irl\",\n\t\"apple\",\n\t\"art\",\n\t\"askreddit\",\n\t\"askscience\",\n\t\"aww\",\n\t\"awwducational\",\n\t\"backpacking\",\n\t\"baking\",\n\t\"battlestations\",\n\t\"beamazed\",\n\t\"bestof\",\n\t\"bikinibottomtwitter\",\n\t\"biology\",\n\t\"bitcoin\",\n\t\"boardgames\",\n\t\"bodyweightfitness\",\n\t\"books\",\n\t\"buildapc\",\n\t\"camping\",\n\t\"canada\",\n\t\"careerguidance\",\n\t\"cars\",\n\t\"cats\",\n\t\"cfb\",\n\t\"changemyview\",\n\t\"chatgpt\",\n\t\"chemistry\",\n\t\"comicbooks\",\n\t\"compsci\",\n\t\"contagiouslaughter\",\n\t\"cooking\",\n\t\"coolguides\",\n\t\"cozyplaces\",\n\t\"crappydesign\",\n\t\"creepy\",\n\t\"cryptocurrency\",\n\t\"dadjokes\",\n\t\"damnthatsinteresting\",\n\t\"dataisbeautiful\",\n\t\"dating\",\n\t\"dating_advice\",\n\t\"daytrading\",\n\t\"design\",\n\t\"destinythegame\",\n\t\"digitalpainting\",\n\t\"diwhy\",\n\t\"diy\",\n\t\"dnd\",\n\t\"documentaries\",\n\t\"drawing\",\n\t\"dundermifflin\",\n\t\"eatcheapandhealthy\",\n\t\"economics\",\n\t\"eldenring\",\n\t\"entertainment\",\n\t\"entrepreneur\",\n\t\"ethereum\",\n\t\"europe\",\n\t\"expectationvsreality\",\n\t\"explainlikeimfive\",\n\t\"eyebleach\",\n\t\"facepalm\",\n\t\"fantasy\",\n\t\"fantasyfootball\",\n\t\"fauxmoi\",\n\t\"femalefashionadvice\",\n\t\"fitness\",\n\t\"food\",\n\t\"foodhacks\",\n\t\"formula1\",\n\t\"fortnitebr\",\n\t\"frugal\",\n\t\"funny\",\n\t\"funnyanimals\",\n\t\"futurology\",\n\t\"gadgets\",\n\t\"gameofthrones\",\n\t\"games\",\n\t\"gaming\",\n\t\"gardening\",\n\t\"genshin_impact\",\n\t\"getmotivated\",\n\t\"gifrecipes\",\n\t\"gifs\",\n\t\"google\",\n\t\"hair\",\n\t\"hardware\",\n\t\"health\",\n\t\"healthyfood\",\n\t\"highqualitygifs\",\n\t\"history\",\n\t\"historymemes\",\n\t\"holup\",\n\t\"homeautomation\",\n\t\"homeimprovement\",\n\t\"homestead\",\n\t\"howto\",\n\t\"humansbeingbros\",\n\t\"iama\",\n\t\"idiotsincars\",\n\t\"indieheads\",\n\t\"interestingasfuck\",\n\t\"internetisbeautiful\",\n\t\"iphone\",\n\t\"itookapicture\",\n\t\"japantravel\",\n\t\"jokes\",\n\t\"keto\",\n\t\"kpop\",\n\t\"leagueoflegends\",\n\t\"learnprogramming\",\n\t\"lifehacks\",\n\t\"lifeprotips\",\n\t\"listentothis\",\n\t\"loseit\",\n\t\"mademesmile\",\n\t\"makeupaddiction\",\n\t\"malefashionadvice\",\n\t\"maliciouscompliance\",\n\t\"marvelmemes\",\n\t\"marvelstudios\",\n\t\"math\",\n\t\"maybemaybemaybe\",\n\t\"mealprepsunday\",\n\t\"meditation\",\n\t\"memes\",\n\t\"mildlyinfuriating\",\n\t\"mildlyinteresting\",\n\t\"minecraft\",\n\t\"minecraftmemes\",\n\t\"mma\",\n\t\"modernwarfareii\",\n\t\"motorcycles\",\n\t\"moviedetails\",\n\t\"movies\",\n\t\"music\",\n\t\"mypeopleneedme\",\n\t\"nails\",\n\t\"nasa\",\n\t\"natureisfuckinglit\",\n\t\"nba\",\n\t\"netflixbestof\",\n\t\"nevertellmetheodds\",\n\t\"news\",\n\t\"nfl\",\n\t\"nintendoswitch\",\n\t\"nosleep\",\n\t\"nostupidquestions\",\n\t\"nottheonion\",\n\t\"nutrition\",\n\t\"oddlysatisfying\",\n\t\"oddlyspecific\",\n\t\"offmychest\",\n\t\"oldschoolcool\",\n\t\"onepiece\",\n\t\"outdoors\",\n\t\"outoftheloop\",\n\t\"overwatch\",\n\t\"painting\",\n\t\"parenting\",\n\t\"pcgaming\",\n\t\"pcmasterrace\",\n\t\"personalfinance\",\n\t\"pettyrevenge\",\n\t\"philosophy\",\n\t\"photography\",\n\t\"photoshopbattles\",\n\t\"pics\",\n\t\"podcasts\",\n\t\"pokemon\",\n\t\"pokemongo\",\n\t\"politics\",\n\t\"popculturechat\",\n\t\"premierleague\",\n\t\"prequelmemes\",\n\t\"productivity\",\n\t\"programmerhumor\",\n\t\"programming\",\n\t\"ps4\",\n\t\"ps5\",\n\t\"psychology\",\n\t\"rarepuppers\",\n\t\"reactiongifs\",\n\t\"recipes\",\n\t\"relationship_advice\",\n\t\"relationshipmemes\",\n\t\"roadtrip\",\n\t\"running\",\n\t\"science\",\n\t\"sciencememes\",\n\t\"scifi\",\n\t\"shoestring\",\n\t\"showerthoughts\",\n\t\"singularity\",\n\t\"skincareaddiction\",\n\t\"slowcooking\",\n\t\"sneakers\",\n\t\"soccer\",\n\t\"socialskills\",\n\t\"solotravel\",\n\t\"space\",\n\t\"spacex\",\n\t\"sports\",\n\t\"standupshots\",\n\t\"starterpacks\",\n\t\"starwars\",\n\t\"steam\",\n\t\"stockmarket\",\n\t\"streetwear\",\n\t\"strength_training\",\n\t\"survival\",\n\t\"tattoos\",\n\t\"taylorswift\",\n\t\"technicallythetruth\",\n\t\"technology\",\n\t\"television\",\n\t\"teslamotors\",\n\t\"thriftstorehauls\",\n\t\"tifu\",\n\t\"tinder\",\n\t\"todayilearned\",\n\t\"travel\",\n\t\"travelhacks\",\n\t\"trippinthroughtime\",\n\t\"unexpected\",\n\t\"unitedkingdom\",\n\t\"unpopularopinion\",\n\t\"upliftingnews\",\n\t\"videos\",\n\t\"wallstreetbets\",\n\t\"watchpeopledieinside\",\n\t\"wearethemusicmakers\",\n\t\"wholesomememes\",\n\t\"woahdude\",\n\t\"woodworking\",\n\t\"worldnews\",\n\t\"writingprompts\",\n\t\"youshouldknow\",\n}\n"
  },
  {
    "path": "components/posts/header.go",
    "content": "package posts\n\nimport (\n\t\"reddittui/components/colors\"\n\t\"reddittui/utils\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar (\n\theaderContainerStyle = lipgloss.NewStyle().MarginBottom(2)\n\ttitleStyle           = lipgloss.NewStyle().\n\t\t\t\tMarginBottom(1).\n\t\t\t\tPadding(0, 2).\n\t\t\t\tHeight(1).\n\t\t\t\tBackground(colors.AdaptiveColors(colors.Blue, colors.Indigo)).\n\t\t\t\tForeground(colors.AdaptiveColors(colors.White, colors.Sand))\n\n\tdefaultDescriptionStyle = lipgloss.NewStyle().\n\t\t\t\tBold(true).\n\t\t\t\tForeground(colors.AdaptiveColor(colors.Text))\n)\n\ntype PostsHeader struct {\n\tDescriptionStyle lipgloss.Style\n\tTitle            string\n\tDescription      string\n\tW                int\n}\n\nfunc NewPostsHeader() PostsHeader {\n\treturn PostsHeader{DescriptionStyle: defaultDescriptionStyle}\n}\n\nfunc (h *PostsHeader) SetSize(width, height int) {\n\th.W = width - headerContainerStyle.GetHorizontalFrameSize()\n\th.DescriptionStyle = h.DescriptionStyle.Width(h.W)\n}\n\nfunc (h PostsHeader) View() string {\n\ttitleView := titleStyle.Render(utils.TruncateString(h.Title, h.W))\n\tdescriptionView := h.DescriptionStyle.Render(h.Description)\n\n\tjoinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView)\n\treturn headerContainerStyle.Render(joinedView)\n}\n\nfunc (h *PostsHeader) SetContent(title, desc string) {\n\th.Title = utils.NormalizeSubreddit(title)\n\th.Description = desc\n}\n"
  },
  {
    "path": "components/posts/keys.go",
    "content": "package posts\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\ntype postsKeyMap struct {\n\tHome   key.Binding\n\tSearch key.Binding\n\tBack   key.Binding\n\tLoad   key.Binding\n}\n\nvar postsKeys = postsKeyMap{\n\tHome: key.NewBinding(\n\t\tkey.WithKeys(\"H\"),\n\t\tkey.WithHelp(\"H\", \"home\")),\n\tSearch: key.NewBinding(\n\t\tkey.WithKeys(\"s\"),\n\t\tkey.WithHelp(\"s\", \"subreddit search\")),\n\tBack: key.NewBinding(\n\t\tkey.WithKeys(\"bs\"),\n\t\tkey.WithHelp(\"bs\", \"back\")),\n\tLoad: key.NewBinding(\n\t\tkey.WithKeys(\"L\"),\n\t\tkey.WithHelp(\"L\", \"load more posts\")),\n}\n\nfunc (k postsKeyMap) ShortHelp() []key.Binding {\n\treturn []key.Binding{k.Home, k.Search, k.Load}\n}\n\nfunc (k postsKeyMap) FullHelp() []key.Binding {\n\treturn []key.Binding{k.Home, k.Search, k.Back, k.Load}\n}\n"
  },
  {
    "path": "components/posts/postsPage.go",
    "content": "package posts\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/client/common\"\n\t\"reddittui/components/messages\"\n\t\"reddittui/components/styles\"\n\t\"reddittui/model\"\n\n\t\"github.com/charmbracelet/bubbles/list\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nconst (\n\tdefaultHeaderTitle       = \"reddit.com\"\n\tdefaultHeaderDescription = \"The front page of the internet\"\n\tpostsErrorText           = \"Could not load posts. Please try again in a few moments.\"\n\tsubredditNotFoundText    = \"Subreddit not found\"\n)\n\ntype PostsPage struct {\n\tSubreddit      string\n\tposts          model.Posts\n\tredditClient   client.RedditClient\n\theader         PostsHeader\n\tlist           list.Model\n\tfocus          bool\n\tHome           bool\n\tcontainerStyle lipgloss.Style\n}\n\nfunc NewPostsPage(redditClient client.RedditClient, home bool) PostsPage {\n\titems := list.New(nil, NewPostsDelegate(), 0, 0)\n\titems.SetShowTitle(false)\n\titems.SetShowStatusBar(false)\n\titems.KeyMap.NextPage.SetEnabled(false)\n\titems.KeyMap.PrevPage.SetEnabled(false)\n\titems.SetFilteringEnabled(false)\n\titems.AdditionalShortHelpKeys = postsKeys.ShortHelp\n\titems.AdditionalFullHelpKeys = postsKeys.FullHelp\n\n\theader := NewPostsHeader()\n\tif home {\n\t\theader.SetContent(defaultHeaderTitle, defaultHeaderDescription)\n\t}\n\n\tcontainerStyle := styles.GlobalStyle\n\n\treturn PostsPage{\n\t\tlist:           items,\n\t\tredditClient:   redditClient,\n\t\theader:         header,\n\t\tHome:           home,\n\t\tcontainerStyle: containerStyle,\n\t}\n}\n\nfunc (p PostsPage) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (p PostsPage) Update(msg tea.Msg) (PostsPage, tea.Cmd) {\n\tvar cmds []tea.Cmd\n\tvar cmd tea.Cmd\n\n\tif p.focus {\n\t\tp, cmd = p.handleFocusedMessages(msg)\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\tp, cmd = p.handleGlobalMessages(msg)\n\tcmds = append(cmds, cmd)\n\n\treturn p, tea.Batch(cmds...)\n}\n\nfunc (p PostsPage) handleGlobalMessages(msg tea.Msg) (PostsPage, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase messages.LoadHomeMsg:\n\t\tif p.Home {\n\t\t\treturn p, p.loadHome()\n\t\t}\n\n\tcase messages.LoadSubredditMsg:\n\t\tif !p.Home {\n\t\t\tsubreddit := string(msg)\n\t\t\treturn p, p.loadSubreddit(subreddit)\n\t\t}\n\n\tcase messages.LoadMorePostsMsg:\n\t\tisHome := bool(msg)\n\t\tif p.Home == isHome {\n\t\t\treturn p, p.loadMorePosts()\n\t\t}\n\n\tcase messages.UpdatePostsMsg:\n\t\tposts := model.Posts(msg)\n\t\tif posts.IsHome == p.Home {\n\t\t\tp.updatePosts(posts)\n\t\t\treturn p, messages.LoadingComplete\n\t\t}\n\n\tcase messages.AddMorePostsMsg:\n\t\tposts := model.Posts(msg)\n\t\tif posts.IsHome == p.Home {\n\t\t\tp.addPosts(posts)\n\t\t\treturn p, messages.LoadingComplete\n\t\t}\n\t}\n\n\treturn p, nil\n}\n\nfunc (p PostsPage) handleFocusedMessages(msg tea.Msg) (PostsPage, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch keypress := msg.String(); keypress {\n\t\tcase \"enter\", \"right\", \"l\":\n\t\t\tloadCommentsCmd := func() tea.Msg {\n\t\t\t\tpost := p.posts.Posts[p.list.Index()]\n\t\t\t\treturn messages.LoadCommentsMsg(post.CommentsUrl)\n\t\t\t}\n\n\t\t\treturn p, loadCommentsCmd\n\n\t\tcase \"q\", \"Q\":\n\t\t\t// Ignore q keystrokes to list.Modal. since it will default to sending a Quit message\n\t\t\t// instead of showing the quit modal. Tui component will correctly handle quit mesages\n\t\t\treturn p, nil\n\n\t\tcase \"L\":\n\t\t\treturn p, messages.LoadMorePosts(p.Home)\n\n\t\tcase \"H\":\n\t\t\treturn p, messages.LoadHome\n\n\t\tcase \"esc\", \"backspace\", \"left\", \"h\":\n\t\t\treturn p, messages.GoBack\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tp.list, cmd = p.list.Update(msg)\n\treturn p, cmd\n}\n\nfunc (p PostsPage) View() string {\n\tif len(p.posts.Posts) == 0 {\n\t\treturn p.containerStyle.Render(\"\")\n\t}\n\n\theaderView := p.header.View()\n\tlistView := p.list.View()\n\tjoined := lipgloss.JoinVertical(lipgloss.Left, headerView, listView)\n\treturn p.containerStyle.Render(joined)\n}\n\nfunc (p *PostsPage) SetSize(w, h int) {\n\tp.containerStyle = p.containerStyle.Width(w).Height(h)\n\tp.resizeComponents()\n}\n\nfunc (p *PostsPage) Focus() {\n\tp.focus = true\n}\n\nfunc (p *PostsPage) Blur() {\n\tp.focus = false\n}\n\nfunc (p *PostsPage) resizeComponents() {\n\tvar (\n\t\tw            = p.containerStyle.GetWidth() - p.containerStyle.GetHorizontalFrameSize()\n\t\th            = p.containerStyle.GetHeight() - p.containerStyle.GetVerticalFrameSize()\n\t\tlistWidth    = w - postsListStyle.GetHorizontalFrameSize()\n\t\theaderHeight = lipgloss.Height(p.header.View())\n\t\tlistHeight   = h - headerHeight\n\t)\n\n\tp.header.SetSize(w, h)\n\tp.list.SetSize(listWidth, listHeight)\n}\n\nfunc (p *PostsPage) loadHome() tea.Cmd {\n\treturn func() tea.Msg {\n\t\tposts, err := p.redditClient.GetHomePosts(\"\")\n\t\tif err != nil {\n\t\t\tslog.Error(postsErrorText, \"error\", err)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}\n\t\t}\n\n\t\treturn messages.UpdatePostsMsg(posts)\n\t}\n}\n\nfunc (p *PostsPage) loadMorePosts() tea.Cmd {\n\treturn func() tea.Msg {\n\t\tvar (\n\t\t\tposts model.Posts\n\t\t\terr   error\n\t\t)\n\n\t\tif len(p.posts.After) == 0 {\n\t\t\tslog.Error(postsErrorText, \"error\", err)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}\n\t\t}\n\n\t\tif p.posts.IsHome {\n\t\t\tposts, err = p.redditClient.GetHomePosts(p.posts.After)\n\t\t} else {\n\t\t\tposts, err = p.redditClient.GetSubredditPosts(p.Subreddit, p.posts.After)\n\t\t}\n\n\t\tif err != nil {\n\t\t\tslog.Error(postsErrorText, \"error\", err)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}\n\t\t}\n\n\t\treturn messages.AddMorePostsMsg(posts)\n\t}\n}\n\nfunc (p PostsPage) loadSubreddit(subreddit string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tposts, err := p.redditClient.GetSubredditPosts(subreddit, \"\")\n\t\tif err == common.ErrNotFound {\n\t\t\tslog.Error(subredditNotFoundText, \"error\", err, \"subreddit\", subreddit)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: fmt.Sprintf(\"%s: %s\", subredditNotFoundText, subreddit)}\n\t\t} else if err != nil {\n\t\t\tslog.Error(postsErrorText, \"error\", err)\n\t\t\treturn messages.ShowErrorModalMsg{ErrorMsg: postsErrorText}\n\t\t}\n\n\t\treturn messages.UpdatePostsMsg(posts)\n\t}\n}\n\nfunc (p *PostsPage) updatePosts(posts model.Posts) {\n\tp.posts = posts\n\n\tif posts.IsHome {\n\t\tp.header.SetContent(defaultHeaderTitle, defaultHeaderDescription)\n\t} else {\n\t\tp.header.SetContent(posts.Subreddit, posts.Description)\n\t\tp.Subreddit = posts.Subreddit\n\t}\n\n\tp.list.ResetSelected()\n\n\tvar listItems []list.Item\n\tfor _, p := range posts.Posts {\n\t\tlistItems = append(listItems, p)\n\t}\n\tp.list.SetItems(listItems)\n\n\t// Need to set size again when content loads so padding and margins are correct\n\tp.resizeComponents()\n}\n\nfunc (p *PostsPage) addPosts(posts model.Posts) {\n\tuniqueTitles := make(map[string]bool)\n\n\tp.posts.Posts = append(p.posts.Posts, posts.Posts...)\n\tp.posts.After = posts.After\n\n\t// Merge existing posts with new posts, avoiding duplicates\n\tvar listItems []list.Item\n\tfor _, p := range p.posts.Posts {\n\t\tif _, ok := uniqueTitles[p.PostTitle]; !ok {\n\t\t\tlistItems = append(listItems, p)\n\t\t\tuniqueTitles[p.PostTitle] = true\n\t\t}\n\t}\n\tfor _, p := range posts.Posts {\n\t\tif _, ok := uniqueTitles[p.PostTitle]; !ok {\n\t\t\tlistItems = append(listItems, p)\n\t\t\tuniqueTitles[p.PostTitle] = true\n\t\t}\n\t}\n\n\tp.list.SetItems(listItems)\n\n\t// Need to set size again when content loads so padding and margins are correct\n\tp.resizeComponents()\n}\n"
  },
  {
    "path": "components/posts/styles.go",
    "content": "package posts\n\nimport (\n\t\"github.com/charmbracelet/bubbles/list\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nvar postsListStyle = lipgloss.NewStyle().MarginRight(4)\n\nfunc NewPostsDelegate() list.DefaultDelegate {\n\tdelegate := list.NewDefaultDelegate()\n\n\tlistStyle := delegate.Styles\n\tlistStyle.NormalTitle = listStyle.NormalTitle.Bold(false)\n\tlistStyle.SelectedTitle = listStyle.SelectedTitle.Bold(true)\n\tdelegate.Styles = listStyle\n\n\treturn delegate\n}\n"
  },
  {
    "path": "components/styles/style.go",
    "content": "package styles\n\nimport \"github.com/charmbracelet/lipgloss\"\n\nvar GlobalStyle = lipgloss.NewStyle().Padding(1, 2)\n"
  },
  {
    "path": "components/tui.go",
    "content": "package components\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"reddittui/client\"\n\t\"reddittui/components/comments\"\n\t\"reddittui/components/messages\"\n\t\"reddittui/components/modal\"\n\t\"reddittui/components/posts\"\n\t\"reddittui/config\"\n\t\"reddittui/utils\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\nconst defaultLoadingMessage = \"loading reddit.com...\"\n\ntype (\n\tpageType int\n)\n\nconst (\n\tHomePage pageType = iota\n\tSubredditPage\n\tCommentsPage\n)\n\ntype RedditTui struct {\n\tredditClient  client.RedditClient\n\thomePage      posts.PostsPage\n\tsubredditPage posts.PostsPage\n\tcommentsPage  comments.CommentsPage\n\tmodalManager  modal.ModalManager\n\tpopup         bool\n\tinitializing  bool\n\tpage          pageType\n\tprevPage      pageType\n\tloadingPage   pageType\n\tinitCmd       tea.Cmd\n}\n\nfunc NewRedditTui(configuration config.Config, subreddit, post string) RedditTui {\n\tredditClient := client.NewRedditClient(configuration)\n\n\thomePage := posts.NewPostsPage(redditClient, true)\n\tsubredditPage := posts.NewPostsPage(redditClient, false)\n\tcommentsPage := comments.NewCommentsPage(redditClient)\n\n\tmodalManager := modal.NewModalManager()\n\n\treturn RedditTui{\n\t\tredditClient:  redditClient,\n\t\thomePage:      homePage,\n\t\tsubredditPage: subredditPage,\n\t\tcommentsPage:  commentsPage,\n\t\tmodalManager:  modalManager,\n\t\tinitializing:  true,\n\t\tinitCmd:       getInitCmd(redditClient.BaseUrl, subreddit, post),\n\t}\n}\n\nfunc getInitCmd(baseUrl, subreddit, post string) tea.Cmd {\n\tif len(subreddit) != 0 {\n\t\treturn messages.LoadSubreddit(subreddit)\n\t} else if len(post) != 0 {\n\t\turl, err := client.GetPostUrl(baseUrl, post)\n\t\tif err != nil {\n\t\t\tpanic(fmt.Sprintf(\"Could not load post %s: %v\", post, err))\n\t\t}\n\n\t\treturn messages.LoadComments(url)\n\t} else {\n\t\treturn tea.Batch(messages.CleanCache, messages.LoadHome)\n\t}\n}\n\nfunc (r RedditTui) Init() tea.Cmd {\n\treturn messages.LoadHome\n}\n\nfunc (r RedditTui) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tvar (\n\t\tcmds []tea.Cmd\n\t\tcmd  tea.Cmd\n\t)\n\n\tswitch msg := msg.(type) {\n\tcase messages.ShowErrorModalMsg:\n\t\tif r.initializing && msg.OnClose == nil {\n\t\t\tslog.Error(\"Error during initialization\")\n\t\t\tif r.loadingPage == HomePage {\n\t\t\t\terrorMsg := \"Could not initialize reddittui. Check the logfile for details.\"\n\t\t\t\treturn r, messages.ShowErrorModalWithCallback(errorMsg, tea.Quit)\n\t\t\t}\n\n\t\t\tvar errorMsg string\n\t\t\tif r.loadingPage == SubredditPage {\n\t\t\t\terrorMsg = \"Error loading subreddit. Returning to home page...\"\n\t\t\t} else {\n\t\t\t\terrorMsg = \"Error loading post. Returning to home page...\"\n\t\t\t}\n\n\t\t\treturn r, messages.ShowErrorModalWithCallback(errorMsg, messages.LoadHome)\n\t\t}\n\n\tcase messages.CleanCacheMsg:\n\t\tr.redditClient.CleanCache()\n\t\treturn r, nil\n\n\tcase messages.OpenModalMsg:\n\t\tr.focusModal()\n\t\treturn r, nil\n\n\tcase messages.LoadingCompleteMsg:\n\t\tcmd = r.completeLoading()\n\t\treturn r, cmd\n\n\tcase messages.ExitModalMsg:\n\t\tr.popup = false\n\t\tr.focusActivePage()\n\t\tcmd = r.modalManager.Blur()\n\t\treturn r, cmd\n\n\tcase messages.GoBackMsg:\n\t\tr.goBack()\n\t\treturn r, nil\n\n\tcase messages.LoadHomeMsg:\n\t\tif r.page == HomePage && !r.initializing {\n\t\t\treturn r, r.modalManager.Blur()\n\t\t}\n\n\t\tr.focusModal()\n\t\tr.loadingPage = HomePage\n\n\t\tcmd = r.modalManager.SetLoading(defaultLoadingMessage)\n\t\tcmds = append(cmds, cmd)\n\n\tcase messages.LoadSubredditMsg:\n\t\tsubreddit := string(msg)\n\t\tr.focusModal()\n\t\tr.loadingPage = SubredditPage\n\n\t\tloadingMsg := fmt.Sprintf(\"loading %s...\", utils.NormalizeSubreddit(subreddit))\n\t\tcmd = r.modalManager.SetLoading(loadingMsg)\n\t\tcmds = append(cmds, cmd)\n\n\tcase messages.LoadMorePostsMsg:\n\t\tr.focusModal()\n\t\tr.loadingPage = r.page\n\n\t\tcmd = r.modalManager.SetLoading(\"loading posts...\")\n\t\tcmds = append(cmds, cmd)\n\n\tcase messages.LoadCommentsMsg:\n\t\tr.focusModal()\n\t\tr.loadingPage = CommentsPage\n\n\t\tcmd = r.modalManager.SetLoading(\"loading comments...\")\n\t\tcmds = append(cmds, cmd)\n\n\tcase messages.OpenUrlMsg:\n\t\turl := string(msg)\n\t\tif err := utils.OpenUrl(url); err != nil {\n\t\t\tslog.Error(\"Error opening url in browser\", \"url\", url, \"error\", err.Error())\n\t\t\tcmd = r.modalManager.SetError(fmt.Sprintf(\"Could not open url %s in browser\", url))\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\n\tcase tea.WindowSizeMsg:\n\t\tr.homePage.SetSize(msg.Width, msg.Height)\n\t\tr.subredditPage.SetSize(msg.Width, msg.Height)\n\t\tr.commentsPage.SetSize(msg.Width, msg.Height)\n\t\tr.modalManager.SetSize(msg.Width, msg.Height)\n\n\tcase tea.KeyMsg:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+c\":\n\t\t\treturn r, tea.Quit\n\t\t}\n\t}\n\n\tr.modalManager, cmd = r.modalManager.Update(msg)\n\tcmds = append(cmds, cmd)\n\n\tr.homePage, cmd = r.homePage.Update(msg)\n\tcmds = append(cmds, cmd)\n\n\tr.subredditPage, cmd = r.subredditPage.Update(msg)\n\tcmds = append(cmds, cmd)\n\n\tr.commentsPage, cmd = r.commentsPage.Update(msg)\n\tcmds = append(cmds, cmd)\n\n\treturn r, tea.Batch(cmds...)\n}\n\nfunc (r RedditTui) View() string {\n\tif r.popup {\n\t\tswitch r.page {\n\t\tcase HomePage:\n\t\t\treturn r.modalManager.View(r.homePage)\n\t\tcase SubredditPage:\n\t\t\treturn r.modalManager.View(r.subredditPage)\n\t\tcase CommentsPage:\n\t\t\treturn r.modalManager.View(r.commentsPage)\n\t\t}\n\t}\n\n\tswitch r.page {\n\tcase HomePage:\n\t\treturn r.homePage.View()\n\tcase SubredditPage:\n\t\treturn r.subredditPage.View()\n\tcase CommentsPage:\n\t\treturn r.commentsPage.View()\n\t}\n\n\treturn \"\"\n}\n\nfunc (r *RedditTui) goBack() {\n\tswitch r.page {\n\tcase CommentsPage:\n\t\tif r.prevPage == HomePage {\n\t\t\tr.setPage(HomePage)\n\t\t} else {\n\t\t\tr.setPage(SubredditPage)\n\t\t}\n\tdefault:\n\t\tr.setPage(HomePage)\n\t}\n\n\tr.focusActivePage()\n}\n\nfunc (r *RedditTui) setPage(page pageType) {\n\tr.page, r.prevPage = page, r.page\n}\n\nfunc (r *RedditTui) completeLoading() tea.Cmd {\n\tinitializing := r.initializing\n\n\tr.initializing = false\n\tr.popup = false\n\tr.setPage(r.loadingPage)\n\tr.focusActivePage()\n\n\tif initializing {\n\t\tr.initializing = false\n\t\treturn r.initCmd\n\t}\n\n\treturn r.modalManager.Blur()\n}\n\nfunc (r *RedditTui) focusModal() {\n\tr.popup = true\n\tr.homePage.Blur()\n\tr.subredditPage.Blur()\n\tr.commentsPage.Blur()\n}\n\nfunc (r *RedditTui) focusActivePage() {\n\tswitch r.page {\n\tcase HomePage:\n\t\tr.homePage.Focus()\n\t\tr.subredditPage.Blur()\n\t\tr.commentsPage.Blur()\n\tcase SubredditPage:\n\t\tr.homePage.Blur()\n\t\tr.subredditPage.Focus()\n\t\tr.commentsPage.Blur()\n\tcase CommentsPage:\n\t\tr.homePage.Blur()\n\t\tr.subredditPage.Blur()\n\t\tr.commentsPage.Focus()\n\t}\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reddittui/utils\"\n\n\t\"github.com/BurntSushi/toml\"\n)\n\nconst (\n\tconfigFilename    = \"reddittui.toml\"\n\tdefaultDomainName = \"old.reddit.com\"\n\tdefaultServerType = \"old\"\n)\n\ntype Config struct {\n\tCore   CoreConfig   `toml:\"core\"`\n\tFilter FilterConfig `toml:\"filter\"`\n\tClient ClientConfig `toml:\"client\"`\n\tServer ServerConfig `toml:\"server\"`\n}\n\ntype CoreConfig struct {\n\tBypassCache   bool\n\tLogLevel      string\n\tClientTimeout int // Legacy\n}\n\ntype FilterConfig struct {\n\tKeywords   []string\n\tSubreddits []string\n}\n\ntype ClientConfig struct {\n\tTimeoutSeconds  int\n\tCacheTtlSeconds int\n}\n\ntype ServerConfig struct {\n\tDomain string\n\tType   string\n}\n\nfunc NewConfig() Config {\n\treturn Config{\n\t\tCore: CoreConfig{\n\t\t\tBypassCache:   false,\n\t\t\tLogLevel:      \"Warn\",\n\t\t\tClientTimeout: 10,\n\t\t},\n\t\tServer: ServerConfig{\n\t\t\tDomain: defaultDomainName,\n\t\t\tType:   defaultServerType,\n\t\t},\n\t}\n}\n\nfunc LoadConfig() (Config, error) {\n\tdefaultConfig := NewConfig()\n\n\tconfigDir, err := utils.GetConfigDir()\n\tif err != nil {\n\t\tslog.Warn(\"Could not get config directory\", \"error\", err)\n\t\treturn defaultConfig, err\n\t}\n\n\terr = os.MkdirAll(configDir, 0755)\n\tif err != nil {\n\t\tslog.Warn(\"Could not make config directory\", \"error\", err)\n\t\treturn defaultConfig, err\n\t}\n\n\tconfigPath := filepath.Join(configDir, configFilename)\n\tconfigFile, err := os.Open(configPath)\n\tif os.IsNotExist(err) {\n\t\tcreateConfigFile(configPath)\n\t\treturn defaultConfig, err\n\t} else if err != nil {\n\t\tslog.Warn(\"Could not open config file\", \"error\", err)\n\t\treturn defaultConfig, err\n\t}\n\n\tdefer configFile.Close()\n\n\tvar configFromFile Config\n\tdecoder := toml.NewDecoder(configFile)\n\tmeta, err := decoder.Decode(&configFromFile)\n\tif err != nil {\n\t\tslog.Warn(\"Could not decode config file\", \"error\", err)\n\t\treturn defaultConfig, err\n\t}\n\n\tmergedConfig := mergeConfig(defaultConfig, configFromFile, meta)\n\treturn mergedConfig, err\n}\n\n// Merge right config into left\nfunc mergeConfig(left, right Config, meta toml.MetaData) Config {\n\tif meta.IsDefined(\"core\", \"bypassCache\") {\n\t\tleft.Core.BypassCache = right.Core.BypassCache\n\t}\n\n\tif meta.IsDefined(\"core\", \"logLevel\") {\n\t\tleft.Core.LogLevel = right.Core.LogLevel\n\t}\n\n\tif meta.IsDefined(\"core\", \"clientTimeout\") {\n\t\tleft.Core.ClientTimeout = right.Core.ClientTimeout\n\t}\n\n\tif meta.IsDefined(\"filter\", \"keywords\") {\n\t\tleft.Filter.Keywords = right.Filter.Keywords\n\t}\n\n\tif meta.IsDefined(\"filter\", \"subreddits\") {\n\t\tleft.Filter.Subreddits = right.Filter.Subreddits\n\t}\n\n\tif meta.IsDefined(\"client\", \"timeoutSeconds\") {\n\t\tleft.Client.TimeoutSeconds = right.Client.TimeoutSeconds\n\t}\n\n\tif meta.IsDefined(\"client\", \"cacheTtlSeconds\") {\n\t\tleft.Client.CacheTtlSeconds = right.Client.CacheTtlSeconds\n\t}\n\n\tif meta.IsDefined(\"server\", \"domain\") {\n\t\tleft.Server.Domain = right.Server.Domain\n\t}\n\n\tif meta.IsDefined(\"server\", \"type\") {\n\t\tleft.Server.Type = right.Server.Type\n\t}\n\n\treturn left\n}\n\nfunc createConfigFile(configFilePath string) error {\n\tconfigFile, err := os.Create(configFilePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = configFile.WriteString(defaultConfiguration)\n\treturn err\n}\n"
  },
  {
    "path": "config/defaultConfig.go",
    "content": "package config\n\nconst defaultConfiguration = `\n#\n# Default configuration for reddittui.\n# Uncomment to configure\n#\n\n#[core]\n#bypassCache = false\n#logLevel = \"Warn\"\n\n#[filter]\n#keywords = [\"drama\"]\n#subreddits = [\"news\", \"politics\"]\n\n#[client]\n#timeoutSeconds = 10\n#cacheTtlSeconds = 3600\n\n#[server]\n#domain = \"old.reddit.com\"\n#type = \"old\"\n`\n"
  },
  {
    "path": "go.mod",
    "content": "module reddittui\n\ngo 1.23.4\n\nrequire (\n\tgithub.com/charmbracelet/bubbletea v1.2.4\n\tgithub.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54\n\tgithub.com/muesli/reflow v0.3.0\n\tgolang.org/x/net v0.39.0\n)\n\nrequire (\n\tgithub.com/aymanbagabas/go-udiff v0.2.0 // indirect\n\tgithub.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect\n)\n\nrequire (\n\tgithub.com/BurntSushi/toml v1.4.0\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/sahilm/fuzzy v0.1.1 // indirect\n)\n\nrequire (\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/charmbracelet/bubbles v0.20.0\n\tgithub.com/charmbracelet/lipgloss v1.0.0\n\tgithub.com/charmbracelet/x/ansi v0.4.5 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.16\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.15.2\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgolang.org/x/sync v0.13.0 // indirect\n\tgolang.org/x/sys v0.32.0 // indirect\n\tgolang.org/x/text v0.24.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=\ngithub.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=\ngithub.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=\ngithub.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=\ngithub.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=\ngithub.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=\ngithub.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=\ngithub.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=\ngithub.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=\ngithub.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=\ngithub.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=\ngithub.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54 h1:VjFUoe3r4PNKSIiKn45jl7KL+ZYSUBh5gr+JxgvFG94=\ngithub.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54/go.mod h1:ag+SpTUkiN/UuUGYPX3Ci4fR1oF3XX97PpGhiXK7i6U=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=\ngithub.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=\ngithub.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=\ngolang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=\ngolang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=\ngolang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=\ngolang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=\ngolang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=\ngolang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=\n"
  },
  {
    "path": "install.sh",
    "content": "#!/bin/bash\nset -e\n\nAPP_NAME=\"reddittui\"\nBUILD_DIR=\"build\"\nGO_MAIN_FILE=\"main.go\"\nINSTALL_DIR=\"/usr/local/bin\"\n\n# Build reddittui\necho \"Building reddittui application...\"\nmkdir -p \"$BUILD_DIR\"\ngo build -o \"$BUILD_DIR/$APP_NAME\" \"$GO_MAIN_FILE\"\n\n# Install reddittui\necho \"Installing reddittui...\"\necho \"Copying binary to $INSTALL_DIR (may require sudo)...\"\nsudo install -m 0755 \"$BUILD_DIR/$APP_NAME\" \"$INSTALL_DIR/$APP_NAME\"\n\necho \"Installation complete. You can now run $APP_NAME' from your terminal.\"\n"
  },
  {
    "path": "integ_test.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"reddittui/components\"\n\t\"reddittui/config\"\n\t\"testing\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/x/exp/teatest\"\n)\n\nconst (\n\ttestTimeout    = 20 * time.Second\n\ttestDomain     = \"old.reddit.com\"\n\ttestServerType = \"old\"\n)\n\nfunc TestStartup(t *testing.T) {\n\tt.Logf(\"Testing startup...\")\n\tconfiguration := getTestConfig()\n\n\ttui := components.NewRedditTui(configuration, \"\", \"\")\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify the loading screen shows on startup...\")\n\tWaitFor(t, tm, \"loading reddit.com...\")\n\n\tt.Logf(\"\\tVerify the home page loads...\")\n\tWaitFor(t, tm, \"The front page of the internet\")\n}\n\nfunc TestSwitchSubreddit(t *testing.T) {\n\tt.Logf(\"Testing switching subreddit...\")\n\tconfiguration := getTestConfig()\n\n\ttui := components.NewRedditTui(configuration, \"\", \"\")\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify home page loads...\")\n\tWaitFor(t, tm, \"The front page of the internet\")\n\n\tt.Logf(\"\\tVerify subreddit selection modal shows...\")\n\tWaitForWithInputs(t, tm, \"s\", \"Choose a subreddit:\")\n\n\tt.Logf(\"\\tVerify dogs subreddit loads...\")\n\ttm.Send(tea.KeyMsg{\n\t\tType:  tea.KeyRunes,\n\t\tRunes: []rune(\"dogs\"),\n\t})\n\ttm.Send(tea.KeyMsg{Type: tea.KeyEnter})\n\tWaitFor(t, tm, \"r/dogs\")\n}\n\nfunc TestReturnToHomePage(t *testing.T) {\n\tt.Logf(\"Testing returning to the home page after switching subreddits...\")\n\tconfiguration := getTestConfig()\n\n\ttui := components.NewRedditTui(configuration, \"\", \"\")\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify home page loads...\")\n\tWaitFor(t, tm, \"The front page of the internet\")\n\n\tt.Logf(\"\\tVerify subreddit selection modal shows...\")\n\tWaitForWithInputs(t, tm, \"s\", \"Choose a subreddit:\")\n\n\tt.Logf(\"\\tVerify dogs subreddit loads...\")\n\ttm.Send(tea.KeyMsg{\n\t\tType:  tea.KeyRunes,\n\t\tRunes: []rune(\"dogs\"),\n\t})\n\ttm.Send(tea.KeyMsg{\n\t\tType: tea.KeyEnter,\n\t})\n\tWaitFor(t, tm, \"r/dogs \")\n\n\tt.Logf(\"\\tVerify home page loads\")\n\tWaitForWithInputs(t, tm, \"H\", \"The front page of the internet\")\n}\n\nfunc TestShowComments(t *testing.T) {\n\tt.Logf(\"Testing show post comments...\")\n\tconfiguration := getTestConfig()\n\n\ttui := components.NewRedditTui(configuration, \"\", \"\")\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify home page loads...\")\n\tWaitFor(t, tm, \"The front page of the internet\")\n\n\tt.Logf(\"\\tVerify subreddit selection modal shows...\")\n\tWaitForWithInputs(t, tm, \"s\", \"Choose a subreddit:\")\n\n\tt.Logf(\"\\tVerify dogs subreddit loads...\")\n\ttm.Send(tea.KeyMsg{\n\t\tType:  tea.KeyRunes,\n\t\tRunes: []rune(\"dogs\"),\n\t})\n\ttm.Send(tea.KeyMsg{Type: tea.KeyEnter})\n\tWaitFor(t, tm, \"r/dogs\", \"/r/dogs\")\n\n\tt.Logf(\"\\tVerify comments header loads...\")\n\ttime.Sleep(time.Second)\n\tWaitForWithInputs(t, tm, \"l\", \"submitted\", \"ago by\", \"point\", \"comment\")\n}\n\nfunc TestLoadInitialPostFromId(t *testing.T) {\n\tt.Logf(\"Testing loading initial post...\")\n\tconfiguration := getTestConfig()\n\n\tpostId := \"1jgxswb\"\n\ttui := components.NewRedditTui(configuration, \"\", postId)\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify comments header loads...\")\n\ttime.Sleep(time.Second)\n\tWaitForWithInputs(t, tm, \"l\", \"submitted\", \"ago by\", \"point\", \"comment\")\n}\n\nfunc TestLoadInitialPostFromUrl(t *testing.T) {\n\tt.Logf(\"Testing loading initial post...\")\n\tconfiguration := getTestConfig()\n\n\tpostUrl := \"https://old.reddit.com/r/dogs/comments/1jh0yne/dog_becoming_cuddlier_as_a_senior/\"\n\ttui := components.NewRedditTui(configuration, \"\", postUrl)\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify comments header loads...\")\n\ttime.Sleep(time.Second)\n\tWaitForWithInputs(t, tm, \"l\", \"submitted\", \"ago by\", \"point\", \"comment\")\n}\n\nfunc TestLoadInitialSubredditAndCanGoBack(t *testing.T) {\n\tt.Logf(\"Testing loading subreddit...\")\n\tconfiguration := getTestConfig()\n\n\ttui := components.NewRedditTui(configuration, \"dogs\", \"\")\n\ttm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100))\n\n\tt.Logf(\"\\tVerify dog subreddit loads...\")\n\tWaitFor(t, tm, \"r/dogs\", \"/r/dogs\")\n\ttime.Sleep(time.Second)\n\n\t// Go back\n\tt.Logf(\"\\tVerify the home page loads...\")\n\tWaitForWithInputs(t, tm, \"h\", \"The front page of the internet\")\n}\n\nfunc WaitFor(t *testing.T, tm *teatest.TestModel, messages ...string) {\n\tWaitForWithInputs(t, tm, \"\", messages...)\n}\n\nfunc WaitForWithInputs(t *testing.T, tm *teatest.TestModel, inputs string, messages ...string) {\n\tif len(inputs) > 0 {\n\t\ttm.Send(tea.KeyMsg{\n\t\t\tType:  tea.KeyRunes,\n\t\t\tRunes: []rune(inputs),\n\t\t})\n\t}\n\n\tteatest.WaitFor(t, tm.Output(), func(bts []byte) bool {\n\t\tfor _, message := range messages {\n\t\t\tif !bytes.Contains(bts, []byte(message)) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\n\t\treturn true\n\t}, teatest.WithCheckInterval(time.Millisecond*50), teatest.WithDuration(testTimeout))\n}\n\nfunc getTestConfig() config.Config {\n\tconfiguration := config.NewConfig()\n\tconfiguration.Core.BypassCache = true\n\n\tdomain := os.Getenv(\"TEST_DOMAIN\")\n\tserverType := os.Getenv(\"TEST_SERVER_TYPE\")\n\tif len(domain) > 0 && len(serverType) > 0 {\n\t\tconfiguration.Server.Domain = domain\n\t\tconfiguration.Server.Type = serverType\n\t}\n\n\treturn configuration\n}\n"
  },
  {
    "path": "justfile",
    "content": "@default: run\n\n@run:\n  go run .\n\ntest:\n  go test -v ./...\n\nclean:\n  rm -rf build/\n  rm -rf ~/.cache/reddittui/*\n  rm -rf ~/.local/state/reddittui/*\n\nbuild:\n  @echo \"Building reddittui...\"\n\n  @echo \"Creating build directory at build/...\"\n  mkdir -p build\n\n  @echo \"Installing dependencies...\"\n  go mod tidy\n\n  @echo \"Building reddittui application...\"\n  go build -o build/reddittui main.go\n\n  @echo \"Build complete.\"\n\ninstall: build\n  @echo \"Installing reddittui...\"\n  ./install.sh\n  @echo \"Installation complete.\"\n\nuninstall: clean\n  @echo \"Cleaning reddittui...\"\n  sudo rm -f /usr/local/bin/reddittui\n  @echo \"Clean complete\"\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"reddittui/components\"\n\t\"reddittui/config\"\n\t\"reddittui/utils\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\nconst version = \"v0.3.9\"\n\ntype CliArgs struct {\n\tsubreddit   string\n\tpostId      string\n\tshowVersion bool\n}\n\nfunc main() {\n\tconfiguration, _ := config.LoadConfig()\n\n\tlogFile, err := utils.InitLogger(configuration.Core.LogLevel)\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Could not open logfile: %v\\n\", err)\n\t}\n\n\tdefer logFile.Close()\n\n\tvar args CliArgs\n\tflag.StringVar(&args.postId, \"post\", \"\", \"Post id\")\n\tflag.StringVar(&args.subreddit, \"subreddit\", \"\", \"Subreddit\")\n\tflag.BoolVar(&args.showVersion, \"version\", false, \"Version\")\n\tflag.Parse()\n\n\tif args.showVersion {\n\t\tfmt.Printf(\"reddittui version %s\\n\", version)\n\t\tos.Exit(0)\n\t}\n\n\treddit := components.NewRedditTui(configuration, args.subreddit, args.postId)\n\tp := tea.NewProgram(reddit, tea.WithAltScreen())\n\n\tif _, err := p.Run(); err != nil {\n\t\tslog.Error(\"Error running reddittui, see logfile for details\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "model/commentsModel.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Comment struct {\n\tAuthor    string `json:\"author\"`\n\tText      string `json:\"text\"`\n\tPoints    string `json:\"points\"`\n\tTimestamp string `json:\"timestamp\"`\n\tDepth     int    `json:\"depth\"`\n}\n\ntype Comments struct {\n\tPostTitle     string    `json:\"title\"`\n\tPostAuthor    string    `json:\"author\"`\n\tSubreddit     string    `json:\"subreddit\"`\n\tPostPoints    string    `json:\"points\"`\n\tPostText      string    `json:\"text\"`\n\tPostUrl       string    `json:\"url\"`\n\tPostTimestamp string    `json:\"timestamp\"`\n\tExpiry        time.Time `json:\"expiry\"`\n\tComments      []Comment `json:\"comments\"`\n}\n\nfunc (c Comment) Title() string {\n\treturn formatDepth(c.Text, c.Depth)\n}\n\nfunc (c Comment) Description() string {\n\tdesc := fmt.Sprintf(\"%s  by %s  %s\", c.Points, c.Author, c.Timestamp)\n\treturn formatDepth(desc, c.Depth)\n}\n\nfunc (c Comment) FilterValue() string {\n\treturn c.Author\n}\n\nfunc formatDepth(s string, depth int) string {\n\tvar results strings.Builder\n\tfor range depth {\n\t\tresults.WriteString(\"  \")\n\t}\n\tresults.WriteString(s)\n\n\treturn results.String()\n}\n"
  },
  {
    "path": "model/postModel.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Post struct {\n\tPostTitle     string    `json:\"title\"`\n\tAuthor        string    `json:\"author\"`\n\tSubreddit     string    `json:\"subreddit\"`\n\tFriendlyDate  string    `json:\"friendlyDate\"`\n\tExpiry        time.Time `json:\"expiry\"`\n\tPostUrl       string    `json:\"postUrl\"`\n\tCommentsUrl   string    `json:\"commentsUrl\"`\n\tTotalComments string    `json:\"totalComments\"`\n\tTotalLikes    string    `json:\"totalLikes\"`\n}\n\ntype Posts struct {\n\tDescription string\n\tSubreddit   string\n\tIsHome      bool\n\tPosts       []Post\n\tAfter       string\n\tExpiry      time.Time\n}\n\nfunc (p Post) Title() string {\n\treturn fmt.Sprintf(\" %s  %s\", p.TotalLikes, p.PostTitle)\n}\n\nfunc (p Post) Description() string {\n\tvar sb strings.Builder\n\tif strings.TrimSpace(p.Subreddit) != \"\" {\n\t\tsb.WriteString(p.Subreddit)\n\t\tsb.WriteString(\"  \")\n\t}\n\n\tif strings.TrimSpace(p.TotalComments) == \"\" {\n\t\tfmt.Fprintf(&sb, \"%d comments  \", 0)\n\t} else {\n\t\tfmt.Fprintf(&sb, \"%s comments  \", p.TotalComments)\n\t}\n\n\tfmt.Fprintf(&sb, \"submitted %s by %s\", p.FriendlyDate, p.Author)\n\treturn sb.String()\n}\n\nfunc (p Post) FilterValue() string {\n\treturn p.PostTitle\n}\n"
  },
  {
    "path": "uninstall.sh",
    "content": "#!/bin/bash\nset -e\n\nAPP_NAME=\"reddittui\"\nINSTALL_DIR=\"/usr/local/bin\"\nBINARY_PATH=\"$INSTALL_DIR/$APP_NAME\"\n\nif [[ -f $BINARY_PATH ]]; then\n    echo \"Uninstalling reddittui...\"\n    echo \"Removing binary from $INSTALL_DIR (may require sudo)...\"\n    sudo rm \"$BINARY_PATH\"\n    echo \"Uninstallation complete.\"\nelse\n    echo \"reddittui is not installed. Nothing to do.\"\nfi\n"
  },
  {
    "path": "utils/browser.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"runtime\"\n)\n\nfunc OpenUrl(url string) error {\n\tswitch runtime.GOOS {\n\tcase \"linux\":\n\t\treturn exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\treturn exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url).Start()\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", url).Start()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported platform\")\n\t}\n}\n"
  },
  {
    "path": "utils/files.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\nconst (\n\tappName          = \"reddittui\"\n\tdefaultConfigDir = \".config\"\n\tdefaultStateDir  = \".local/state\"\n\tdefaultCacheDir  = \".cache\"\n\tlogFileName      = \"reddittui.log\"\n)\n\nfunc GetConfigDir() (string, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn filepath.Join(homeDir, defaultConfigDir, appName), nil\n}\n\nfunc GetStateDir() (string, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn filepath.Join(homeDir, defaultStateDir, appName), nil\n}\n\nfunc GetCacheDir() (string, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn filepath.Join(homeDir, defaultCacheDir, appName), nil\n}\n\nfunc OpenLogFile() (*os.File, error) {\n\tstateDir, err := GetStateDir()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = os.Mkdir(stateDir, 0750)\n\tif err != nil && !os.IsExist(err) {\n\t\treturn nil, err\n\t}\n\n\tlogPath := filepath.Join(stateDir, logFileName)\n\treturn os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)\n}\n\nfunc FileExists(path string) bool {\n\t_, err := os.Open(path)\n\treturn os.IsNotExist(err)\n}\n"
  },
  {
    "path": "utils/logger.go",
    "content": "package utils\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc InitLogger(logLevel string) (*os.File, error) {\n\tvar level slog.Level\n\n\tlogFile, err := OpenLogFile()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tswitch l := strings.ToLower(logLevel); l {\n\tcase \"debug\":\n\t\tlevel = slog.LevelDebug\n\tcase \"info\":\n\t\tlevel = slog.LevelInfo\n\tcase \"warn\":\n\t\tlevel = slog.LevelWarn\n\tcase \"error\":\n\t\tlevel = slog.LevelError\n\tdefault:\n\t\tlevel = slog.LevelInfo\n\t}\n\n\toptions := slog.HandlerOptions{Level: level}\n\tlogger := slog.New(slog.NewJSONHandler(logFile, &options))\n\tslog.SetDefault(logger)\n\treturn logFile, nil\n}\n"
  },
  {
    "path": "utils/timer.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"time\"\n)\n\ntype Timer struct {\n\tContext string\n\tStart   time.Time\n\tEnd     time.Time\n\ttime.Duration\n}\n\nfunc NewTimer(context string) Timer {\n\treturn Timer{\n\t\tContext: context,\n\t\tStart:   time.Now(),\n\t}\n}\n\nfunc (t *Timer) Stop() {\n\tt.End = time.Now()\n\tt.Duration = t.End.Sub(t.Start)\n}\n\nfunc (t *Timer) StopAndLog(args ...any) {\n\tt.Stop()\n\n\tslogArgs := []any{\n\t\t\"duration\",\n\t\tfmt.Sprintf(\"%d ms\", t.Milliseconds()),\n\t}\n\n\tfor _, arg := range args {\n\t\tslogArgs = append(slogArgs, any(arg))\n\t}\n\n\tslog.Debug(t.Context, slogArgs...)\n}\n"
  },
  {
    "path": "utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n)\n\nfunc NormalizeSubreddit(subreddit string) string {\n\tif subreddit == \"reddit.com\" {\n\t\treturn subreddit\n\t}\n\n\tif len(subreddit) >= 2 && subreddit[:2] == \"r/\" {\n\t\treturn subreddit\n\t}\n\n\treturn fmt.Sprintf(\"r/%s\", subreddit)\n}\n\nfunc TruncateString(s string, w int) string {\n\tif w <= 0 {\n\t\treturn s\n\t} else if len(s) <= w || len(s) <= 3 {\n\t\treturn s\n\t}\n\n\treturn fmt.Sprintf(\"%s...\", s[:w-3])\n}\n\nfunc Clamp(min, max, val int) int {\n\tif val < min {\n\t\treturn min\n\t} else if val > max {\n\t\treturn max\n\t}\n\n\treturn val\n}\n\nfunc GetSingularPlural(s, singular, plural string) string {\n\tif s == \"1\" {\n\t\treturn fmt.Sprintf(\"%s %s\", s, singular)\n\t}\n\n\treturn fmt.Sprintf(\"%s %s\", s, plural)\n}\n"
  },
  {
    "path": "utils/utils_test.go",
    "content": "package utils\n\nimport \"testing\"\n\nfunc TestNormalizeSubreddit(t *testing.T) {\n\ttests := []struct {\n\t\tsubreddit string\n\t\twant      string\n\t}{\n\t\t{\"neovim\", \"r/neovim\"},\n\t\t{\"r/neovim\", \"r/neovim\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := NormalizeSubreddit(tt.subreddit)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"got %s, want %s\", got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestTruncateString(t *testing.T) {\n\ttests := []struct {\n\t\ts     string\n\t\twidth int\n\t\twant  string\n\t}{\n\t\t{\"abc\", 3, \"abc\"},\n\t\t{\"abcd\", 4, \"abcd\"},\n\t\t{\"abcde\", 4, \"a...\"},\n\t\t{\"abcdef\", 5, \"ab...\"},\n\t\t{\"abcdefg\", 6, \"abc...\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := TruncateString(tt.s, tt.width)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"got %s, want %s with input %s\", got, tt.want, tt.s)\n\t\t}\n\t}\n}\n\nfunc TestClamp(t *testing.T) {\n\ttests := []struct {\n\t\tmin  int\n\t\tmax  int\n\t\tval  int\n\t\twant int\n\t}{\n\t\t{0, 2, 0, 0},\n\t\t{0, 2, 1, 1},\n\t\t{0, 2, 2, 2},\n\t\t{0, 2, 3, 2},\n\t\t{0, 2, -1, 0},\n\t\t{0, 10, 5, 5},\n\t\t{0, 10, -5, 0},\n\t\t{0, 10, 15, 10},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := Clamp(tt.min, tt.max, tt.val)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"got %d, want %d with input: min %d, max %d, val %d\", got, tt.want, tt.min, tt.max, tt.val)\n\t\t}\n\t}\n}\n\nfunc TestGetSingularPlural(t *testing.T) {\n\ttests := []struct {\n\t\ts        string\n\t\tsingular string\n\t\tplural   string\n\t\twant     string\n\t}{\n\t\t{\"0\", \"banana\", \"bananas\", \"0 bananas\"},\n\t\t{\"1\", \"banana\", \"bananas\", \"1 banana\"},\n\t\t{\"2\", \"banana\", \"bananas\", \"2 bananas\"},\n\t\t{\"3\", \"banana\", \"bananas\", \"3 bananas\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := GetSingularPlural(tt.s, tt.singular, tt.plural)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"got %s, want %s with input: s %s, singular %s, plural %s\", got, tt.want, tt.s, tt.singular, tt.plural)\n\t\t}\n\t}\n}\n"
  }
]