├── .gitignore ├── internal └── rssole │ ├── libs │ ├── bootstrap-icons.woff │ ├── bootstrap-icons.woff2 │ ├── favicon.svg │ ├── htmx.min.js │ └── bootstrap.min.js │ ├── templates │ ├── components │ │ ├── spinner.go.html │ │ ├── itemline.go.html │ │ └── feedline.go.html │ ├── settings.go.html │ ├── feedlist.go.html │ ├── item.go.html │ ├── base.go.html │ ├── items.go.html │ └── crudfeed.go.html │ ├── lastmodified.go │ ├── scrape_test.go │ ├── isread_test.go │ ├── scrape.go │ ├── isread.go │ ├── rssole.go │ ├── feeds.go │ ├── feeds_test.go │ ├── item_test.go │ ├── feed_test.go │ ├── item.go │ ├── feed.go │ ├── endpoints.go │ └── endpoints_test.go ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── CONTRIBUTING.md ├── .golangci.yaml ├── go.mod ├── badge.svg ├── badge.svg.template ├── LICENSE ├── Makefile ├── .goreleaser.yaml ├── cmd └── rssole │ └── main.go ├── rssole.json ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /rssole 2 | /rssole_readcache.json 3 | /cover.out 4 | /cover.html 5 | 6 | dist/ 7 | -------------------------------------------------------------------------------- /internal/rssole/libs/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheMightyGit/rssole/HEAD/internal/rssole/libs/bootstrap-icons.woff -------------------------------------------------------------------------------- /internal/rssole/libs/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheMightyGit/rssole/HEAD/internal/rssole/libs/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please chat to me first before working on any big PRs. This project has very limited scope and I'm unlikely to accept anything that overreaches. 2 | -------------------------------------------------------------------------------- /internal/rssole/templates/components/spinner.go.html: -------------------------------------------------------------------------------- 1 | {{define "components/spinner"}} 2 |
3 | Loading... 4 |
5 | {{end}} 6 | -------------------------------------------------------------------------------- /internal/rssole/templates/components/itemline.go.html: -------------------------------------------------------------------------------- 1 | {{define "components/itemline"}} 2 | {{if .Title}} 3 | {{if .IsUnread}}{{end}}{{.Title}}{{if .IsUnread}}{{end}}{{if .IsUnread}}{{if .Summary}} — {{.Summary}}{{end}}{{end}} 4 | {{else}} 5 | {{if .IsUnread}}{{end}}{{.Summary}}{{if .IsUnread}}{{end}} 6 | {{end}} 7 | {{end}} 8 | -------------------------------------------------------------------------------- /internal/rssole/libs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /internal/rssole/templates/components/feedline.go.html: -------------------------------------------------------------------------------- 1 | {{define "components/feedline"}} 2 | 3 | {{.UnreadItemCount}} 4 | 5 |   6 | 7 | {{.Title}} 8 | 9 |   10 | 11 |
12 |
13 | {{end}} 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: stable 16 | 17 | - name: Lint 18 | uses: golangci/golangci-lint-action@v3 19 | 20 | - name: Build 21 | run: go build -v ./... 22 | 23 | - name: Test 24 | run: go test -v -race -cover ./... 25 | -------------------------------------------------------------------------------- /internal/rssole/lastmodified.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // global last modified for use in Last-Modified/If-Modified-Since 9 | var ( 10 | muLastmodified sync.Mutex 11 | lastmodified time.Time 12 | ) 13 | 14 | func updateLastmodified() { 15 | muLastmodified.Lock() 16 | lastmodified = time.Now() 17 | muLastmodified.Unlock() 18 | } 19 | 20 | func getLastmodified() time.Time { 21 | muLastmodified.Lock() 22 | defer muLastmodified.Unlock() 23 | 24 | return lastmodified 25 | } 26 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - bodyclose 5 | - errorlint 6 | - gocritic 7 | - nlreturn 8 | - revive 9 | - staticcheck 10 | - usestdlibvars 11 | - wrapcheck 12 | - wsl 13 | exclusions: 14 | generated: lax 15 | presets: 16 | - comments 17 | - common-false-positives 18 | - legacy 19 | - std-error-handling 20 | paths: 21 | - third_party$ 22 | - builtin$ 23 | - examples$ 24 | formatters: 25 | exclusions: 26 | generated: lax 27 | paths: 28 | - third_party$ 29 | - builtin$ 30 | - examples$ 31 | -------------------------------------------------------------------------------- /internal/rssole/templates/settings.go.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /internal/rssole/templates/feedlist.go.html: -------------------------------------------------------------------------------- 1 |
2 | {{range $category, $feeds := .Feeds.FeedTree}} 3 | {{$category}} 4 |
5 | {{range $feeds}} 6 | 11 | {{template "components/feedline" .}} 12 | 13 | {{end}} 14 |
15 | {{end}} 16 |
17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/TheMightyGit/rssole 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.1.0 9 | github.com/NYTimes/gziphandler v1.1.1 10 | github.com/andybalholm/cascadia v1.3.2 11 | github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 12 | github.com/k3a/html2text v1.2.1 13 | github.com/mmcdole/gofeed v1.3.0 14 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de 15 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f 16 | golang.org/x/net v0.31.0 17 | ) 18 | 19 | require ( 20 | github.com/JohannesKaufmann/dom v0.1.1-0.20240706125338-ff9f3b772364 // indirect 21 | github.com/PuerkitoBio/goquery v1.10.0 // indirect 22 | github.com/json-iterator/go v1.1.12 // indirect 23 | github.com/mmcdole/goxpp v1.1.1 // indirect 24 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 25 | github.com/modern-go/reflect2 v1.0.2 // indirect 26 | golang.org/x/text v0.20.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /badge.svg: -------------------------------------------------------------------------------- 1 | coverage: 79%coverage79% 2 | -------------------------------------------------------------------------------- /badge.svg.template: -------------------------------------------------------------------------------- 1 | coverage: 100%coverage100% 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Johnny Marshall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/rssole/templates/item.go.html: -------------------------------------------------------------------------------- 1 |
2 | {{if .Enclosures}} 3 | 22 | {{end}} 23 | {{range .Images}} 24 | 25 | {{end}} 26 |
{{.Description}}
27 |
28 |
29 | {{template "components/itemline" .}} 30 |
31 |
32 | {{template "components/feedline" .Feed}} 33 |
34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | # packages: write 12 | # issues: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - run: git fetch --force --tags 22 | - uses: actions/setup-go@v4 23 | with: 24 | go-version: stable 25 | 26 | - name: Lint 27 | uses: golangci/golangci-lint-action@v3 28 | 29 | - name: Build 30 | run: go build -v ./... 31 | 32 | - name: Test 33 | run: go test -v -race ./... 34 | 35 | # More assembly might be required: Docker logins, GPG, etc. It all depends 36 | # on your needs. 37 | - uses: goreleaser/goreleaser-action@v4 38 | with: 39 | # either 'goreleaser' (default) or 'goreleaser-pro': 40 | distribution: goreleaser 41 | version: latest 42 | args: release --clean 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 46 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' 47 | # distribution: 48 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 49 | 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## 2 | ## NOTE: This makefile is just helpful stuff for the developer. 3 | ## You don't need it to build this project, just use the regular go tooling. 4 | ## 5 | GO?=go 6 | GO_CODE=$(shell find . -name '*.go') 7 | GO_TEMPLATES=$(shell find . -name '*.go.html') 8 | SOURCES=go.mod Makefile $(GO_CODE) $(GO_TEMPLATES) 9 | 10 | .PHONY: all loc test lint gotest build run updatedeps clean releaselocal 11 | 12 | all: build 13 | 14 | loc: 15 | wc -l `git ls-files '*.go'` | sort 16 | wc -l `git ls-files '*.go.html'` | sort 17 | 18 | test: lint gotest badge.svg 19 | 20 | lint: 21 | golangci-lint run --timeout 5m0s ./... 22 | 23 | gotest: 24 | $(GO) test -race -cover ./... 25 | 26 | badge.svg: $(SOURCES) 27 | AMOUNT=$(shell $(GO) test -cover ./internal/rssole | cut -f 4 | cut -f 2 -d ' ' | cut -f 1 -d '.'); \ 28 | sed "s/100%/$$AMOUNT%/g" $@.template >$@ 29 | 30 | build: rssole 31 | 32 | run: 33 | $(GO) run -race ./cmd/rssole 34 | 35 | rssole: $(SOURCES) 36 | $(GO) build ./cmd/rssole 37 | 38 | updatedeps: 39 | $(GO) get -u ./... 40 | 41 | coveragereport: 42 | go test -v -coverprofile cover.out ./... 43 | go tool cover -html cover.out -o cover.html 44 | open cover.html 45 | 46 | releaselocal: 47 | goreleaser release --snapshot --clean 48 | 49 | clean: 50 | $(GO) clean 51 | $(GO) clean -cache -modcache -testcache 52 | rm -Rf dist 53 | rm -Rf .test_dummy 54 | rm -f rssole 55 | 56 | deploycolin: 57 | -ssh colin.local "killall rssole" 58 | GOOS=linux make build 59 | scp rssole colin.local:. 60 | ssh colin.local "nohup ./rssole 2>rssole.out 1>rssole.out &" 61 | 62 | deploymalcolm: 63 | -ssh malcolm.local "killall rssole" 64 | GOOS=linux make build 65 | scp rssole malcolm.local:. 66 | ssh malcolm.local "nohup ./rssole 2>rssole.out 1>rssole.out &" 67 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go test -race ./... 7 | builds: 8 | - main: ./cmd/rssole/ 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | ldflags: 16 | - -s -w -X github.com/TheMightyGit/rssole/internal/rssole.Version={{.Version}} 17 | 18 | archives: 19 | - format: tar.gz 20 | # this name template makes the OS and Arch compatible with the results of uname. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else if eq .Arch "386" }}i386 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | files: 33 | - rssole.json 34 | 35 | brews: 36 | - 37 | license: "MIT" 38 | repository: 39 | owner: themightygit 40 | name: homebrew-rssole 41 | token: "{{ .Env.TAP_GITHUB_TOKEN }}" 42 | homepage: 'https://github.com/TheMightyGit/rssole/' 43 | description: 'An RSS Reader inspired by the late Google Reader.' 44 | 45 | nfpms: 46 | - license: "MIT" 47 | maintainer: "the.mighty.git@gmail.com" 48 | homepage: "https://github.com/TheMightyGit/rssole" 49 | description: 'An RSS Reader inspired by the late Google Reader.' 50 | formats: 51 | - rpm 52 | - deb 53 | - apk 54 | - archlinux 55 | 56 | checksum: 57 | name_template: 'checksums.txt' 58 | 59 | snapshot: 60 | version_template: "{{ incpatch .Version }}-next" 61 | 62 | changelog: 63 | sort: asc 64 | filters: 65 | exclude: 66 | - '^docs:' 67 | - '^test:' 68 | -------------------------------------------------------------------------------- /internal/rssole/scrape_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestScrape(t *testing.T) { 11 | ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 12 | fmt.Fprintln(w, ` 13 | 14 |
15 |

Title 1

16 | Title 1 17 |
18 |
19 |

Title 2

20 | Title 2 21 |
22 | 23 | `) 24 | })) 25 | defer ts1.Close() 26 | 27 | ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 28 | fmt.Fprintln(w, ` 29 | 30 |
31 |

Title 3

32 | Title 3 33 |
34 | 35 | `) 36 | })) 37 | defer ts2.Close() 38 | 39 | expectedFeedStr := ` 40 | 41 | 42 | ` + ts1.URL + ` 43 | ` + ts1.URL + ` 44 | This RSS was scraped 45 | 46 | Title 1 47 | http://title1.com/ 48 | Title 1 49 | 50 | 51 | Title 2 52 | http://title2.com/ 53 | Title 2 54 | 55 | 56 | Title 3 57 | http://title3.com/ 58 | Title 3 59 | 60 | 61 | ` 62 | 63 | conf := scrape{ 64 | URLs: []string{ 65 | ts1.URL, 66 | ts2.URL, 67 | }, 68 | Item: ".item", 69 | Title: ".title", 70 | Link: ".link", 71 | } 72 | 73 | feedStr, err := conf.GeneratePseudoRssFeed() 74 | if err != nil { 75 | t.Fatal(feedStr, "error is not nil") 76 | } 77 | 78 | if feedStr != expectedFeedStr { 79 | t.Fatal(feedStr, "does not equal", expectedFeedStr) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/rssole/templates/base.go.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | RSSOLE 5 | 6 | 7 | 8 | 9 | 10 | 11 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 | 41 |
42 |
43 | 50 |
51 | 54 |
55 | 56 |
57 | 58 |
59 | {{template "components/spinner" .}} 60 |
61 | 62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /internal/rssole/isread_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestIsUnread(t *testing.T) { 10 | dir, err := os.MkdirTemp("", "Test_IsUnread") 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | 15 | defer os.RemoveAll(dir) 16 | 17 | file, err := os.CreateTemp(dir, "*") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | err = os.WriteFile(file.Name(), []byte(`{"persisted_read":"2023-07-21T18:11:29.802432+01:00"}`), 0o644) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | readLut1 := unreadLut{ 28 | Filename: file.Name(), 29 | } 30 | 31 | readLut1.loadReadLut() 32 | 33 | readLut1.markRead("this_is_read") 34 | 35 | if readLut1.isUnread("persisted_read") { 36 | t.Fatal("persisted_read should be read") 37 | } 38 | 39 | if readLut1.isUnread("this_is_read") { 40 | t.Fatal("this_is_read should be read") 41 | } 42 | 43 | if !readLut1.isUnread("this_is_unread") { 44 | t.Fatal("this_is_unread should be unread") 45 | } 46 | 47 | readLut1.persistReadLut() 48 | 49 | // unpon reload the same things should still be true... 50 | 51 | readLut2 := unreadLut{ 52 | Filename: file.Name(), 53 | } 54 | 55 | readLut2.loadReadLut() 56 | 57 | if readLut2.isUnread("persisted_read") { 58 | t.Fatal("this_is_read should be read after reloading") 59 | } 60 | 61 | if readLut2.isUnread("this_is_read") { 62 | t.Fatal("this_is_read should be read after reloading") 63 | } 64 | 65 | if !readLut2.isUnread("this_is_unread") { 66 | t.Fatal("this_is_unread should be unread after reloading") 67 | } 68 | } 69 | 70 | func TestRemoveOld(t *testing.T) { 71 | readLut := unreadLut{ 72 | lut: map[string]time.Time{ 73 | "something_old": time.Now().Add(-61 * time.Hour * 24), // 61 days old 74 | "something_new": time.Now().Add(-59 * time.Hour * 24), // 59 days old 75 | }, 76 | } 77 | 78 | if readLut.isUnread("something_old") { 79 | t.Fatal("something_old should exist before cleanup") 80 | } 81 | 82 | if readLut.isUnread("something_new") { 83 | t.Fatal("something_new should exist before cleanup") 84 | } 85 | 86 | before := time.Now().Add(-60 * time.Hour * 24) // 60 days 87 | readLut.removeOldEntries(before) 88 | 89 | if !readLut.isUnread("something_old") { 90 | t.Fatal("something_old should no longer be present after cleanup") 91 | } 92 | 93 | if readLut.isUnread("something_new") { 94 | t.Fatal("something_new should exist after cleanup") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/rssole/scrape.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/andybalholm/cascadia" 8 | "golang.org/x/net/html" 9 | ) 10 | 11 | type scrape struct { 12 | URLs []string `json:"urls"` 13 | Item string `json:"item"` 14 | Title string `json:"title"` 15 | Link string `json:"link"` 16 | } 17 | 18 | func (conf *scrape) GeneratePseudoRssFeed() (string, error) { 19 | rss := ` 20 | 21 | 22 | ` + conf.URLs[0] + ` 23 | ` + conf.URLs[0] + ` 24 | This RSS was scraped 25 | ` 26 | 27 | for _, url := range conf.URLs { 28 | if url == "" { 29 | continue 30 | } 31 | 32 | resp, err := http.Get(url) 33 | if err != nil { 34 | return "", fmt.Errorf("get %s %w", url, err) 35 | } 36 | 37 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 38 | resp.Body.Close() 39 | 40 | return "", fmt.Errorf("get non-success %d %s %w", resp.StatusCode, url, err) 41 | } 42 | 43 | doc, err := html.Parse(resp.Body) 44 | resp.Body.Close() 45 | 46 | if err != nil { 47 | return "", fmt.Errorf("parse %s %w", url, err) 48 | } 49 | 50 | for _, p := range queryAll(doc, conf.Item) { 51 | titleNode := query(p, conf.Title) 52 | if titleNode != nil { 53 | titleChild := titleNode.FirstChild 54 | title := titleChild.Data 55 | // title := Query(p, f.Scrape.Title).FirstChild.Data 56 | link := attrOr(query(p, conf.Link), "href", "(No link available)") 57 | itemRss := ` 58 | ` + title + ` 59 | ` + link + ` 60 | ` + title + ` 61 | 62 | ` 63 | rss += itemRss 64 | } 65 | } 66 | } 67 | 68 | rss += ` 69 | ` 70 | 71 | return rss, nil 72 | } 73 | 74 | func query(n *html.Node, query string) *html.Node { 75 | sel, err := cascadia.Parse(query) 76 | if err != nil { 77 | return &html.Node{} 78 | } 79 | 80 | return cascadia.Query(n, sel) 81 | } 82 | 83 | func queryAll(n *html.Node, query string) []*html.Node { 84 | sel, err := cascadia.Parse(query) 85 | if err != nil { 86 | return []*html.Node{} 87 | } 88 | 89 | return cascadia.QueryAll(n, sel) 90 | } 91 | 92 | func attrOr(n *html.Node, attrName, or string) string { 93 | for _, a := range n.Attr { 94 | if a.Key == attrName { 95 | return a.Val 96 | } 97 | } 98 | 99 | return or 100 | } 101 | -------------------------------------------------------------------------------- /internal/rssole/isread.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "sync" 7 | "time" 8 | 9 | "golang.org/x/exp/slog" 10 | ) 11 | 12 | type unreadLut struct { 13 | Filename string 14 | 15 | lut map[string]time.Time 16 | mu sync.RWMutex 17 | } 18 | 19 | func (u *unreadLut) loadReadLut() { 20 | u.mu.Lock() 21 | defer u.mu.Unlock() 22 | 23 | body, err := os.ReadFile(u.Filename) 24 | if err != nil { 25 | slog.Error("ReadFile failed", "filename", u.Filename, "error", err) 26 | } else { 27 | err = json.Unmarshal(body, &u.lut) 28 | if err != nil { 29 | slog.Error("error unmarshal", "filename", u.Filename, "error", err) 30 | } 31 | } 32 | } 33 | 34 | const ( 35 | minusTwoDays = -2 * time.Hour * 24 // 2 days ago 36 | updateFrequency = 1 * time.Hour 37 | ) 38 | 39 | func (u *unreadLut) startCleanupTicker() { 40 | ago := minusTwoDays 41 | 42 | go func() { 43 | ticker := time.NewTicker(updateFrequency) 44 | for range ticker.C { 45 | before := time.Now().Add(ago) 46 | readLut.removeOldEntries(before) 47 | readLut.persistReadLut() 48 | } 49 | }() 50 | } 51 | 52 | func (u *unreadLut) removeOldEntries(before time.Time) { 53 | u.mu.Lock() 54 | defer u.mu.Unlock() 55 | 56 | slog.Info("removing old readcache entries", "before", before) 57 | 58 | for url, when := range u.lut { 59 | if when.Before(before) { 60 | slog.Info("removing old readcache entry", "url", url, "when", when) 61 | delete(u.lut, url) 62 | } 63 | } 64 | } 65 | 66 | func (u *unreadLut) isUnread(url string) bool { 67 | u.mu.RLock() 68 | defer u.mu.RUnlock() 69 | 70 | _, found := u.lut[url] 71 | 72 | return !found 73 | } 74 | 75 | func (u *unreadLut) markRead(url string) { 76 | u.mu.Lock() 77 | defer u.mu.Unlock() 78 | 79 | if u.lut == nil { 80 | u.lut = map[string]time.Time{} 81 | } 82 | 83 | u.lut[url] = time.Now() 84 | 85 | updateLastmodified() 86 | } 87 | 88 | func (u *unreadLut) extendLifeIfFound(url string) { 89 | if !u.isUnread(url) { 90 | u.markRead(url) 91 | } 92 | } 93 | 94 | const lutFilePerms = 0o644 95 | 96 | func (u *unreadLut) persistReadLut() { 97 | u.mu.Lock() 98 | defer u.mu.Unlock() 99 | 100 | jsonString, err := json.Marshal(u.lut) 101 | if err != nil { 102 | slog.Error("error marshaling readlut", "error", err) 103 | 104 | return 105 | } 106 | 107 | err = os.WriteFile(u.Filename, jsonString, lutFilePerms) 108 | if err != nil { 109 | slog.Error("error writefile", "filename", u.Filename, "error", err) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/rssole/rssole.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | "net/http" 8 | "text/template" 9 | "time" 10 | 11 | "github.com/NYTimes/gziphandler" 12 | "golang.org/x/exp/slog" 13 | ) 14 | 15 | const ( 16 | templatesDir = "templates" 17 | ) 18 | 19 | var Version = "dev" 20 | 21 | var ( 22 | //go:embed templates/* 23 | files embed.FS 24 | templates map[string]*template.Template 25 | 26 | //go:embed libs/* 27 | wwwlibs embed.FS 28 | ) 29 | 30 | var ( 31 | allFeeds = &feeds{} 32 | readLut = &unreadLut{} 33 | ) 34 | 35 | func loadTemplates() error { 36 | if templates == nil { 37 | templates = make(map[string]*template.Template) 38 | } 39 | 40 | tmplFiles, err := fs.ReadDir(files, templatesDir) 41 | if err != nil { 42 | return fmt.Errorf("loadTemplates readdir - %w", err) 43 | } 44 | 45 | for _, tmpl := range tmplFiles { 46 | if tmpl.IsDir() { 47 | continue 48 | } 49 | 50 | pt, err := template.ParseFS(files, templatesDir+"/"+tmpl.Name(), templatesDir+"/components/*.go.html") 51 | if err != nil { 52 | return fmt.Errorf("loadTemplates parsefs - %w", err) 53 | } 54 | 55 | templates[tmpl.Name()] = pt 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func Start(configFilename, configReadCacheFilename, listenAddress string, updateTime time.Duration) error { 62 | slog.Info("RSSOLE", "version", Version) 63 | 64 | err := loadTemplates() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | readLut.Filename = configReadCacheFilename 70 | readLut.loadReadLut() 71 | readLut.startCleanupTicker() 72 | 73 | if err := allFeeds.readFeedsFile(configFilename); err != nil { 74 | return err 75 | } 76 | 77 | allFeeds.UpdateTime = updateTime 78 | allFeeds.BeginFeedUpdates() 79 | 80 | http.HandleFunc("GET /{$}", index) 81 | http.HandleFunc("GET /feeds", feedlist) 82 | http.HandleFunc("GET /items", items) 83 | http.HandleFunc("POST /items", items) 84 | http.HandleFunc("GET /item", item) 85 | http.HandleFunc("GET /crudfeed", crudfeedGet) 86 | http.HandleFunc("POST /crudfeed", crudfeedPost) 87 | http.HandleFunc("GET /settings", settingsGet) 88 | http.HandleFunc("POST /settings", settingsPost) 89 | 90 | // As the static files won't change we force the browser to cache them. 91 | httpFS := http.FileServer(http.FS(wwwlibs)) 92 | http.Handle("GET /libs/", forceCache(httpFS)) 93 | 94 | slog.Info("Listening", "address", listenAddress) 95 | 96 | if err := http.ListenAndServe(listenAddress, gziphandler.GzipHandler(http.DefaultServeMux)); err != nil { 97 | return fmt.Errorf("error during ListenAndServe - %w", err) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func forceCache(h http.Handler) http.Handler { 104 | fn := func(w http.ResponseWriter, r *http.Request) { 105 | w.Header().Set("Cache-Control", "max-age=86400") // 24 hours 106 | h.ServeHTTP(w, r) 107 | } 108 | 109 | return http.HandlerFunc(fn) 110 | } 111 | -------------------------------------------------------------------------------- /internal/rssole/feeds.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "sync" 10 | "time" 11 | 12 | "golang.org/x/exp/slog" 13 | ) 14 | 15 | type feeds struct { 16 | Config ConfigSection `json:"config"` 17 | Feeds []*feed `json:"feeds"` 18 | UpdateTime time.Duration `json:"-"` 19 | mu sync.RWMutex 20 | filename string 21 | } 22 | 23 | type ConfigSection struct { 24 | Listen string `json:"listen"` 25 | UpdateSeconds int `json:"update_seconds"` 26 | } 27 | 28 | func (f *feeds) addFeed(feedToAdd *feed) { 29 | f.mu.Lock() 30 | defer f.mu.Unlock() 31 | 32 | feedToAdd.StartTickedUpdate(f.UpdateTime) 33 | f.Feeds = append(f.Feeds, feedToAdd) 34 | } 35 | 36 | func (f *feeds) delFeed(feedID string) { 37 | f.mu.Lock() 38 | defer f.mu.Unlock() 39 | 40 | newFeeds := []*feed{} 41 | 42 | for _, f := range f.Feeds { 43 | if f.ID() != feedID { 44 | newFeeds = append(newFeeds, f) 45 | } else { 46 | slog.Info("Removed feed", "url", f.URL) 47 | } 48 | } 49 | 50 | f.Feeds = newFeeds 51 | } 52 | 53 | func (f *feeds) getFeedByID(id string) *feed { 54 | f.mu.Lock() 55 | defer f.mu.Unlock() 56 | 57 | for _, f := range f.Feeds { 58 | if f.ID() == id { 59 | return f 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func (f *feeds) readFeedsFile(filename string) error { 67 | f.mu.Lock() 68 | defer f.mu.Unlock() 69 | 70 | f.filename = filename 71 | 72 | jsonFile, err := os.Open(f.filename) 73 | if err != nil { 74 | return fmt.Errorf("error opening file: %w", err) 75 | } 76 | defer jsonFile.Close() 77 | 78 | d := json.NewDecoder(jsonFile) 79 | 80 | err = d.Decode(f) 81 | if err != nil { 82 | return fmt.Errorf("error unmarshalling JSON: %w", err) 83 | } 84 | 85 | // NOTE: we must .Init() every loaded feed or logging will break 86 | for _, f := range f.Feeds { 87 | f.Init() 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (f *feeds) saveFeedsFile() error { 94 | f.mu.Lock() 95 | defer f.mu.Unlock() 96 | 97 | jsonFile, err := os.Create(f.filename) 98 | if err != nil { 99 | return fmt.Errorf("error opening file: %w", err) 100 | } 101 | defer jsonFile.Close() 102 | 103 | e := json.NewEncoder(jsonFile) 104 | e.SetIndent("", " ") 105 | 106 | err = e.Encode(f) 107 | if err != nil { 108 | return fmt.Errorf("error marshalling JSON: %w", err) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (f *feeds) FeedTree() map[string][]*feed { 115 | f.mu.RLock() 116 | defer f.mu.RUnlock() 117 | 118 | cats := map[string][]*feed{} 119 | for _, feed := range f.Feeds { 120 | cats[feed.Category] = append(cats[feed.Category], feed) 121 | } 122 | 123 | return cats 124 | } 125 | 126 | func (f *feeds) BeginFeedUpdates() { 127 | // ignore cert errors 128 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 129 | 130 | f.mu.Lock() 131 | defer f.mu.Unlock() 132 | 133 | for _, feed := range f.Feeds { 134 | feed.StartTickedUpdate(f.UpdateTime) 135 | } 136 | } 137 | 138 | func (f *feeds) ChangeTickedUpdate(d time.Duration) { 139 | f.mu.Lock() 140 | defer f.mu.Unlock() 141 | 142 | f.Config.UpdateSeconds = int(d.Seconds()) 143 | for _, feed := range f.Feeds { 144 | feed.ChangeTickedUpdate(d) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/rssole/templates/items.go.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{if .Link}} 5 | {{.Title}} 6 | {{else}} 7 | {{.Title}} 8 | {{end}} 9 | 10 | Loading... 11 | 12 |
13 |
14 |
17 | {{- range $idx, $item := .Items -}} 18 | {{if $item.IsUnread}} 19 | {{end}} 20 | {{- end -}} 21 | 29 |
  40 |
41 |
42 |
43 | 44 |
45 | {{range $idx, $item := .Items}} 46 |
47 |

48 | 53 |

54 | 55 |
56 |
57 |
58 | {{if $item.Link}} 59 |
60 |  link 61 |
62 | {{end}} 63 | {{if $item.Categories}} 64 |
65 | 66 | {{range $item.Categories}} 67 | {{.}} 68 | {{end}} 69 | 70 |
71 | {{end}} 72 |
73 | {{$item.PublishedParsed}} 74 |
75 |
76 |
77 |
81 | {{template "components/spinner" .}} 82 |
83 |
84 |
85 |
86 | {{end}} 87 |
88 | -------------------------------------------------------------------------------- /cmd/rssole/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "time" 10 | 11 | "golang.org/x/exp/slog" 12 | 13 | "github.com/TheMightyGit/rssole/internal/rssole" 14 | ) 15 | 16 | const ( 17 | defaultListenAddress = "0.0.0.0:8090" 18 | defaultUpdateTimeSeconds = 300 19 | 20 | defaultConfigFilename = "rssole.json" 21 | defaultReadCacheFilename = "rssole_readcache.json" 22 | oldDefaultConfigFilename = "feeds.json" 23 | oldDefaultReadCacheFilename = "readcache.json" 24 | ) 25 | 26 | type configFile struct { 27 | Config rssole.ConfigSection `json:"config"` 28 | } 29 | 30 | func getFeedsFileConfigSection(filename string) (rssole.ConfigSection, error) { 31 | var cfgFile configFile 32 | 33 | jsonFile, err := os.Open(filename) 34 | if err != nil { 35 | return cfgFile.Config, fmt.Errorf("error opening file: %w", err) 36 | } 37 | defer jsonFile.Close() 38 | 39 | decoder := json.NewDecoder(jsonFile) 40 | 41 | err = decoder.Decode(&cfgFile) 42 | if err != nil { 43 | return cfgFile.Config, fmt.Errorf("error unmarshalling JSON: %w", err) 44 | } 45 | 46 | return cfgFile.Config, nil 47 | } 48 | 49 | func handleFlags(configFilename, configReadCacheFilename *string) { 50 | originalUsage := flag.Usage 51 | flag.Usage = func() { 52 | fmt.Println("RSSOLE version", rssole.Version) 53 | fmt.Println() 54 | originalUsage() 55 | } 56 | 57 | flag.StringVar(configFilename, "c", defaultConfigFilename, "config filename, must be writable") 58 | flag.StringVar(configReadCacheFilename, "r", defaultReadCacheFilename, "readcache filename, must be writable") 59 | flag.Parse() 60 | } 61 | 62 | func loadConfig(configFilename string) (rssole.ConfigSection, error) { 63 | cfg, err := getFeedsFileConfigSection(configFilename) 64 | if err != nil { 65 | return rssole.ConfigSection{}, err 66 | } 67 | 68 | if cfg.Listen == "" { 69 | cfg.Listen = defaultListenAddress 70 | } 71 | 72 | if cfg.UpdateSeconds == 0 { 73 | cfg.UpdateSeconds = defaultUpdateTimeSeconds 74 | } 75 | 76 | return cfg, nil 77 | } 78 | 79 | func main() { 80 | var configFilename, configReadCacheFilename string 81 | 82 | handleFlags(&configFilename, &configReadCacheFilename) 83 | 84 | // If the config file doesn't exist, try the old default name. 85 | if _, err := os.Stat(configFilename); errors.Is(err, os.ErrNotExist) { 86 | if configFilename != oldDefaultConfigFilename { 87 | if _, err := os.Stat(oldDefaultConfigFilename); err == nil { 88 | slog.Info("Falling back to old config filename:", "filename", oldDefaultConfigFilename) 89 | configFilename = oldDefaultConfigFilename 90 | } 91 | } 92 | } 93 | 94 | // If the readcache file doesn't exist, try the old default name. 95 | if _, err := os.Stat(configReadCacheFilename); errors.Is(err, os.ErrNotExist) { 96 | if configReadCacheFilename != oldDefaultReadCacheFilename { 97 | if _, err := os.Stat(oldDefaultReadCacheFilename); err == nil { 98 | slog.Info("Falling back to old readcache filename:", "filename", oldDefaultReadCacheFilename) 99 | configReadCacheFilename = oldDefaultReadCacheFilename 100 | } 101 | } 102 | } 103 | 104 | cfg, err := loadConfig(configFilename) 105 | if err != nil { 106 | slog.Error("unable to get config section of config file", "filename", configFilename, "error", err) 107 | os.Exit(1) 108 | } 109 | 110 | // Start service 111 | err = rssole.Start(configFilename, configReadCacheFilename, cfg.Listen, time.Duration(cfg.UpdateSeconds)*time.Second) 112 | if err != nil { 113 | slog.Error("rssole.Start exited with error", "error", err) 114 | os.Exit(1) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /rssole.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "listen": "0.0.0.0:8090", 4 | "update_seconds": 900 5 | }, 6 | "feeds": [ 7 | { 8 | "url": "https://github.com/TheMightyGit/rssole/releases.atom", 9 | "category": "Github Releases" 10 | }, 11 | { 12 | "url": "https://github.com/hajimehoshi/ebiten/releases.atom", 13 | "category": "Github Releases" 14 | }, 15 | { 16 | "url": "https://news.ycombinator.com/rss", 17 | "category": "Nerd" 18 | }, 19 | { 20 | "url": "https://xkcd.com/rss.xml", 21 | "category": "Nerd" 22 | }, 23 | { 24 | "url": "http://feeds.bbci.co.uk/news/rss.xml", 25 | "category": "General News" 26 | }, 27 | { 28 | "url": "https://www.rockpapershotgun.com/feed", 29 | "category": "Games" 30 | }, 31 | { 32 | "url": "https://gamedev.stackexchange.com/feeds", 33 | "category": "GameDev" 34 | }, 35 | { 36 | "url": "https://gamedev.net/articles/feed", 37 | "category": "GameDev" 38 | }, 39 | { 40 | "url": "https://www.gamesindustry.biz/feed/news", 41 | "category": "GameDev" 42 | }, 43 | { 44 | "url": "https://www.phoronix.com/rss.php", 45 | "category": "Nerd" 46 | }, 47 | { 48 | "url": "https://mas.to/@curiousordinary.rss", 49 | "category": "Mastodon" 50 | }, 51 | { 52 | "url": "https://mstdn.social/@TheMightyGit.rss", 53 | "category": "Mastodon" 54 | }, 55 | { 56 | "url": "https://rss.slashdot.org/Slashdot/slashdotMain", 57 | "category": "Nerd" 58 | }, 59 | { 60 | "url": "https://www.theregister.com/headlines.atom", 61 | "category": "Nerd" 62 | }, 63 | { 64 | "url": "https://go.dev/blog/feed.atom?format=xml", 65 | "category": "Golang" 66 | }, 67 | { 68 | "url": "https://godotengine.org/rss.xml", 69 | "category": "GameDev" 70 | }, 71 | { 72 | "url": "https://feeds.feedburner.com/TheDailyWtf", 73 | "category": "Funny" 74 | }, 75 | { 76 | "url": "https://store.steampowered.com/feeds/news/app/1675200/", 77 | "name": "Steam Deck News Hub", 78 | "category": "Games" 79 | }, 80 | { 81 | "url": "https://www.pcgamer.com/uk/news/", 82 | "name": "PCGamer News", 83 | "category": "Games", 84 | "scrape": { 85 | "urls": [ 86 | "https://www.pcgamer.com/uk/news/", 87 | "https://www.pcgamer.com/uk/news/page/2/", 88 | "https://www.pcgamer.com/uk/news/page/3/" 89 | ], 90 | "item": ".listingResult", 91 | "title": ".article-name", 92 | "link": ".article-link" 93 | } 94 | }, 95 | { 96 | "url": "https://www.pcgamer.com/uk/reviews/", 97 | "name": "PCGamer Reviews", 98 | "category": "Games", 99 | "scrape": { 100 | "urls": [ 101 | "https://www.pcgamer.com/uk/reviews/", 102 | "https://www.pcgamer.com/uk/reviews/page/2/", 103 | "https://www.pcgamer.com/uk/reviews/page/3/" 104 | ], 105 | "item": ".listingResult", 106 | "title": ".article-name", 107 | "link": ".article-link" 108 | } 109 | }, 110 | { 111 | "url": "https://www.iflscience.com/rss/ifls-latest-rss.xml", 112 | "category": "Nerd" 113 | }, 114 | { 115 | "url": "https://hackaday.com/blog/feed/", 116 | "category": "Nerd" 117 | }, 118 | { 119 | "url": "https://www.youtube.com/feeds/videos.xml?channel_id=UCgye4RmWOR8AmleinMxgbYw", 120 | "category": "YouTube" 121 | }, 122 | { 123 | "url": "https://movieweb.com/feed/" 124 | }, 125 | { 126 | "url": "https://bsky.app/profile/happytoast.bsky.social/rss", 127 | "category": "BlueSky" 128 | }, 129 | { 130 | "url": "https://openrss.org/bsky.app/profile/happytoast.bsky.social", 131 | "category": "BlueSky" 132 | } 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /internal/rssole/feeds_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var tempDir string 11 | 12 | func feedsSetUpTearDown(_ *testing.T) func(t *testing.T) { 13 | // We don't want to make a mess of the local fs 14 | // so clobber the readcache with one that uses a tmp file. 15 | var err error 16 | 17 | tempDir, err = os.MkdirTemp("", "Test_Feeds") 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | return func(_ *testing.T) { 23 | os.RemoveAll(tempDir) 24 | } 25 | } 26 | 27 | func TestReadFeedsFile_Success(t *testing.T) { 28 | defer feedsSetUpTearDown(t)(t) 29 | 30 | file, err := os.CreateTemp(tempDir, "*") 31 | if err != nil { 32 | log.Println(err) 33 | 34 | return 35 | } 36 | 37 | // make it valid json 38 | _, _ = file.WriteString("{}") 39 | 40 | f := feeds{} 41 | 42 | err = f.readFeedsFile(file.Name()) 43 | 44 | if err != nil { 45 | t.Fatal("unexpected error calling readFeedsFile on good", err) 46 | } 47 | } 48 | 49 | func TestReadFeedsFile_BadJson(t *testing.T) { 50 | defer feedsSetUpTearDown(t)(t) 51 | 52 | file, err := os.CreateTemp(tempDir, "*") 53 | if err != nil { 54 | log.Println(err) 55 | 56 | return 57 | } 58 | 59 | _, _ = file.WriteString("NOT_VALID_JSON") 60 | 61 | f := feeds{} 62 | 63 | err = f.readFeedsFile(file.Name()) 64 | 65 | if err == nil { 66 | t.Fatal("expected error calling readFeedsFile on bad json", err) 67 | } 68 | } 69 | 70 | func TestReadFeedsFile_NoSuchFile(t *testing.T) { 71 | f := feeds{} 72 | 73 | err := f.readFeedsFile("file_doesnt_exist.json") 74 | 75 | if err == nil { 76 | t.Fatal("expected error calling readFeedsFile on non-existent file", err) 77 | } 78 | } 79 | 80 | func TestAddFeed(t *testing.T) { 81 | f := feeds{ 82 | UpdateTime: 1 * time.Second, 83 | } 84 | 85 | f1 := &feed{} 86 | f1.Init() 87 | 88 | f2 := &feed{} 89 | f2.Init() 90 | 91 | f.addFeed(f1) 92 | f.addFeed(f2) 93 | 94 | if len(f.Feeds) != 2 { 95 | t.Fatal("expected 2 feeds to be added") 96 | } 97 | } 98 | 99 | func TestDelFeed(t *testing.T) { 100 | f := feeds{ 101 | UpdateTime: 1 * time.Second, 102 | } 103 | 104 | fd1 := &feed{URL: "1"} 105 | fd1.Init() 106 | 107 | fd2 := &feed{URL: "2"} 108 | fd2.Init() 109 | 110 | fd3 := &feed{URL: "3"} 111 | fd3.Init() 112 | 113 | f.addFeed(fd1) 114 | f.addFeed(fd2) 115 | f.addFeed(fd3) 116 | 117 | f.delFeed(fd1.ID()) 118 | 119 | if len(f.Feeds) != 2 { 120 | t.Fatal("expected 2 feeds to be left") 121 | } 122 | } 123 | 124 | func TestGetFeedByID(t *testing.T) { 125 | f := feeds{ 126 | UpdateTime: 1 * time.Second, 127 | } 128 | 129 | f1 := &feed{URL: "1"} 130 | f1.Init() 131 | 132 | f2 := &feed{URL: "2"} 133 | f2.Init() 134 | 135 | f3 := &feed{URL: "3"} 136 | f3.Init() 137 | 138 | f.addFeed(f1) 139 | f.addFeed(f2) 140 | f.addFeed(f3) 141 | 142 | found1 := f.getFeedByID(f1.ID()) 143 | found2 := f.getFeedByID(f2.ID()) 144 | found3 := f.getFeedByID(f3.ID()) 145 | found4 := f.getFeedByID("no such id") 146 | 147 | if found1 != f1 { 148 | t.Fatal("expected to find f1") 149 | } 150 | 151 | if found2 != f2 { 152 | t.Fatal("expected to find f2") 153 | } 154 | 155 | if found3 != f3 { 156 | t.Fatal("expected to find f3") 157 | } 158 | 159 | if found4 != nil { 160 | t.Fatal("expected not to find unadded feed") 161 | } 162 | } 163 | 164 | func TestSaveFeedsFile_Success(t *testing.T) { 165 | defer feedsSetUpTearDown(t)(t) 166 | 167 | file, err := os.CreateTemp(tempDir, "*") 168 | if err != nil { 169 | log.Println(err) 170 | 171 | return 172 | } 173 | 174 | f := feeds{ 175 | filename: file.Name(), 176 | } 177 | expectedFileContents := `{ 178 | "config": { 179 | "listen": "", 180 | "update_seconds": 0 181 | }, 182 | "feeds": null 183 | } 184 | ` 185 | 186 | err = f.saveFeedsFile() 187 | 188 | if err != nil { 189 | t.Fatal("unexpected error calling saveFeedsFile", err) 190 | } 191 | 192 | data, err := os.ReadFile(file.Name()) 193 | if err != nil { 194 | t.Fatal("unexpected error reading file back in", err) 195 | } 196 | 197 | if string(data) != expectedFileContents { 198 | t.Fatal("expected data, got:", string(data)) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /internal/rssole/item_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/mmcdole/gofeed" 9 | ext "github.com/mmcdole/gofeed/extensions" 10 | ) 11 | 12 | func TestSummary_TruncateAt200(t *testing.T) { 13 | w := wrappedItem{ 14 | Item: &gofeed.Item{ 15 | Description: strings.Repeat("x", 300), 16 | }, 17 | } 18 | 19 | s := w.Summary() 20 | 21 | if len(s) != 200 { 22 | t.Fatal("summary not truncted to 200") 23 | } 24 | } 25 | 26 | func TestSummary_BlankIfSummaryIdenticalToTitle(t *testing.T) { 27 | w := wrappedItem{ 28 | Item: &gofeed.Item{ 29 | Title: "These are the same", 30 | Description: "These are the same", 31 | }, 32 | } 33 | 34 | s := w.Summary() 35 | 36 | if s != "" { 37 | t.Fatal("summary was not blanked when identical to title") 38 | } 39 | } 40 | 41 | func TestSummary_BlankIfSummaryAURL(t *testing.T) { 42 | w := wrappedItem{ 43 | Item: &gofeed.Item{ 44 | Description: "http://example.com", 45 | }, 46 | } 47 | 48 | s := w.Summary() 49 | 50 | if s != "" { 51 | t.Fatal("summary was not blanked when a url") 52 | } 53 | } 54 | 55 | func TestDescription_HtmlSanitised(t *testing.T) { 56 | w := wrappedItem{ 57 | Item: &gofeed.Item{ 58 | Description: ` 59 | 60 | 61 | 62 | 63 | 64 | 65 | my alt 66 | 67 | 68 |
69 | `, 70 | }, 71 | } 72 | expectedHTML := `

my alt

73 | ` 74 | 75 | d := w.Description() 76 | 77 | if d != expectedHTML { 78 | t.Fatal("description not as expected. got:", d, "expected:", expectedHTML) 79 | } 80 | } 81 | 82 | func TestImages_ShouldDedupe(t *testing.T) { 83 | w := wrappedItem{ 84 | Item: &gofeed.Item{ 85 | Image: &gofeed.Image{ 86 | URL: "this_image_is_present_in_both", 87 | }, 88 | Description: ` 89 | 90 | 91 | `, 92 | }, 93 | } 94 | 95 | images := w.Images() 96 | 97 | if len(images) != 0 { 98 | t.Error("expected image list to be zero as it should be de-duped") 99 | } 100 | } 101 | 102 | func TestImages_ShouldDedupeIgnoringAllQueryStrings(t *testing.T) { 103 | w := wrappedItem{ 104 | Item: &gofeed.Item{ 105 | Image: &gofeed.Image{ 106 | URL: "this_image_is_present_in_both?also_ignores_query_string_here=7", 107 | }, 108 | Description: ` 109 | 110 | 111 | `, 112 | }, 113 | } 114 | 115 | images := w.Images() 116 | 117 | if len(images) != 0 { 118 | t.Error("expected image list to be zero as it should be de-duped") 119 | } 120 | } 121 | 122 | func TestImages_ShouldNotDedupe(t *testing.T) { 123 | w := wrappedItem{ 124 | Item: &gofeed.Item{ 125 | Image: &gofeed.Image{ 126 | URL: "http://example.com/this_image_is_only_present_in_meta.gif", 127 | }, 128 | Description: ` 129 | 130 | `, 131 | }, 132 | } 133 | 134 | images := w.Images() 135 | 136 | fmt.Println(images) 137 | 138 | if len(images) != 1 { 139 | t.Error("expected image list to be 1 as it should not be de-duped") 140 | } 141 | } 142 | 143 | func TestImages_MastodonExtensionImages(t *testing.T) { 144 | w := wrappedItem{ 145 | Item: &gofeed.Item{ 146 | Extensions: map[string]map[string][]ext.Extension{ 147 | "media": { 148 | "content": { 149 | { 150 | Attrs: map[string]string{ 151 | "medium": "image", 152 | "url": "image_url_1", 153 | }, 154 | }, 155 | { 156 | Attrs: map[string]string{ 157 | "medium": "image", 158 | "url": "image_url_2", 159 | }, 160 | }, 161 | }, 162 | }, 163 | }, 164 | }, 165 | } 166 | 167 | images := w.Images() 168 | 169 | if len(images) != 2 { 170 | t.Error("expected image list to be 2") 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /internal/rssole/templates/crudfeed.go.html: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 33 | {{if .}} 34 | 40 | {{end}} 41 |
42 | {{if .}} 43 | 44 | {{end}} 45 |
46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 | 59 |
60 |
61 | 62 | 64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 | 76 |
77 |
78 | 81 | {{if .}} 82 | 88 | {{end}} 89 |
90 | {{if .}} 91 | 92 | {{end}} 93 |
94 | 95 | {{if .}} 96 |
97 | {{if not .Scrape}} 98 | W3C Feed Validator 99 | {{end}} 100 |
{{.RecentLogs}}
101 |
102 | {{end}} 103 |
104 | -------------------------------------------------------------------------------- /internal/rssole/feed_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | /* TODO: 15 | 16 | Scrape during Update - test it works 17 | Unread count 18 | Sorting 19 | 20 | */ 21 | 22 | func feedSetUpTearDown(_ *testing.T) func(t *testing.T) { 23 | // We don't want to make a mess of the local fs 24 | // so clobber the readcache with one that uses a tmp file. 25 | readCacheDir, err := os.MkdirTemp("", "Test_Feed") 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | file, err := os.CreateTemp(readCacheDir, "*") 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | // swap the global one out to a safe one 36 | readLut = &unreadLut{ 37 | Filename: file.Name(), 38 | } 39 | 40 | return func(_ *testing.T) { 41 | os.RemoveAll(readCacheDir) 42 | } 43 | } 44 | 45 | func TestUpdate_InvalidRssFeed(t *testing.T) { 46 | defer feedSetUpTearDown(t)(t) 47 | 48 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 49 | fmt.Fprintln(w, "Invalid RSS Feed") 50 | })) 51 | defer ts.Close() 52 | 53 | feed := &feed{ 54 | URL: ts.URL, 55 | } 56 | feed.Init() 57 | 58 | err := feed.Update() 59 | 60 | if err == nil { 61 | t.Fatal("expected an error for an invalid feed") 62 | } 63 | } 64 | 65 | func TestUpdate_ValidRssFeed(t *testing.T) { 66 | defer feedSetUpTearDown(t)(t) 67 | 68 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 69 | fmt.Fprintln(w, ` 70 | 71 | 72 | Feed Title 73 | Feed Link 74 | This is a test 75 | 76 | Title 1 77 | http://title1.com/ 78 | Title 1 79 | 80 | 81 | Title 2 82 | http://title2.com/ 83 | Title 2 84 | 85 | 86 | Title 3 87 | http://title3.com/ 88 | Title 3 89 | 90 | 91 | `) 92 | })) 93 | defer ts.Close() 94 | 95 | feed := &feed{ 96 | URL: ts.URL, 97 | } 98 | feed.Init() 99 | 100 | err := feed.Update() 101 | if err != nil { 102 | t.Fatal("unexpected error for a valid", err) 103 | } 104 | 105 | if feed.feed == nil { 106 | t.Fatal("expected feed not to be nil") 107 | } 108 | } 109 | 110 | func TestUpdate_ValidScrape(t *testing.T) { 111 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 112 | fmt.Fprintln(w, ` 113 | 114 |
115 |

Title 1

116 | Title 1 117 |
118 |
119 |

Title 2

120 | Title 2 121 |
122 | 123 | `) 124 | })) 125 | defer ts.Close() 126 | 127 | feed := &feed{ 128 | URL: ts.URL, 129 | Scrape: &scrape{ 130 | URLs: []string{ 131 | ts.URL, 132 | ts.URL, 133 | }, 134 | Item: ".item", 135 | Title: ".title", 136 | Link: ".link", 137 | }, 138 | } 139 | feed.Init() 140 | 141 | err := feed.Update() 142 | if err != nil { 143 | t.Fatal("unexpected error for a valid", err) 144 | } 145 | 146 | if feed.feed == nil { 147 | t.Fatal("expected feed not to be nil") 148 | } 149 | } 150 | 151 | func TestUpdate_InvalidScrape(t *testing.T) { 152 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 153 | w.WriteHeader(http.StatusBadRequest) 154 | })) 155 | defer ts.Close() 156 | 157 | feed := &feed{ 158 | URL: ts.URL, 159 | Scrape: &scrape{ 160 | URLs: []string{ 161 | ts.URL, 162 | ts.URL, 163 | }, 164 | Item: ".item", 165 | Title: ".title", 166 | Link: ".link", 167 | }, 168 | } 169 | feed.Init() 170 | 171 | err := feed.Update() 172 | 173 | if err == nil { 174 | t.Fatal("expected error for an invalid", err) 175 | } 176 | 177 | if feed.feed != nil { 178 | t.Fatal("expected feed to be nil") 179 | } 180 | } 181 | 182 | func TestStartTickedUpdate(t *testing.T) { 183 | defer feedSetUpTearDown(t)(t) 184 | 185 | updateCount := 0 186 | 187 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 188 | updateCount++ 189 | 190 | fmt.Fprintln(w, ` 191 | 192 | 193 | Feed Title 194 | Feed Link 195 | This is a test 196 | 197 | Title 1 198 | http://title1.com/ 199 | Title 1 200 | 201 | 202 | `) 203 | })) 204 | defer ts.Close() 205 | 206 | feed := &feed{ 207 | URL: ts.URL, 208 | } 209 | feed.Init() 210 | 211 | feed.StartTickedUpdate(10 * time.Millisecond) 212 | time.Sleep(45 * time.Millisecond) 213 | feed.StopTickedUpdate() 214 | 215 | if updateCount == 1 { 216 | t.Fatal("expected more than 1 updates to have happened, got", updateCount) 217 | } 218 | 219 | if feed.Title() != "Feed Title" { 220 | t.Fatal("unexpected feed title of:", feed.Title()) 221 | } 222 | } 223 | 224 | func TestLog(t *testing.T) { 225 | feed := &feed{} 226 | feed.Init() 227 | 228 | feed.log.Info("line 1") 229 | 230 | if !strings.Contains(feed.RecentLogs.String(), "line 1") { 231 | t.Fatal("expected to find line 1 in:", feed.RecentLogs.String()) 232 | } 233 | } 234 | 235 | func TestLog_ExceedMaxLines(t *testing.T) { 236 | feed := &feed{} 237 | feed.Init() 238 | 239 | // overflow the max by 1 240 | for i := 0; i <= maxRecentLogLines+1; i++ { 241 | feed.log.Info(fmt.Sprintf("line %d here", i)) 242 | } 243 | 244 | if strings.Contains(feed.RecentLogs.String(), "line 1 here") { 245 | t.Fatal("expected not to find line 1 in:", feed.RecentLogs.String()) 246 | } 247 | 248 | if !strings.Contains(feed.RecentLogs.String(), "line 2 here") { 249 | t.Fatal("expected to find line 2 in:", feed.RecentLogs.String()) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /internal/rssole/item.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "log/slog" 8 | "net/url" 9 | "strings" 10 | "sync" 11 | 12 | htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" 13 | "github.com/gomarkdown/markdown" 14 | "github.com/gomarkdown/markdown/html" 15 | "github.com/gomarkdown/markdown/parser" 16 | "github.com/k3a/html2text" 17 | "github.com/mmcdole/gofeed" 18 | "github.com/mpvl/unique" 19 | ) 20 | 21 | type wrappedItem struct { 22 | IsUnread bool 23 | Feed *feed 24 | *gofeed.Item 25 | 26 | summary *string 27 | description *string 28 | images *[]string 29 | onceDescription sync.Once 30 | } 31 | 32 | func (w *wrappedItem) MarkReadID() string { 33 | id := w.Link 34 | if id == "" { 35 | id = w.GUID 36 | if id == "" { 37 | id = url.QueryEscape(w.Title) 38 | } 39 | } 40 | 41 | return id 42 | } 43 | 44 | func (w *wrappedItem) Images() []string { 45 | if w.images != nil { // used cached version 46 | return *w.images 47 | } 48 | 49 | images := []string{} 50 | 51 | // standard supplied image 52 | if w.Item.Image != nil { 53 | images = append(images, w.Item.Image.URL) 54 | } 55 | 56 | // mastodon/gibiz images 57 | if media, found := w.Item.Extensions["media"]; found { 58 | if content, found := media["content"]; found { 59 | for _, v := range content { 60 | if v.Attrs["medium"] == "image" { 61 | imageURL := v.Attrs["url"] 62 | images = append(images, imageURL) 63 | } 64 | } 65 | } 66 | } 67 | 68 | // youtube style media:group 69 | group := w.Item.Extensions["media"]["group"] 70 | if len(group) > 0 { 71 | thumbnail := group[0].Children["thumbnail"] 72 | if len(thumbnail) > 0 { 73 | url := thumbnail[0].Attrs["url"] 74 | if url != "" { 75 | images = append(images, url) 76 | } 77 | } 78 | } 79 | 80 | // also add images found in enclosures 81 | for _, enclosure := range w.Enclosures { 82 | if strings.HasPrefix(enclosure.Type, "image/") { 83 | images = append(images, enclosure.URL) 84 | } 85 | } 86 | 87 | // Now... remove any meta images that are embedded in the description. 88 | // Ignore any query string args. 89 | 90 | dedupedImages := []string{} 91 | 92 | // Remove any image sources already within the description... 93 | for _, img := range images { 94 | srcNoQueryString := strings.Split(img, "?")[0] 95 | if !strings.Contains(w.Description(), srcNoQueryString) { 96 | dedupedImages = append(dedupedImages, img) 97 | } else { 98 | slog.Info("dedeuped meta image as already found in content", "src", img) 99 | } 100 | } 101 | 102 | // Remove any internal duplicates within the list... 103 | unique.Strings(&dedupedImages) 104 | 105 | w.images = &dedupedImages 106 | 107 | return *w.images 108 | } 109 | 110 | func (w *wrappedItem) Description() string { 111 | w.onceDescription.Do(func() { 112 | // create a list of descriptions from various sources, 113 | // we'll pick the longest later on. 114 | descSources := []*string{ 115 | &w.Item.Description, 116 | &w.Item.Content, 117 | } 118 | 119 | // youtube style media:group ? 120 | group := w.Item.Extensions["media"]["group"] 121 | if len(group) > 0 { 122 | description := group[0].Children["description"] 123 | if len(description) > 0 { 124 | descSources = append(descSources, &description[0].Value) 125 | } 126 | } 127 | 128 | // IFLS a10 ? 129 | a10content := w.Item.Extensions["a10"]["content"] 130 | if len(a10content) > 0 { 131 | description := a10content[0].Value 132 | if len(description) > 0 { 133 | descSources = append(descSources, &description) 134 | } 135 | } 136 | 137 | var desc *string 138 | 139 | // pick the longest description as the story content 140 | for _, d := range descSources { 141 | if desc == nil || len(*desc) < len(*d) { 142 | desc = d 143 | } 144 | } 145 | 146 | // Now simplify the (potential) HTML by converting 147 | // it to and from markdown. 148 | 149 | // First convert rando HTML to Markdown.... 150 | doc, err := htmltomarkdown.ConvertString(*desc) 151 | 152 | switch { 153 | case err != nil: 154 | slog.Warn("htmltomarkdown.ConvertString failed, returning unsanitised content", "error", err) 155 | 156 | w.description = desc 157 | case doc == "": 158 | slog.Warn("htmltomarkdown.ConvertString result blank, using original.") 159 | 160 | w.description = desc 161 | default: 162 | // parse markdown 163 | p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock) 164 | md := p.Parse([]byte(doc)) 165 | 166 | absRoot := "" 167 | 168 | if u, err := url.Parse(w.Link); err == nil { 169 | // some stories (e.g. Go Blog) have root relative links, so we need to supply a root (of the site, not story). 170 | absRoot = fmt.Sprintf("%s://%s", u.Scheme, u.Host) 171 | } 172 | 173 | // render to HTML (we choose to exclude embedded images and rely on them being passed in metadata) 174 | renderer := html.NewRenderer(html.RendererOptions{ 175 | AbsolutePrefix: absRoot, 176 | Flags: html.CommonFlags | html.HrefTargetBlank, 177 | }) 178 | mdHTML := string(markdown.Render(md, renderer)) 179 | w.description = &mdHTML 180 | } 181 | }) 182 | 183 | return *w.description 184 | } 185 | 186 | const maxDescriptionLength = 200 187 | 188 | func (w *wrappedItem) Summary() string { 189 | if w.summary != nil { 190 | return *w.summary 191 | } 192 | 193 | plainDesc := html2text.HTML2TextWithOptions(w.Description()) 194 | if len(plainDesc) > maxDescriptionLength { 195 | plainDesc = plainDesc[:maxDescriptionLength] 196 | } 197 | 198 | plainDesc = strings.TrimSpace(plainDesc) 199 | 200 | // if summary is identical to title return nothing 201 | if plainDesc == w.Title { 202 | plainDesc = "" 203 | } 204 | 205 | // if summary is just a url then return nothing (hacker news does this) 206 | if _, err := url.ParseRequestURI(plainDesc); err == nil { 207 | plainDesc = "" 208 | } 209 | 210 | w.summary = &plainDesc 211 | 212 | return *w.summary 213 | } 214 | 215 | func (w *wrappedItem) ID() string { 216 | hash := md5.Sum([]byte(w.MarkReadID())) 217 | 218 | return hex.EncodeToString(hash[:]) 219 | } 220 | -------------------------------------------------------------------------------- /internal/rssole/feed.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "sort" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/mmcdole/gofeed" 18 | "golang.org/x/exp/slog" 19 | ) 20 | 21 | type feed struct { 22 | URL string `json:"url"` 23 | Name string `json:"name,omitempty"` // optional override name 24 | Category string `json:"category,omitempty"` // optional grouping 25 | Scrape *scrape `json:"scrape,omitempty"` 26 | RecentLogs *limitLinesBuffer `json:"-"` 27 | 28 | ticker *time.Ticker 29 | feed *gofeed.Feed 30 | mu sync.RWMutex 31 | wrappedItems []*wrappedItem 32 | log *slog.Logger 33 | 34 | eTag string 35 | lastModified time.Time 36 | } 37 | 38 | var ( 39 | ErrNotModified = errors.New("not modified") 40 | 41 | gmtTimeZoneLocation *time.Location 42 | 43 | httpClientTimeout = 30 * time.Second 44 | 45 | httpClient = &http.Client{ 46 | Timeout: httpClientTimeout, 47 | } 48 | ) 49 | 50 | func init() { 51 | loc, err := time.LoadLocation("GMT") 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | gmtTimeZoneLocation = loc 57 | } 58 | 59 | const maxRecentLogLines = 30 60 | 61 | type limitLinesBuffer struct { 62 | MaxLines int 63 | *bytes.Buffer 64 | } 65 | 66 | func (llw *limitLinesBuffer) Write(p []byte) (int, error) { 67 | n, err := llw.Buffer.Write(p) 68 | 69 | numLines := strings.Count(llw.Buffer.String(), "\n") 70 | if numLines > llw.MaxLines { 71 | cappedLines := strings.Join( 72 | strings.Split(llw.Buffer.String(), "\n")[numLines-maxRecentLogLines:], 73 | "\n", 74 | ) 75 | 76 | llw.Buffer.Reset() 77 | llw.Buffer.WriteString(cappedLines) 78 | } 79 | 80 | return n, fmt.Errorf("limitLinesWriter error - %w", err) 81 | } 82 | 83 | func (f *feed) Init() { 84 | f.RecentLogs = &limitLinesBuffer{ 85 | MaxLines: maxRecentLogLines, 86 | Buffer: bytes.NewBufferString(""), 87 | } 88 | 89 | th := slog.NewTextHandler(io.MultiWriter(os.Stdout, f.RecentLogs), nil) 90 | f.log = slog.New(th).With("feed", f.URL) 91 | } 92 | 93 | func (f *feed) Link() string { 94 | if f.feed != nil { 95 | return f.feed.Link 96 | } 97 | 98 | return "" 99 | } 100 | 101 | func (f *feed) Title() string { 102 | if f.Name != "" { 103 | return f.Name 104 | } 105 | 106 | if f.feed != nil { 107 | return f.feed.Title 108 | } 109 | 110 | return f.URL 111 | } 112 | 113 | func (f *feed) UnreadItemCount() int { 114 | if f.feed == nil { 115 | return 0 116 | } 117 | 118 | cnt := 0 119 | 120 | for _, item := range f.Items() { 121 | if item.IsUnread { 122 | cnt++ 123 | } 124 | } 125 | 126 | return cnt 127 | } 128 | 129 | func (f *feed) Items() []*wrappedItem { 130 | return f.wrappedItems 131 | } 132 | 133 | func (f *feed) Update() error { 134 | var feed *gofeed.Feed 135 | 136 | fp := gofeed.NewParser() 137 | 138 | if f.Scrape != nil { 139 | f.log.Info("Scraping website pages", "urls", f.Scrape.URLs) 140 | 141 | pseudoRss, err := f.Scrape.GeneratePseudoRssFeed() 142 | if err != nil { 143 | return fmt.Errorf("rss GeneratePseudoRssFeed %s %w", f.URL, err) 144 | } 145 | 146 | f.log.Info("Parsing pseudo feed") 147 | 148 | feed, err = fp.ParseString(pseudoRss) 149 | if err != nil { 150 | return fmt.Errorf("rss parsestring %s %w", f.URL, err) 151 | } 152 | } else { 153 | f.log.Info("Fetching and parsing feed", "url", f.URL) 154 | 155 | req, err := http.NewRequest(http.MethodGet, f.URL, nil) 156 | if err != nil { 157 | return fmt.Errorf("cannot create new request: %w", err) 158 | } 159 | 160 | req.Header.Set("User-Agent", "Gofeed/1.0") 161 | 162 | if f.eTag != "" { 163 | req.Header.Set("If-None-Match", fmt.Sprintf(`"%s"`, f.eTag)) 164 | } 165 | 166 | req.Header.Set("If-Modified-Since", f.lastModified.In(gmtTimeZoneLocation).Format(time.RFC1123)) 167 | 168 | resp, err := httpClient.Do(req) 169 | 170 | if err != nil { 171 | return fmt.Errorf("unable to do request: %w", err) 172 | } 173 | 174 | if resp != nil { 175 | defer func() { 176 | ce := resp.Body.Close() 177 | if ce != nil { 178 | err = ce 179 | } 180 | }() 181 | } 182 | 183 | if resp.StatusCode == http.StatusNotModified { 184 | f.freshenUrlsInReadCache() 185 | 186 | return ErrNotModified 187 | } 188 | 189 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 190 | return gofeed.HTTPError{ 191 | StatusCode: resp.StatusCode, 192 | Status: resp.Status, 193 | } 194 | } 195 | 196 | feed, err = fp.Parse(resp.Body) 197 | if err != nil { 198 | return fmt.Errorf("rss parseurl %s %w", f.URL, err) 199 | } 200 | 201 | if eTag := resp.Header.Get("Etag"); eTag != "" { 202 | f.eTag = eTag 203 | } 204 | 205 | if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" { 206 | parsed, err := time.ParseInLocation(time.RFC1123, lastModified, gmtTimeZoneLocation) 207 | if err == nil { 208 | f.lastModified = parsed 209 | } 210 | } 211 | } 212 | 213 | f.mu.Lock() 214 | f.feed = feed 215 | f.wrappedItems = make([]*wrappedItem, len(f.feed.Items)) 216 | 217 | f.log.Info("Items in feed", "length", len(f.feed.Items)) 218 | 219 | for idx, item := range f.feed.Items { 220 | wItem := &wrappedItem{ 221 | Feed: f, 222 | Item: item, 223 | } 224 | wItem.IsUnread = readLut.isUnread(wItem.MarkReadID()) 225 | f.wrappedItems[idx] = wItem 226 | } 227 | 228 | sort.Slice(f.wrappedItems, func(i, j int) bool { 229 | // unread always higher than read 230 | if f.wrappedItems[i].IsUnread && !f.wrappedItems[j].IsUnread { 231 | return true 232 | } 233 | 234 | if !f.wrappedItems[i].IsUnread && f.wrappedItems[j].IsUnread { 235 | return false 236 | } 237 | 238 | iDate := f.wrappedItems[i].UpdatedParsed 239 | if iDate == nil { 240 | iDate = f.wrappedItems[i].PublishedParsed 241 | } 242 | 243 | jDate := f.wrappedItems[j].UpdatedParsed 244 | if jDate == nil { 245 | jDate = f.wrappedItems[j].PublishedParsed 246 | } 247 | 248 | if iDate != nil && jDate != nil { 249 | return jDate.Before(*iDate) 250 | } 251 | 252 | return false // retain current order 253 | }) 254 | 255 | f.mu.Unlock() 256 | 257 | f.log.Info("Finished updating feed") 258 | 259 | f.freshenUrlsInReadCache() 260 | 261 | readLut.persistReadLut() 262 | 263 | updateLastmodified() 264 | 265 | return nil 266 | } 267 | 268 | func (f *feed) freshenUrlsInReadCache() { 269 | // extend the life of anything valid still in the 270 | // read cache. 271 | for _, wi := range f.wrappedItems { 272 | readLut.extendLifeIfFound(wi.MarkReadID()) 273 | } 274 | } 275 | 276 | func (f *feed) StartTickedUpdate(updateTime time.Duration) { 277 | if f.ticker != nil { 278 | return // already running 279 | } 280 | 281 | f.log.Info("Starting feed update ticker", "duration", updateTime) 282 | f.ticker = time.NewTicker(updateTime) 283 | 284 | go func() { 285 | if err := f.Update(); err != nil { 286 | f.log.Error("update failed", "error", err) 287 | } 288 | 289 | for range f.ticker.C { 290 | if err := f.Update(); err != nil { 291 | f.log.Error("update failed", "error", err) 292 | } 293 | } 294 | }() 295 | } 296 | 297 | func (f *feed) ChangeTickedUpdate(d time.Duration) { 298 | if f.ticker != nil { 299 | f.log.Info("Update ticker", "update", d) 300 | f.ticker.Reset(d) 301 | } 302 | } 303 | 304 | func (f *feed) StopTickedUpdate() { 305 | if f.ticker != nil { 306 | f.log.Info("Stopped update ticker") 307 | f.ticker.Stop() 308 | f.ticker = nil 309 | } 310 | } 311 | 312 | func (f *feed) ID() string { 313 | hash := md5.Sum([]byte(f.URL)) 314 | 315 | return hex.EncodeToString(hash[:]) 316 | } 317 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![badge](./badge.svg) ![workflow status](https://github.com/TheMightyGit/rssole/actions/workflows/build.yml/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/TheMightyGit/rssole)](https://goreportcard.com/report/github.com/TheMightyGit/rssole) 2 | 3 | # rssole 4 | 5 | An absolutely no frills RSS Reader inspired by the late Google Reader. Runs on 6 | your local machine or local network serving your RSS feeds via a clean 7 | responsive web interface. 8 | 9 | ![Screenshot 2023-08-10 at 14 21 53](https://github.com/TheMightyGit/rssole/assets/888751/a44ae604-72a4-4e92-8ed7-5580663eaf0c) 10 | 11 | A single executable with a single config file that can largely be configured 12 | within the web UI. 13 | 14 | Its greatest feature is the lack of excess features. It tries to do a simple 15 | job well and not get in the way. 16 | 17 | ## Background 18 | 19 | I really miss Google Reader, and I really like simplicity. So I made this 20 | non-SaaS ode to Google Reader so I can triage my incoming information in one 21 | place with one interface in a way I like. At heart this is a very self serving 22 | project solely based around my needs, and because of that it's something I use 23 | constantly. Hopefully it's of use to some other people, or you can build upon 24 | it (MIT license, do what you want to it - make it comfortable for you). 25 | 26 | ## Advantages/Limitations 27 | 28 | I see these as advantages (so they are unlikely to be added as features), but 29 | some may see them as limitations... 30 | 31 | - Only shows what's in the feed currently, does not store stories beyond their 32 | lifetime in the feed. 33 | - Doesn't try to fetch anything from the linked page, only shows info present 34 | in the feed. The aim is not to keep you inside the RRS reader, if you want 35 | more then follow the link to the origin site. 36 | - No bookmarks/favourites - you can already do this in the browser. 37 | - It's not multi-user, there is no login or security protection. It's not 38 | intended as a SaaS product, it's just for you on your local machine or 39 | network. But you can stick an authenticating HTTP proxy in front of it if you 40 | wish. 41 | 42 | ## Pre-Built Binaries and Packages 43 | 44 | Check out the [Releases](https://github.com/TheMightyGit/rssole/releases/) 45 | section in github, there should be a good selection of pre-built binaries 46 | and packages for various platforms. 47 | 48 | ## Installing via Brew 49 | 50 | ```console 51 | $ brew install themightygit/rssole/rssole 52 | ``` 53 | 54 | ## Installing via Go 55 | 56 | You can install the binary with go install: 57 | 58 | ```console 59 | $ go install github.com/TheMightyGit/rssole/cmd/rssole@latest 60 | ``` 61 | 62 | ## Building 63 | 64 | NOTE: You can ignore the `Makefile`, that's really just a helper for me during 65 | development. 66 | 67 | To build for your local architecture/OS... 68 | 69 | ```console 70 | $ go build ./cmd/... 71 | ``` 72 | 73 | It should also cross build for all the usual golang targets fine as well (as no 74 | CGO is used)... 75 | 76 | ```console 77 | $ GOOS=linux GOARCH=amd64 go build ./cmd/... 78 | $ GOOS=linux GOARCH=arm64 go build ./cmd/... 79 | $ GOOS=darwin GOARCH=amd64 go build ./cmd/... 80 | $ GOOS=darwin GOARCH=arm64 go build ./cmd/... 81 | $ GOOS=windows GOARCH=amd64 go build ./cmd/... 82 | $ GOOS=windows GOARCH=arm64 go build ./cmd/... 83 | ``` 84 | 85 | ...but I only regularly test on `darwin/amd64` and `linux/amd64`. 86 | I've seen it run on `windows/amd64`, but it's not something I try regularly. 87 | 88 | ### Smallest Binary 89 | 90 | Go binaries can be a tad chunky, so if you're really space constrained then... 91 | 92 | ```console 93 | $ go build -ldflags "-s -w" ./cmd/... 94 | $ upx rssole 95 | ``` 96 | 97 | ## Running 98 | 99 | ### Command Line 100 | 101 | If you built locally then it should be in the current directory: 102 | 103 | ```console 104 | $ ./rssole 105 | ``` 106 | 107 | If you used `go install` or brew then it should be on your path already: 108 | 109 | ```console 110 | $ rssole 111 | ``` 112 | 113 | ### GUI 114 | 115 | Double click on the file, I guess. 116 | 117 | If your system has restrictions on which binaries it will run then try 118 | compiling locally instead of using the pre-built binaries. 119 | 120 | ## Now read your feeds with your browser 121 | 122 | Now open your browser on `:8090` e.g. http://localhost:8090 123 | 124 | ## Network Options 125 | 126 | By default it binds to `0.0.0.0:8090`, so it will be available on all network 127 | adaptors on your host. You can change this in the `rssole.json` config file. 128 | 129 | I run rssole within a private network so this is good enough for me so that I 130 | can run it once but access it from all my devices. If you run this on an alien 131 | network then someone else can mess with the UI (there's no protection at all on 132 | it) - change the `listen` value in `rssole.json` to `127.0.0.1:8090` if you 133 | only want it to serve locally. 134 | 135 | If you want to protect rssole behind a username and password or encryption 136 | (because you want rssole wide open on the net so you can use it from anywhere) 137 | then you'll need a web proxy that can be configured to sit in front of it to 138 | provide that protection. I'm highly unlikely to add username/password or 139 | encryption directly to rssole as I don't need it. Maybe someone will create a 140 | docker image that autoconfigures all of that... maybe that someone is you? 141 | 142 | ## Config 143 | 144 | ### Arguments 145 | 146 | ```console 147 | $ ./rssole -h 148 | Usage of ./rssole: 149 | -c string 150 | config filename (default "rssole.json") 151 | -r string 152 | readcache location (default "rssole_readcache.json") 153 | ``` 154 | 155 | ### `rssole.json` 156 | 157 | There are two types of feed definition... 158 | 159 | - Regular RSS URLs. 160 | - Scrape from website (for those pesky sites that have no RSS feed). 161 | - Scraping uses css selectors and is not well documented yet. 162 | 163 | Use `category` to group similar feeds together. 164 | 165 | ```json 166 | { 167 | "config": { 168 | "listen": "0.0.0.0:8090", 169 | "update_seconds": 300 170 | }, 171 | "feeds": [ 172 | {"url":"https://github.com/TheMightyGit/rssole/releases.atom", "category":"Github Releases"}, 173 | {"url":"https://news.ycombinator.com/rss", "category":"Nerd"}, 174 | {"url":"http://feeds.bbci.co.uk/news/rss.xml", "category":"News"}, 175 | { 176 | "url":"https://www.pcgamer.com/uk/news/", "category":"Games", 177 | "name":"PCGamer News", 178 | "scrape": { 179 | "urls": [ 180 | "https://www.pcgamer.com/uk/news/", 181 | "https://www.pcgamer.com/uk/news/page/2/", 182 | "https://www.pcgamer.com/uk/news/page/3/" 183 | ], 184 | "item": ".listingResult", 185 | "title": ".article-name", 186 | "link": ".article-link" 187 | } 188 | } 189 | ] 190 | } 191 | ``` 192 | 193 | ## Key Dependencies 194 | 195 | I haven't had to implement anything actually difficult, I just do a bit of 196 | plumbing. All the difficult stuff has been done for me by these projects... 197 | 198 | - github.com/mmcdole/gofeed - for reading all sorts of RSS formats. 199 | - github.com/andybalholm/cascadia - for css selectors during website scrapes. 200 | - github.com/JohannesKaufmann/html-to-markdown/v2 to convert HTML into Markdown 201 | (thus sanitizing and simplifying it). 202 | - github.com/gomarkdown/markdown to render content markdown back to HTML. 203 | - github.com/k3a/html2text - for making a plain text summary of html. 204 | - HTMX - for the javascript anti-framework (and a backend engineers delight). 205 | - Bootstrap 5 - for HTML niceness simply because I know it slightly better than 206 | the alternatives. 207 | -------------------------------------------------------------------------------- /internal/rssole/endpoints.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "golang.org/x/exp/slog" 12 | ) 13 | 14 | const MinUpdateSeconds = 900 15 | 16 | func index(w http.ResponseWriter, req *http.Request) { 17 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 18 | 19 | if err := templates["base.go.html"].Execute(w, map[string]any{ 20 | "Version": Version, 21 | }); err != nil { 22 | logger.Error("base.go.html", "error", err) 23 | } 24 | } 25 | 26 | func feedlistCommon(w http.ResponseWriter, selected string, logger *slog.Logger) { 27 | allFeeds.mu.RLock() 28 | defer allFeeds.mu.RUnlock() 29 | 30 | w.Header().Add("Last-Modified", getLastmodified().Format(http.TimeFormat)) 31 | 32 | for _, f := range allFeeds.Feeds { 33 | f.mu.RLock() 34 | } 35 | 36 | defer func() { 37 | for _, f := range allFeeds.Feeds { 38 | f.mu.RUnlock() 39 | } 40 | }() 41 | 42 | if err := templates["feedlist.go.html"].Execute(w, map[string]any{ 43 | "Selected": selected, 44 | "Feeds": allFeeds, 45 | }); err != nil { 46 | logger.Error("feedlist.go.html", "error", err) 47 | } 48 | } 49 | 50 | func feedsNotModified(req *http.Request) bool { 51 | // make precision equal for test 52 | lastmod, _ := http.ParseTime(getLastmodified().Format(http.TimeFormat)) 53 | 54 | imsRaw := req.Header.Get("if-modified-since") 55 | if imsRaw != "" { 56 | // has any feed (or mark as read) been modified since last time? 57 | ims, err := http.ParseTime(req.Header.Get("if-modified-since")) 58 | if err == nil { 59 | if ims.After(lastmod) || 60 | ims.Equal(lastmod) { 61 | return true 62 | } 63 | } 64 | } 65 | 66 | return false 67 | } 68 | 69 | func feedlist(w http.ResponseWriter, req *http.Request) { 70 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 71 | 72 | // To greatly reduce the bandwidth from polling we use Last-Modified/If-Modified-Since 73 | // which is respected by htmx. 74 | if feedsNotModified(req) { 75 | w.WriteHeader(http.StatusNotModified) 76 | 77 | return 78 | } 79 | 80 | selected := req.URL.Query().Get("selected") 81 | feedlistCommon(w, selected, logger) 82 | } 83 | 84 | func items(w http.ResponseWriter, req *http.Request) { 85 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 86 | 87 | feedURL := req.URL.Query().Get("url") 88 | 89 | allFeeds.mu.RLock() 90 | defer allFeeds.mu.RUnlock() 91 | 92 | if req.Method == http.MethodPost { 93 | _ = req.ParseForm() 94 | markRead := map[string]bool{} 95 | 96 | for k, v := range req.Form { 97 | if k == "read" { 98 | for _, v2 := range v { 99 | markRead[v2] = true 100 | } 101 | } 102 | } 103 | 104 | for _, f := range allFeeds.Feeds { 105 | if f.feed != nil && f.URL == feedURL { 106 | f.mu.Lock() 107 | for _, i := range f.Items() { 108 | if markRead[i.MarkReadID()] { 109 | logger.Info("marking read", "MarkReadID", i.MarkReadID()) 110 | i.IsUnread = false 111 | readLut.markRead(i.MarkReadID()) 112 | } 113 | } 114 | f.mu.Unlock() 115 | } 116 | } 117 | 118 | readLut.persistReadLut() 119 | } 120 | 121 | for _, f := range allFeeds.Feeds { 122 | f.mu.RLock() 123 | if f.URL == feedURL { 124 | if err := templates["items.go.html"].Execute(w, f); err != nil { 125 | logger.Error("items.go.html", "error", err) 126 | } 127 | 128 | // update feed list (oob) 129 | feedlistCommon(w, f.Title(), logger) 130 | } 131 | f.mu.RUnlock() 132 | } 133 | } 134 | 135 | func item(w http.ResponseWriter, req *http.Request) { 136 | feedURL := req.URL.Query().Get("url") 137 | id := req.URL.Query().Get("id") 138 | 139 | allFeeds.mu.RLock() 140 | for _, f := range allFeeds.Feeds { 141 | f.mu.RLock() 142 | if f.feed != nil && f.URL == feedURL { 143 | for _, item := range f.Items() { 144 | if item.ID() == id { 145 | item.IsUnread = false 146 | if err := templates["item.go.html"].Execute(w, item); err != nil { 147 | slog.Error("item.go.html", "error", err) 148 | } 149 | 150 | readLut.markRead(item.MarkReadID()) 151 | readLut.persistReadLut() 152 | 153 | break 154 | } 155 | } 156 | } 157 | f.mu.RUnlock() 158 | } 159 | allFeeds.mu.RUnlock() 160 | } 161 | 162 | func crudfeedGet(w http.ResponseWriter, req *http.Request) { 163 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 164 | 165 | var f *feed 166 | 167 | feedID := req.URL.Query().Get("feed") 168 | if feedID != "" { 169 | f = allFeeds.getFeedByID(feedID) 170 | } 171 | 172 | if err := templates["crudfeed.go.html"].Execute(w, f); err != nil { 173 | logger.Error("crudfeed.go.html", "error", err) 174 | } 175 | } 176 | 177 | func crudfeedPost(w http.ResponseWriter, req *http.Request) { 178 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 179 | 180 | err := req.ParseForm() 181 | if err != nil { 182 | logger.Error("ParseForm", "error", err) 183 | } 184 | 185 | id := req.FormValue("id") 186 | feedurl := req.FormValue("url") 187 | name := req.FormValue("name") 188 | category := req.FormValue("category") 189 | 190 | scrapeURLs := req.FormValue("scrape.urls") 191 | scrapeItem := req.FormValue("scrape.item") 192 | scrapeTitle := req.FormValue("scrape.title") 193 | scrapeLink := req.FormValue("scrape.link") 194 | 195 | var scr *scrape 196 | if scrapeURLs != "" || scrapeItem != "" || scrapeTitle != "" || scrapeLink != "" { 197 | scr = &scrape{ 198 | URLs: strings.Split(strings.TrimSpace(scrapeURLs), "\n"), 199 | Item: scrapeItem, 200 | Title: scrapeTitle, 201 | Link: scrapeLink, 202 | } 203 | } 204 | 205 | if id != "" { // edit or delete 206 | del := req.FormValue("delete") 207 | if del != "" { 208 | allFeeds.delFeed(id) 209 | fmt.Fprint(w, `Deleted.`) 210 | feedlistCommon(w, "_", logger) 211 | } else { 212 | // update 213 | f := allFeeds.getFeedByID(id) 214 | if f != nil { 215 | f.mu.Lock() 216 | f.URL = feedurl 217 | f.Name = name 218 | f.Category = category 219 | f.Scrape = scr 220 | f.mu.Unlock() 221 | feedlistCommon(w, f.Title(), logger) 222 | fmt.Fprintf(w, `
`, url.QueryEscape(f.URL)) 223 | } else { 224 | fmt.Fprint(w, `Not found.`) 225 | } 226 | } 227 | } else { // add 228 | feed := &feed{ 229 | URL: feedurl, 230 | Name: name, 231 | Category: category, 232 | Scrape: scr, 233 | } 234 | feed.Init() 235 | allFeeds.addFeed(feed) 236 | 237 | fmt.Fprintf(w, `
`, url.QueryEscape(feed.URL)) 238 | } 239 | // something may have changed, so save it. 240 | if err := allFeeds.saveFeedsFile(); err != nil { 241 | logger.Error("saveFeedsFile", "error", err) 242 | } 243 | } 244 | 245 | func settingsGet(w http.ResponseWriter, req *http.Request) { 246 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 247 | 248 | if err := templates["settings.go.html"].Execute(w, allFeeds.Config); err != nil { 249 | logger.Error("settings.go.html", "error", err) 250 | } 251 | } 252 | 253 | func settingsPost(w http.ResponseWriter, req *http.Request) { 254 | defer settingsGet(w, req) 255 | 256 | logger := slog.Default().With("endpoint", req.URL, "method", req.Method) 257 | 258 | err := req.ParseForm() 259 | if err != nil { 260 | logger.Error("ParseForm", "error", err) 261 | } 262 | 263 | updateSeconds, err := strconv.Atoi(req.FormValue("update_seconds")) 264 | if err != nil { 265 | logger.Error("Cannot parse update_seconds", "error", err) 266 | 267 | return 268 | } 269 | 270 | if updateSeconds < MinUpdateSeconds { 271 | logger.Error("Error, update_seconds is below 900") 272 | 273 | return 274 | } 275 | 276 | if updateSeconds != allFeeds.Config.UpdateSeconds { 277 | allFeeds.ChangeTickedUpdate(time.Duration(updateSeconds) * time.Second) 278 | } 279 | 280 | // something may have changed, so save it. 281 | if err := allFeeds.saveFeedsFile(); err != nil { 282 | logger.Error("saveFeedsFile", "error", err) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/JohannesKaufmann/dom v0.1.1-0.20240706125338-ff9f3b772364 h1:TDlO/A2QqlNhdvH+hDnu8cv1rouhfHgLwhGzJeHGgFQ= 2 | github.com/JohannesKaufmann/dom v0.1.1-0.20240706125338-ff9f3b772364/go.mod h1:U+fBZLZTYiZCOwQUT04V3J4I+0TxyLNnj0R8nBlO4fk= 3 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.1.0 h1:k6vBBqTmQOqLnaYkELgCU/F9xVPt3xhO1754hvlP/HM= 4 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.1.0/go.mod h1:djCj8ehU80KpSAepQciLcNzrp8hwZ1vQFnYKRo4/Cio= 5 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= 6 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 7 | github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= 8 | github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= 9 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 10 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 h1:5lyLWsV+qCkoYqsKUDuycESh9DEIPVKN6iCFeL7ag50= 15 | github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 16 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 17 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 18 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 19 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 20 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 21 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 22 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 23 | github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= 24 | github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= 25 | github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= 26 | github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= 27 | github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= 28 | github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= 29 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 30 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 31 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 32 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 33 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 34 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= 35 | github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= 39 | github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= 40 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 41 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 42 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 43 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 44 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 45 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 46 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 47 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 48 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 49 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 50 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 51 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 52 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 53 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 54 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 55 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= 56 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= 57 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 58 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 59 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 60 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 61 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 62 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 63 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 64 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 65 | golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= 66 | golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 67 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 69 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 70 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 71 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 74 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 78 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 79 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 80 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 83 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 84 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 85 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 86 | golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= 87 | golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= 88 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 89 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 90 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 91 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 92 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 93 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 94 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 95 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 96 | -------------------------------------------------------------------------------- /internal/rssole/endpoints_test.go: -------------------------------------------------------------------------------- 1 | package rssole 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/mmcdole/gofeed" 14 | ) 15 | 16 | var testItem1 = &wrappedItem{ 17 | IsUnread: true, 18 | Feed: &feed{}, 19 | Item: &gofeed.Item{ 20 | Title: "Story 1 Title", 21 | Description: "Story 1 Description", 22 | Link: "http://example.com/story/1", 23 | }, 24 | } 25 | 26 | func init() { 27 | // We need the templates lodaed for endpoint tests. 28 | _ = loadTemplates() 29 | 30 | testItem1.Feed.Init() 31 | 32 | // Set up some test feeds and items. 33 | allFeeds.Feeds = append(allFeeds.Feeds, &feed{ 34 | URL: "http://example.com/woo_feed", 35 | Name: "Woo Feed!", 36 | }) 37 | allFeeds.Feeds = append(allFeeds.Feeds, &feed{ 38 | URL: "http://example.com/yay_feed", 39 | Name: "Yay Feed!", 40 | feed: &gofeed.Feed{}, 41 | wrappedItems: []*wrappedItem{ 42 | testItem1, 43 | }, 44 | }) 45 | 46 | allFeeds.Feeds[0].Init() 47 | allFeeds.Feeds[1].Init() 48 | 49 | // zero will cause errors if UpdateTime is not set positive 50 | allFeeds.UpdateTime = 10 51 | 52 | allFeeds.Config.Listen = "1.2.3.4:5678" 53 | allFeeds.Config.UpdateSeconds = 987 54 | } 55 | 56 | var readCacheDir string 57 | 58 | func setUpTearDown(_ *testing.T) func(t *testing.T) { 59 | // We don't want to make a mess of the local fs 60 | // so clobber the readcache with one that uses a tmp file. 61 | var err error 62 | 63 | readCacheDir, err = os.MkdirTemp("", "Test_Endpoints") 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | 68 | file, err := os.CreateTemp(readCacheDir, "*") 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | // swap the global one out to a safe one 74 | readLut = &unreadLut{ 75 | Filename: file.Name(), 76 | } 77 | 78 | return func(_ *testing.T) { 79 | os.RemoveAll(readCacheDir) 80 | } 81 | } 82 | 83 | func TestIndex(t *testing.T) { 84 | defer setUpTearDown(t)(t) 85 | 86 | req, err := http.NewRequest(http.MethodGet, "/", nil) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. 92 | rr := httptest.NewRecorder() 93 | handler := http.HandlerFunc(index) 94 | 95 | // Our handlers satisfy http.Handler, so we can call their ServeHTTP method 96 | // directly and pass in our Request and ResponseRecorder. 97 | handler.ServeHTTP(rr, req) 98 | 99 | if status := rr.Code; status != http.StatusOK { 100 | t.Errorf("handler returned wrong status code: got %v want %v", 101 | status, http.StatusOK) 102 | } 103 | 104 | // Check the response contains at least a ]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function f(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(R,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function U(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function d(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function Y(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){Y(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});Y(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||f(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(d(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(d(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=d(H(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(d(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Oe(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=d(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=d(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Re(t,i);u.tasks.push(function(){Re(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(d(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(f(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;Y(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0}function ze(e,t,r,o){if(!o){o={}}e=y(e);const n=document.activeElement;let i={};try{i={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const s=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=D(t);s.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(X(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,We);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,We);c.pollInterval=h(b(o,/[,\[\s]/));b(o,We);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const a={trigger:u};var i=rt(e,o,"event");if(i){a.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,We);const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(b(o,x))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=ot(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(b(o,x))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=b(o,x)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=b(o,x)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}b(o,We)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(f(e,"form")){return[{trigger:"submit"}]}else if(f(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(f(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function dt(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(ft(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(f(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const a=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||ht(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!f(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){de(s,"htmx:trigger");l(s,e);a.throttle=E().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=E().setTimeout(function(){de(s,"htmx:trigger");l(s,e)},u.delay)}else{de(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Ut=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Yt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Ut=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Wt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function tn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const a=ee(c,"name");rn(a,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!f(e,"form")){se(d(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function an(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function fn(e){e=Ln(e);let n="";e.forEach(function(e,t){n=an(n,t,e)});return n}function dn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=U(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||f(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Bt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return fn(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function On(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Rn(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return he(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return he(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const a=c.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Ee(r,"hx-sync")}else{d=ce(ae(r,I))}h=(A[1]||"drop").trim();c=ie(d);if(h==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(h==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const Z=h.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!de(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let x=dn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=hn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const O=$[1];let R=n;if(E){R=z;const Y=!v.keys().next().done;if(Y){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=fn(v);if(O){R+="#"+O}}}if(!qn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const W=x[k];On(p,k,W)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=Rn(p);M(r,H);if(H.keepIndicators!==true){en(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Wt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(u){a="replace";f=u}else if(c){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Bn(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Un(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Yt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); -------------------------------------------------------------------------------- /internal/rssole/libs/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.3.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(s)&&P(t,e,i,r.callable,r.delegationSelector)}function M(t){return t=t.replace(E,""),$[t]||t}const j={on(t,e,i,s){N(t,e,i,s,!1)},one(t,e,i,s){N(t,e,i,s,!0)},off(t,e,i,s){if("string"!=typeof e||!t)return;const[n,o,r]=D(e,i,s),a=r!==e,l=O(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))x(t,l,i,e.slice(1));for(const[i,s]of Object.entries(c)){const n=i.replace(C,"");a&&!e.includes(n)||P(t,l,r,s.callable,s.delegationSelector)}}else{if(!Object.keys(c).length)return;P(t,l,r,o,n?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const s=f();let n=null,o=!0,r=!0,a=!1;e!==M(e)&&s&&(n=s.Event(e,i),s(t).trigger(n),o=!n.isPropagationStopped(),r=!n.isImmediatePropagationStopped(),a=n.isDefaultPrevented());const l=F(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&n&&n.preventDefault(),l}};function F(t,e={}){for(const[i,s]of Object.entries(e))try{t[i]=s}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>s})}return t}function z(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function H(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const B={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${H(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${H(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const s of i){let i=s.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=z(t.dataset[s])}return e},getDataAttribute:(t,e)=>z(t.getAttribute(`data-bs-${H(e)}`))};class q{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=l(e)?B.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...l(e)?B.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[s,n]of Object.entries(e)){const e=t[s],o=l(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(n).test(o))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${s}" provided type "${o}" but expected type "${n}".`)}var i}}class W extends q{constructor(t,e){super(),(t=c(t))&&(this._element=t,this._config=this._getConfig(e),n.set(this._element,this.constructor.DATA_KEY,this))}dispose(){n.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){y(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return n.get(c(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.1"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const R=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return r(e)},K={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let s=t.parentNode.closest(e);for(;s;)i.push(s),s=s.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!d(t)&&h(t)))},getSelectorFromElement(t){const e=R(t);return e&&K.findOne(e)?e:null},getElementFromSelector(t){const e=R(t);return e?K.findOne(e):null},getMultipleElementsFromSelector(t){const e=R(t);return e?K.find(e):[]}},V=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),d(this))return;const n=K.getElementFromSelector(this)||this.closest(`.${s}`);t.getOrCreateInstance(n)[e]()}))},Q=".bs.alert",X=`close${Q}`,Y=`closed${Q}`;class U extends W{static get NAME(){return"alert"}close(){if(j.trigger(this._element,X).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,Y),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=U.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}V(U,"close"),b(U);const G='[data-bs-toggle="button"]';class J extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=J.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}j.on(document,"click.bs.button.data-api",G,(t=>{t.preventDefault();const e=t.target.closest(G);J.getOrCreateInstance(e).toggle()})),b(J);const Z=".bs.swipe",tt=`touchstart${Z}`,et=`touchmove${Z}`,it=`touchend${Z}`,st=`pointerdown${Z}`,nt=`pointerup${Z}`,ot={endCallback:null,leftCallback:null,rightCallback:null},rt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class at extends q{constructor(t,e){super(),this._element=t,t&&at.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return ot}static get DefaultType(){return rt}static get NAME(){return"swipe"}dispose(){j.off(this._element,Z)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),v(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&v(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(j.on(this._element,st,(t=>this._start(t))),j.on(this._element,nt,(t=>this._end(t))),this._element.classList.add("pointer-event")):(j.on(this._element,tt,(t=>this._start(t))),j.on(this._element,et,(t=>this._move(t))),j.on(this._element,it,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const lt=".bs.carousel",ct=".data-api",ht="next",dt="prev",ut="left",_t="right",gt=`slide${lt}`,ft=`slid${lt}`,mt=`keydown${lt}`,pt=`mouseenter${lt}`,bt=`mouseleave${lt}`,vt=`dragstart${lt}`,yt=`load${lt}${ct}`,wt=`click${lt}${ct}`,At="carousel",Et="active",Ct=".active",Tt=".carousel-item",kt=Ct+Tt,$t={ArrowLeft:_t,ArrowRight:ut},St={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Lt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Ot extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=K.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===At&&this.cycle()}static get Default(){return St}static get DefaultType(){return Lt}static get NAME(){return"carousel"}next(){this._slide(ht)}nextWhenVisible(){!document.hidden&&h(this._element)&&this.next()}prev(){this._slide(dt)}pause(){this._isSliding&&a(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?j.one(this._element,ft,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,ft,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const s=t>i?ht:dt;this._slide(s,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&j.on(this._element,mt,(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,pt,(()=>this.pause())),j.on(this._element,bt,(()=>this._maybeEnableCycle()))),this._config.touch&&at.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of K.find(".carousel-item img",this._element))j.on(t,vt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ut)),rightCallback:()=>this._slide(this._directionToOrder(_t)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new at(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=$t[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=K.findOne(Ct,this._indicatorsElement);e.classList.remove(Et),e.removeAttribute("aria-current");const i=K.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(Et),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),s=t===ht,n=e||w(this._getItems(),i,s,this._config.wrap);if(n===i)return;const o=this._getItemIndex(n),r=e=>j.trigger(this._element,e,{relatedTarget:n,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(gt).defaultPrevented)return;if(!i||!n)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=n;const l=s?"carousel-item-start":"carousel-item-end",c=s?"carousel-item-next":"carousel-item-prev";n.classList.add(c),g(n),i.classList.add(l),n.classList.add(l),this._queueCallback((()=>{n.classList.remove(l,c),n.classList.add(Et),i.classList.remove(Et,c,l),this._isSliding=!1,r(ft)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return K.findOne(kt,this._element)}_getItems(){return K.find(Tt,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ut?dt:ht:t===ut?ht:dt}_orderToDirection(t){return p()?t===dt?ut:_t:t===dt?_t:ut}static jQueryInterface(t){return this.each((function(){const e=Ot.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}j.on(document,wt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=K.getElementFromSelector(this);if(!e||!e.classList.contains(At))return;t.preventDefault();const i=Ot.getOrCreateInstance(e),s=this.getAttribute("data-bs-slide-to");return s?(i.to(s),void i._maybeEnableCycle()):"next"===B.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),j.on(window,yt,(()=>{const t=K.find('[data-bs-ride="carousel"]');for(const e of t)Ot.getOrCreateInstance(e)})),b(Ot);const It=".bs.collapse",Dt=`show${It}`,Nt=`shown${It}`,Pt=`hide${It}`,xt=`hidden${It}`,Mt=`click${It}.data-api`,jt="show",Ft="collapse",zt="collapsing",Ht=`:scope .${Ft} .${Ft}`,Bt='[data-bs-toggle="collapse"]',qt={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Rt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=K.find(Bt);for(const t of i){const e=K.getSelectorFromElement(t),i=K.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return qt}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Rt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(j.trigger(this._element,Dt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Ft),this._element.classList.add(zt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft,jt),this._element.style[e]="",j.trigger(this._element,Nt)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,Pt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,g(this._element),this._element.classList.add(zt),this._element.classList.remove(Ft,jt);for(const t of this._triggerArray){const e=K.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft),j.trigger(this._element,xt)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(jt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=c(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Bt);for(const e of t){const t=K.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=K.find(Ht,this._config.parent);return K.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Rt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,Mt,Bt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of K.getMultipleElementsFromSelector(this))Rt.getOrCreateInstance(t,{toggle:!1}).toggle()})),b(Rt);const Kt="dropdown",Vt=".bs.dropdown",Qt=".data-api",Xt="ArrowUp",Yt="ArrowDown",Ut=`hide${Vt}`,Gt=`hidden${Vt}`,Jt=`show${Vt}`,Zt=`shown${Vt}`,te=`click${Vt}${Qt}`,ee=`keydown${Vt}${Qt}`,ie=`keyup${Vt}${Qt}`,se="show",ne='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',oe=`${ne}.${se}`,re=".dropdown-menu",ae=p()?"top-end":"top-start",le=p()?"top-start":"top-end",ce=p()?"bottom-end":"bottom-start",he=p()?"bottom-start":"bottom-end",de=p()?"left-start":"right-start",ue=p()?"right-start":"left-start",_e={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},ge={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class fe extends W{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=K.next(this._element,re)[0]||K.prev(this._element,re)[0]||K.findOne(re,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return _e}static get DefaultType(){return ge}static get NAME(){return Kt}toggle(){return this._isShown()?this.hide():this.show()}show(){if(d(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!j.trigger(this._element,Jt,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(se),this._element.classList.add(se),j.trigger(this._element,Zt,t)}}hide(){if(d(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!j.trigger(this._element,Ut,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._popper&&this._popper.destroy(),this._menu.classList.remove(se),this._element.classList.remove(se),this._element.setAttribute("aria-expanded","false"),B.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,Gt,t)}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!l(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Kt.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===i)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let t=this._element;"parent"===this._config.reference?t=this._parent:l(this._config.reference)?t=c(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=i.createPopper(t,this._menu,e)}_isShown(){return this._menu.classList.contains(se)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return de;if(t.classList.contains("dropstart"))return ue;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?le:ae:e?he:ce}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(B.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...v(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=K.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>h(t)));i.length&&w(i,e,t===Yt,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=K.find(oe);for(const i of e){const e=fe.getInstance(i);if(!e||!1===e._config.autoClose)continue;const s=t.composedPath(),n=s.includes(e._menu);if(s.includes(e._element)||"inside"===e._config.autoClose&&!n||"outside"===e._config.autoClose&&n)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,s=[Xt,Yt].includes(t.key);if(!s&&!i)return;if(e&&!i)return;t.preventDefault();const n=this.matches(ne)?this:K.prev(this,ne)[0]||K.next(this,ne)[0]||K.findOne(ne,t.delegateTarget.parentNode),o=fe.getOrCreateInstance(n);if(s)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),n.focus())}}j.on(document,ee,ne,fe.dataApiKeydownHandler),j.on(document,ee,re,fe.dataApiKeydownHandler),j.on(document,te,fe.clearMenus),j.on(document,ie,fe.clearMenus),j.on(document,te,ne,(function(t){t.preventDefault(),fe.getOrCreateInstance(this).toggle()})),b(fe);const me="backdrop",pe="show",be=`mousedown.bs.${me}`,ve={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},ye={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class we extends q{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ve}static get DefaultType(){return ye}static get NAME(){return me}show(t){if(!this._config.isVisible)return void v(t);this._append();const e=this._getElement();this._config.isAnimated&&g(e),e.classList.add(pe),this._emulateAnimation((()=>{v(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(pe),this._emulateAnimation((()=>{this.dispose(),v(t)}))):v(t)}dispose(){this._isAppended&&(j.off(this._element,be),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=c(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),j.on(t,be,(()=>{v(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){y(t,this._getElement(),this._config.isAnimated)}}const Ae=".bs.focustrap",Ee=`focusin${Ae}`,Ce=`keydown.tab${Ae}`,Te="backward",ke={autofocus:!0,trapElement:null},$e={autofocus:"boolean",trapElement:"element"};class Se extends q{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ke}static get DefaultType(){return $e}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),j.off(document,Ae),j.on(document,Ee,(t=>this._handleFocusin(t))),j.on(document,Ce,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,Ae))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=K.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===Te?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Te:"forward")}}const Le=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",Oe=".sticky-top",Ie="padding-right",De="margin-right";class Ne{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ie,(e=>e+t)),this._setElementAttributes(Le,Ie,(e=>e+t)),this._setElementAttributes(Oe,De,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ie),this._resetElementAttributes(Le,Ie),this._resetElementAttributes(Oe,De)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const s=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+s)return;this._saveInitialAttribute(t,e);const n=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(n))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&B.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=B.getDataAttribute(t,e);null!==i?(B.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(l(t))e(t);else for(const i of K.find(t,this._element))e(i)}}const Pe=".bs.modal",xe=`hide${Pe}`,Me=`hidePrevented${Pe}`,je=`hidden${Pe}`,Fe=`show${Pe}`,ze=`shown${Pe}`,He=`resize${Pe}`,Be=`click.dismiss${Pe}`,qe=`mousedown.dismiss${Pe}`,We=`keydown.dismiss${Pe}`,Re=`click${Pe}.data-api`,Ke="modal-open",Ve="show",Qe="modal-static",Xe={backdrop:!0,focus:!0,keyboard:!0},Ye={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ue extends W{constructor(t,e){super(t,e),this._dialog=K.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ne,this._addEventListeners()}static get Default(){return Xe}static get DefaultType(){return Ye}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,Fe,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ke),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(j.trigger(this._element,xe).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Ve),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){j.off(window,Pe),j.off(this._dialog,Pe),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new we({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=K.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),g(this._element),this._element.classList.add(Ve),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,ze,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){j.on(this._element,We,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),j.on(window,He,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),j.on(this._element,qe,(t=>{j.one(this._element,Be,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Ke),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,je)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,Me).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Qe)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Qe),this._queueCallback((()=>{this._element.classList.remove(Qe),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ue.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,Re,'[data-bs-toggle="modal"]',(function(t){const e=K.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,Fe,(t=>{t.defaultPrevented||j.one(e,je,(()=>{h(this)&&this.focus()}))}));const i=K.findOne(".modal.show");i&&Ue.getInstance(i).hide(),Ue.getOrCreateInstance(e).toggle(this)})),V(Ue),b(Ue);const Ge=".bs.offcanvas",Je=".data-api",Ze=`load${Ge}${Je}`,ti="show",ei="showing",ii="hiding",si=".offcanvas.show",ni=`show${Ge}`,oi=`shown${Ge}`,ri=`hide${Ge}`,ai=`hidePrevented${Ge}`,li=`hidden${Ge}`,ci=`resize${Ge}`,hi=`click${Ge}${Je}`,di=`keydown.dismiss${Ge}`,ui={backdrop:!0,keyboard:!0,scroll:!1},_i={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class gi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return ui}static get DefaultType(){return _i}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,ni,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ne).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(ei),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(ti),this._element.classList.remove(ei),j.trigger(this._element,oi,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,ri).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ii),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(ti,ii),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ne).reset(),j.trigger(this._element,li)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new we({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():j.trigger(this._element,ai)}:null})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_addEventListeners(){j.on(this._element,di,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():j.trigger(this._element,ai))}))}static jQueryInterface(t){return this.each((function(){const e=gi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,hi,'[data-bs-toggle="offcanvas"]',(function(t){const e=K.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this))return;j.one(e,li,(()=>{h(this)&&this.focus()}));const i=K.findOne(si);i&&i!==e&&gi.getInstance(i).hide(),gi.getOrCreateInstance(e).toggle(this)})),j.on(window,Ze,(()=>{for(const t of K.find(si))gi.getOrCreateInstance(t).show()})),j.on(window,ci,(()=>{for(const t of K.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&gi.getOrCreateInstance(t).hide()})),V(gi),b(gi);const fi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},mi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),pi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bi=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!mi.has(i)||Boolean(pi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},vi={allowList:fi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},yi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},wi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ai extends q{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return vi}static get DefaultType(){return yi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},wi)}_setContent(t,e,i){const s=K.findOne(i,t);s&&((e=this._resolvePossibleFunction(e))?l(e)?this._putElementInTemplate(c(e),s):this._config.html?s.innerHTML=this._maybeSanitize(e):s.textContent=e:s.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const s=(new window.DOMParser).parseFromString(t,"text/html"),n=[].concat(...s.body.querySelectorAll("*"));for(const t of n){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const s=[].concat(...t.attributes),n=[].concat(e["*"]||[],e[i]||[]);for(const e of s)bi(e,n)||t.removeAttribute(e.nodeName)}return s.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return v(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ei=new Set(["sanitize","allowList","sanitizeFn"]),Ci="fade",Ti="show",ki=".modal",$i="hide.bs.modal",Si="hover",Li="focus",Oi={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},Ii={allowList:fi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Di={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ni extends W{constructor(t,e){if(void 0===i)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Ii}static get DefaultType(){return Di}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ki),$i,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.eventName("show")),e=(u(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:s}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(s.append(i),j.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._queueCallback((()=>{j.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!j.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._activeTrigger.click=!1,this._activeTrigger[Li]=!1,this._activeTrigger[Si]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ci,Ti),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ci),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ai({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ci)}_isShown(){return this.tip&&this.tip.classList.contains(Ti)}_createPopper(t){const e=v(this._config.placement,[this,t,this._element]),s=Oi[e.toUpperCase()];return i.createPopper(this._element,t,this._getPopperConfig(s))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return v(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...v(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)j.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Si?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===Si?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");j.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?Li:Si]=!0,e._enter()})),j.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?Li:Si]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ki),$i,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=B.getDataAttributes(this._element);for(const t of Object.keys(e))Ei.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:c(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Ni);const Pi={...Ni.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},xi={...Ni.DefaultType,content:"(null|string|element|function)"};class Mi extends Ni{static get Default(){return Pi}static get DefaultType(){return xi}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=Mi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Mi);const ji=".bs.scrollspy",Fi=`activate${ji}`,zi=`click${ji}`,Hi=`load${ji}.data-api`,Bi="active",qi="[href]",Wi=".nav-link",Ri=`${Wi}, .nav-item > ${Wi}, .list-group-item`,Ki={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Vi={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Qi extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Ki}static get DefaultType(){return Vi}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=c(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(j.off(this._config.target,zi),j.on(this._config.target,zi,qi,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,s=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:s,behavior:"smooth"});i.scrollTop=s}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},s=(this._rootElement||document.documentElement).scrollTop,n=s>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=s;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(n&&t){if(i(o),!s)return}else n||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=K.find(qi,this._config.target);for(const e of t){if(!e.hash||d(e))continue;const t=K.findOne(decodeURI(e.hash),this._element);h(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(Bi),this._activateParents(t),j.trigger(this._element,Fi,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))K.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(Bi);else for(const e of K.parents(t,".nav, .list-group"))for(const t of K.prev(e,Ri))t.classList.add(Bi)}_clearActiveClass(t){t.classList.remove(Bi);const e=K.find(`${qi}.${Bi}`,t);for(const t of e)t.classList.remove(Bi)}static jQueryInterface(t){return this.each((function(){const e=Qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,Hi,(()=>{for(const t of K.find('[data-bs-spy="scroll"]'))Qi.getOrCreateInstance(t)})),b(Qi);const Xi=".bs.tab",Yi=`hide${Xi}`,Ui=`hidden${Xi}`,Gi=`show${Xi}`,Ji=`shown${Xi}`,Zi=`click${Xi}`,ts=`keydown${Xi}`,es=`load${Xi}`,is="ArrowLeft",ss="ArrowRight",ns="ArrowUp",os="ArrowDown",rs="Home",as="End",ls="active",cs="fade",hs="show",ds=":not(.dropdown-toggle)",us='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',_s=`.nav-link${ds}, .list-group-item${ds}, [role="tab"]${ds}, ${us}`,gs=`.${ls}[data-bs-toggle="tab"], .${ls}[data-bs-toggle="pill"], .${ls}[data-bs-toggle="list"]`;class fs extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),j.on(this._element,ts,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?j.trigger(e,Yi,{relatedTarget:t}):null;j.trigger(t,Gi,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(ls),this._activate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),j.trigger(t,Ji,{relatedTarget:e})):t.classList.add(hs)}),t,t.classList.contains(cs)))}_deactivate(t,e){t&&(t.classList.remove(ls),t.blur(),this._deactivate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),j.trigger(t,Ui,{relatedTarget:e})):t.classList.remove(hs)}),t,t.classList.contains(cs)))}_keydown(t){if(![is,ss,ns,os,rs,as].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!d(t)));let i;if([rs,as].includes(t.key))i=e[t.key===rs?0:e.length-1];else{const s=[ss,os].includes(t.key);i=w(e,t.target,s,!0)}i&&(i.focus({preventScroll:!0}),fs.getOrCreateInstance(i).show())}_getChildren(){return K.find(_s,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=K.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const s=(t,s)=>{const n=K.findOne(t,i);n&&n.classList.toggle(s,e)};s(".dropdown-toggle",ls),s(".dropdown-menu",hs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(ls)}_getInnerElement(t){return t.matches(_s)?t:K.findOne(_s,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=fs.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,Zi,us,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this)||fs.getOrCreateInstance(this).show()})),j.on(window,es,(()=>{for(const t of K.find(gs))fs.getOrCreateInstance(t)})),b(fs);const ms=".bs.toast",ps=`mouseover${ms}`,bs=`mouseout${ms}`,vs=`focusin${ms}`,ys=`focusout${ms}`,ws=`hide${ms}`,As=`hidden${ms}`,Es=`show${ms}`,Cs=`shown${ms}`,Ts="hide",ks="show",$s="showing",Ss={animation:"boolean",autohide:"boolean",delay:"number"},Ls={animation:!0,autohide:!0,delay:5e3};class Os extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Ls}static get DefaultType(){return Ss}static get NAME(){return"toast"}show(){j.trigger(this._element,Es).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Ts),g(this._element),this._element.classList.add(ks,$s),this._queueCallback((()=>{this._element.classList.remove($s),j.trigger(this._element,Cs),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(j.trigger(this._element,ws).defaultPrevented||(this._element.classList.add($s),this._queueCallback((()=>{this._element.classList.add(Ts),this._element.classList.remove($s,ks),j.trigger(this._element,As)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(ks),super.dispose()}isShown(){return this._element.classList.contains(ks)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,ps,(t=>this._onInteraction(t,!0))),j.on(this._element,bs,(t=>this._onInteraction(t,!1))),j.on(this._element,vs,(t=>this._onInteraction(t,!0))),j.on(this._element,ys,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Os.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return V(Os),b(Os),{Alert:U,Button:J,Carousel:Ot,Collapse:Rt,Dropdown:fe,Modal:Ue,Offcanvas:gi,Popover:Mi,ScrollSpy:Qi,Tab:fs,Toast:Os,Tooltip:Ni}})); 7 | //# sourceMappingURL=bootstrap.min.js.map --------------------------------------------------------------------------------