diff --git a/.gitignore b/.gitignore index 2cd84fc0..4338ed4a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /playground /.idea /glance*.yml +/.env \ No newline at end of file diff --git a/README.md b/README.md index 4ed527a3..009ca825 100644 --- a/README.md +++ b/README.md @@ -1,436 +1,14 @@ -
What if you could see everything at a...
-Install • Configuration • Discord • Sponsor
-Community widgets • Preconfigured pages • Themes
+# Pulse - AI-based activity feed aggregator - + -## Features -### Various widgets -* RSS feeds -* Subreddit posts -* Hacker News posts -* Weather forecasts -* YouTube channel uploads -* Twitch channels -* Market prices -* Docker containers status -* Server stats -* Custom widgets -- [and many more...](docs/configuration.md#configuring-glance) +## Usage -### Fast and lightweight -* Low memory usage -* Few dependencies -* Minimal vanilla JS -* Single <20mb binary available for multiple OSs & architectures and just as small Docker container -* Uncached pages usually load within ~1s (depending on internet speed and number of widgets) - -### Tons of customizability -* Different layouts -* As many pages/tabs as you need -* Numerous configuration options for each widget -* Multiple styles for some widgets -* Custom CSS - -### Optimized for mobile devices -Because you'll want to take it with you on the go. - - - -### Themeable -Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md). - - - -All sites are online
- -All sites are online
- -{{ .Description }}
- {{ end }} - {{ if gt (len .Categories) 0 }} - - {{ end }} -{{ .StreamTitle }}
-" + html.EscapeString(string(content)) + "") - } -} - -func fetchExtension(options extensionRequestOptions) (extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - if len(options.Parameters) > 0 { - request.URL.RawQuery = options.Parameters.toQueryString() - } - - for key, value := range options.Headers { - request.Header.Add(key, value) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - slog.Error("Failed fetching extension", "url", options.URL, "error", err) - return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - slog.Error("Failed reading response body of extension", "url", options.URL, "error", err) - return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err) - } - - extension := extension{} - - if response.Header.Get(extensionHeaderTitle) == "" { - extension.Title = "Extension" - } else { - extension.Title = response.Header.Get(extensionHeaderTitle) - } - - if response.Header.Get(extensionHeaderTitleURL) != "" { - extension.TitleURL = response.Header.Get(extensionHeaderTitleURL) - } - - contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] - - if !ok { - contentType, ok = extensionStringToType[options.FallbackContentType] - - if !ok { - contentType = extensionContentUnknown - } - } - - if stringToBool(response.Header.Get(extensionHeaderContentFrameless)) { - extension.Frameless = true - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/glance/widget-html.go b/internal/glance/widget-html.go deleted file mode 100644 index 0e32a468..00000000 --- a/internal/glance/widget-html.go +++ /dev/null @@ -1,20 +0,0 @@ -package glance - -import ( - "html/template" -) - -type htmlWidget struct { - widgetBase `yaml:",inline"` - Source template.HTML `yaml:"source"` -} - -func (widget *htmlWidget) initialize() error { - widget.withTitle("").withError(nil) - - return nil -} - -func (widget *htmlWidget) Render() template.HTML { - return widget.Source -} diff --git a/internal/glance/widget-iframe.go b/internal/glance/widget-iframe.go deleted file mode 100644 index 830b3837..00000000 --- a/internal/glance/widget-iframe.go +++ /dev/null @@ -1,43 +0,0 @@ -package glance - -import ( - "errors" - "fmt" - "html/template" - "net/url" -) - -var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html") - -type iframeWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - Source string `yaml:"source"` - Height int `yaml:"height"` -} - -func (widget *iframeWidget) initialize() error { - widget.withTitle("IFrame").withError(nil) - - if widget.Source == "" { - return errors.New("source is required") - } - - if _, err := url.Parse(widget.Source); err != nil { - return fmt.Errorf("parsing URL: %v", err) - } - - if widget.Height == 50 { - widget.Height = 300 - } else if widget.Height < 50 { - widget.Height = 50 - } - - widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate) - - return nil -} - -func (widget *iframeWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go deleted file mode 100644 index b53b10a1..00000000 --- a/internal/glance/widget-markets.go +++ /dev/null @@ -1,228 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "log/slog" - "math" - "net/http" - "sort" - "strings" - "time" -) - -var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html") - -type marketsWidget struct { - widgetBase `yaml:",inline"` - StocksRequests []marketRequest `yaml:"stocks"` - MarketRequests []marketRequest `yaml:"markets"` - ChartLinkTemplate string `yaml:"chart-link-template"` - SymbolLinkTemplate string `yaml:"symbol-link-template"` - Sort string `yaml:"sort-by"` - Markets marketList `yaml:"-"` -} - -func (widget *marketsWidget) initialize() error { - widget.withTitle("Markets").withCacheDuration(time.Hour) - - // legacy support, remove in v0.10.0 - if len(widget.MarketRequests) == 0 { - widget.MarketRequests = widget.StocksRequests - } - - for i := range widget.MarketRequests { - m := &widget.MarketRequests[i] - - if widget.ChartLinkTemplate != "" && m.ChartLink == "" { - m.ChartLink = strings.ReplaceAll(widget.ChartLinkTemplate, "{SYMBOL}", m.Symbol) - } - - if widget.SymbolLinkTemplate != "" && m.SymbolLink == "" { - m.SymbolLink = strings.ReplaceAll(widget.SymbolLinkTemplate, "{SYMBOL}", m.Symbol) - } - } - - return nil -} - -func (widget *marketsWidget) update(ctx context.Context) { - markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Sort == "absolute-change" { - markets.sortByAbsChange() - } else if widget.Sort == "change" { - markets.sortByChange() - } - - widget.Markets = markets -} - -func (widget *marketsWidget) Render() template.HTML { - return widget.renderTemplate(widget, marketsWidgetTemplate) -} - -type marketRequest struct { - CustomName string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type market struct { - marketRequest - Name string - Currency string - Price float64 - PriceHint int - PercentChange float64 - SvgChartPoints string -} - -type marketList []market - -func (t marketList) sortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -func (t marketList) sortByChange() { - sort.Slice(t, func(i, j int) bool { - return t[i].PercentChange > t[j].PercentChange - }) -} - -type marketResponseJson struct { - Chart struct { - Result []struct { - Meta struct { - Currency string `json:"currency"` - Symbol string `json:"symbol"` - RegularMarketPrice float64 `json:"regularMarketPrice"` - ChartPreviousClose float64 `json:"chartPreviousClose"` - ShortName string `json:"shortName"` - PriceHint int `json:"priceHint"` - } `json:"meta"` - Indicators struct { - Quote []struct { - Close []float64 `json:"close,omitempty"` - } `json:"quote"` - } `json:"indicators"` - } `json:"result"` - } `json:"chart"` -} - -// TODO: allow changing chart time frame -const marketChartDays = 21 - -func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) { - requests := make([]*http.Request, 0, len(marketRequests)) - - for i := range marketRequests { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) - setBrowserUserAgentHeader(request) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests) - responses, errs, err := workerPoolDo(job) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - markets := make(marketList, 0, len(responses)) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) - continue - } - - response := responses[i] - - if len(response.Chart.Result) == 0 { - failed++ - slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) - continue - } - - result := &response.Chart.Result[0] - prices := result.Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := result.Meta.RegularMarketPrice - - if len(prices) >= 2 && prices[len(prices)-2] != 0 { - previous = prices[len(prices)-2] - } - - points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - - currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)] - if !exists { - currency = result.Meta.Currency - } - - markets = append(markets, market{ - marketRequest: marketRequests[i], - Price: result.Meta.RegularMarketPrice, - Currency: currency, - PriceHint: result.Meta.PriceHint, - Name: ternary(marketRequests[i].CustomName == "", - result.Meta.ShortName, - marketRequests[i].CustomName, - ), - PercentChange: percentChange( - result.Meta.RegularMarketPrice, - previous, - ), - SvgChartPoints: points, - }) - } - - if len(markets) == 0 { - return nil, errNoContent - } - - if failed > 0 { - return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed) - } - - return markets, nil -} - -var currencyToSymbol = map[string]string{ - "USD": "$", - "EUR": "€", - "JPY": "¥", - "CAD": "C$", - "AUD": "A$", - "GBP": "£", - "CHF": "Fr", - "NZD": "N$", - "INR": "₹", - "BRL": "R$", - "RUB": "₽", - "TRY": "₺", - "ZAR": "R", - "CNY": "¥", - "KRW": "₩", - "HKD": "HK$", - "SGD": "S$", - "SEK": "kr", - "NOK": "kr", - "DKK": "kr", - "PLN": "zł", - "PHP": "₱", -} diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go deleted file mode 100644 index cdce0d6b..00000000 --- a/internal/glance/widget-monitor.go +++ /dev/null @@ -1,193 +0,0 @@ -package glance - -import ( - "context" - "errors" - "html/template" - "net/http" - "slices" - "strconv" - "time" -) - -var ( - monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html") - monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html") -) - -type monitorWidget struct { - widgetBase `yaml:",inline"` - Sites []struct { - *SiteStatusRequest `yaml:",inline"` - Status *siteStatus `yaml:"-"` - URL string `yaml:"-"` - ErrorURL string `yaml:"error-url"` - Title string `yaml:"title"` - Icon customIconField `yaml:"icon"` - SameTab bool `yaml:"same-tab"` - StatusText string `yaml:"-"` - StatusStyle string `yaml:"-"` - AltStatusCodes []int `yaml:"alt-status-codes"` - } `yaml:"sites"` - Style string `yaml:"style"` - ShowFailingOnly bool `yaml:"show-failing-only"` - HasFailing bool `yaml:"-"` -} - -func (widget *monitorWidget) initialize() error { - widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) - - return nil -} - -func (widget *monitorWidget) update(ctx context.Context) { - requests := make([]*SiteStatusRequest, len(widget.Sites)) - - for i := range widget.Sites { - requests[i] = widget.Sites[i].SiteStatusRequest - } - - statuses, err := fetchStatusForSites(requests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.HasFailing = false - - for i := range widget.Sites { - site := &widget.Sites[i] - status := &statuses[i] - site.Status = status - - if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.Error != nil) { - widget.HasFailing = true - } - - if status.Error != nil && site.ErrorURL != "" { - site.URL = site.ErrorURL - } else { - site.URL = site.DefaultURL - } - - site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes) - site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes) - } -} - -func (widget *monitorWidget) Render() template.HTML { - if widget.Style == "compact" { - return widget.renderTemplate(widget, monitorWidgetCompactTemplate) - } - - return widget.renderTemplate(widget, monitorWidgetTemplate) -} - -func statusCodeToText(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "OK" - } - if status == 404 { - return "Not Found" - } - if status == 403 { - return "Forbidden" - } - if status == 401 { - return "Unauthorized" - } - if status >= 500 { - return "Server Error" - } - if status >= 400 { - return "Client Error" - } - - return strconv.Itoa(status) -} - -func statusCodeToStyle(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "ok" - } - - return "error" -} - -type SiteStatusRequest struct { - DefaultURL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` - Timeout durationField `yaml:"timeout"` - BasicAuth struct { - Username string `yaml:"username"` - Password string `yaml:"password"` - } `yaml:"basic-auth"` -} - -type siteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.DefaultURL - } - - timeout := ternary(statusRequest.Timeout > 0, time.Duration(statusRequest.Timeout), 3*time.Second) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return siteStatus{ - Error: err, - }, nil - } - - if statusRequest.BasicAuth.Username != "" || statusRequest.BasicAuth.Password != "" { - request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password) - } - - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultHTTPClient.Do(request) - } else { - response, err = defaultInsecureHTTPClient.Do(request) - } - - status := siteStatus{ResponseTime: time.Since(requestSentAt)} - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - status.TimedOut = true - } - - status.Error = err - return status, nil - } - - defer response.Body.Close() - - status.Code = response.StatusCode - - return status, nil -} - -func fetchStatusForSites(requests []*SiteStatusRequest) ([]siteStatus, error) { - job := newJob(fetchSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/glance/widget-old-calendar.go b/internal/glance/widget-old-calendar.go deleted file mode 100644 index e4fbe74c..00000000 --- a/internal/glance/widget-old-calendar.go +++ /dev/null @@ -1,86 +0,0 @@ -package glance - -import ( - "context" - "html/template" - "time" -) - -var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html") - -type oldCalendarWidget struct { - widgetBase `yaml:",inline"` - Calendar *calendar - StartSunday bool `yaml:"start-sunday"` -} - -func (widget *oldCalendarWidget) initialize() error { - widget.withTitle("Calendar").withCacheOnTheHour() - - return nil -} - -func (widget *oldCalendarWidget) update(ctx context.Context) { - widget.Calendar = newCalendar(time.Now(), widget.StartSunday) - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *oldCalendarWidget) Render() template.HTML { - return widget.renderTemplate(widget, oldCalendarWidgetTemplate) -} - -type calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -// TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing between showing the previous and next week and the entire month -func newCalendar(now time.Time, startSunday bool) *calendar { - year, week := now.ISOWeek() - weekday := now.Weekday() - if !startSunday { - weekday = (weekday + 6) % 7 // Shift Monday to 0 - } - - currentMonthDays := daysInMonth(now.Month(), year) - - var previousMonthDays int - - if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 { - previousMonthDays = daysInMonth(12, year-1) - } else { - previousMonthDays = daysInMonth(previousMonthNumber, year) - } - - startDaysFrom := now.Day() - int(weekday) - 7 - - days := make([]int, 21) - - for i := 0; i < 21; i++ { - day := startDaysFrom + i - - if day < 1 { - day = previousMonthDays + day - } else if day > currentMonthDays { - day = day - currentMonthDays - } - - days[i] = day - } - - return &calendar{ - CurrentDay: now.Day(), - CurrentWeekNumber: week, - CurrentMonthName: now.Month().String(), - CurrentYear: year, - Days: days, - } -} - -func daysInMonth(m time.Month, year int) int { - return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() -} diff --git a/internal/glance/widget-repository.go b/internal/glance/widget-repository.go deleted file mode 100644 index 1eeb8b4b..00000000 --- a/internal/glance/widget-repository.go +++ /dev/null @@ -1,238 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "net/http" - "strings" - "sync" - "time" -) - -var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html") - -type repositoryWidget struct { - widgetBase `yaml:",inline"` - RequestedRepository string `yaml:"repository"` - Token string `yaml:"token"` - PullRequestsLimit int `yaml:"pull-requests-limit"` - IssuesLimit int `yaml:"issues-limit"` - CommitsLimit int `yaml:"commits-limit"` - Repository repository `yaml:"-"` -} - -func (widget *repositoryWidget) initialize() error { - widget.withTitle("Repository").withCacheDuration(1 * time.Hour) - - if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 { - widget.PullRequestsLimit = 3 - } - - if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 { - widget.IssuesLimit = 3 - } - - if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { - widget.CommitsLimit = -1 - } - - return nil -} - -func (widget *repositoryWidget) update(ctx context.Context) { - details, err := fetchRepositoryDetailsFromGithub( - widget.RequestedRepository, - string(widget.Token), - widget.PullRequestsLimit, - widget.IssuesLimit, - widget.CommitsLimit, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Repository = details -} - -func (widget *repositoryWidget) Render() template.HTML { - return widget.renderTemplate(widget, repositoryWidgetTemplate) -} - -type repository struct { - Name string - Stars int - Forks int - OpenPullRequests int - PullRequests []githubTicket - OpenIssues int - Issues []githubTicket - LastCommits int - Commits []githubCommitDetails -} - -type githubTicket struct { - Number int - CreatedAt time.Time - Title string -} - -type githubCommitDetails struct { - Sha string - Author string - CreatedAt time.Time - Message string -} - -type githubRepositoryResponseJson struct { - Name string `json:"full_name"` - Stars int `json:"stargazers_count"` - Forks int `json:"forks_count"` -} - -type githubTicketResponseJson struct { - Count int `json:"total_count"` - Tickets []struct { - Number int `json:"number"` - CreatedAt string `json:"created_at"` - Title string `json:"title"` - } `json:"items"` -} - -type gitHubCommitResponseJson struct { - Sha string `json:"sha"` - Commit struct { - Author struct { - Name string `json:"name"` - Date string `json:"date"` - } `json:"author"` - Message string `json:"message"` - } `json:"commit"` -} - -func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) { - repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil) - if err != nil { - return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err) - } - - PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil) - issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil) - CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil) - - if token != "" { - token = fmt.Sprintf("Bearer %s", token) - repositoryRequest.Header.Add("Authorization", token) - PRsRequest.Header.Add("Authorization", token) - issuesRequest.Header.Add("Authorization", token) - CommitsRequest.Header.Add("Authorization", token) - } - - var repositoryResponse githubRepositoryResponseJson - var detailsErr error - var PRsResponse githubTicketResponseJson - var PRsErr error - var issuesResponse githubTicketResponseJson - var issuesErr error - var commitsResponse []gitHubCommitResponseJson - var CommitsErr error - var wg sync.WaitGroup - - wg.Add(1) - go (func() { - defer wg.Done() - repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest) - })() - - if maxPRs > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest) - })() - } - - if maxIssues > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest) - })() - } - - if maxCommits > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest) - })() - } - - wg.Wait() - - if detailsErr != nil { - return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr) - } - - details := repository{ - Name: repositoryResponse.Name, - Stars: repositoryResponse.Stars, - Forks: repositoryResponse.Forks, - PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)), - Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)), - Commits: make([]githubCommitDetails, 0, len(commitsResponse)), - } - - err = nil - - if maxPRs > 0 { - if PRsErr != nil { - err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr) - } else { - details.OpenPullRequests = PRsResponse.Count - - for i := range PRsResponse.Tickets { - details.PullRequests = append(details.PullRequests, githubTicket{ - Number: PRsResponse.Tickets[i].Number, - CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), - Title: PRsResponse.Tickets[i].Title, - }) - } - } - } - - if maxIssues > 0 { - if issuesErr != nil { - // TODO: fix, overwriting the previous error - err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr) - } else { - details.OpenIssues = issuesResponse.Count - - for i := range issuesResponse.Tickets { - details.Issues = append(details.Issues, githubTicket{ - Number: issuesResponse.Tickets[i].Number, - CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), - Title: issuesResponse.Tickets[i].Title, - }) - } - } - } - - if maxCommits > 0 { - if CommitsErr != nil { - err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr) - } else { - for i := range commitsResponse { - details.Commits = append(details.Commits, githubCommitDetails{ - Sha: commitsResponse[i].Sha, - Author: commitsResponse[i].Commit.Author.Name, - CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), - Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0], - }) - } - } - } - - return details, err -} diff --git a/internal/glance/widget-search.go b/internal/glance/widget-search.go deleted file mode 100644 index 300361d9..00000000 --- a/internal/glance/widget-search.go +++ /dev/null @@ -1,78 +0,0 @@ -package glance - -import ( - "fmt" - "html/template" - "strings" -) - -var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html") - -type SearchBang struct { - Title string - Shortcut string - URL string -} - -type searchWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - SearchEngine string `yaml:"search-engine"` - Bangs []SearchBang `yaml:"bangs"` - NewTab bool `yaml:"new-tab"` - Target string `yaml:"target"` - Autofocus bool `yaml:"autofocus"` - Placeholder string `yaml:"placeholder"` -} - -func convertSearchUrl(url string) string { - // Go's template is being stubborn and continues to escape the curlies in the - // URL regardless of what the type of the variable is so this is my way around it - return strings.ReplaceAll(url, "{QUERY}", "!QUERY!") -} - -var searchEngines = map[string]string{ - "duckduckgo": "https://duckduckgo.com/?q={QUERY}", - "google": "https://www.google.com/search?q={QUERY}", - "bing": "https://www.bing.com/search?q={QUERY}", - "perplexity": "https://www.perplexity.ai/search?q={QUERY}", - "kagi": "https://kagi.com/search?q={QUERY}", - "startpage": "https://www.startpage.com/search?q={QUERY}", -} - -func (widget *searchWidget) initialize() error { - widget.withTitle("Search").withError(nil) - - if widget.SearchEngine == "" { - widget.SearchEngine = "duckduckgo" - } - - if widget.Placeholder == "" { - widget.Placeholder = "Type here to search…" - } - - if url, ok := searchEngines[widget.SearchEngine]; ok { - widget.SearchEngine = url - } - - widget.SearchEngine = convertSearchUrl(widget.SearchEngine) - - for i := range widget.Bangs { - if widget.Bangs[i].Shortcut == "" { - return fmt.Errorf("search bang #%d has no shortcut", i+1) - } - - if widget.Bangs[i].URL == "" { - return fmt.Errorf("search bang #%d has no URL", i+1) - } - - widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL) - } - - widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate) - return nil -} - -func (widget *searchWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-server-stats.go b/internal/glance/widget-server-stats.go deleted file mode 100644 index 90bf8db2..00000000 --- a/internal/glance/widget-server-stats.go +++ /dev/null @@ -1,117 +0,0 @@ -package glance - -import ( - "context" - "html/template" - "log/slog" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/glanceapp/glance/pkg/sysinfo" -) - -var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html") - -type serverStatsWidget struct { - widgetBase `yaml:",inline"` - Servers []serverStatsRequest `yaml:"servers"` -} - -func (widget *serverStatsWidget) initialize() error { - widget.withTitle("Server Stats").withCacheDuration(15 * time.Second) - widget.widgetBase.WIP = true - - if len(widget.Servers) == 0 { - widget.Servers = []serverStatsRequest{{Type: "local"}} - } - - for i := range widget.Servers { - widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/") - - if widget.Servers[i].Timeout == 0 { - widget.Servers[i].Timeout = durationField(3 * time.Second) - } - } - - return nil -} - -func (widget *serverStatsWidget) update(context.Context) { - // Refactor later, most of it may change depending on feedback - var wg sync.WaitGroup - - for i := range widget.Servers { - serv := &widget.Servers[i] - - if serv.Type == "local" { - info, errs := sysinfo.Collect(serv.SystemInfoRequest) - - if len(errs) > 0 { - for i := range errs { - slog.Warn("Getting system info: " + errs[i].Error()) - } - } - - serv.IsReachable = true - serv.Info = info - } else { - wg.Add(1) - go func() { - defer wg.Done() - info, err := fetchRemoteServerInfo(serv) - if err != nil { - slog.Warn("Getting remote system info: " + err.Error()) - serv.IsReachable = false - serv.Info = &sysinfo.SystemInfo{ - Hostname: "Unnamed server #" + strconv.Itoa(i+1), - } - } else { - serv.IsReachable = true - serv.Info = info - } - }() - } - } - - wg.Wait() - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *serverStatsWidget) Render() template.HTML { - return widget.renderTemplate(widget, serverStatsWidgetTemplate) -} - -type serverStatsRequest struct { - *sysinfo.SystemInfoRequest `yaml:",inline"` - Info *sysinfo.SystemInfo `yaml:"-"` - IsReachable bool `yaml:"-"` - StatusText string `yaml:"-"` - Name string `yaml:"name"` - HideSwap bool `yaml:"hide-swap"` - Type string `yaml:"type"` - URL string `yaml:"url"` - Token string `yaml:"token"` - Timeout durationField `yaml:"timeout"` - // Support for other agents - // Provider string `yaml:"provider"` -} - -func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout)) - defer cancel() - - request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil) - if infoReq.Token != "" { - request.Header.Set("Authorization", "Bearer "+infoReq.Token) - } - - info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request) - if err != nil { - return nil, err - } - - return info, nil -} diff --git a/internal/glance/widget-todo.go b/internal/glance/widget-todo.go deleted file mode 100644 index a261e5d5..00000000 --- a/internal/glance/widget-todo.go +++ /dev/null @@ -1,24 +0,0 @@ -package glance - -import ( - "html/template" -) - -var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html") - -type todoWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - TodoID string `yaml:"id"` -} - -func (widget *todoWidget) initialize() error { - widget.withTitle("To-do").withError(nil) - - widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate) - return nil -} - -func (widget *todoWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-twitch-channels.go b/internal/glance/widget-twitch-channels.go deleted file mode 100644 index 1290a265..00000000 --- a/internal/glance/widget-twitch-channels.go +++ /dev/null @@ -1,238 +0,0 @@ -package glance - -import ( - "context" - "encoding/json" - "fmt" - "html/template" - "log/slog" - "net/http" - "sort" - "strings" - "time" -) - -var twitchChannelsWidgetTemplate = mustParseTemplate("twitch-channels.html", "widget-base.html") - -type twitchChannelsWidget struct { - widgetBase `yaml:",inline"` - ChannelsRequest []string `yaml:"channels"` - Channels []twitchChannel `yaml:"-"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` -} - -func (widget *twitchChannelsWidget) initialize() error { - widget. - withTitle("Twitch Channels"). - withTitleURL("https://www.twitch.tv/directory/following"). - withCacheDuration(time.Minute * 10) - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.SortBy != "viewers" && widget.SortBy != "live" { - widget.SortBy = "viewers" - } - - return nil -} - -func (widget *twitchChannelsWidget) update(ctx context.Context) { - channels, err := fetchChannelsFromTwitch(widget.ChannelsRequest) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.SortBy == "viewers" { - channels.sortByViewers() - } else if widget.SortBy == "live" { - channels.sortByLive() - } - - widget.Channels = channels -} - -func (widget *twitchChannelsWidget) Render() template.HTML { - return widget.renderTemplate(widget, twitchChannelsWidgetTemplate) -} - -type twitchChannel struct { - Login string - Exists bool - Name string - StreamTitle string - AvatarUrl string - IsLive bool - LiveSince time.Time - Category string - CategorySlug string - ViewersCount int -} - -type twitchChannelList []twitchChannel - -func (channels twitchChannelList) sortByViewers() { - sort.Slice(channels, func(i, j int) bool { - return channels[i].ViewersCount > channels[j].ViewersCount - }) -} - -func (channels twitchChannelList) sortByLive() { - sort.SliceStable(channels, func(i, j int) bool { - return channels[i].IsLive && !channels[j].IsLive - }) -} - -type twitchOperationResponse struct { - Data json.RawMessage - Extensions struct { - OperationName string `json:"operationName"` - } -} - -type twitchChannelShellOperationResponse struct { - UserOrError struct { - Type string `json:"__typename"` - DisplayName string `json:"displayName"` - ProfileImageUrl string `json:"profileImageURL"` - Stream *struct { - ViewersCount int `json:"viewersCount"` - } - } `json:"userOrError"` -} - -type twitchStreamMetadataOperationResponse struct { - UserOrNull *struct { - Stream *struct { - StartedAt string `json:"createdAt"` - Game *struct { - Slug string `json:"slug"` - Name string `json:"name"` - } `json:"game"` - } `json:"stream"` - LastBroadcast *struct { - Title string `json:"title"` - } - } `json:"user"` -} - -const twitchChannelStatusOperationRequestBody = `[ -{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}}, -{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}} -]` - -// TODO: rework -// The operations for multiple channels can all be sent in a single request -// rather than sending a separate request for each channel. Need to figure out -// what the limit is for max operations per request and batch operations in -// multiple requests if number of channels exceeds allowed limit. - -func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { - result := twitchChannel{ - Login: strings.ToLower(channel), - } - - reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel)) - request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) - request.Header.Add("Client-ID", twitchGqlClientId) - - response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request) - if err != nil { - return result, err - } - - if len(response) != 2 { - return result, fmt.Errorf("expected 2 operation responses, got %d", len(response)) - } - - var channelShell twitchChannelShellOperationResponse - var streamMetadata twitchStreamMetadataOperationResponse - - for i := range response { - switch response[i].Extensions.OperationName { - case "ChannelShell": - if err = json.Unmarshal(response[i].Data, &channelShell); err != nil { - return result, fmt.Errorf("unmarshalling channel shell: %w", err) - } - case "StreamMetadata": - if err = json.Unmarshal(response[i].Data, &streamMetadata); err != nil { - return result, fmt.Errorf("unmarshalling stream metadata: %w", err) - } - default: - return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName) - } - } - - if channelShell.UserOrError.Type != "User" { - result.Name = result.Login - return result, nil - } - - result.Exists = true - result.Name = channelShell.UserOrError.DisplayName - result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl - - if channelShell.UserOrError.Stream != nil { - result.IsLive = true - result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount - - if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil { - if streamMetadata.UserOrNull.LastBroadcast != nil { - result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title - } - - if streamMetadata.UserOrNull.Stream.Game != nil { - result.Category = streamMetadata.UserOrNull.Stream.Game.Name - result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug - } - startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt) - - if err == nil { - result.LiveSince = startedAt - } else { - slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) - } - } - } else { - // This prevents live channels with 0 viewers from being - // incorrectly sorted lower than offline channels - result.ViewersCount = -1 - } - - return result, nil -} - -func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) { - result := make(twitchChannelList, 0, len(channelLogins)) - - job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10) - channels, errs, err := workerPoolDo(job) - if err != nil { - return result, err - } - - var failed int - - for i := range channels { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i]) - continue - } - - result = append(result, channels[i]) - } - - if failed == len(channelLogins) { - return result, errNoContent - } - - if failed > 0 { - return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed) - } - - return result, nil -} diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go deleted file mode 100644 index 4235bc96..00000000 --- a/internal/glance/widget-twitch-top-games.go +++ /dev/null @@ -1,125 +0,0 @@ -package glance - -import ( - "context" - "errors" - "fmt" - "html/template" - "net/http" - "slices" - "strings" - "time" -) - -var twitchGamesWidgetTemplate = mustParseTemplate("twitch-games-list.html", "widget-base.html") - -type twitchGamesWidget struct { - widgetBase `yaml:",inline"` - Categories []twitchCategory `yaml:"-"` - Exclude []string `yaml:"exclude"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *twitchGamesWidget) initialize() error { - widget. - withTitle("Top games on Twitch"). - withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT"). - withCacheDuration(time.Minute * 10) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - return nil -} - -func (widget *twitchGamesWidget) update(ctx context.Context) { - categories, err := fetchTopGamesFromTwitch(widget.Exclude, widget.Limit) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Categories = categories -} - -func (widget *twitchGamesWidget) Render() template.HTML { - return widget.renderTemplate(widget, twitchGamesWidgetTemplate) -} - -type twitchCategory struct { - Slug string `json:"slug"` - Name string `json:"name"` - AvatarUrl string `json:"avatarURL"` - ViewersCount int `json:"viewersCount"` - Tags []struct { - Name string `json:"tagName"` - } `json:"tags"` - GameReleaseDate string `json:"originalReleaseDate"` - IsNew bool `json:"-"` -} - -type twitchDirectoriesOperationResponse struct { - Data struct { - DirectoriesWithTags struct { - Edges []struct { - Node twitchCategory `json:"node"` - } `json:"edges"` - } `json:"directoriesWithTags"` - } `json:"data"` -} - -const twitchDirectoriesOperationRequestBody = `[ -{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}} -]` - -func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, error) { - reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) - request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) - request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultHTTPClient, request) - if err != nil { - return nil, err - } - - if len(response) == 0 { - return nil, errors.New("no categories could be retrieved") - } - - edges := (response)[0].Data.DirectoriesWithTags.Edges - categories := make([]twitchCategory, 0, len(edges)) - - for i := range edges { - if slices.Contains(exclude, edges[i].Node.Slug) { - continue - } - - category := &edges[i].Node - category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1) - - if len(category.Tags) > 2 { - category.Tags = category.Tags[:2] - } - - gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate) - - if err == nil { - if time.Since(gameReleasedDate) < 14*24*time.Hour { - category.IsNew = true - } - } - - categories = append(categories, *category) - } - - if len(categories) > limit { - categories = categories[:limit] - } - - return categories, nil -} diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go deleted file mode 100644 index ff798640..00000000 --- a/internal/glance/widget-videos.go +++ /dev/null @@ -1,216 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "log/slog" - "net/http" - "net/url" - "sort" - "strings" - "time" -) - -const videosWidgetPlaylistPrefix = "playlist:" - -var ( - videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html") - videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - videosWidgetVerticalListTemplate = mustParseTemplate("videos-vertical-list.html", "widget-base.html") -) - -type videosWidget struct { - widgetBase `yaml:",inline"` - Videos videoList `yaml:"-"` - VideoUrlTemplate string `yaml:"video-url-template"` - Style string `yaml:"style"` - CollapseAfter int `yaml:"collapse-after"` - CollapseAfterRows int `yaml:"collapse-after-rows"` - Channels []string `yaml:"channels"` - Playlists []string `yaml:"playlists"` - Limit int `yaml:"limit"` - IncludeShorts bool `yaml:"include-shorts"` -} - -func (widget *videosWidget) initialize() error { - widget.withTitle("Videos").withCacheDuration(time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 25 - } - - if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 { - widget.CollapseAfterRows = 4 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 7 - } - - // A bit cheeky, but from a user's perspective it makes more sense when channels and - // playlists are separate things rather than specifying a list of channels and some of - // them awkwardly have a "playlist:" prefix - if len(widget.Playlists) > 0 { - initialLen := len(widget.Channels) - widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...) - - for i := range widget.Playlists { - widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i] - } - } - - return nil -} - -func (widget *videosWidget) update(ctx context.Context) { - videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(videos) > widget.Limit { - videos = videos[:widget.Limit] - } - - widget.Videos = videos -} - -func (widget *videosWidget) Render() template.HTML { - var template *template.Template - - switch widget.Style { - case "grid-cards": - template = videosWidgetGridTemplate - case "vertical-list": - template = videosWidgetVerticalListTemplate - default: - template = videosWidgetTemplate - } - - return widget.renderTemplate(widget, template) -} - -type youtubeFeedResponseXml struct { - Channel string `xml:"author>name"` - ChannelLink string `xml:"author>uri"` - Videos []struct { - Title string `xml:"title"` - Published string `xml:"published"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - - Group struct { - Thumbnail struct { - Url string `xml:"url,attr"` - } `xml:"http://search.yahoo.com/mrss/ thumbnail"` - } `xml:"http://search.yahoo.com/mrss/ group"` - } `xml:"entry"` -} - -func parseYoutubeFeedTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - if err != nil { - return time.Now() - } - - return parsedTime -} - -type video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type videoList []video - -func (v videoList) sortByNewest() videoList { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} - -func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool) (videoList, error) { - requests := make([]*http.Request, 0, len(channelOrPlaylistIDs)) - - for i := range channelOrPlaylistIDs { - var feedUrl string - if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) { - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + - strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) - } else if !includeShorts && strings.HasPrefix(channelOrPlaylistIDs[i], "UC") { - playlistId := strings.Replace(channelOrPlaylistIDs[i], "UC", "UULF", 1) - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId - } else { - feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelOrPlaylistIDs[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultHTTPClient), requests).withWorkers(30) - responses, errs, err := workerPoolDo(job) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - videos := make(videoList, 0, len(channelOrPlaylistIDs)*15) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelOrPlaylistIDs[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - v := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = v.Link.Href - } else { - parsedUrl, err := url.Parse(v.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, video{ - ThumbnailUrl: v.Group.Thumbnail.Url, - Title: v.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(v.Published), - }) - } - } - - if len(videos) == 0 { - return nil, errNoContent - } - - videos.sortByNewest() - - if failed > 0 { - return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed) - } - - return videos, nil -} diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go deleted file mode 100644 index 79861d0b..00000000 --- a/internal/glance/widget-weather.go +++ /dev/null @@ -1,326 +0,0 @@ -package glance - -import ( - "context" - "errors" - "fmt" - "html/template" - "math" - "net/http" - "net/url" - "slices" - "strings" - "time" - - _ "time/tzdata" -) - -var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html") - -type weatherWidget struct { - widgetBase `yaml:",inline"` - Location string `yaml:"location"` - ShowAreaName bool `yaml:"show-area-name"` - HideLocation bool `yaml:"hide-location"` - HourFormat string `yaml:"hour-format"` - Units string `yaml:"units"` - Place *openMeteoPlaceResponseJson `yaml:"-"` - Weather *weather `yaml:"-"` - TimeLabels [12]string `yaml:"-"` -} - -var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"} -var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"} - -func (widget *weatherWidget) initialize() error { - widget.withTitle("Weather").withCacheOnTheHour() - - if widget.Location == "" { - return fmt.Errorf("location is required") - } - - if widget.HourFormat == "" || widget.HourFormat == "12h" { - widget.TimeLabels = timeLabels12h - } else if widget.HourFormat == "24h" { - widget.TimeLabels = timeLabels24h - } else { - return errors.New("hour-format must be either 12h or 24h") - } - - if widget.Units == "" { - widget.Units = "metric" - } else if widget.Units != "metric" && widget.Units != "imperial" { - return errors.New("units must be either metric or imperial") - } - - return nil -} - -func (widget *weatherWidget) update(ctx context.Context) { - if widget.Place == nil { - place, err := fetchOpenMeteoPlaceFromName(widget.Location) - if err != nil { - widget.withError(err).scheduleEarlyUpdate() - return - } - - widget.Place = place - } - - weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Weather = weather -} - -func (widget *weatherWidget) Render() template.HTML { - return widget.renderTemplate(widget, weatherWidgetTemplate) -} - -type weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -func (w *weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -type openMeteoPlacesResponseJson struct { - Results []openMeteoPlaceResponseJson -} - -type openMeteoPlaceResponseJson struct { - Name string - Area string `json:"admin1"` - Latitude float64 - Longitude float64 - Timezone string - Country string - location *time.Location -} - -type openMeteoWeatherResponseJson struct { - Daily struct { - Sunrise []int64 `json:"sunrise"` - Sunset []int64 `json:"sunset"` - } `json:"daily"` - - Hourly struct { - Temperature []float64 `json:"temperature_2m"` - PrecipitationProbability []int `json:"precipitation_probability"` - } `json:"hourly"` - - Current struct { - Temperature float64 `json:"temperature_2m"` - ApparentTemperature float64 `json:"apparent_temperature"` - WeatherCode int `json:"weather_code"` - } `json:"current"` -} - -type weatherColumn struct { - Temperature int - Scale float64 - HasPrecipitation bool -} - -var commonCountryAbbreviations = map[string]string{ - "US": "United States", - "USA": "United States", - "UK": "United Kingdom", -} - -func expandCountryAbbreviations(name string) string { - if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok { - return expanded - } - - return name -} - -// Separates the location that Open Meteo accepts from the administrative area -// which can then be used to filter to the correct place after the list of places -// has been retrieved. Also expands abbreviations since Open Meteo does not accept -// country names like "US", "USA" and "UK" -func parsePlaceName(name string) (string, string) { - parts := strings.Split(name, ",") - - if len(parts) == 1 { - return name, "" - } - - if len(parts) == 2 { - return parts[0] + ", " + expandCountryAbbreviations(parts[1]), "" - } - - return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1]) -} - -func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) { - location, area := parsePlaceName(location) - requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=20&language=en&format=json", url.QueryEscape(location)) - request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request) - if err != nil { - return nil, fmt.Errorf("fetching places data: %v", err) - } - - if len(responseJson.Results) == 0 { - return nil, fmt.Errorf("no places found for %s", location) - } - - var place *openMeteoPlaceResponseJson - - if area != "" { - area = strings.ToLower(area) - - for i := range responseJson.Results { - if strings.ToLower(responseJson.Results[i].Area) == area { - place = &responseJson.Results[i] - break - } - } - - if place == nil { - return nil, fmt.Errorf("no place found for %s in %s", location, area) - } - } else { - place = &responseJson.Results[0] - } - - loc, err := time.LoadLocation(place.Timezone) - if err != nil { - return nil, fmt.Errorf("loading location: %v", err) - } - - place.location = loc - - return place, nil -} - -func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) { - query := url.Values{} - var temperatureUnit string - - if units == "imperial" { - temperatureUnit = "fahrenheit" - } else { - temperatureUnit = "celsius" - } - - query.Add("latitude", fmt.Sprintf("%f", place.Latitude)) - query.Add("longitude", fmt.Sprintf("%f", place.Longitude)) - query.Add("timeformat", "unixtime") - query.Add("timezone", place.Timezone) - query.Add("forecast_days", "1") - query.Add("current", "temperature_2m,apparent_temperature,weather_code") - query.Add("hourly", "temperature_2m,precipitation_probability") - query.Add("daily", "sunrise,sunset") - query.Add("temperature_unit", temperatureUnit) - - requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() - request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - now := time.Now().In(place.location) - bars := make([]weatherColumn, 0, 24) - currentBar := now.Hour() / 2 - sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2 - sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2 - - if sunsetBar < 0 { - sunsetBar = 0 - } - - if len(responseJson.Hourly.Temperature) == 24 { - temperatures := make([]int, 12) - precipitations := make([]bool, 12) - - t := responseJson.Hourly.Temperature - p := responseJson.Hourly.PrecipitationProbability - - for i := 0; i < 24; i += 2 { - if i/2 == currentBar { - temperatures[i/2] = int(responseJson.Current.Temperature) - } else { - temperatures[i/2] = int(math.Round((t[i] + t[i+1]) / 2)) - } - - precipitations[i/2] = (p[i]+p[i+1])/2 > 75 - } - - minT := slices.Min(temperatures) - maxT := slices.Max(temperatures) - - temperaturesRange := float64(maxT - minT) - - for i := 0; i < 12; i++ { - bars = append(bars, weatherColumn{ - Temperature: temperatures[i], - HasPrecipitation: precipitations[i], - }) - - if temperaturesRange > 0 { - bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange - } else { - bars[i].Scale = 1 - } - } - } - - return &weather{ - Temperature: int(responseJson.Current.Temperature), - ApparentTemperature: int(responseJson.Current.ApparentTemperature), - WeatherCode: responseJson.Current.WeatherCode, - CurrentColumn: currentBar, - SunriseColumn: sunriseBar, - SunsetColumn: sunsetBar, - Columns: bars, - }, nil -} - -var weatherCodeTable = map[int]string{ - 0: "Clear Sky", - 1: "Mainly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Rime Fog", - 51: "Drizzle", - 53: "Drizzle", - 55: "Drizzle", - 56: "Drizzle", - 57: "Drizzle", - 61: "Rain", - 63: "Moderate Rain", - 65: "Heavy Rain", - 66: "Freezing Rain", - 67: "Freezing Rain", - 71: "Snow", - 73: "Moderate Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Rain", - 81: "Moderate Rain", - 82: "Heavy Rain", - 85: "Snow", - 86: "Snow", - 95: "Thunderstorm", - 96: "Thunderstorm", - 99: "Thunderstorm", -} diff --git a/internal/glance/widget.go b/internal/glance/widget.go deleted file mode 100644 index 50dc3cb5..00000000 --- a/internal/glance/widget.go +++ /dev/null @@ -1,367 +0,0 @@ -package glance - -import ( - "bytes" - "context" - "errors" - "fmt" - "html/template" - "log/slog" - "math" - "net/http" - "sync/atomic" - "time" - - "gopkg.in/yaml.v3" -) - -var widgetIDCounter atomic.Uint64 - -func newWidget(widgetType string) (widget, error) { - if widgetType == "" { - return nil, errors.New("widget 'type' property is empty or not specified") - } - - var w widget - - switch widgetType { - case "calendar": - w = &calendarWidget{} - case "calendar-legacy": - w = &oldCalendarWidget{} - case "clock": - w = &clockWidget{} - case "weather": - w = &weatherWidget{} - case "bookmarks": - w = &bookmarksWidget{} - case "iframe": - w = &iframeWidget{} - case "html": - w = &htmlWidget{} - case "hacker-news": - w = &hackerNewsWidget{} - case "releases": - w = &releasesWidget{} - case "videos": - w = &videosWidget{} - case "markets", "stocks": - w = &marketsWidget{} - case "reddit": - w = &redditWidget{} - case "rss": - w = &rssWidget{} - case "monitor": - w = &monitorWidget{} - case "twitch-top-games": - w = &twitchGamesWidget{} - case "twitch-channels": - w = &twitchChannelsWidget{} - case "lobsters": - w = &lobstersWidget{} - case "change-detection": - w = &changeDetectionWidget{} - case "repository": - w = &repositoryWidget{} - case "search": - w = &searchWidget{} - case "extension": - w = &extensionWidget{} - case "group": - w = &groupWidget{} - case "dns-stats": - w = &dnsStatsWidget{} - case "split-column": - w = &splitColumnWidget{} - case "custom-api": - w = &customAPIWidget{} - case "docker-containers": - w = &dockerContainersWidget{} - case "server-stats": - w = &serverStatsWidget{} - case "to-do": - w = &todoWidget{} - default: - return nil, fmt.Errorf("unknown widget type: %s", widgetType) - } - - w.setID(widgetIDCounter.Add(1)) - - return w, nil -} - -type widgets []widget - -func (w *widgets) UnmarshalYAML(node *yaml.Node) error { - var nodes []yaml.Node - - if err := node.Decode(&nodes); err != nil { - return err - } - - for _, node := range nodes { - meta := struct { - Type string `yaml:"type"` - }{} - - if err := node.Decode(&meta); err != nil { - return err - } - - widget, err := newWidget(meta.Type) - if err != nil { - return fmt.Errorf("line %d: %w", node.Line, err) - } - - if err = node.Decode(widget); err != nil { - return err - } - - *w = append(*w, widget) - } - - return nil -} - -type widget interface { - // These need to be exported because they get called in templates - Render() template.HTML - GetType() string - GetID() uint64 - - initialize() error - requiresUpdate(*time.Time) bool - setProviders(*widgetProviders) - update(context.Context) - setID(uint64) - handleRequest(w http.ResponseWriter, r *http.Request) - setHideHeader(bool) -} - -type cacheType int - -const ( - cacheTypeInfinite cacheType = iota - cacheTypeDuration - cacheTypeOnTheHour -) - -type widgetBase struct { - ID uint64 `yaml:"-"` - Providers *widgetProviders `yaml:"-"` - Type string `yaml:"type"` - Title string `yaml:"title"` - TitleURL string `yaml:"title-url"` - HideHeader bool `yaml:"hide-header"` - CSSClass string `yaml:"css-class"` - CustomCacheDuration durationField `yaml:"cache"` - ContentAvailable bool `yaml:"-"` - WIP bool `yaml:"-"` - Error error `yaml:"-"` - Notice error `yaml:"-"` - templateBuffer bytes.Buffer `yaml:"-"` - cacheDuration time.Duration `yaml:"-"` - cacheType cacheType `yaml:"-"` - nextUpdate time.Time `yaml:"-"` - updateRetriedTimes int `yaml:"-"` -} - -type widgetProviders struct { - assetResolver func(string) string -} - -func (w *widgetBase) requiresUpdate(now *time.Time) bool { - if w.cacheType == cacheTypeInfinite { - return false - } - - if w.nextUpdate.IsZero() { - return true - } - - return now.After(w.nextUpdate) -} - -func (w *widgetBase) IsWIP() bool { - return w.WIP -} - -func (w *widgetBase) update(ctx context.Context) { - -} - -func (w *widgetBase) GetID() uint64 { - return w.ID -} - -func (w *widgetBase) setID(id uint64) { - w.ID = id -} - -func (w *widgetBase) setHideHeader(value bool) { - w.HideHeader = value -} - -func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) -} - -func (w *widgetBase) GetType() string { - return w.Type -} - -func (w *widgetBase) setProviders(providers *widgetProviders) { - w.Providers = providers -} - -func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { - w.templateBuffer.Reset() - err := t.Execute(&w.templateBuffer, data) - if err != nil { - w.ContentAvailable = false - w.Error = err - - slog.Error("Failed to render template", "error", err) - - // need to immediately re-render with the error, - // otherwise risk breaking the page since the widget - // will likely be partially rendered with tags not closed. - w.templateBuffer.Reset() - err2 := t.Execute(&w.templateBuffer, data) - - if err2 != nil { - slog.Error("Failed to render error within widget", "error", err2, "initial_error", err) - w.templateBuffer.Reset() - // TODO: add some kind of a generic widget error template when the widget - // failed to render, and we also failed to re-render the widget with the error - } - } - - return template.HTML(w.templateBuffer.String()) -} - -func (w *widgetBase) withTitle(title string) *widgetBase { - if w.Title == "" { - w.Title = title - } - - return w -} - -func (w *widgetBase) withTitleURL(titleURL string) *widgetBase { - if w.TitleURL == "" { - w.TitleURL = titleURL - } - - return w -} - -func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase { - w.cacheType = cacheTypeDuration - - if duration == -1 || w.CustomCacheDuration == 0 { - w.cacheDuration = duration - } else { - w.cacheDuration = time.Duration(w.CustomCacheDuration) - } - - return w -} - -func (w *widgetBase) withCacheOnTheHour() *widgetBase { - w.cacheType = cacheTypeOnTheHour - - return w -} - -func (w *widgetBase) withNotice(err error) *widgetBase { - w.Notice = err - - return w -} - -func (w *widgetBase) withError(err error) *widgetBase { - if err == nil && !w.ContentAvailable { - w.ContentAvailable = true - } - - w.Error = err - - return w -} - -func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool { - // TODO: needs covering more edge cases. - // if there's partial content and we update early there's a chance - // the early update returns even less content than the initial update. - // need some kind of mechanism that tells us whether we should update early - // or not depending on the number of things that failed during the initial - // and subsequent update and how they failed - ie whether it was server - // error (like gateway timeout, do retry early) or client error (like - // hitting a rate limit, don't retry early). will require reworking a - // good amount of code in the feed package and probably having a custom - // error type that holds more information because screw wrapping errors. - // alternatively have a resource cache and only refetch the failed resources, - // then rebuild the widget. - - if err != nil { - w.scheduleEarlyUpdate() - - if !errors.Is(err, errPartialContent) { - w.withError(err) - w.withNotice(nil) - return false - } - - w.withError(nil) - w.withNotice(err) - return true - } - - w.withNotice(nil) - w.withError(nil) - w.scheduleNextUpdate() - return true -} - -func (w *widgetBase) getNextUpdateTime() time.Time { - now := time.Now() - - if w.cacheType == cacheTypeDuration { - return now.Add(w.cacheDuration) - } - - if w.cacheType == cacheTypeOnTheHour { - return now.Add(time.Duration( - ((60-now.Minute())*60)-now.Second(), - ) * time.Second) - } - - return time.Time{} -} - -func (w *widgetBase) scheduleNextUpdate() *widgetBase { - w.nextUpdate = w.getNextUpdateTime() - w.updateRetriedTimes = 0 - - return w -} - -func (w *widgetBase) scheduleEarlyUpdate() *widgetBase { - w.updateRetriedTimes++ - - if w.updateRetriedTimes > 5 { - w.updateRetriedTimes = 5 - } - - nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute) - nextUsualUpdate := w.getNextUpdateTime() - - if nextEarlyUpdate.After(nextUsualUpdate) { - w.nextUpdate = nextUsualUpdate - } else { - w.nextUpdate = nextEarlyUpdate - } - - return w -} diff --git a/main.go b/main.go index fa1d7f2f..e676ad0f 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,9 @@ package main import ( "os" - "github.com/glanceapp/glance/internal/glance" + "github.com/glanceapp/glance/pkg/widgets" ) func main() { - os.Exit(glance.Main()) + os.Exit(widgets.Main()) } diff --git a/internal/glance/widget-shared.go b/pkg/sources/forum-post.go similarity index 66% rename from internal/glance/widget-shared.go rename to pkg/sources/forum-post.go index 45144ac8..4ad99658 100644 --- a/internal/glance/widget-shared.go +++ b/pkg/sources/forum-post.go @@ -1,4 +1,4 @@ -package glance +package sources import ( "math" @@ -6,13 +6,14 @@ import ( "time" ) -const twitchGqlEndpoint = "https://gql.twitch.tv/gql" -const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" - -var forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") - type forumPost struct { - Title string + ID string + title string + Description string + // MatchSummary is the LLM generated rationale for why this is a good match for the filter query + MatchSummary string + // MatchScore is the LLM generated score indicating how well this post matches the query + MatchScore int DiscussionUrl string TargetUrl string TargetUrlDomain string @@ -25,6 +26,30 @@ type forumPost struct { IsCrosspost bool } +func (f forumPost) UID() string { + return f.ID +} + +func (f forumPost) Title() string { + return f.title +} + +func (f forumPost) Body() string { + return f.Description +} + +func (f forumPost) URL() string { + return f.TargetUrl +} + +func (f forumPost) ImageURL() string { + return f.ThumbnailUrl +} + +func (f forumPost) CreatedAt() time.Time { + return f.TimePosted +} + type forumPostList []forumPost const depreciatePostsOlderThanHours = 7 diff --git a/pkg/sources/http-proxy.go b/pkg/sources/http-proxy.go new file mode 100644 index 00000000..6d678174 --- /dev/null +++ b/pkg/sources/http-proxy.go @@ -0,0 +1,95 @@ +package sources + +import ( + "crypto/tls" + "fmt" + "gopkg.in/yaml.v3" + "net/http" + "net/url" + "regexp" + "strconv" + "time" +) + +type proxyOptionsField struct { + URL string `yaml:"url"` + AllowInsecure bool `yaml:"allow-insecure"` + Timeout durationField `yaml:"timeout"` + client *http.Client `yaml:"-"` +} + +func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error { + type proxyOptionsFieldAlias proxyOptionsField + alias := (*proxyOptionsFieldAlias)(p) + var proxyURL string + + if err := node.Decode(&proxyURL); err != nil { + if err := node.Decode(alias); err != nil { + return err + } + } + + if proxyURL == "" && p.URL == "" { + return nil + } + + if p.URL != "" { + proxyURL = p.URL + } + + parsedUrl, err := url.Parse(proxyURL) + if err != nil { + return fmt.Errorf("parsing proxy URL: %v", err) + } + + var timeout = defaultClientTimeout + if p.Timeout > 0 { + timeout = time.Duration(p.Timeout) + } + + p.client = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedUrl), + TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure}, + }, + } + + return nil +} + +var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) + +type durationField time.Duration + +func (d *durationField) UnmarshalYAML(node *yaml.Node) error { + var value string + + if err := node.Decode(&value); err != nil { + return err + } + + matches := durationFieldPattern.FindStringSubmatch(value) + + if len(matches) != 3 { + return fmt.Errorf("invalid duration format: %s", value) + } + + duration, err := strconv.Atoi(matches[1]) + if err != nil { + return err + } + + switch matches[2] { + case "s": + *d = durationField(time.Duration(duration) * time.Second) + case "m": + *d = durationField(time.Duration(duration) * time.Minute) + case "h": + *d = durationField(time.Duration(duration) * time.Hour) + case "d": + *d = durationField(time.Duration(duration) * 24 * time.Hour) + } + + return nil +} diff --git a/pkg/sources/source.go b/pkg/sources/source.go new file mode 100644 index 00000000..522a64cf --- /dev/null +++ b/pkg/sources/source.go @@ -0,0 +1,222 @@ +package sources + +import ( + "errors" + "fmt" + "math" + "time" +) + +func NewSource(widgetType string) (Source, error) { + if widgetType == "" { + return nil, errors.New("widget 'type' property is empty or not specified") + } + + var s Source + + switch widgetType { + case "mastodon": + s = &mastodonSource{} + case "hacker-news": + s = &hackerNewsSource{} + case "reddit": + s = &redditSource{} + case "lobsters": + s = &lobstersSource{} + case "rss": + s = &rssSource{} + case "releases": + s = &githubReleasesSource{} + case "issues": + s = &githubIssuesSource{} + case "change-detection": + s = &changeDetectionWidget{} + default: + return nil, fmt.Errorf("unknown widget type: %s", widgetType) + } + + return s, nil +} + +// Source TODO(pulse): Feed() returns cached activities, but refactor it to fetch fresh activities given filters and cache them in a global activity registry. +type Source interface { + // Feed return cached feed entries in a standard Activity format. + Feed() []Activity + RequiresUpdate(now *time.Time) bool +} + +type Activity interface { + UID() string + Title() string + Body() string + URL() string + ImageURL() string + CreatedAt() time.Time + // TODO: Add Metadata() that returns custom fields? +} + +type cacheType int + +const ( + cacheTypeInfinite cacheType = iota + cacheTypeDuration + cacheTypeOnTheHour +) + +type sourceBase struct { + ID uint64 `yaml:"-"` + Title string `yaml:"title"` + TitleURL string `yaml:"title-url"` + ContentAvailable bool `yaml:"-"` + Error error `yaml:"-"` + Notice error `yaml:"-"` + Providers *sourceProviders `yaml:"-"` + CustomCacheDuration durationField `yaml:"cache"` + cacheDuration time.Duration `yaml:"-"` + cacheType cacheType `yaml:"-"` + nextUpdate time.Time `yaml:"-"` + updateRetriedTimes int `yaml:"-"` +} + +// TODO(pulse): Do we need this? +type sourceProviders struct { + assetResolver func(string) string +} + +func (w *sourceBase) withTitle(title string) *sourceBase { + if w.Title == "" { + w.Title = title + } + + return w +} + +func (w *sourceBase) withTitleURL(titleURL string) *sourceBase { + if w.TitleURL == "" { + w.TitleURL = titleURL + } + + return w +} + +func (w *sourceBase) RequiresUpdate(now *time.Time) bool { + if w.cacheType == cacheTypeInfinite { + return false + } + + if w.nextUpdate.IsZero() { + return true + } + + return now.After(w.nextUpdate) +} + +func (w *sourceBase) withCacheDuration(duration time.Duration) *sourceBase { + w.cacheType = cacheTypeDuration + + if duration == -1 || w.CustomCacheDuration == 0 { + w.cacheDuration = duration + } else { + w.cacheDuration = time.Duration(w.CustomCacheDuration) + } + + return w +} + +func (w *sourceBase) withCacheOnTheHour() *sourceBase { + w.cacheType = cacheTypeOnTheHour + + return w +} + +func (w *sourceBase) getNextUpdateTime() time.Time { + now := time.Now() + + if w.cacheType == cacheTypeDuration { + return now.Add(w.cacheDuration) + } + + if w.cacheType == cacheTypeOnTheHour { + return now.Add(time.Duration( + ((60-now.Minute())*60)-now.Second(), + ) * time.Second) + } + + return time.Time{} +} + +func (w *sourceBase) scheduleNextUpdate() *sourceBase { + w.nextUpdate = w.getNextUpdateTime() + w.updateRetriedTimes = 0 + + return w +} + +func (w *sourceBase) scheduleEarlyUpdate() *sourceBase { + w.updateRetriedTimes++ + + if w.updateRetriedTimes > 5 { + w.updateRetriedTimes = 5 + } + + nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute) + nextUsualUpdate := w.getNextUpdateTime() + + if nextEarlyUpdate.After(nextUsualUpdate) { + w.nextUpdate = nextUsualUpdate + } else { + w.nextUpdate = nextEarlyUpdate + } + + return w +} + +func (w *sourceBase) withNotice(err error) *sourceBase { + w.Notice = err + + return w +} + +func (w *sourceBase) withError(err error) *sourceBase { + if err == nil && !w.ContentAvailable { + w.ContentAvailable = true + } + + w.Error = err + + return w +} + +func (s *sourceBase) canContinueUpdateAfterHandlingErr(err error) bool { + // TODO: needs covering more edge cases. + // if there's partial content and we update early there's a chance + // the early update returns even less content than the initial update. + // need some kind of mechanism that tells us whether we should update early + // or not depending on the number of things that failed during the initial + // and subsequent update and how they failed - ie whether it was server + // error (like gateway timeout, do retry early) or client error (like + // hitting a rate limit, don't retry early). will require reworking a + // good amount of code in the feed package and probably having a custom + // error type that holds more information because screw wrapping errors. + // alternatively have a resource cache and only refetch the failed resources, + // then rebuild the widget. + + if err != nil { + s.scheduleEarlyUpdate() + + if !errors.Is(err, errPartialContent) { + s.withError(err) + s.withNotice(nil) + return false + } + + s.withError(nil) + s.withNotice(err) + return true + } + + s.withNotice(nil) + s.withError(nil) + s.scheduleNextUpdate() + return true +} diff --git a/internal/glance/widget-utils.go b/pkg/sources/utils.go similarity index 82% rename from internal/glance/widget-utils.go rename to pkg/sources/utils.go index fa2fad55..cd7c4dc4 100644 --- a/internal/glance/widget-utils.go +++ b/pkg/sources/utils.go @@ -1,4 +1,4 @@ -package glance +package sources import ( "context" @@ -10,7 +10,10 @@ import ( "io" "math/rand/v2" "net/http" + "net/url" + "regexp" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -41,7 +44,9 @@ type requestDoer interface { Do(*http.Request) (*http.Response, error) } -var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance" +var BuildVersion = "dev" + +var pulseUserAgentString = "Pulse/" + BuildVersion + " +https://github.com/bartolomej/pulse" var userAgentPersistentVersion atomic.Int32 func getBrowserUserAgentHeader() string { @@ -238,3 +243,51 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error) return results, errs, err } + +func limitStringLength(s string, max int) (string, bool) { + asRunes := []rune(s) + + if len(asRunes) > max { + return string(asRunes[:max]), true + } + + return s, false +} + +func parseRFC3339Time(t string) time.Time { + parsed, err := time.Parse(time.RFC3339, t) + if err != nil { + return time.Now() + } + + return parsed +} + +func normalizeVersionFormat(version string) string { + version = strings.ToLower(strings.TrimSpace(version)) + + if len(version) > 0 && version[0] != 'v' { + return "v" + version + } + + return version +} + +var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) + +func stripURLScheme(url string) string { + return urlSchemePattern.ReplaceAllString(url, "") +} + +func extractDomainFromUrl(u string) string { + if u == "" { + return "" + } + + parsed, err := url.Parse(u) + if err != nil { + return "" + } + + return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") +} diff --git a/internal/glance/widget-changedetection.go b/pkg/sources/widget-changedetection.go similarity index 68% rename from internal/glance/widget-changedetection.go rename to pkg/sources/widget-changedetection.go index 8ca8803b..1dfb7520 100644 --- a/internal/glance/widget-changedetection.go +++ b/pkg/sources/widget-changedetection.go @@ -1,9 +1,8 @@ -package glance +package sources import ( "context" "fmt" - "html/template" "log/slog" "net/http" "sort" @@ -11,10 +10,8 @@ import ( "time" ) -var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html") - type changeDetectionWidget struct { - widgetBase `yaml:",inline"` + sourceBase `yaml:",inline"` ChangeDetections changeDetectionWatchList `yaml:"-"` WatchUUIDs []string `yaml:"watches"` InstanceURL string `yaml:"instance-url"` @@ -23,60 +20,89 @@ type changeDetectionWidget struct { CollapseAfter int `yaml:"collapse-after"` } -func (widget *changeDetectionWidget) initialize() error { - widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) +func (s *changeDetectionWidget) Feed() []Activity { + activities := make([]Activity, len(s.ChangeDetections)) + for i, c := range s.ChangeDetections { + activities[i] = c + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 10 +func (s *changeDetectionWidget) initialize() error { + s.withTitle("Change Detection").withCacheDuration(1 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 10 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.InstanceURL == "" { - widget.InstanceURL = "https://www.changedetection.io" + if s.InstanceURL == "" { + s.InstanceURL = "https://www.changedetection.io" } return nil } -func (widget *changeDetectionWidget) update(ctx context.Context) { - if len(widget.WatchUUIDs) == 0 { - uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token)) +func (s *changeDetectionWidget) update(ctx context.Context) { + if len(s.WatchUUIDs) == 0 { + uuids, err := fetchWatchUUIDsFromChangeDetection(s.InstanceURL, string(s.Token)) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - widget.WatchUUIDs = uuids + s.WatchUUIDs = uuids } - watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) + watches, err := fetchWatchesFromChangeDetection(s.InstanceURL, s.WatchUUIDs, string(s.Token)) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(watches) > widget.Limit { - watches = watches[:widget.Limit] + if len(watches) > s.Limit { + watches = watches[:s.Limit] } - widget.ChangeDetections = watches -} - -func (widget *changeDetectionWidget) Render() template.HTML { - return widget.renderTemplate(widget, changeDetectionWidgetTemplate) + s.ChangeDetections = watches } type changeDetectionWatch struct { - Title string - URL string + title string + url string LastChanged time.Time DiffURL string PreviousHash string } +func (c changeDetectionWatch) UID() string { + return fmt.Sprintf("%s-%d", c.url, c.LastChanged.Unix()) +} + +func (c changeDetectionWatch) Title() string { + return c.title +} + +func (c changeDetectionWatch) Body() string { + return "" +} + +func (c changeDetectionWatch) URL() string { + return c.url +} + +func (c changeDetectionWatch) ImageURL() string { + // TODO(pulse): Use website favicon + return "" +} + +func (c changeDetectionWatch) CreatedAt() time.Time { + return c.LastChanged +} + type changeDetectionWatchList []changeDetectionWatch func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList { @@ -154,7 +180,7 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str watchJson := responses[i] watch := changeDetectionWatch{ - URL: watchJson.URL, + url: watchJson.URL, DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), } @@ -165,9 +191,9 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str } if watchJson.Title != "" { - watch.Title = watchJson.Title + watch.title = watchJson.Title } else { - watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.") + watch.title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.") } if watchJson.PreviousHash != "" { diff --git a/pkg/sources/widget-github-issues.go b/pkg/sources/widget-github-issues.go new file mode 100644 index 00000000..6b1ae257 --- /dev/null +++ b/pkg/sources/widget-github-issues.go @@ -0,0 +1,323 @@ +package sources + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type githubIssuesSource struct { + sourceBase `yaml:",inline"` + Issues issueActivityList `yaml:"-"` + Repositories []*issueRequest `yaml:"repositories"` + Token string `yaml:"token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ActivityTypes []string `yaml:"activity-types"` +} + +func (s *githubIssuesSource) Feed() []Activity { + activities := make([]Activity, len(s.Issues)) + for i, issue := range s.Issues { + activities[i] = issue + } + return activities +} + +type issueActivity struct { + ID string + Summary string + Description string + Source string + SourceIconURL string + Repository string + IssueNumber int + title string + State string + ActivityType string + IssueType string + HTMLURL string + TimeUpdated time.Time + MatchScore int +} + +func (i issueActivity) UID() string { + return i.ID +} + +func (i issueActivity) Title() string { + return i.title +} + +func (i issueActivity) Body() string { + return i.Description +} + +func (i issueActivity) URL() string { + return i.HTMLURL +} + +func (i issueActivity) ImageURL() string { + return i.SourceIconURL +} + +func (i issueActivity) CreatedAt() time.Time { + return i.TimeUpdated +} + +type issueActivityList []issueActivity + +func (i issueActivityList) sortByNewest() issueActivityList { + sort.Slice(i, func(a, b int) bool { + return i[a].TimeUpdated.After(i[b].TimeUpdated) + }) + return i +} + +type issueRequest struct { + Repository string `yaml:"repository"` + token *string +} + +func (i *issueRequest) UnmarshalYAML(node *yaml.Node) error { + var repository string + + if err := node.Decode(&repository); err != nil { + type issueRequestAlias issueRequest + alias := (*issueRequestAlias)(i) + if err := node.Decode(alias); err != nil { + return fmt.Errorf("could not unmarshal repository into string or struct: %v", err) + } + } + + if i.Repository == "" { + if repository == "" { + return errors.New("repository is required") + } + i.Repository = repository + } + + return nil +} + +func (s *githubIssuesSource) initialize() error { + s.withTitle("Issue Activity").withCacheDuration(30 * time.Minute) + + if s.Limit <= 0 { + s.Limit = 10 + } + + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 + } + + if len(s.ActivityTypes) == 0 { + s.ActivityTypes = []string{"opened", "closed", "commented"} + } + + for i := range s.Repositories { + r := s.Repositories[i] + if s.Token != "" { + r.token = &s.Token + } + } + + return nil +} + +func (s *githubIssuesSource) update(ctx context.Context) { + activities, err := fetchIssueActivities(s.Repositories, s.ActivityTypes) + + if !s.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(activities) > s.Limit { + activities = activities[:s.Limit] + } + + for i := range activities { + activities[i].SourceIconURL = s.Providers.assetResolver("icons/github.svg") + } + + s.Issues = activities +} + +type githubIssueResponse struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + UpdatedAt string `json:"updated_at"` + Body string `json:"body"` + PullRequest *struct{} `json:"pull_request,omitempty"` +} + +type githubIssueCommentResponse struct { + ID int `json:"ID"` + Body string `json:"body"` + IssueURL string `json:"issue_url"` + HTMLURL string `json:"html_url"` + UpdatedAt string `json:"updated_at"` +} + +func fetchIssueActivities(requests []*issueRequest, activityTypes []string) (issueActivityList, error) { + job := newJob(fetchIssueActivityTask, requests).withWorkers(20) + results, errs, err := workerPoolDo(job) + if err != nil { + return nil, err + } + + var failed int + activities := make(issueActivityList, 0, len(requests)*len(activityTypes)) + + for i := range results { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch issue activity", "repository", requests[i].Repository, "error", errs[i]) + continue + } + + activities = append(activities, results[i]...) + } + + if failed == len(requests) { + return nil, errNoContent + } + + activities.sortByNewest() + + if failed > 0 { + return activities, fmt.Errorf("%w: could not get issue activities for %d repositories", errPartialContent, failed) + } + + return activities, nil +} + +func fetchIssueActivityTask(request *issueRequest) ([]issueActivity, error) { + activities := make([]issueActivity, 0) + + issues, err := fetchLatestIssues(request) + if err != nil { + return nil, err + } + + comments, err := fetchLatestComments(request) + if err != nil { + return nil, err + } + + for _, issue := range issues { + issueType := "issue" + if issue.PullRequest != nil { + issueType = "pull request" + } + activities = append(activities, issueActivity{ + ID: fmt.Sprintf("issue-%d", issue.Number), + Description: issue.Body, + Source: "github", + Repository: request.Repository, + IssueNumber: issue.Number, + title: issue.Title, + State: issue.State, + ActivityType: issue.State, + IssueType: issueType, + HTMLURL: issue.HTMLURL, + TimeUpdated: parseRFC3339Time(issue.UpdatedAt), + }) + } + + for _, comment := range comments { + issueNumber := 0 + if comment.IssueURL != "" { + parts := strings.Split(comment.IssueURL, "/") + if len(parts) > 0 { + if n, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + issueNumber = n + } + } + } + title := comment.Body + titleLimit := 40 + if len(title) > titleLimit { + title = title[:titleLimit] + "..." + } + activities = append(activities, issueActivity{ + ID: fmt.Sprintf("comment-%d", comment.ID), + Description: comment.Body, + IssueNumber: issueNumber, + Source: "github", + Repository: request.Repository, + ActivityType: "commented", + title: title, + IssueType: "issue", + HTMLURL: comment.HTMLURL, + TimeUpdated: parseRFC3339Time(comment.UpdatedAt), + }) + } + + return activities, nil +} + +func fetchLatestIssues(request *issueRequest) ([]githubIssueResponse, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/issues?state=all&sort=updated&direction=desc&per_page=10", request.Repository), + nil, + ) + if err != nil { + return nil, err + } + + // TODO(pulse): Change secrets config approach + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } + + response, err := decodeJsonFromRequest[[]githubIssueResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return response, nil +} + +func fetchLatestComments(request *issueRequest) ([]githubIssueCommentResponse, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/issues/comments?sort=updated&direction=desc&per_page=10", request.Repository), + nil, + ) + if err != nil { + return nil, err + } + + // TODO(pulse): Change secrets config approach + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } + + response, err := decodeJsonFromRequest[[]githubIssueCommentResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/internal/glance/widget-releases.go b/pkg/sources/widget-github-releases.go similarity index 83% rename from internal/glance/widget-releases.go rename to pkg/sources/widget-github-releases.go index de56bc51..665ddcb9 100644 --- a/internal/glance/widget-releases.go +++ b/pkg/sources/widget-github-releases.go @@ -1,24 +1,23 @@ -package glance +package sources import ( "context" "errors" "fmt" - "html/template" "log/slog" "net/http" "net/url" + "os" "sort" + "strconv" "strings" "time" "gopkg.in/yaml.v3" ) -var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html") - -type releasesWidget struct { - widgetBase `yaml:",inline"` +type githubReleasesSource struct { + sourceBase `yaml:",inline"` Releases appReleaseList `yaml:"-"` Repositories []*releaseRequest `yaml:"repositories"` Token string `yaml:"token"` @@ -28,50 +27,54 @@ type releasesWidget struct { ShowSourceIcon bool `yaml:"show-source-icon"` } -func (widget *releasesWidget) initialize() error { - widget.withTitle("Releases").withCacheDuration(2 * time.Hour) +func (s *githubReleasesSource) Feed() []Activity { + activities := make([]Activity, len(s.Releases)) + for i, r := range s.Releases { + activities[i] = r + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 10 +func (s *githubReleasesSource) initialize() error { + s.withTitle("Releases").withCacheDuration(2 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 10 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - for i := range widget.Repositories { - r := widget.Repositories[i] + for i := range s.Repositories { + r := s.Repositories[i] - if r.source == releaseSourceGithub && widget.Token != "" { - r.token = &widget.Token - } else if r.source == releaseSourceGitlab && widget.GitLabToken != "" { - r.token = &widget.GitLabToken + if r.source == releaseSourceGithub && s.Token != "" { + r.token = &s.Token + } else if r.source == releaseSourceGitlab && s.GitLabToken != "" { + r.token = &s.GitLabToken } } return nil } -func (widget *releasesWidget) update(ctx context.Context) { - releases, err := fetchLatestReleases(widget.Repositories) +func (s *githubReleasesSource) update(ctx context.Context) { + releases, err := fetchLatestReleases(s.Repositories) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(releases) > widget.Limit { - releases = releases[:widget.Limit] + if len(releases) > s.Limit { + releases = releases[:s.Limit] } for i := range releases { - releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") + releases[i].SourceIconURL = s.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") } - widget.Releases = releases -} - -func (widget *releasesWidget) Render() template.HTML { - return widget.renderTemplate(widget, releasesWidgetTemplate) + s.Releases = releases } type releaseSource string @@ -84,6 +87,8 @@ const ( ) type appRelease struct { + ID string + Description string Source releaseSource SourceIconURL string Name string @@ -91,6 +96,32 @@ type appRelease struct { NotesUrl string TimeReleased time.Time Downvotes int + MatchSummary string + MatchScore int +} + +func (a appRelease) UID() string { + return a.ID +} + +func (a appRelease) Title() string { + return a.Name +} + +func (a appRelease) Body() string { + return a.Description +} + +func (a appRelease) URL() string { + return a.NotesUrl +} + +func (a appRelease) ImageURL() string { + return a.SourceIconURL +} + +func (a appRelease) CreatedAt() time.Time { + return a.TimeReleased } type appReleaseList []appRelease @@ -203,9 +234,11 @@ func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) { } type githubReleaseResponseJson struct { + ID int `json:"ID"` TagName string `json:"tag_name"` PublishedAt string `json:"published_at"` HtmlUrl string `json:"html_url"` + Body string `json:"body"` Reactions struct { Downvotes int `json:"-1"` } `json:"reactions"` @@ -224,9 +257,14 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { return nil, err } + // TODO(pulse): Change secrets config approach if request.token != nil { httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } var response githubReleaseResponseJson @@ -249,6 +287,8 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { } return &appRelease{ + ID: strconv.Itoa(response.ID), + Description: response.Body, Source: releaseSourceGithub, Name: request.Repository, Version: normalizeVersionFormat(response.TagName), diff --git a/internal/glance/widget-hacker-news.go b/pkg/sources/widget-hacker-news.go similarity index 64% rename from internal/glance/widget-hacker-news.go rename to pkg/sources/widget-hacker-news.go index ad00df01..8f2c42ba 100644 --- a/internal/glance/widget-hacker-news.go +++ b/pkg/sources/widget-hacker-news.go @@ -1,18 +1,19 @@ -package glance +package sources import ( "context" "fmt" - "html/template" "log/slog" "net/http" "strconv" "strings" "time" + + "github.com/go-shiori/go-readability" ) -type hackerNewsWidget struct { - widgetBase `yaml:",inline"` +type hackerNewsSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` Limit int `yaml:"limit"` SortBy string `yaml:"sort-by"` @@ -22,52 +23,56 @@ type hackerNewsWidget struct { ShowThumbnails bool `yaml:"-"` } -func (widget *hackerNewsWidget) initialize() error { - widget. +func (s *hackerNewsSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *hackerNewsSource) initialize() error { + s. withTitle("Hacker News"). withTitleURL("https://news.ycombinator.com/"). withCacheDuration(30 * time.Minute) - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { - widget.SortBy = "top" + if s.SortBy != "top" && s.SortBy != "new" && s.SortBy != "best" { + s.SortBy = "top" } return nil } -func (widget *hackerNewsWidget) update(ctx context.Context) { - posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) +func (s *hackerNewsSource) update(ctx context.Context) { + posts, err := fetchHackerNewsPosts(s.SortBy, 40, s.CommentsUrlTemplate) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if widget.ExtraSortBy == "engagement" { + if s.ExtraSortBy == "engagement" { posts.calculateEngagement() posts.sortByEngagement() } - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] + if s.Limit < len(posts) { + posts = posts[:s.Limit] } - widget.Posts = posts -} - -func (widget *hackerNewsWidget) Render() template.HTML { - return widget.renderTemplate(widget, forumPostsTemplate) + s.Posts = posts } type hackerNewsPostResponseJson struct { - Id int `json:"id"` + Id int `json:"ID"` Score int `json:"score"` Title string `json:"title"` TargetUrl string `json:"url,omitempty"` @@ -102,7 +107,7 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for posts := make(forumPostList, 0, len(postIds)) - for i := range results { + for i, res := range results { if errs[i] != nil { slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) continue @@ -111,20 +116,31 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for var commentsUrl string if commentsUrlTemplate == "" { - commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) + commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(res.Id) } else { - commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) + commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(res.Id)) } - posts = append(posts, forumPost{ - Title: results[i].Title, + forumPost := forumPost{ + ID: strconv.Itoa(res.Id), + title: res.Title, + Description: res.Title, DiscussionUrl: commentsUrl, - TargetUrl: results[i].TargetUrl, - TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl), - CommentCount: results[i].CommentCount, - Score: results[i].Score, - TimePosted: time.Unix(results[i].TimePosted, 0), - }) + TargetUrl: res.TargetUrl, + TargetUrlDomain: extractDomainFromUrl(res.TargetUrl), + CommentCount: res.CommentCount, + Score: res.Score, + TimePosted: time.Unix(res.TimePosted, 0), + } + + article, err := readability.FromURL(forumPost.TargetUrl, 5*time.Second) + if err == nil { + forumPost.Description = article.TextContent + } else { + slog.Error("Failed to fetch hacker news article", "error", err, "url", forumPost.TargetUrl) + } + + posts = append(posts, forumPost) } if len(posts) == 0 { diff --git a/internal/glance/widget-lobsters.go b/pkg/sources/widget-lobsters.go similarity index 54% rename from internal/glance/widget-lobsters.go rename to pkg/sources/widget-lobsters.go index 786d1dfb..28e4b2c2 100644 --- a/internal/glance/widget-lobsters.go +++ b/pkg/sources/widget-lobsters.go @@ -1,15 +1,17 @@ -package glance +package sources import ( "context" - "html/template" + "log/slog" "net/http" "strings" "time" + + "github.com/go-shiori/go-readability" ) -type lobstersWidget struct { - widgetBase `yaml:",inline"` +type lobstersSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` InstanceURL string `yaml:"instance-url"` CustomURL string `yaml:"custom-url"` @@ -20,49 +22,54 @@ type lobstersWidget struct { ShowThumbnails bool `yaml:"-"` } -func (widget *lobstersWidget) initialize() error { - widget.withTitle("Lobsters").withCacheDuration(time.Hour) +func (s *lobstersSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *lobstersSource) initialize() error { + s.withTitle("Lobsters").withCacheDuration(time.Hour) - if widget.InstanceURL == "" { - widget.withTitleURL("https://lobste.rs") + if s.InstanceURL == "" { + s.withTitleURL("https://lobste.rs") } else { - widget.withTitleURL(widget.InstanceURL) + s.withTitleURL(s.InstanceURL) } - if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { - widget.SortBy = "hot" + if s.SortBy == "" || (s.SortBy != "hot" && s.SortBy != "new") { + s.SortBy = "hot" } - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } return nil } -func (widget *lobstersWidget) update(ctx context.Context) { - posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) +func (s *lobstersSource) update(ctx context.Context) { + posts, err := fetchLobstersPosts(s.CustomURL, s.InstanceURL, s.SortBy, s.Tags) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] + if s.Limit < len(posts) { + posts = posts[:s.Limit] } - widget.Posts = posts -} - -func (widget *lobstersWidget) Render() template.HTML { - return widget.renderTemplate(widget, forumPostsTemplate) + s.Posts = posts } type lobstersPostResponseJson struct { + ID string `json:"short_id"` CreatedAt string `json:"created_at"` Title string `json:"title"` URL string `json:"url"` @@ -87,19 +94,30 @@ func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { posts := make(forumPostList, 0, len(feed)) - for i := range feed { - createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) - - posts = append(posts, forumPost{ - Title: feed[i].Title, - DiscussionUrl: feed[i].CommentsURL, - TargetUrl: feed[i].URL, - TargetUrlDomain: extractDomainFromUrl(feed[i].URL), - CommentCount: feed[i].CommentCount, - Score: feed[i].Score, + for _, post := range feed { + createdAt, _ := time.Parse(time.RFC3339, post.CreatedAt) + + forumPost := forumPost{ + ID: post.ID, + title: post.Title, + Description: post.Title, + DiscussionUrl: post.CommentsURL, + TargetUrl: post.URL, + TargetUrlDomain: extractDomainFromUrl(post.URL), + CommentCount: post.CommentCount, + Score: post.Score, TimePosted: createdAt, - Tags: feed[i].Tags, - }) + Tags: post.Tags, + } + + article, err := readability.FromURL(post.URL, 5*time.Second) + if err == nil { + forumPost.Description = article.TextContent + } else { + slog.Error("Failed to fetch lobster article", "error", err, "url", forumPost.TargetUrl) + } + + posts = append(posts, forumPost) } if len(posts) == 0 { diff --git a/pkg/sources/widget-mastodon.go b/pkg/sources/widget-mastodon.go new file mode 100644 index 00000000..b67bdf2c --- /dev/null +++ b/pkg/sources/widget-mastodon.go @@ -0,0 +1,198 @@ +package sources + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/net/html" +) + +type mastodonSource struct { + sourceBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + InstanceURL string `yaml:"instance-url"` + Accounts []string `yaml:"accounts"` + Hashtags []string `yaml:"hashtags"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ShowThumbnails bool `yaml:"-"` +} + +func (s *mastodonSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *mastodonSource) initialize() error { + if s.InstanceURL == "" { + return fmt.Errorf("instance-url is required") + } + + s. + withTitle("Mastodon"). + withTitleURL(s.InstanceURL). + withCacheDuration(30 * time.Minute) + + if s.Limit <= 0 { + s.Limit = 15 + } + + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 + } + + return nil +} + +func (s *mastodonSource) update(ctx context.Context) { + posts, err := fetchMastodonPosts(s.InstanceURL, s.Accounts, s.Hashtags) + + if !s.canContinueUpdateAfterHandlingErr(err) { + return + } + + if s.Limit < len(posts) { + posts = posts[:s.Limit] + } + + s.Posts = posts +} + +type mastodonPostResponseJson struct { + ID string `json:"ID"` + Content string `json:"content"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + Reblogs int `json:"reblogs_count"` + Favorites int `json:"favourites_count"` + Replies int `json:"replies_count"` + Account struct { + Username string `json:"username"` + URL string `json:"url"` + } `json:"account"` + MediaAttachments []struct { + URL string `json:"url"` + } `json:"media_attachments"` + Tags []struct { + Name string `json:"name"` + } `json:"tags"` +} + +func fetchMastodonPosts(instanceURL string, accounts []string, hashtags []string) (forumPostList, error) { + instanceURL = strings.TrimRight(instanceURL, "/") + var posts forumPostList + + // Fetch posts from specified accounts + for _, account := range accounts { + url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses", instanceURL, account) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + accountPosts, err := decodeJsonFromRequest[[]mastodonPostResponseJson](defaultHTTPClient, request) + if err != nil { + slog.Error("Failed to fetch Mastodon account posts", "error", err, "account", account) + continue + } + + for _, post := range accountPosts { + forumPost := convertMastodonPostToForumPost(post) + posts = append(posts, forumPost) + } + } + + // Fetch posts from specified hashtags + for _, hashtag := range hashtags { + url := fmt.Sprintf("%s/api/v1/timelines/tag/%s", instanceURL, hashtag) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + hashtagPosts, err := decodeJsonFromRequest[[]mastodonPostResponseJson](defaultHTTPClient, request) + if err != nil { + slog.Error("Failed to fetch Mastodon hashtag posts", "error", err, "hashtag", hashtag) + continue + } + + for _, post := range hashtagPosts { + forumPost := convertMastodonPostToForumPost(post) + posts = append(posts, forumPost) + } + } + + if len(posts) == 0 { + return nil, errNoContent + } + + return posts, nil +} + +func convertMastodonPostToForumPost(post mastodonPostResponseJson) forumPost { + tags := make([]string, len(post.Tags)) + for i, tag := range post.Tags { + tags[i] = "#" + tag.Name + } + + plainText := extractTextFromHTML(post.Content) + title := oneLineTitle(plainText, 50) + + forumPost := forumPost{ + ID: post.ID, + title: title, + Description: plainText, + DiscussionUrl: post.URL, + CommentCount: post.Replies, + Score: post.Reblogs + post.Favorites, + TimePosted: post.CreatedAt, + // TODO(pulse): Hide tags for now, as they introduce too much noise + // Tags: tags, + } + + if len(post.MediaAttachments) > 0 { + forumPost.ThumbnailUrl = post.MediaAttachments[0].URL + } + + return forumPost +} + +func extractTextFromHTML(htmlStr string) string { + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + return htmlStr + } + var b strings.Builder + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.TextNode { + b.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(doc) + return strings.TrimSpace(b.String()) +} + +func oneLineTitle(text string, maxLen int) string { + // Replace newlines and tabs with spaces, collapse multiple spaces + re := regexp.MustCompile(`\s+`) + t := re.ReplaceAllString(text, " ") + t = strings.TrimSpace(t) + if utf8.RuneCountInString(t) > maxLen { + runes := []rune(t) + return string(runes[:maxLen-1]) + "…" + } + return t +} diff --git a/internal/glance/widget-reddit.go b/pkg/sources/widget-reddit.go similarity index 65% rename from internal/glance/widget-reddit.go rename to pkg/sources/widget-reddit.go index a2cb5d9a..3f97e8d1 100644 --- a/internal/glance/widget-reddit.go +++ b/pkg/sources/widget-reddit.go @@ -1,25 +1,22 @@ -package glance +package sources import ( "context" "errors" "fmt" "html" - "html/template" + "log/slog" "net/http" "net/url" "strconv" "strings" "time" -) -var ( - redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html") - redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html") + "github.com/go-shiori/go-readability" ) -type redditWidget struct { - widgetBase `yaml:",inline"` +type redditSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` Subreddit string `yaml:"subreddit"` Proxy proxyOptionsField `yaml:"proxy"` @@ -37,7 +34,7 @@ type redditWidget struct { AppAuth struct { Name string `yaml:"name"` - ID string `yaml:"id"` + ID string `yaml:"ID"` Secret string `yaml:"secret"` enabled bool @@ -46,36 +43,44 @@ type redditWidget struct { } `yaml:"app-auth"` } -func (widget *redditWidget) initialize() error { - if widget.Subreddit == "" { +func (s *redditSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *redditSource) initialize() error { + if s.Subreddit == "" { return errors.New("subreddit is required") } - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - s := widget.SortBy - if s != "hot" && s != "new" && s != "top" && s != "rising" { - widget.SortBy = "hot" + sort := s.SortBy + if sort != "hot" && sort != "new" && sort != "top" && sort != "rising" { + s.SortBy = "hot" } - p := widget.TopPeriod + p := s.TopPeriod if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" { - widget.TopPeriod = "day" + s.TopPeriod = "day" } - if widget.RequestURLTemplate != "" { - if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") { + if s.RequestURLTemplate != "" { + if !strings.Contains(s.RequestURLTemplate, "{REQUEST-URL}") { return errors.New("no `{REQUEST-URL}` placeholder specified") } } - a := &widget.AppAuth + a := &s.AppAuth if a.Name != "" || a.ID != "" || a.Secret != "" { if a.Name == "" || a.ID == "" || a.Secret == "" { return errors.New("application name, client ID and client secret are required") @@ -83,51 +88,39 @@ func (widget *redditWidget) initialize() error { a.enabled = true } - widget. - withTitle("r/" + widget.Subreddit). - withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). + s. + withTitle("r/" + s.Subreddit). + withTitleURL("https://www.reddit.com/r/" + s.Subreddit + "/"). withCacheDuration(30 * time.Minute) return nil } -func (widget *redditWidget) update(ctx context.Context) { - posts, err := widget.fetchSubredditPosts() - if !widget.canContinueUpdateAfterHandlingErr(err) { +func (s *redditSource) update(ctx context.Context) { + posts, err := s.fetchSubredditPosts() + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(posts) > widget.Limit { - posts = posts[:widget.Limit] + if len(posts) > s.Limit { + posts = posts[:s.Limit] } - if widget.ExtraSortBy == "engagement" { + if s.ExtraSortBy == "engagement" { posts.calculateEngagement() posts.sortByEngagement() } - widget.Posts = posts -} - -func (widget *redditWidget) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate) - } - - if widget.Style == "vertical-cards" { - return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate) - } - - return widget.renderTemplate(widget, forumPostsTemplate) - + s.Posts = posts } type subredditResponseJson struct { Data struct { Children []struct { Data struct { - Id string `json:"id"` + Id string `json:"ID"` Title string `json:"title"` + SelfText string `json:"selftext"` Upvotes int `json:"ups"` Url string `json:"url"` Time float64 `json:"created"` @@ -140,7 +133,7 @@ type subredditResponseJson struct { Thumbnail string `json:"thumbnail"` Flair string `json:"link_flair_text"` ParentList []struct { - Id string `json:"id"` + Id string `json:"ID"` Subreddit string `json:"subreddit"` Permalink string `json:"permalink"` } `json:"crosspost_parent_list"` @@ -149,21 +142,21 @@ type subredditResponseJson struct { } `json:"data"` } -func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string { - template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit) +func (s *redditSource) parseCustomCommentsURL(subreddit, postId, postPath string) string { + template := strings.ReplaceAll(s.CommentsURLTemplate, "{SUBREDDIT}", subreddit) template = strings.ReplaceAll(template, "{POST-ID}", postId) template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/")) return template } -func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { +func (s *redditSource) fetchSubredditPosts() (forumPostList, error) { var client requestDoer = defaultHTTPClient var baseURL string var requestURL string var headers http.Header query := url.Values{} - app := &widget.AppAuth + app := &s.AppAuth if !app.enabled { baseURL = "https://www.reddit.com" @@ -174,7 +167,7 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { baseURL = "https://oauth.reddit.com" if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) { - if err := widget.fetchNewAppAccessToken(); err != nil { + if err := s.fetchNewAppAccessToken(); err != nil { return nil, fmt.Errorf("fetching new app access token: %v", err) } } @@ -185,25 +178,25 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { } } - if widget.Limit > 25 { - query.Set("limit", strconv.Itoa(widget.Limit)) + if s.Limit > 25 { + query.Set("limit", strconv.Itoa(s.Limit)) } - if widget.Search != "" { - query.Set("q", widget.Search+" subreddit:"+widget.Subreddit) - query.Set("sort", widget.SortBy) + if s.Search != "" { + query.Set("q", s.Search+" subreddit:"+s.Subreddit) + query.Set("sort", s.SortBy) requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode()) } else { - if widget.SortBy == "top" { - query.Set("t", widget.TopPeriod) + if s.SortBy == "top" { + query.Set("t", s.TopPeriod) } - requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode()) + requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, s.Subreddit, s.SortBy, query.Encode()) } - if widget.RequestURLTemplate != "" { - requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL) - } else if widget.Proxy.client != nil { - client = widget.Proxy.client + if s.RequestURLTemplate != "" { + requestURL = strings.ReplaceAll(s.RequestURLTemplate, "{REQUEST-URL}", requestURL) + } else if s.Proxy.client != nil { + client = s.Proxy.client } request, err := http.NewRequest("GET", requestURL, nil) @@ -232,14 +225,16 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { var commentsUrl string - if widget.CommentsURLTemplate == "" { + if s.CommentsURLTemplate == "" { commentsUrl = "https://www.reddit.com" + post.Permalink } else { - commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink) + commentsUrl = s.parseCustomCommentsURL(s.Subreddit, post.Id, post.Permalink) } forumPost := forumPost{ - Title: html.UnescapeString(post.Title), + ID: post.Id, + title: html.UnescapeString(post.Title), + Description: post.SelfText, DiscussionUrl: commentsUrl, TargetUrlDomain: post.Domain, CommentCount: post.CommentsCount, @@ -255,7 +250,7 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { forumPost.TargetUrl = post.Url } - if widget.ShowFlairs && post.Flair != "" { + if s.ShowFlairs && post.Flair != "" { forumPost.Tags = append(forumPost.Tags, post.Flair) } @@ -263,10 +258,10 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { forumPost.IsCrosspost = true forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit - if widget.CommentsURLTemplate == "" { + if s.CommentsURLTemplate == "" { forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink } else { - forumPost.TargetUrl = widget.parseCustomCommentsURL( + forumPost.TargetUrl = s.parseCustomCommentsURL( post.ParentList[0].Subreddit, post.ParentList[0].Id, post.ParentList[0].Permalink, @@ -274,20 +269,29 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { } } + if forumPost.TargetUrl != "" { + article, err := readability.FromURL(forumPost.TargetUrl, 5*time.Second) + if err == nil { + forumPost.Description += fmt.Sprintf("\n\nReferenced article: \n%s", article.TextContent) + } else { + slog.Error("Failed to fetch reddit article", "error", err, "url", forumPost.TargetUrl) + } + } + posts = append(posts, forumPost) } return posts, nil } -func (widget *redditWidget) fetchNewAppAccessToken() error { +func (s *redditSource) fetchNewAppAccessToken() error { body := strings.NewReader("grant_type=client_credentials") req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body) if err != nil { return fmt.Errorf("creating request for app access token: %v", err) } - app := &widget.AppAuth + app := &s.AppAuth req.SetBasicAuth(app.ID, app.Secret) req.Header.Add("User-Agent", app.Name+"/1.0") req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -297,7 +301,10 @@ func (widget *redditWidget) fetchNewAppAccessToken() error { ExpiresIn int `json:"expires_in"` } - client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient) + client := defaultHTTPClient + if s.Proxy.client != nil { + client = s.Proxy.client + } response, err := decodeJsonFromRequest[tokenResponse](client, req) if err != nil { return err diff --git a/internal/glance/widget-rss.go b/pkg/sources/widget-rss.go similarity index 72% rename from internal/glance/widget-rss.go rename to pkg/sources/widget-rss.go index fe17b2fb..db6f0db1 100644 --- a/internal/glance/widget-rss.go +++ b/pkg/sources/widget-rss.go @@ -1,10 +1,9 @@ -package glance +package sources import ( "context" "fmt" "html" - "html/template" "io" "log/slog" "net/http" @@ -19,17 +18,10 @@ import ( gofeedext "github.com/mmcdole/gofeed/extensions" ) -var ( - rssWidgetTemplate = mustParseTemplate("rss-list.html", "widget-base.html") - rssWidgetDetailedListTemplate = mustParseTemplate("rss-detailed-list.html", "widget-base.html") - rssWidgetHorizontalCardsTemplate = mustParseTemplate("rss-horizontal-cards.html", "widget-base.html") - rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html") -) - var feedParser = gofeed.NewParser() -type rssWidget struct { - widgetBase `yaml:",inline"` +type rssSource struct { + sourceBase `yaml:",inline"` FeedRequests []rssFeedRequest `yaml:"feeds"` Style string `yaml:"style"` ThumbnailHeight float64 `yaml:"thumbnail-height"` @@ -46,69 +38,61 @@ type rssWidget struct { cachedFeeds map[string]*cachedRSSFeed `yaml:"-"` } -func (widget *rssWidget) initialize() error { - widget.withTitle("RSS Feed").withCacheDuration(2 * time.Hour) +func (s *rssSource) Feed() []Activity { + activities := make([]Activity, len(s.Items)) + for i, item := range s.Items { + activities[i] = item + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 25 +func (s *rssSource) initialize() error { + s.withTitle("RSS Feed").withCacheDuration(2 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 25 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.ThumbnailHeight < 0 { - widget.ThumbnailHeight = 0 + if s.ThumbnailHeight < 0 { + s.ThumbnailHeight = 0 } - if widget.CardHeight < 0 { - widget.CardHeight = 0 + if s.CardHeight < 0 { + s.CardHeight = 0 } - if widget.Style == "detailed-list" { - for i := range widget.FeedRequests { - widget.FeedRequests[i].IsDetailed = true + if s.Style == "detailed-list" { + for i := range s.FeedRequests { + s.FeedRequests[i].IsDetailed = true } } - widget.NoItemsMessage = "No items were returned from the feeds." - widget.cachedFeeds = make(map[string]*cachedRSSFeed) + s.NoItemsMessage = "No items were returned from the feeds." + s.cachedFeeds = make(map[string]*cachedRSSFeed) return nil } -func (widget *rssWidget) update(ctx context.Context) { - items, err := widget.fetchItemsFromFeeds() +func (s *rssSource) update(ctx context.Context) { + items, err := s.fetchItemsFromFeeds() - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if !widget.PreserveOrder { + if !s.PreserveOrder { items.sortByNewest() } - if len(items) > widget.Limit { - items = items[:widget.Limit] - } - - widget.Items = items -} - -func (widget *rssWidget) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.renderTemplate(widget, rssWidgetHorizontalCardsTemplate) - } - - if widget.Style == "horizontal-cards-2" { - return widget.renderTemplate(widget, rssWidgetHorizontalCards2Template) - } - - if widget.Style == "detailed-list" { - return widget.renderTemplate(widget, rssWidgetDetailedListTemplate) + if len(items) > s.Limit { + items = items[:s.Limit] } - return widget.renderTemplate(widget, rssWidgetTemplate) + s.Items = items } type cachedRSSFeed struct { @@ -118,16 +102,41 @@ type cachedRSSFeed struct { } type rssFeedItem struct { + ID string ChannelName string ChannelURL string - Title string + title string Link string - ImageURL string + imageURL string Categories []string Description string PublishedAt time.Time } +func (r rssFeedItem) UID() string { + return r.ID +} + +func (r rssFeedItem) Title() string { + return r.title +} + +func (r rssFeedItem) Body() string { + return r.Description +} + +func (r rssFeedItem) URL() string { + return r.Link +} + +func (r rssFeedItem) ImageURL() string { + return r.imageURL +} + +func (r rssFeedItem) CreatedAt() time.Time { + return r.PublishedAt +} + type rssFeedRequest struct { URL string `yaml:"url"` Title string `yaml:"title"` @@ -149,10 +158,10 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList { return f } -func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) { - requests := widget.FeedRequests +func (s *rssSource) fetchItemsFromFeeds() (rssFeedItemList, error) { + requests := s.FeedRequests - job := newJob(widget.fetchItemsFromFeedTask, requests).withWorkers(30) + job := newJob(s.fetchItemsFromFeedTask, requests).withWorkers(30) feeds, errs, err := workerPoolDo(job) if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) @@ -189,16 +198,16 @@ func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) { return entries, nil } -func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { +func (s *rssSource) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { req, err := http.NewRequest("GET", request.URL, nil) if err != nil { return nil, err } - req.Header.Add("User-Agent", glanceUserAgentString) + req.Header.Add("User-Agent", pulseUserAgentString) - widget.cachedFeedsMutex.Lock() - cache, isCached := widget.cachedFeeds[request.URL] + s.cachedFeedsMutex.Lock() + cache, isCached := s.cachedFeeds[request.URL] if isCached { if cache.etag != "" { req.Header.Add("If-None-Match", cache.etag) @@ -207,7 +216,7 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe req.Header.Add("If-Modified-Since", cache.lastModified) } } - widget.cachedFeedsMutex.Unlock() + s.cachedFeedsMutex.Unlock() for key, value := range request.Headers { req.Header.Set(key, value) @@ -247,6 +256,7 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe item := feed.Items[i] rssItem := rssFeedItem{ + ID: item.GUID, ChannelURL: feed.Link, } @@ -274,9 +284,9 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if item.Title != "" { - rssItem.Title = html.UnescapeString(item.Title) + rssItem.title = html.UnescapeString(item.Title) } else { - rssItem.Title = shortenFeedDescriptionLen(item.Description, 100) + rssItem.title = shortenFeedDescriptionLen(item.Description, 100) } if request.IsDetailed { @@ -310,14 +320,14 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if item.Image != nil { - rssItem.ImageURL = item.Image.URL + rssItem.imageURL = item.Image.URL } else if url := findThumbnailInItemExtensions(item); url != "" { - rssItem.ImageURL = url + rssItem.imageURL = url } else if feed.Image != nil { if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' { - rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL + rssItem.imageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL } else { - rssItem.ImageURL = feed.Image.URL + rssItem.imageURL = feed.Image.URL } } @@ -331,13 +341,13 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if resp.Header.Get("ETag") != "" || resp.Header.Get("Last-Modified") != "" { - widget.cachedFeedsMutex.Lock() - widget.cachedFeeds[request.URL] = &cachedRSSFeed{ + s.cachedFeedsMutex.Lock() + s.cachedFeeds[request.URL] = &cachedRSSFeed{ etag: resp.Header.Get("ETag"), lastModified: resp.Header.Get("Last-Modified"), items: items, } - widget.cachedFeedsMutex.Unlock() + s.cachedFeedsMutex.Unlock() } return items, nil @@ -374,6 +384,7 @@ func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extens } var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`) +var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func sanitizeFeedDescription(description string) string { if description == "" { diff --git a/internal/glance/auth.go b/pkg/widgets/auth.go similarity index 99% rename from internal/glance/auth.go rename to pkg/widgets/auth.go index e6497a19..45b09e69 100644 --- a/internal/glance/auth.go +++ b/pkg/widgets/auth.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/auth_test.go b/pkg/widgets/auth_test.go similarity index 99% rename from internal/glance/auth_test.go rename to pkg/widgets/auth_test.go index 97e6bc92..649e2c62 100644 --- a/internal/glance/auth_test.go +++ b/pkg/widgets/auth_test.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/cli.go b/pkg/widgets/cli.go similarity index 97% rename from internal/glance/cli.go rename to pkg/widgets/cli.go index 5544b8bc..75327be8 100644 --- a/internal/glance/cli.go +++ b/pkg/widgets/cli.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "flag" @@ -17,7 +17,6 @@ const ( cliIntentServe cliIntentConfigValidate cliIntentConfigPrint - cliIntentDiagnose cliIntentSensorsPrint cliIntentMountpointInfo cliIntentSecretMake @@ -76,8 +75,6 @@ func parseCliOptions() (*cliOptions, error) { intent = cliIntentConfigPrint } else if args[0] == "sensors:print" { intent = cliIntentSensorsPrint - } else if args[0] == "diagnose" { - intent = cliIntentDiagnose } else if args[0] == "secret:make" { intent = cliIntentSecretMake } else { diff --git a/internal/glance/config-fields.go b/pkg/widgets/config-fields.go similarity index 72% rename from internal/glance/config-fields.go rename to pkg/widgets/config-fields.go index d3681404..6c8bc981 100644 --- a/internal/glance/config-fields.go +++ b/pkg/widgets/config-fields.go @@ -1,17 +1,13 @@ -package glance +package widgets import ( - "crypto/tls" "fmt" + "gopkg.in/yaml.v3" "html/template" - "net/http" "net/url" "regexp" "strconv" "strings" - "time" - - "gopkg.in/yaml.v3" ) var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`) @@ -93,42 +89,6 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return nil } -var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) - -type durationField time.Duration - -func (d *durationField) UnmarshalYAML(node *yaml.Node) error { - var value string - - if err := node.Decode(&value); err != nil { - return err - } - - matches := durationFieldPattern.FindStringSubmatch(value) - - if len(matches) != 3 { - return fmt.Errorf("invalid duration format: %s", value) - } - - duration, err := strconv.Atoi(matches[1]) - if err != nil { - return err - } - - switch matches[2] { - case "s": - *d = durationField(time.Duration(duration) * time.Second) - case "m": - *d = durationField(time.Duration(duration) * time.Minute) - case "h": - *d = durationField(time.Duration(duration) * time.Hour) - case "d": - *d = durationField(time.Duration(duration) * 24 * time.Hour) - } - - return nil -} - type customIconField struct { URL template.URL IsFlatIcon bool @@ -194,53 +154,6 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { return nil } -type proxyOptionsField struct { - URL string `yaml:"url"` - AllowInsecure bool `yaml:"allow-insecure"` - Timeout durationField `yaml:"timeout"` - client *http.Client `yaml:"-"` -} - -func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error { - type proxyOptionsFieldAlias proxyOptionsField - alias := (*proxyOptionsFieldAlias)(p) - var proxyURL string - - if err := node.Decode(&proxyURL); err != nil { - if err := node.Decode(alias); err != nil { - return err - } - } - - if proxyURL == "" && p.URL == "" { - return nil - } - - if p.URL != "" { - proxyURL = p.URL - } - - parsedUrl, err := url.Parse(proxyURL) - if err != nil { - return fmt.Errorf("parsing proxy URL: %v", err) - } - - var timeout = defaultClientTimeout - if p.Timeout > 0 { - timeout = time.Duration(p.Timeout) - } - - p.client = &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - Proxy: http.ProxyURL(parsedUrl), - TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure}, - }, - } - - return nil -} - type queryParametersField map[string][]string func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error { diff --git a/internal/glance/config.go b/pkg/widgets/config.go similarity index 99% rename from internal/glance/config.go rename to pkg/widgets/config.go index 84714d0d..4cbc84ae 100644 --- a/internal/glance/config.go +++ b/pkg/widgets/config.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/glance.go b/pkg/widgets/glance.go similarity index 90% rename from internal/glance/glance.go rename to pkg/widgets/glance.go index 2980f456..8d0679b8 100644 --- a/internal/glance/glance.go +++ b/pkg/widgets/glance.go @@ -1,10 +1,12 @@ -package glance +package widgets import ( "bytes" "context" "encoding/base64" "fmt" + "github.com/glanceapp/glance/pkg/sources" + "github.com/glanceapp/glance/web" "log" "net/http" "path/filepath" @@ -15,6 +17,7 @@ import ( "time" "golang.org/x/crypto/bcrypt" + "golang.org/x/sync/errgroup" ) var ( @@ -46,7 +49,7 @@ type application struct { func newApplication(c *config) (*application, error) { app := &application{ - Version: buildVersion, + Version: sources.BuildVersion, CreatedAt: time.Now(), Config: *c, slugToPage: make(map[string]*page), @@ -228,43 +231,40 @@ func newApplication(c *config) (*application, error) { return app, nil } -func (p *page) updateOutdatedWidgets() { +func (p *page) updateOutdatedWidgets() error { now := time.Now() - var wg sync.WaitGroup - context := context.Background() - + var allWidgets []widget for w := range p.HeadWidgets { - widget := p.HeadWidgets[w] - - if !widget.requiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.update(context) - }() + allWidgets = append(allWidgets, p.HeadWidgets[w]) } - for c := range p.Columns { for w := range p.Columns[c].Widgets { - widget := p.Columns[c].Widgets[w] + allWidgets = append(allWidgets, p.Columns[c].Widgets[w]) + } + } - if !widget.requiresUpdate(&now) { - continue - } + var eg errgroup.Group + ctx := context.Background() - wg.Add(1) - go func() { - defer wg.Done() - widget.update(context) - }() + for _, widget := range allWidgets { + if !widget.source().RequiresUpdate(&now) { + continue } + + eg.Go(func() error { + widget.update(ctx) + // TODO: Handle errors + return nil + }) + } + + err := eg.Wait() + if err != nil { + return fmt.Errorf("widget update: %w", err) } - wg.Wait() + return nil } func (a *application) resolveUserDefinedAssetPath(path string) string { @@ -276,7 +276,8 @@ func (a *application) resolveUserDefinedAssetPath(path string) string { } type templateRequestData struct { - Theme *themeProperties + Theme *themeProperties + Filter string } type templateData struct { @@ -297,6 +298,7 @@ func (a *application) populateTemplateRequestData(data *templateRequestData, r * } data.Theme = theme + data.Filter = r.URL.Query().Get("filter") } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { @@ -342,6 +344,8 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re Page: page, } + a.populateTemplateRequestData(&pageData.Request, r) + var err error var responseBytes bytes.Buffer @@ -349,7 +353,10 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re page.mu.Lock() defer page.mu.Unlock() - page.updateOutdatedWidgets() + err = page.updateOutdatedWidgets() + if err != nil { + return + } err = pageContentTemplate.Execute(&responseBytes, pageData) }() @@ -421,7 +428,7 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request } func (a *application) StaticAssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset + return a.Config.Server.BaseURL + "/static/" + web.StaticFSHash + "/" + asset } func (a *application) VersionedAssetPath(asset string) string { @@ -449,10 +456,10 @@ func (a *application) server() (func() error, func() error) { } mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + fmt.Sprintf("GET /static/%s/{path...}", web.StaticFSHash), http.StripPrefix( - "/static/"+staticFSHash, - fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION), + "/static/"+web.StaticFSHash, + fileServerWithCache(http.FS(web.StaticFS), STATIC_ASSETS_CACHE_DURATION), ), ) @@ -461,10 +468,10 @@ func (a *application) server() (func() error, func() error) { int(STATIC_ASSETS_CACHE_DURATION.Seconds()), ) - mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", web.StaticFSHash), func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Cache-Control", assetCacheControlValue) w.Header().Add("Content-Type", "text/css; charset=utf-8") - w.Write(bundledCSSContents) + w.Write(web.BundledCSSContents) }) mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/widgets/llm.go b/pkg/widgets/llm.go new file mode 100644 index 00000000..a7a72d26 --- /dev/null +++ b/pkg/widgets/llm.go @@ -0,0 +1,91 @@ +package widgets + +import ( + "context" + "fmt" + "strings" + + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/llms/openai" + "github.com/tmc/langchaingo/outputparser" +) + +type LLM struct { + model llms.Model +} + +func NewLLM() (*LLM, error) { + model, err := openai.New( + openai.WithModel("gpt-4o-mini"), + ) + if err != nil { + return nil, err + } + return &LLM{model: model}, nil +} + +type feedMatch struct { + ID string `json:"id"` + Score int `json:"score" description:"How closely this item matches the query, from 0 to 10."` + Highlight string `json:"highlight" description:"A short and concise summary for why this item is a good match for the query. No any unecessary filler text (e.g. 'This includes...'). Must be two or three short sentences max."` +} + +type completionResponse struct { + Matches []feedMatch `json:"matches"` +} + +// filterFeed returns the IDs of feed entries that match the query +func (llm *LLM) filterFeed(ctx context.Context, feed []feedEntry, query string) ([]feedMatch, error) { + prompt := strings.Builder{} + + prompt.WriteString(` +## Role +You are an activity feed personalization assistant, +that helps the user find and focus on the most relevant content. + +You are given a list of feed entries with id, title, and description fields - given the natural language query, +you should rank these entries based on how well they match the query on a scale of 0 to 10. + +## Relevance scoring +For each entry, use the associated highlight text as a reflective summary to help assess how well the entry matches the user’s query. Follow these rules: + • If the highlight does not clearly explain how the entry is relevant to the user’s query, assign a low relevance score (≤ 3/10), even if the entry is interesting on its own. + • If the highlight is vague or generic (e.g., asks a broad question or restates the title), treat it as insufficient evidence of relevance unless the original content clearly supports the query. + • Strong highlights should: + • Explicitly mention key topics, entities, or themes from the user query. + • Clearly describe how the entry contributes useful, novel, or actionable insight toward the user’s intent. + • Use the highlight as a justification tool: If it doesn’t support the match, downgrade the score. If it adds clarity and alignment, consider upgrading the score. + +Always base the relevance score on how well the highlight connects the entry to the user’s information needs, not just on the entry’s popularity or standalone quality. +`) + prompt.WriteString(fmt.Sprintf("filter query: %s\n", query)) + + for _, entry := range feed { + prompt.WriteString(fmt.Sprintf("id: %s\n", entry.ID)) + prompt.WriteString(fmt.Sprintf("title: %s\n", entry.Title)) + prompt.WriteString(fmt.Sprintf("description: %s\n", entry.Description)) + prompt.WriteString("\n") + } + + parser, err := outputparser.NewDefined(completionResponse{}) + if err != nil { + return nil, fmt.Errorf("creating parser: %w", err) + } + + prompt.WriteString(fmt.Sprintf("\n\n%s", parser.GetFormatInstructions())) + + out, err := llms.GenerateFromSinglePrompt( + ctx, + llm.model, + prompt.String(), + ) + if err != nil { + return nil, fmt.Errorf("generating completion: %w", err) + } + + response, err := parser.Parse(out) + if err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return response.Matches, nil +} diff --git a/internal/glance/main.go b/pkg/widgets/main.go similarity index 73% rename from internal/glance/main.go rename to pkg/widgets/main.go index 6d73a831..dcd2b0a2 100644 --- a/internal/glance/main.go +++ b/pkg/widgets/main.go @@ -1,17 +1,12 @@ -package glance +package widgets import ( "fmt" - "io" - "log" - "net/http" - "os" - + "github.com/glanceapp/glance/pkg/sources" "golang.org/x/crypto/bcrypt" + "log" ) -var buildVersion = "dev" - func Main() int { options, err := parseCliOptions() if err != nil { @@ -21,13 +16,8 @@ func Main() int { switch options.intent { case cliIntentVersionPrint: - fmt.Println(buildVersion) + fmt.Println(sources.BuildVersion) case cliIntentServe: - // remove in v0.10.0 - if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { - return 1 - } - if err := serveApp(options.configPath); err != nil { fmt.Println(err) return 1 @@ -55,8 +45,6 @@ func Main() int { return cliSensorsPrint() case cliIntentMountpointInfo: return cliMountpointInfo(options.args[1]) - case cliIntentDiagnose: - runDiagnostic() case cliIntentSecretMake: key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) if err != nil { @@ -179,41 +167,3 @@ func serveApp(configPath string) error { <-exitChannel return nil } - -func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { - if !isRunningInsideDockerContainer() { - return false - } - - if _, err := os.Stat(configPath); err == nil { - return false - } - - // glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory - if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() { - return false - } - - templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") - bodyContents, _ := io.ReadAll(templateFile) - - fmt.Println("!!! WARNING !!!") - fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.") - fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.") - - mux := http.NewServeMux() - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(bodyContents)) - }) - - server := http.Server{ - Addr: ":8080", - Handler: mux, - } - server.ListenAndServe() - - return true -} diff --git a/internal/glance/templates.go b/pkg/widgets/templates.go similarity index 79% rename from internal/glance/templates.go rename to pkg/widgets/templates.go index 97c32294..5b95ebae 100644 --- a/internal/glance/templates.go +++ b/pkg/widgets/templates.go @@ -1,7 +1,8 @@ -package glance +package widgets import ( "fmt" + "github.com/glanceapp/glance/web" "html/template" "math" "strconv" @@ -56,12 +57,26 @@ var globalTemplateFunctions = template.FuncMap{ return template.HTML(value + ` ` + label + ``) }, + "matchScoreBadgeClass": func(score int) string { + switch { + case score <= 2: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 4: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 6: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 8: + return "ai-match-badge score-" + strconv.Itoa(score) + default: + return "ai-match-badge score-" + strconv.Itoa(score) + } + }, } func mustParseTemplate(primary string, dependencies ...string) *template.Template { t, err := template.New(primary). Funcs(globalTemplateFunctions). - ParseFS(templateFS, append([]string{primary}, dependencies...)...) + ParseFS(web.TemplateFS, append([]string{primary}, dependencies...)...) if err != nil { panic(err) diff --git a/internal/glance/theme.go b/pkg/widgets/theme.go similarity index 99% rename from internal/glance/theme.go rename to pkg/widgets/theme.go index 07f3921c..2c3fd947 100644 --- a/internal/glance/theme.go +++ b/pkg/widgets/theme.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "fmt" diff --git a/internal/glance/utils.go b/pkg/widgets/utils.go similarity index 61% rename from internal/glance/utils.go rename to pkg/widgets/utils.go index 21cd69b6..414311e6 100644 --- a/internal/glance/utils.go +++ b/pkg/widgets/utils.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" @@ -6,10 +6,8 @@ import ( "html/template" "math" "net/http" - "net/url" "os" "regexp" - "slices" "strings" "time" ) @@ -28,72 +26,6 @@ func percentChange(current, previous float64) float64 { return (current/previous - 1) * 100 } -func extractDomainFromUrl(u string) string { - if u == "" { - return "" - } - - parsed, err := url.Parse(u) - if err != nil { - return "" - } - - return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") -} - -func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { - if len(values) < 2 { - return "" - } - - verticalPadding := height * 0.02 - height -= verticalPadding * 2 - coordinates := make([]string, len(values)) - distanceBetweenPoints := width / float64(len(values)-1) - min := slices.Min(values) - max := slices.Max(values) - - for i := range values { - coordinates[i] = fmt.Sprintf( - "%.2f,%.2f", - float64(i)*distanceBetweenPoints, - ((max-values[i])/(max-min))*height+verticalPadding, - ) - } - - return strings.Join(coordinates, " ") -} - -func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T { - if len(values) == 0 { - return values - } - - for i := range values { - if values[i] != 0 { - continue - } - - c := make([]T, 0, len(values)-1) - - for i := range values { - if values[i] != 0 { - c = append(c, values[i]) - } - } - - return c - } - - return values -} - -var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) - -func stripURLScheme(url string) string { - return urlSchemePattern.ReplaceAllString(url, "") -} - func isRunningInsideDockerContainer() bool { _, err := os.Stat("/.dockerenv") return err == nil @@ -109,35 +41,6 @@ func prefixStringLines(prefix string, s string) string { return strings.Join(lines, "\n") } -func limitStringLength(s string, max int) (string, bool) { - asRunes := []rune(s) - - if len(asRunes) > max { - return string(asRunes[:max]), true - } - - return s, false -} - -func parseRFC3339Time(t string) time.Time { - parsed, err := time.Parse(time.RFC3339, t) - if err != nil { - return time.Now() - } - - return parsed -} - -func normalizeVersionFormat(version string) string { - version = strings.ToLower(strings.TrimSpace(version)) - - if len(version) > 0 && version[0] != 'v' { - return "v" + version - } - - return version -} - func titleToSlug(s string) string { s = strings.ToLower(s) s = sequentialWhitespacePattern.ReplaceAllString(s, "-") @@ -167,10 +70,6 @@ func executeTemplateToString(t *template.Template, data any) (string, error) { return b.String(), nil } -func stringToBool(s string) bool { - return s == "true" || s == "yes" -} - func itemAtIndexOrDefault[T any](items []T, index int, def T) T { if index >= len(items) { return def diff --git a/internal/glance/widget-container.go b/pkg/widgets/widget-container.go similarity index 89% rename from internal/glance/widget-container.go rename to pkg/widgets/widget-container.go index 4c9f33a7..02220425 100644 --- a/internal/glance/widget-container.go +++ b/pkg/widgets/widget-container.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -27,7 +27,7 @@ func (widget *containerWidgetBase) _update(ctx context.Context) { for w := range widget.Widgets { widget := widget.Widgets[w] - if !widget.requiresUpdate(&now) { + if !widget.source().RequiresUpdate(&now) { continue } @@ -49,7 +49,7 @@ func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) { func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool { for i := range widget.Widgets { - if widget.Widgets[i].requiresUpdate(now) { + if widget.Widgets[i].source().RequiresUpdate(now) { return true } } diff --git a/internal/glance/widget-group.go b/pkg/widgets/widget-group.go similarity index 93% rename from internal/glance/widget-group.go rename to pkg/widgets/widget-group.go index 2ea38133..04fc9d52 100644 --- a/internal/glance/widget-group.go +++ b/pkg/widgets/widget-group.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -15,7 +15,8 @@ type groupWidget struct { } func (widget *groupWidget) initialize() error { - widget.withError(nil) + // TODO(pulse): Refactor error handling + //widget.withError(nil) widget.HideHeader = true for i := range widget.Widgets { diff --git a/internal/glance/widget-split-column.go b/pkg/widgets/widget-split-column.go similarity index 88% rename from internal/glance/widget-split-column.go rename to pkg/widgets/widget-split-column.go index 71747c92..e07e2b4c 100644 --- a/internal/glance/widget-split-column.go +++ b/pkg/widgets/widget-split-column.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -15,7 +15,8 @@ type splitColumnWidget struct { } func (widget *splitColumnWidget) initialize() error { - widget.withError(nil).withTitle("Split Column").setHideHeader(true) + // TODO(pulse): Refactor error handling + //widget.withError(nil).withTitle("Split Column").setHideHeader(true) if err := widget.containerWidgetBase._initializeWidgets(); err != nil { return err diff --git a/pkg/widgets/widget.go b/pkg/widgets/widget.go new file mode 100644 index 00000000..0a86ed56 --- /dev/null +++ b/pkg/widgets/widget.go @@ -0,0 +1,209 @@ +package widgets + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/glanceapp/glance/pkg/sources" + "html/template" + "log/slog" + "net/http" + "sync/atomic" + "time" + + "gopkg.in/yaml.v3" +) + +var widgetIDCounter atomic.Uint64 + +func newWidget(widgetType string) (widget, error) { + if widgetType == "" { + return nil, errors.New("widget 'type' property is empty or not specified") + } + + var w widget + + switch widgetType { + case "group": + w = &groupWidget{} + case "split-column": + w = &splitColumnWidget{} + default: + // widget type is treated as a data source type in this case, + // which depends on the base widget that renders the generic widget display card + w = &widgetBase{} + } + + w.setID(widgetIDCounter.Add(1)) + + return w, nil +} + +type widgets []widget + +func (w *widgets) UnmarshalYAML(node *yaml.Node) error { + var nodes []yaml.Node + + if err := node.Decode(&nodes); err != nil { + return err + } + + for _, node := range nodes { + meta := struct { + Type string `yaml:"type"` + }{} + + if err := node.Decode(&meta); err != nil { + return err + } + + widget, err := newWidget(meta.Type) + if err != nil { + return fmt.Errorf("line %d: %w", node.Line, err) + } + + source, err := sources.NewSource(meta.Type) + if err != nil { + return fmt.Errorf("line %d: %w", node.Line, err) + } + + widget.setSource(source) + + if err = node.Decode(widget); err != nil { + return err + } + + *w = append(*w, widget) + } + + return nil +} + +type widget interface { + // These need to be exported because they get called in templates + Render() template.HTML + GetType() string + GetID() uint64 + + initialize() error + setProviders(*widgetProviders) + update(context.Context) + setID(uint64) + handleRequest(w http.ResponseWriter, r *http.Request) + setHideHeader(bool) + source() sources.Source + setSource(sources.Source) +} + +type feedEntry struct { + ID string + Title string + Description string + URL string + ImageURL string + PublishedAt time.Time +} + +type cacheType int + +const ( + cacheTypeInfinite cacheType = iota + cacheTypeDuration + cacheTypeOnTheHour +) + +type widgetBase struct { + ID uint64 `yaml:"-"` + Providers *widgetProviders `yaml:"-"` + Type string `yaml:"type"` + HideHeader bool `yaml:"hide-header"` + CSSClass string `yaml:"css-class"` + ContentAvailable bool `yaml:"-"` + WIP bool `yaml:"-"` + Error error `yaml:"-"` + Notice error `yaml:"-"` + // Source TODO(pulse): Temporary store source on a widget. Later it should be stored in a source registry and only passed to the widget for rendering. + Source sources.Source `yaml:"-"` + templateBuffer bytes.Buffer `yaml:"-"` +} + +type widgetProviders struct { + assetResolver func(string) string +} + +func (w *widgetBase) IsWIP() bool { + return w.WIP +} + +func (w *widgetBase) update(ctx context.Context) { + +} + +func (w *widgetBase) GetID() uint64 { + return w.ID +} + +func (w *widgetBase) setID(id uint64) { + w.ID = id +} + +func (w *widgetBase) setHideHeader(value bool) { + w.HideHeader = value +} + +func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) +} + +func (w *widgetBase) GetType() string { + return w.Type +} + +func (w *widgetBase) setProviders(providers *widgetProviders) { + w.Providers = providers +} + +func (w *widgetBase) source() sources.Source { + return w.Source +} + +func (w *widgetBase) setSource(s sources.Source) { + w.Source = s +} + +func (w *widgetBase) Render() template.HTML { + //TODO(pulse) render the generic widget card + panic("implement me") +} + +func (w *widgetBase) initialize() error { + //TODO(pulse) implement me + panic("implement me") +} + +func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { + w.templateBuffer.Reset() + err := t.Execute(&w.templateBuffer, data) + if err != nil { + w.ContentAvailable = false + w.Error = err + + slog.Error("Failed to render template", "error", err) + + // need to immediately re-render with the error, + // otherwise risk breaking the page since the widget + // will likely be partially rendered with tags not closed. + w.templateBuffer.Reset() + err2 := t.Execute(&w.templateBuffer, data) + + if err2 != nil { + slog.Error("Failed to render error within widget", "error", err2, "initial_error", err) + w.templateBuffer.Reset() + // TODO: add some kind of a generic widget error template when the widget + // failed to render, and we also failed to re-render the widget with the error + } + } + + return template.HTML(w.templateBuffer.String()) +} diff --git a/internal/glance/embed.go b/web/embed.go similarity index 89% rename from internal/glance/embed.go rename to web/embed.go index e09caa84..bab614ef 100644 --- a/internal/glance/embed.go +++ b/web/embed.go @@ -1,4 +1,4 @@ -package glance +package web import ( "bytes" @@ -23,15 +23,17 @@ var _staticFS embed.FS //go:embed templates var _templateFS embed.FS -var staticFS, _ = fs.Sub(_staticFS, "static") -var templateFS, _ = fs.Sub(_templateFS, "templates") +var StaticFS, _ = fs.Sub(_staticFS, "static") +var TemplateFS, _ = fs.Sub(_templateFS, "templates") + +var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`) func readAllFromStaticFS(path string) ([]byte, error) { // For some reason fs.FS only works with forward slashes, so in case we're // running on Windows or pass paths with backslashes we need to replace them. path = strings.ReplaceAll(path, "\\", "/") - file, err := staticFS.Open(path) + file, err := StaticFS.Open(path) if err != nil { return nil, err } @@ -39,8 +41,8 @@ func readAllFromStaticFS(path string) ([]byte, error) { return io.ReadAll(file) } -var staticFSHash = func() string { - hash, err := computeFSHash(staticFS) +var StaticFSHash = func() string { + hash, err := computeFSHash(StaticFS) if err != nil { log.Printf("Could not compute static assets cache key: %v", err) return strconv.FormatInt(time.Now().Unix(), 10) @@ -83,8 +85,8 @@ func computeFSHash(files fs.FS) (string, error) { var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`) var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`) -// Yes, we bundle at runtime, give comptime pls -var bundledCSSContents = func() []byte { +// BundledCSSContents Yes, we bundle at runtime, give comptime pls +var BundledCSSContents = func() []byte { const mainFilePath = "css/main.css" var recursiveParseImports func(path string, depth int) ([]byte, error) diff --git a/internal/glance/static/app-icon.png b/web/static/app-icon.png similarity index 100% rename from internal/glance/static/app-icon.png rename to web/static/app-icon.png diff --git a/internal/glance/static/css/forum-posts.css b/web/static/css/forum-posts.css similarity index 74% rename from internal/glance/static/css/forum-posts.css rename to web/static/css/forum-posts.css index e58ac6ea..f0d8f91b 100644 --- a/internal/glance/static/css/forum-posts.css +++ b/web/static/css/forum-posts.css @@ -12,6 +12,13 @@ transform: translateY(-0.15rem); } +.forum-post-match-summary { + font-size: 0.9em; + color: var(--color-text-subdue); + margin: 0.3rem 0; + line-height: 1.4; +} + @container widget (max-width: 550px) { .forum-post-autohide { display: none; diff --git a/internal/glance/static/css/login.css b/web/static/css/login.css similarity index 100% rename from internal/glance/static/css/login.css rename to web/static/css/login.css diff --git a/internal/glance/static/css/main.css b/web/static/css/main.css similarity index 100% rename from internal/glance/static/css/main.css rename to web/static/css/main.css diff --git a/internal/glance/static/css/mobile.css b/web/static/css/mobile.css similarity index 100% rename from internal/glance/static/css/mobile.css rename to web/static/css/mobile.css diff --git a/internal/glance/static/css/popover.css b/web/static/css/popover.css similarity index 100% rename from internal/glance/static/css/popover.css rename to web/static/css/popover.css diff --git a/internal/glance/static/css/site.css b/web/static/css/site.css similarity index 85% rename from internal/glance/static/css/site.css rename to web/static/css/site.css index fbf3c8ad..3c69429a 100644 --- a/internal/glance/static/css/site.css +++ b/web/static/css/site.css @@ -396,3 +396,57 @@ kbd:active { .theme-picker.popover-active .current-theme-preview, .theme-picker:hover { opacity: 1; } + +.page-filter-bar { + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: var(--border-radius); + padding: 1.2rem 1.5rem 1.2rem 1.5rem; + margin-bottom: var(--widget-gap); + margin-top: 0.5rem; + display: flex; + align-items: center; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.02); +} + +.filter-form { + gap: 0.5rem; + width: 100%; +} + +.filter-form button { + background: var(--color-primary); + color: #fff; + border-radius: var(--border-radius); + padding: 0.7em 1.5em; + font-size: 1em; + font-weight: 600; + border: none; + transition: background .2s; +} + +.filter-form button:hover, .filter-form button:focus { + background: var(--color-primary-hover, #1d4ed8); +} + +.filter-form textarea { + background: var(--color-widget-background-highlight); + border: 1px solid var(--color-widget-content-border); + border-radius: var(--border-radius); + padding: 0.7em 1.1em; + font-size: 1em; + color: var(--color-text-base); + transition: border-color .2s; + width: 100%; + max-width: 500px; + min-height: 2.5em; + max-height: 8em; + resize: vertical; + font-family: inherit; + box-sizing: border-box; +} + +.filter-form textarea:focus { + border-color: var(--color-primary); + outline: none; +} diff --git a/internal/glance/static/css/utils.css b/web/static/css/utils.css similarity index 100% rename from internal/glance/static/css/utils.css rename to web/static/css/utils.css diff --git a/internal/glance/static/css/widget-bookmarks.css b/web/static/css/widget-bookmarks.css similarity index 100% rename from internal/glance/static/css/widget-bookmarks.css rename to web/static/css/widget-bookmarks.css diff --git a/internal/glance/static/css/widget-calendar.css b/web/static/css/widget-calendar.css similarity index 100% rename from internal/glance/static/css/widget-calendar.css rename to web/static/css/widget-calendar.css diff --git a/internal/glance/static/css/widget-clock.css b/web/static/css/widget-clock.css similarity index 100% rename from internal/glance/static/css/widget-clock.css rename to web/static/css/widget-clock.css diff --git a/internal/glance/static/css/widget-dns-stats.css b/web/static/css/widget-dns-stats.css similarity index 100% rename from internal/glance/static/css/widget-dns-stats.css rename to web/static/css/widget-dns-stats.css diff --git a/internal/glance/static/css/widget-docker-containers.css b/web/static/css/widget-docker-containers.css similarity index 100% rename from internal/glance/static/css/widget-docker-containers.css rename to web/static/css/widget-docker-containers.css diff --git a/internal/glance/static/css/widget-group.css b/web/static/css/widget-group.css similarity index 100% rename from internal/glance/static/css/widget-group.css rename to web/static/css/widget-group.css diff --git a/internal/glance/static/css/widget-markets.css b/web/static/css/widget-markets.css similarity index 100% rename from internal/glance/static/css/widget-markets.css rename to web/static/css/widget-markets.css diff --git a/internal/glance/static/css/widget-monitor.css b/web/static/css/widget-monitor.css similarity index 100% rename from internal/glance/static/css/widget-monitor.css rename to web/static/css/widget-monitor.css diff --git a/internal/glance/static/css/widget-reddit.css b/web/static/css/widget-reddit.css similarity index 100% rename from internal/glance/static/css/widget-reddit.css rename to web/static/css/widget-reddit.css diff --git a/internal/glance/static/css/widget-releases.css b/web/static/css/widget-releases.css similarity index 100% rename from internal/glance/static/css/widget-releases.css rename to web/static/css/widget-releases.css diff --git a/internal/glance/static/css/widget-rss.css b/web/static/css/widget-rss.css similarity index 100% rename from internal/glance/static/css/widget-rss.css rename to web/static/css/widget-rss.css diff --git a/internal/glance/static/css/widget-search.css b/web/static/css/widget-search.css similarity index 100% rename from internal/glance/static/css/widget-search.css rename to web/static/css/widget-search.css diff --git a/internal/glance/static/css/widget-server-stats.css b/web/static/css/widget-server-stats.css similarity index 100% rename from internal/glance/static/css/widget-server-stats.css rename to web/static/css/widget-server-stats.css diff --git a/internal/glance/static/css/widget-todo.css b/web/static/css/widget-todo.css similarity index 100% rename from internal/glance/static/css/widget-todo.css rename to web/static/css/widget-todo.css diff --git a/internal/glance/static/css/widget-twitch.css b/web/static/css/widget-twitch.css similarity index 100% rename from internal/glance/static/css/widget-twitch.css rename to web/static/css/widget-twitch.css diff --git a/internal/glance/static/css/widget-videos.css b/web/static/css/widget-videos.css similarity index 100% rename from internal/glance/static/css/widget-videos.css rename to web/static/css/widget-videos.css diff --git a/internal/glance/static/css/widget-weather.css b/web/static/css/widget-weather.css similarity index 100% rename from internal/glance/static/css/widget-weather.css rename to web/static/css/widget-weather.css diff --git a/internal/glance/static/css/widgets.css b/web/static/css/widgets.css similarity index 77% rename from internal/glance/static/css/widgets.css rename to web/static/css/widgets.css index 07b41c8e..41e90244 100644 --- a/internal/glance/static/css/widgets.css +++ b/web/static/css/widgets.css @@ -91,3 +91,30 @@ .widget + .widget { margin-top: var(--widget-gap); } + +.ai-match-badge { + display: inline-block; + color: #fff; + font-size: 0.85em; + font-weight: 600; + border-radius: 999px; + padding: 0.15em 0.7em; + margin-left: 0.5em; + vertical-align: middle; + letter-spacing: 0.02em; + cursor: help; +} + +.ai-match-badge.score-0, .ai-match-badge.score-1, .ai-match-badge.score-2, .ai-match-badge.score-3 { + background: #fca5a5; + color: #991b1b; +} +.ai-match-badge.score-4, .ai-match-badge.score-5, .ai-match-badge.score-6 { + background: #fde68a; + color: #a16207; +} +.ai-match-badge.score-7, .ai-match-badge.score-8, .ai-match-badge.score-9, .ai-match-badge.score-10 { + background: #bbf7d0; + color: #166534; +} + \ No newline at end of file diff --git a/internal/glance/static/favicon.png b/web/static/favicon.png similarity index 100% rename from internal/glance/static/favicon.png rename to web/static/favicon.png diff --git a/internal/glance/static/favicon.svg b/web/static/favicon.svg similarity index 100% rename from internal/glance/static/favicon.svg rename to web/static/favicon.svg diff --git a/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 b/web/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/glance/static/fonts/JetBrainsMono-Regular.woff2 rename to web/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/glance/static/icons/codeberg.svg b/web/static/icons/codeberg.svg similarity index 100% rename from internal/glance/static/icons/codeberg.svg rename to web/static/icons/codeberg.svg diff --git a/internal/glance/static/icons/dockerhub.svg b/web/static/icons/dockerhub.svg similarity index 100% rename from internal/glance/static/icons/dockerhub.svg rename to web/static/icons/dockerhub.svg diff --git a/internal/glance/static/icons/github.svg b/web/static/icons/github.svg similarity index 100% rename from internal/glance/static/icons/github.svg rename to web/static/icons/github.svg diff --git a/internal/glance/static/icons/gitlab.svg b/web/static/icons/gitlab.svg similarity index 100% rename from internal/glance/static/icons/gitlab.svg rename to web/static/icons/gitlab.svg diff --git a/internal/glance/static/js/animations.js b/web/static/js/animations.js similarity index 100% rename from internal/glance/static/js/animations.js rename to web/static/js/animations.js diff --git a/internal/glance/static/js/calendar.js b/web/static/js/calendar.js similarity index 100% rename from internal/glance/static/js/calendar.js rename to web/static/js/calendar.js diff --git a/internal/glance/static/js/login.js b/web/static/js/login.js similarity index 100% rename from internal/glance/static/js/login.js rename to web/static/js/login.js diff --git a/internal/glance/static/js/masonry.js b/web/static/js/masonry.js similarity index 100% rename from internal/glance/static/js/masonry.js rename to web/static/js/masonry.js diff --git a/internal/glance/static/js/page.js b/web/static/js/page.js similarity index 96% rename from internal/glance/static/js/page.js rename to web/static/js/page.js index e3a3a84f..ccd80166 100644 --- a/internal/glance/static/js/page.js +++ b/web/static/js/page.js @@ -6,7 +6,14 @@ import { elem, find, findAll } from './templating.js'; async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs // TODO: add retries - const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`); + const urlParams = new URLSearchParams(window.location.search); + const reqParams = new URLSearchParams(); + + if (urlParams.has("filter")) { + reqParams.set("filter", urlParams.get("filter")); + } + + const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/?${reqParams.toString()}`); const content = await response.text(); return content; @@ -775,6 +782,20 @@ async function setupPage() { document.body.classList.add("page-columns-transitioned"); }, 300); } + + if (document.getElementById('filter-form')) { + document.getElementById('filter-form').addEventListener('submit', function(e) { + e.preventDefault(); + const filter = document.getElementById('filter-input').value.trim(); + const url = new URL(window.location.href); + if (filter) { + url.searchParams.set('filter', filter); + } else { + url.searchParams.delete('filter'); + } + window.location.href = url.toString(); + }); + } } setupPage(); diff --git a/internal/glance/static/js/popover.js b/web/static/js/popover.js similarity index 100% rename from internal/glance/static/js/popover.js rename to web/static/js/popover.js diff --git a/internal/glance/static/js/templating.js b/web/static/js/templating.js similarity index 100% rename from internal/glance/static/js/templating.js rename to web/static/js/templating.js diff --git a/internal/glance/static/js/todo.js b/web/static/js/todo.js similarity index 100% rename from internal/glance/static/js/todo.js rename to web/static/js/todo.js diff --git a/internal/glance/static/js/utils.js b/web/static/js/utils.js similarity index 100% rename from internal/glance/static/js/utils.js rename to web/static/js/utils.js diff --git a/internal/glance/templates/document.html b/web/templates/document.html similarity index 100% rename from internal/glance/templates/document.html rename to web/templates/document.html diff --git a/internal/glance/templates/extension.html b/web/templates/extension.html similarity index 100% rename from internal/glance/templates/extension.html rename to web/templates/extension.html diff --git a/internal/glance/templates/footer.html b/web/templates/footer.html similarity index 100% rename from internal/glance/templates/footer.html rename to web/templates/footer.html diff --git a/internal/glance/templates/group.html b/web/templates/group.html similarity index 100% rename from internal/glance/templates/group.html rename to web/templates/group.html diff --git a/internal/glance/templates/login.html b/web/templates/login.html similarity index 100% rename from internal/glance/templates/login.html rename to web/templates/login.html diff --git a/internal/glance/templates/manifest.json b/web/templates/manifest.json similarity index 100% rename from internal/glance/templates/manifest.json rename to web/templates/manifest.json diff --git a/internal/glance/templates/page-content.html b/web/templates/page-content.html similarity index 64% rename from internal/glance/templates/page-content.html rename to web/templates/page-content.html index 4cf67a72..52622d8a 100644 --- a/internal/glance/templates/page-content.html +++ b/web/templates/page-content.html @@ -10,6 +10,14 @@ {{ end }} + +