diff --git a/.gitignore b/.gitignore index f7e0f6c..e466992 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /assets /build /playground +/.idea glance*.yml diff --git a/Dockerfile b/Dockerfile index e4019ba..b89541a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.5-alpine3.20 AS builder +FROM golang:1.23.1-alpine3.20 AS builder WORKDIR /app COPY . /app @@ -9,5 +9,8 @@ FROM alpine:3.20 WORKDIR /app COPY --from=builder /app/glance . +HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ + CMD wget --spider -q http://localhost:8080/api/healthz + EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index dec9ac4..eaf8336 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -3,6 +3,8 @@ FROM alpine:3.20 WORKDIR /app COPY glance . -EXPOSE 8080/tcp +HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ + CMD wget --spider -q http://localhost:8080/api/healthz -ENTRYPOINT ["/app/glance"] +EXPOSE 8080/tcp +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/README.md b/README.md index 0e8cfb4..da6fb58 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ * Twitch channels & top games * GitHub releases * Repository overview +* Docker containers * Site monitor * Search box @@ -51,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a ``` #### Docker + + > [!IMPORTANT] > > Make sure you have a valid `glance.yml` file in the same directory before running the container. diff --git a/docs/configuration.md b/docs/configuration.md index fd31f39..7bc11d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,8 @@ - [Reddit](#reddit) - [Search](#search-widget) - [Group](#group) + - [Split Column](#split-column) + - [Custom API](#custom-api) - [Extension](#extension) - [Weather](#weather) - [Monitor](#monitor) @@ -30,8 +32,10 @@ - [Twitch Top Games](#twitch-top-games) - [iframe](#iframe) - [HTML](#html) + - [Docker](#docker) ## Intro + Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. ## Preconfigured page @@ -111,6 +115,8 @@ This will give you a page that looks like the following: Configure the widgets, add more of them, add extra pages, etc. Make it your own! + + ## Server Server configuration is done through a top level `server` property. Example: @@ -314,6 +320,7 @@ pages: | width | string | no | | | center-vertically | boolean | no | false | | hide-desktop-navigation | boolean | no | false | +| expand-mobile-page-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | | columns | array | yes | | @@ -340,6 +347,9 @@ When set to `true`, vertically centers the content on the page. Has no effect if #### `hide-desktop-navigation` Whether to show the navigation links at the top of the page on desktop. +#### `expand-mobile-page-navigation` +Whether the mobile page navigation should be expanded by default. + #### `show-mobile-header` Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. @@ -526,10 +536,22 @@ An array of RSS/atom feeds. The title can optionally be changed. | hide-categories | boolean | no | false | Only applicable for `detailed-list` style | | hide-description | boolean | no | false | Only applicable for `detailed-list` style | | item-link-prefix | string | no | | | +| headers | key (string) & value (string) | no | | | ###### `item-link-prefix` If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property. +###### `headers` +Optionally specify the headers that will be sent with the request. Example: + +```yaml +- type: rss + feeds: + - url: https://domain.com/rss + headers: + User-Agent: Custom User Agent +``` + ##### `limit` The maximum number of articles to show. @@ -561,12 +583,21 @@ Preview: | channels | array | yes | | | limit | integer | no | 25 | | style | string | no | horizontal-cards | +| collapse-after | integer | no | 7 | | collapse-after-rows | integer | no | 4 | | include-shorts | boolean | no | false | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | ##### `channels` -A list of channel IDs. One way of getting the ID of a channel is going to the channel's page and clicking on its description: +A list of channel or playlist IDs. To specify a playlist, use the `playlist:` prefix like such: + +```yaml +channels: + - playlist:PL8mG-RkN2uTyZZ00ObwZxxoG_nJbs3qec + - playlist:PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg +``` + +One way of getting the ID of a channel is going to the channel's page and clicking on its description: ![](images/videos-channel-description-example.png) @@ -577,11 +608,18 @@ Then scroll down and click on "Share channel", then "Copy channel ID": ##### `limit` The maximum number of videos to show. +##### `collapse-after` +Specify the number of videos to show when using the `vertical-list` style before the "SHOW MORE" button appears. + ##### `collapse-after-rows` Specify the number of rows to show when using the `grid-cards` style before the "SHOW MORE" button appears. ##### `style` -Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`. +Used to change the appearance of the widget. Possible values are `horizontal-cards`, `vertical-list` and `grid-cards`. + +Preview of `vertical-list`: + +![](images/videos-widget-vertical-list-preview.png) Preview of `grid-cards`: @@ -716,6 +754,7 @@ Example: | collapse-after | integer | no | 5 | | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} | | request-url-template | string | no | | +| proxy | string or multiple parameters | no | | | sort-by | string | no | hot | | top-period | string | no | day | | search | string | no | | @@ -777,7 +816,7 @@ r/selfhosted/comments/bsp01i/welcome_to_rselfhosted_please_read_this_first/ `{SUBREDDIT}` - the subreddit name ##### `request-url-template` -A custom request url that will be used to fetch the data instead. This is useful when you're hosting Glance on a VPS and Reddit is blocking the requests, and you want to route it through an HTTP proxy. +A custom request URL that will be used to fetch the data. This is useful when you're hosting Glance on a VPS where Reddit is blocking the requests and you want to route them through a proxy that accepts the URL as either a part of the path or a query parameter. Placeholders: @@ -788,6 +827,29 @@ https://proxy/{REQUEST-URL} https://your.proxy/?url={REQUEST-URL} ``` +##### `proxy` +A custom HTTP/HTTPS proxy URL that will be used to fetch the data. This is useful when you're hosting Glance on a VPS where Reddit is blocking the requests and you want to bypass the restriction by routing the requests through a proxy. Example: + +```yaml +proxy: http://user:pass@proxy.com:8080 +proxy: https://user:pass@proxy.com:443 +``` + +Alternatively, you can specify the proxy URL as well as additional options by using multiple parameters: + +```yaml +proxy: + url: http://proxy.com:8080 + allow-insecure: true + timeout: 10s +``` + +###### `allow-insecure` +When set to `true`, allows the use of insecure connections such as when the proxy has a self-signed certificate. + +###### `timeout` +The maximum time to wait for a response from the proxy. The value is a string and must be a number followed by one of s, m, h, d. Example: `10s` for 10 seconds, `1m` for 1 minute, etc + ##### `sort-by` Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`. @@ -829,6 +891,7 @@ Preview: | Enter | Perform search in the same tab | Search input is focused and not empty | | Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | | Escape | Leave focus | Search input is focused | +| Up | Insert the last search query since the page was opened into the input field | Search input is focused | > [!TIP] > @@ -840,6 +903,7 @@ Preview: | search-engine | string | no | duckduckgo | | new-tab | boolean | no | false | | autofocus | boolean | no | false | +| placeholder | string | no | Type here to search… | | bangs | array | no | | ##### `search-engine` @@ -856,6 +920,9 @@ When set to `true`, swaps the shortcuts for showing results in the same or new t ##### `autofocus` When set to `true`, automatically focuses the search input on page load. +##### `placeholder` +When set, modifies the text displayed in the input field before typing. + ##### `bangs` What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: @@ -891,7 +958,7 @@ url: https://www.amazon.com/s?k={QUERY} ``` ### Group -Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget. +Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget. Example: @@ -934,6 +1001,67 @@ Example: <<: *shared-properties ``` +### Split Column + +Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column. + +Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns: + +
+View config + +```yaml +shared: + - &reddit-props + type: reddit + collapse-after: 4 + show-thumbnails: true + +pages: + - name: Split Column Demo + width: wide + columns: + - size: full + widgets: + - type: split-column + widgets: + - subreddit: gaming + <<: *reddit-props + - subreddit: worldnews + <<: *reddit-props + - subreddit: lifeprotips + <<: *reddit-props + show-thumbnails: false + - subreddit: askreddit + <<: *reddit-props + show-thumbnails: false + + - size: full + widgets: + - type: split-column + widgets: + - subreddit: todayilearned + <<: *reddit-props + collapse-after: 2 + - subreddit: aww + <<: *reddit-props + - subreddit: science + <<: *reddit-props + - subreddit: showerthoughts + <<: *reddit-props + show-thumbnails: false +``` +
+ +
+ +Preview: + +![](images/split-column-widget-preview.png) + +### Custom API + + ### Extension Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). @@ -949,12 +1077,16 @@ Display a widget provided by an external source (3rd party). If you want to lear | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | url | string | yes | | +| fallback-content-type | string | no | | | allow-potentially-dangerous-html | boolean | no | false | | parameters | key & value | no | | ##### `url` The URL of the extension. **Note that the query gets stripped from this URL and the one defined by `parameters` gets used instead.** +##### `fallback-content-type` +Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`. + ##### `allow-potentially-dangerous-html` Whether to allow the extension to display HTML. @@ -1066,11 +1198,19 @@ You can hover over the "ERROR" text to view more information. | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | sites | array | yes | | +| style | string | no | | | show-failing-only | boolean | no | false | ##### `show-failing-only` Shows only a list of failing sites when set to `true`. +##### `style` +Used to change the appearance of the widget. Possible values are `compact`. + +Preview of `compact`: + +![](images/monitor-widget-compact-preview.png) + ##### `sites` Properties for each site: @@ -1080,9 +1220,11 @@ Properties for each site: | title | string | yes | | | url | string | yes | | | check-url | string | no | | +| error-url | string | no | | | icon | string | no | | | allow-insecure | boolean | no | false | | same-tab | boolean | no | false | +| alt-status-codes | array | no | | `title` @@ -1096,9 +1238,13 @@ The public facing URL of a monitored service, the user will be redirected here. The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used. +`error-url` + +If the monitored service returns an error, the user will be redirected here. If not specified, the `url` property is used. + `icon` -Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix: +Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix: ```yaml icon: si:jellyfin @@ -1108,7 +1254,7 @@ icon: si:adguard > [!WARNING] > -> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. +> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. `allow-insecure` @@ -1118,6 +1264,15 @@ Whether to ignore invalid/self-signed certificates. Whether to open the link in the same or a new tab. +`alt-status-codes` + +Status codes other than 200 that you want to return "OK". + +```yaml +alt-status-codes: + - 403 +``` + ### Releases Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub. @@ -1160,7 +1315,7 @@ repositories: - codeberg:redict/redict ``` -Official images on Docker Hub can be specified by ommiting the owner: +Official images on Docker Hub can be specified by omitting the owner: ```yaml repositories: @@ -1169,7 +1324,7 @@ repositories: - dockerhub:alpine ``` -You can also specify specific tags for Docker Hub images: +You can also specify exact tags for Docker Hub images: ```yaml repositories: @@ -1239,15 +1394,21 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | service | string | no | pihole | +| allow-insecure | bool | no | false | | url | string | yes | | | username | string | when service is `adguard` | | | password | string | when service is `adguard` | | | token | string | when service is `pihole` | | +| hide-graph | bool | no | false | +| hide-top-domains | bool | no | false | | hour-format | string | no | 12h | ##### `service` Either `adguard` or `pihole`. +##### `allow-insecure` +Whether to allow invalid/self-signed certificates when making the request to the service. + ##### `url` The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. @@ -1260,6 +1421,12 @@ Only required when using AdGuard Home. The password used to log into the admin d ##### `token` Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. +##### `hide-graph` +Whether to hide the graph showing the number of queries over time. + +##### `hide-top-domains` +Whether to hide the list of top blocked domains. + ##### `hour-format` Whether to display the relative time in the graph in `12h` or `24h` format. @@ -1364,6 +1531,13 @@ An array of groups which can optionally have a title and a custom color. | title | string | no | | | color | HSL | no | the primary color of the theme | | links | array | yes | | +| same-tab | boolean | no | false | +| hide-arrow | boolean | no | false | +| target | string | no | | + +> [!TIP] +> +> You can set `same-tab`, `hide-arrow` and `target` either on the group which will apply them to all links in that group, or on each individual link which will override the value set on the group. ###### Properties for each link | Name | Type | Required | Default | @@ -1373,10 +1547,11 @@ An array of groups which can optionally have a title and a custom color. | icon | string | no | | | same-tab | boolean | no | false | | hide-arrow | boolean | no | false | +| target | string | no | | `icon` -URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix: +URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix: ```yaml icon: si:gmail @@ -1386,7 +1561,7 @@ icon: si:reddit > [!WARNING] > -> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. +> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. `same-tab` @@ -1396,6 +1571,10 @@ Whether to open the link in the same tab or a new one. Whether to hide the colored arrow on each link. +`target` + +Set a custom value for the link's `target` attribute. Possible values are `_blank`, `_self`, `_parent` and `_top`, you can read more about what they do [here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). This property has precedence over `same-tab`. + ### ChangeDetection.io Display a list watches from changedetection.io. @@ -1495,15 +1674,25 @@ Example: ```yaml - type: calendar + start-sunday: false ``` Preview: ![](images/calendar-widget-preview.png) +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| start-sunday | boolean | no | false | + +##### `start-sunday` +Whether calendar weeks start on Sunday or Monday. + > [!NOTE] > -> There is currently no customizability available for the calendar. Extra features will be added in the future. +> There is currently little customizability available for the calendar. Extra features will be added in the future. ### Markets Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. @@ -1535,14 +1724,30 @@ Preview: | ---- | ---- | -------- | | markets | array | yes | | sort-by | string | no | +| chart-link-template | string | no | +| symbol-link-template | string | no | ##### `markets` An array of markets for which to display information about. ##### `sort-by` -By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. +By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%). -###### Properties for each stock +##### `chart-link-template` +A template for the link to go to when clicking on the chart that will be applied to all markets. The value `{SYMBOL}` will be replaced with the symbol of the market. You can override this on a per-market basis by specifying a `chart-link` property. Example: + +```yaml +chart-link-template: https://www.tradingview.com/chart/?symbol={SYMBOL} +``` + +##### `symbol-link-template` +A template for the link to go to when clicking on the symbol that will be applied to all markets. The value `{SYMBOL}` will be replaced with the symbol of the market. You can override this on a per-market basis by specifying a `symbol-link` property. Example: + +```yaml +symbol-link-template: https://www.google.com/search?tbm=nws&q={SYMBOL} +``` + +###### Properties for each market | Name | Type | Required | | ---- | ---- | -------- | | symbol | string | yes | @@ -1559,9 +1764,11 @@ The symbol, as seen in Yahoo Finance. The name that will be displayed under the symbol. `symbol-link` + The link to go to when clicking on the symbol. `chart-link` + The link to go to when clicking on the chart. ### Twitch Channels @@ -1675,3 +1882,75 @@ Example: ``` Note the use of `|` after `source:`, this allows you to insert a multi-line string. + +### Docker Containers + +The Docker widget allows you to monitor your Docker containers. +To enable this feature, ensure that your setup provides access to the **docker.sock** file (also you may use a TCP connection). + +Add the following to your `docker-compose` or `docker run` command to enable the Docker widget: + +**Docker Example:** +```bash +docker run -d -p 8080:8080 \ + -v ./glance.yml:/app/glance.yml \ + -v /etc/timezone:/etc/timezone:ro \ + -v /etc/localtime:/etc/localtime:ro \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + glanceapp/glance +``` + +**Docker Compose Example:** +```yaml +services: + glance: + image: glanceapp/glance + volumes: + - ./glance.yml:/app/glance.yml + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - 8080:8080 + restart: unless-stopped +``` + +#### Configuration +To integrate the Docker widget into your dashboard, include the following snippet in your `glance.yml` file: + +```yaml +- type: docker + host-url: tcp://localhost:2375 + cache: 1m +``` + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| host-url | string | no | `unix:///var/run/docker.sock` | + +#### Leveraging Container Labels +You can use container labels to control visibility, URLs, icons, and titles within the Docker widget. Add the following labels to your container configuration for enhanced customization: + +```yaml +labels: + - "glance.enable=true" # Enable or disable visibility of the container (default: true) + - "glance.title=Glance" # Optional friendly name (defaults to container name) + - "glance.url=https://app.example.com" # Optional URL associated with the container + - "glance.iconUrl=si:docker" # Optional URL to an image which will be used as the icon for the site + +``` + +**Default Values:** + +| Name | Default | +|----------------|------------| +| glance.enable | true | +| glance.title | Container name | +| glance.url | (none) | +| glance.iconUrl | si:docker | + +Preview: + +![](images/docker-widget-preview.png) diff --git a/docs/extensions.md b/docs/extensions.md index 06db1ae..b1fa4fa 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -29,6 +29,9 @@ Used to specify the title of the widget. If not provided, the widget's title wil ### `Widget-Content-Type` Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text. +### `Widget-Content-Frameless` +When set to `true`, the widget's content will be displayed without the default background or "frame". + ## Content Types > [!NOTE] diff --git a/docs/images/docker-widget-preview.png b/docs/images/docker-widget-preview.png new file mode 100644 index 0000000..5b644d4 Binary files /dev/null and b/docs/images/docker-widget-preview.png differ diff --git a/docs/images/monitor-widget-compact-preview.png b/docs/images/monitor-widget-compact-preview.png new file mode 100644 index 0000000..3e81fce Binary files /dev/null and b/docs/images/monitor-widget-compact-preview.png differ diff --git a/docs/images/split-column-widget-preview.png b/docs/images/split-column-widget-preview.png new file mode 100644 index 0000000..f1931f8 Binary files /dev/null and b/docs/images/split-column-widget-preview.png differ diff --git a/docs/images/videos-widget-vertical-list-preview.png b/docs/images/videos-widget-vertical-list-preview.png new file mode 100644 index 0000000..e33ce86 Binary files /dev/null and b/docs/images/videos-widget-vertical-list-preview.png differ diff --git a/go.mod b/go.mod index 7034fe5..aa66fa8 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,24 @@ module github.com/glanceapp/glance -go 1.22.5 +go 1.23.1 require ( + github.com/fsnotify/fsnotify v1.8.0 github.com/mmcdole/gofeed v1.3.0 + github.com/tidwall/gjson v1.18.0 golang.org/x/text v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.9.2 // indirect - github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/PuerkitoBio/goquery v1.10.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 1f81f1c..7840f0b 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,15 @@ -github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= -github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= +github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -23,21 +28,45 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -45,21 +74,42 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/assets/files.go b/internal/assets/files.go deleted file mode 100644 index 2c7c09e..0000000 --- a/internal/assets/files.go +++ /dev/null @@ -1,56 +0,0 @@ -package assets - -import ( - "crypto/md5" - "embed" - "encoding/hex" - "io" - "io/fs" - "log/slog" - "strconv" - "time" -) - -//go:embed static -var _publicFS embed.FS - -//go:embed templates -var _templateFS embed.FS - -var PublicFS, _ = fs.Sub(_publicFS, "static") -var TemplateFS, _ = fs.Sub(_templateFS, "templates") - -func getFSHash(files fs.FS) string { - hash := md5.New() - - err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - file, err := files.Open(path) - - if err != nil { - return err - } - - if _, err := io.Copy(hash, file); err != nil { - return err - } - - return nil - }) - - if err == nil { - return hex.EncodeToString(hash.Sum(nil))[:10] - } - - slog.Warn("Could not compute assets cache", "err", err) - return strconv.FormatInt(time.Now().Unix(), 10) -} - -var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/assets/templates.go b/internal/assets/templates.go deleted file mode 100644 index 85abb69..0000000 --- a/internal/assets/templates.go +++ /dev/null @@ -1,109 +0,0 @@ -package assets - -import ( - "fmt" - "html/template" - "math" - "strconv" - "time" - - "golang.org/x/text/language" - "golang.org/x/text/message" -) - -var ( - PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") - PageContentTemplate = compileTemplate("content.html") - CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") - ClockTemplate = compileTemplate("clock.html", "widget-base.html") - BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") - IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") - WeatherTemplate = compileTemplate("weather.html", "widget-base.html") - ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html") - RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") - RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") - ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") - ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") - VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") - VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - MarketsTemplate = compileTemplate("markets.html", "widget-base.html") - RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") - RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") - RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") - RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") - MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") - TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") - TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") - RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") - SearchTemplate = compileTemplate("search.html", "widget-base.html") - ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") - GroupTemplate = compileTemplate("group.html", "widget-base.html") - DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") -) - -var globalTemplateFunctions = template.FuncMap{ - "relativeTime": relativeTimeSince, - "formatViewerCount": formatViewerCount, - "formatNumber": intl.Sprint, - "absInt": func(i int) int { - return int(math.Abs(float64(i))) - }, - "formatPrice": func(price float64) string { - return intl.Sprintf("%.2f", price) - }, - "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { - return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) - }, -} - -func compileTemplate(primary string, dependencies ...string) *template.Template { - t, err := template.New(primary). - Funcs(globalTemplateFunctions). - ParseFS(TemplateFS, append([]string{primary}, dependencies...)...) - - if err != nil { - panic(err) - } - - return t -} - -var intl = message.NewPrinter(language.English) - -func formatViewerCount(count int) string { - if count < 1_000 { - return strconv.Itoa(count) - } - - if count < 10_000 { - return fmt.Sprintf("%.1fk", float64(count)/1_000) - } - - if count < 1_000_000 { - return fmt.Sprintf("%dk", count/1_000) - } - - return fmt.Sprintf("%.1fm", float64(count)/1_000_000) -} - -func relativeTimeSince(t time.Time) string { - delta := time.Since(t) - - if delta < time.Minute { - return "1m" - } - if delta < time.Hour { - return fmt.Sprintf("%dm", delta/time.Minute) - } - if delta < 24*time.Hour { - return fmt.Sprintf("%dh", delta/time.Hour) - } - if delta < 30*24*time.Hour { - return fmt.Sprintf("%dd", delta/(24*time.Hour)) - } - if delta < 12*30*24*time.Hour { - return fmt.Sprintf("%dmo", delta/(30*24*time.Hour)) - } - - return fmt.Sprintf("%dy", delta/(365*24*time.Hour)) -} diff --git a/internal/assets/templates/extension.html b/internal/assets/templates/extension.html deleted file mode 100644 index e5794c8..0000000 --- a/internal/assets/templates/extension.html +++ /dev/null @@ -1,5 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -{{ .Extension.Content }} -{{ end }} diff --git a/internal/assets/templates/forum-posts.html b/internal/assets/templates/forum-posts.html deleted file mode 100644 index 8a71d22..0000000 --- a/internal/assets/templates/forum-posts.html +++ /dev/null @@ -1,49 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/assets/templates/page-style-overrides.gotmpl b/internal/assets/templates/page-style-overrides.gotmpl deleted file mode 100644 index 0bf2a99..0000000 --- a/internal/assets/templates/page-style-overrides.gotmpl +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/internal/assets/templates/repository.html b/internal/assets/templates/repository.html deleted file mode 100644 index 53b6617..0000000 --- a/internal/assets/templates/repository.html +++ /dev/null @@ -1,61 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -{{ .RepositoryDetails.Name }} - - -{{ if gt (len .RepositoryDetails.Commits) 0 }} -
-Last {{ .CommitsLimit }} commits -
- - -
-{{ end }} - -{{ if gt (len .RepositoryDetails.PullRequests) 0 }} -
-Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total) -
- - -
-{{ end }} - -{{ if gt (len .RepositoryDetails.Issues) 0 }} -
-Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total) -
- - -
-{{ end }} - -{{ end }} diff --git a/internal/feed/adguard.go b/internal/feed/adguard.go deleted file mode 100644 index 87182c3..0000000 --- a/internal/feed/adguard.go +++ /dev/null @@ -1,120 +0,0 @@ -package feed - -import ( - "net/http" - "strings" -) - -type adguardStatsResponse struct { - TotalQueries int `json:"num_dns_queries"` - QueriesSeries []int `json:"dns_queries"` - BlockedQueries int `json:"num_blocked_filtering"` - BlockedSeries []int `json:"blocked_filtering"` - ResponseTime float64 `json:"avg_processing_time"` - TopBlockedDomains []map[string]int `json:"top_blocked_domains"` -} - -func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - request.SetBasicAuth(username, password) - - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - ResponseTime: int(responseJson.ResponseTime * 1000), - TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount), - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - - for i := 0; i < topBlockedDomainsCount; i++ { - domain := responseJson.TopBlockedDomains[i] - var firstDomain string - - for k := range domain { - firstDomain = k - break - } - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) - } - } - - queriesSeries := responseJson.QueriesSeries - blockedSeries := responseJson.BlockedSeries - - const bars = 8 - const hoursSpan = 24 - const hoursPerBar int = hoursSpan / bars - - if len(queriesSeries) > hoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] - } else if len(queriesSeries) < hoursSpan { - queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > hoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] - } else if len(blockedSeries) < hoursSpan { - blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := 0; i < bars; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < hoursPerBar; j++ { - queries += queriesSeries[i*hoursPerBar+j] - blocked += blockedSeries[i*hoursPerBar+j] - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := 0; i < bars; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/calendar.go b/internal/feed/calendar.go deleted file mode 100644 index f7ec5d4..0000000 --- a/internal/feed/calendar.go +++ /dev/null @@ -1,53 +0,0 @@ -package feed - -import "time" - -// TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing first day of week -// TODO: allow changing between showing the previous and next week and the entire month -func NewCalendar(now time.Time) *Calendar { - year, week := now.ISOWeek() - weekday := now.Weekday() - - if weekday == 0 { - weekday = 7 - } - - 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+6) - - 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/feed/codeberg.go b/internal/feed/codeberg.go deleted file mode 100644 index d5e7b7c..0000000 --- a/internal/feed/codeberg.go +++ /dev/null @@ -1,39 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" -) - -type codebergReleaseResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` -} - -func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://codeberg.org/api/v1/repos/%s/releases/latest", - request.Repository, - ), - nil, - ) - if err != nil { - return nil, err - } - - response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - return &AppRelease{ - Source: ReleaseSourceCodeberg, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - }, nil -} diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go deleted file mode 100644 index e979d37..0000000 --- a/internal/feed/dockerhub.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "strings" -) - -type dockerHubRepositoryTagsResponse struct { - Results []dockerHubRepositoryTagResponse `json:"results"` -} - -type dockerHubRepositoryTagResponse struct { - Name string `json:"name"` - LastPushed string `json:"tag_last_pushed"` -} - -const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" -const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" -const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" -const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" - -func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) { - - nameParts := strings.Split(request.Repository, "/") - - if len(nameParts) > 2 { - return nil, fmt.Errorf("invalid repository name: %s", request.Repository) - } else if len(nameParts) == 1 { - nameParts = []string{"library", nameParts[0]} - } - - tagParts := strings.SplitN(nameParts[1], ":", 2) - - var requestURL string - - if len(tagParts) == 2 { - requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) - } else { - requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) - } - - httpRequest, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) - } - - var tag *dockerHubRepositoryTagResponse - - if len(tagParts) == 1 { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - if len(response.Results) == 0 { - return nil, fmt.Errorf("no tags found for repository: %s", request.Repository) - } - - tag = &response.Results[0] - } else { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - tag = &response - } - - var repo string - var displayName string - var notesURL string - - if len(tagParts) == 1 { - repo = nameParts[1] - } else { - repo = tagParts[0] - } - - if nameParts[0] == "library" { - displayName = repo - notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) - } else { - displayName = nameParts[0] + "/" + repo - notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) - } - - return &AppRelease{ - Source: ReleaseSourceDockerHub, - NotesUrl: notesURL, - Name: displayName, - Version: tag.Name, - TimeReleased: parseRFC3339Time(tag.LastPushed), - }, nil -} diff --git a/internal/feed/extension.go b/internal/feed/extension.go deleted file mode 100644 index 3aa499a..0000000 --- a/internal/feed/extension.go +++ /dev/null @@ -1,97 +0,0 @@ -package feed - -import ( - "fmt" - "html" - "html/template" - "io" - "log/slog" - "net/http" - "net/url" -) - -type ExtensionType int - -const ( - ExtensionContentHTML ExtensionType = iota - ExtensionContentUnknown = iota -) - -var ExtensionStringToType = map[string]ExtensionType{ - "html": ExtensionContentHTML, -} - -const ( - ExtensionHeaderTitle = "Widget-Title" - ExtensionHeaderContentType = "Widget-Content-Type" -) - -type ExtensionRequestOptions struct { - URL string `yaml:"url"` - Parameters map[string]string `yaml:"parameters"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` -} - -type Extension struct { - Title string - Content template.HTML -} - -func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML { - switch contentType { - case ExtensionContentHTML: - if options.AllowHtml { - return template.HTML(content) - } - - fallthrough - default: - return template.HTML(html.EscapeString(string(content))) - } -} - -func FetchExtension(options ExtensionRequestOptions) (Extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - - query := url.Values{} - - for key, value := range options.Parameters { - query.Set(key, value) - } - - request.URL.RawQuery = query.Encode() - - response, err := http.DefaultClient.Do(request) - - if err != nil { - slog.Error("failed fetching extension", "error", err, "url", options.URL) - 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", "error", err, "url", options.URL) - 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) - } - - contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)] - - if !ok { - contentType = ExtensionContentUnknown - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go deleted file mode 100644 index 3ff0f00..0000000 --- a/internal/feed/gitlab.go +++ /dev/null @@ -1,48 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "net/url" -) - -type gitlabReleaseResponseJson struct { - TagName string `json:"tag_name"` - ReleasedAt string `json:"released_at"` - Links struct { - Self string `json:"self"` - } `json:"_links"` -} - -func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", - url.QueryEscape(request.Repository), - ), - nil, - ) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token) - } - - response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - return &AppRelease{ - Source: ReleaseSourceGitlab, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.Links.Self, - TimeReleased: parseRFC3339Time(response.ReleasedAt), - }, nil -} diff --git a/internal/feed/hacker-news.go b/internal/feed/hacker-news.go deleted file mode 100644 index f1db111..0000000 --- a/internal/feed/hacker-news.go +++ /dev/null @@ -1,98 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "strconv" - "strings" - "time" -) - -type hackerNewsPostResponseJson struct { - Id int `json:"id"` - Score int `json:"score"` - Title string `json:"title"` - TargetUrl string `json:"url,omitempty"` - CommentCount int `json:"descendants"` - TimePosted int64 `json:"time"` -} - -func getHackerNewsPostIds(sort string) ([]int, error) { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) - response, err := decodeJsonFromRequest[[]int](defaultClient, request) - - if err != nil { - return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent) - } - - return response, nil -} - -func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) { - requests := make([]*http.Request, len(postIds)) - - for i, id := range postIds { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil) - requests[i] = request - } - - task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient) - job := newJob(task, requests).withWorkers(30) - results, errs, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 0, len(postIds)) - - for i := range results { - if errs[i] != nil { - slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) - continue - } - - var commentsUrl string - - if commentsUrlTemplate == "" { - commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) - } else { - commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) - } - - posts = append(posts, ForumPost{ - Title: results[i].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), - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - if len(posts) != len(postIds) { - return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent) - } - - return posts, nil -} - -func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) { - postIds, err := getHackerNewsPostIds(sort) - - if err != nil { - return nil, err - } - - if len(postIds) > limit { - postIds = postIds[:limit] - } - - return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate) -} diff --git a/internal/feed/lobsters.go b/internal/feed/lobsters.go deleted file mode 100644 index 1bb5420..0000000 --- a/internal/feed/lobsters.go +++ /dev/null @@ -1,91 +0,0 @@ -package feed - -import ( - "net/http" - "strings" - "time" -) - -type lobstersPostResponseJson struct { - CreatedAt string `json:"created_at"` - Title string `json:"title"` - URL string `json:"url"` - Score int `json:"score"` - CommentCount int `json:"comment_count"` - CommentsURL string `json:"comments_url"` - Tags []string `json:"tags"` -} - -type lobstersFeedResponseJson []lobstersPostResponseJson - -func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) { - request, err := http.NewRequest("GET", feedUrl, nil) - - if err != nil { - return nil, err - } - - feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 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, - TimePosted: createdAt, - Tags: feed[i].Tags, - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - return posts, nil -} - -func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) { - var feedUrl string - - if customURL != "" { - feedUrl = customURL - } else { - if instanceURL != "" { - instanceURL = strings.TrimRight(instanceURL, "/") + "/" - } else { - instanceURL = "https://lobste.rs/" - } - - if sortBy == "hot" { - sortBy = "hottest" - } else if sortBy == "new" { - sortBy = "newest" - } - - if len(tags) == 0 { - feedUrl = instanceURL + sortBy + ".json" - } else { - tags := strings.Join(tags, ",") - feedUrl = instanceURL + "t/" + tags + ".json" - } - } - - posts, err := getLobstersPostsFromFeed(feedUrl) - - if err != nil { - return nil, err - } - - return posts, nil -} diff --git a/internal/feed/monitor.go b/internal/feed/monitor.go deleted file mode 100644 index a3da636..0000000 --- a/internal/feed/monitor.go +++ /dev/null @@ -1,77 +0,0 @@ -package feed - -import ( - "context" - "errors" - "net/http" - "time" -) - -type SiteStatusRequest struct { - URL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` -} - -type SiteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.URL - } - request, err := http.NewRequest(http.MethodGet, url, nil) - - if err != nil { - return SiteStatus{ - Error: err, - }, nil - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) - defer cancel() - request = request.WithContext(ctx) - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultClient.Do(request) - } else { - response, err = defaultInsecureClient.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(getSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/feed/pihole.go b/internal/feed/pihole.go deleted file mode 100644 index 3c7f1b5..0000000 --- a/internal/feed/pihole.go +++ /dev/null @@ -1,136 +0,0 @@ -package feed - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "sort" - "strings" -) - -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries map[int64]int `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array -// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int - -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { - // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow - // because of the UnmarshalJSON method getting called recursively - temp := make(map[string]int) - - err := json.Unmarshal(data, &temp) - - if err != nil { - *p = make(piholeTopBlockedDomains) - } else { - *p = temp - } - - return nil -} - -func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - BlockedPercent: int(responseJson.BlockedPercentage), - DomainsBlocked: responseJson.DomainsBlocked, - } - - if len(responseJson.TopBlockedDomains) > 0 { - domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) - - for domain, count := range responseJson.TopBlockedDomains { - domains = append(domains, DNSStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { - slog.Warn( - "DNS stats for pihole: did not get expected 144 data points", - "len(queries)", len(responseJson.QueriesSeries), - "len(blocked)", len(responseJson.BlockedSeries), - ) - return stats, nil - } - - var lowestTimestamp int64 = 0 - - for timestamp := range responseJson.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - - queries += responseJson.QueriesSeries[index] - blocked += responseJson.BlockedSeries[index] - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - } - - for i := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go deleted file mode 100644 index 755b002..0000000 --- a/internal/feed/primitives.go +++ /dev/null @@ -1,241 +0,0 @@ -package feed - -import ( - "math" - "sort" - "time" -) - -type ForumPost struct { - Title string - DiscussionUrl string - TargetUrl string - TargetUrlDomain string - ThumbnailUrl string - CommentCount int - Score int - Engagement float64 - TimePosted time.Time - Tags []string - IsCrosspost bool -} - -type ForumPosts []ForumPost - -type Calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -type Weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -type AppRelease struct { - Source ReleaseSource - SourceIconURL string - Name string - Version string - NotesUrl string - TimeReleased time.Time - Downvotes int -} - -type AppReleases []AppRelease - -type Video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type Videos []Video - -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": "₱", -} - -type DNSStats struct { - TotalQueries int - BlockedQueries int - BlockedPercent int - ResponseTime int - DomainsBlocked int - Series [8]DNSStatsSeries - TopBlockedDomains []DNSStatsBlockedDomain -} - -type DNSStatsSeries struct { - Queries int - Blocked int - PercentTotal int - PercentBlocked int -} - -type DNSStatsBlockedDomain struct { - Domain string - PercentBlocked int -} - -type MarketRequest struct { - Name string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type Market struct { - MarketRequest - Currency string `yaml:"-"` - Price float64 `yaml:"-"` - PercentChange float64 `yaml:"-"` - SvgChartPoints string `yaml:"-"` -} - -type Markets []Market - -func (t Markets) SortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -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", -} - -func (w *Weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -const depreciatePostsOlderThanHours = 7 -const maxDepreciation = 0.9 -const maxDepreciationAfterHours = 24 - -func (p ForumPosts) CalculateEngagement() { - var totalComments int - var totalScore int - - for i := range p { - totalComments += p[i].CommentCount - totalScore += p[i].Score - } - - numberOfPosts := float64(len(p)) - averageComments := float64(totalComments) / numberOfPosts - averageScore := float64(totalScore) / numberOfPosts - - for i := range p { - p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 - - elapsed := time.Since(p[i].TimePosted) - - if elapsed < time.Hour*depreciatePostsOlderThanHours { - continue - } - - p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation - } -} - -func (p ForumPosts) SortByEngagement() { - sort.Slice(p, func(i, j int) bool { - return p[i].Engagement > p[j].Engagement - }) -} - -func (s *ForumPost) HasTargetUrl() bool { - return s.TargetUrl != "" -} - -func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost { - recent := make([]ForumPost, 0, len(p)) - - for i := range p { - if time.Since(p[i].TimePosted) < postedBefore { - recent = append(recent, p[i]) - } - } - - return recent -} - -func (r AppReleases) SortByNewest() AppReleases { - sort.Slice(r, func(i, j int) bool { - return r[i].TimeReleased.After(r[j].TimeReleased) - }) - - return r -} - -func (v Videos) SortByNewest() Videos { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} diff --git a/internal/feed/yahoo.go b/internal/feed/yahoo.go deleted file mode 100644 index f962695..0000000 --- a/internal/feed/yahoo.go +++ /dev/null @@ -1,104 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" -) - -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"` - } `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) (Markets, 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) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - markets := make(Markets, 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 - } - - prices := response.Chart.Result[0].Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := response.Chart.Result[0].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[response.Chart.Result[0].Meta.Currency] - - if !exists { - currency = response.Chart.Result[0].Meta.Currency - } - - markets = append(markets, Market{ - MarketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, - Currency: currency, - PercentChange: percentChange( - response.Chart.Result[0].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 -} diff --git a/internal/feed/youtube.go b/internal/feed/youtube.go deleted file mode 100644 index 5016b6b..0000000 --- a/internal/feed/youtube.go +++ /dev/null @@ -1,115 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "time" -) - -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 -} - -func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) { - requests := make([]*http.Request, 0, len(channelIds)) - - for i := range channelIds { - var feedUrl string - if !includeShorts && strings.HasPrefix(channelIds[i], "UC") { - playlistId := strings.Replace(channelIds[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=" + channelIds[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) - - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - videos := make(Videos, 0, len(channelIds)*15) - - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - video := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = video.Link.Href - } else { - parsedUrl, err := url.Parse(video.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, Video{ - ThumbnailUrl: video.Group.Thumbnail.Url, - Title: video.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(video.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/cli.go b/internal/glance/cli.go index 5987368..e231706 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -2,41 +2,66 @@ package glance import ( "flag" + "fmt" "os" + "strings" ) -type CliIntent uint8 +type cliIntent uint8 const ( - CliIntentServe CliIntent = iota - CliIntentCheckConfig = iota + cliIntentServe cliIntent = iota + cliIntentConfigValidate = iota + cliIntentConfigPrint = iota + cliIntentDiagnose = iota ) -type CliOptions struct { - Intent CliIntent - ConfigPath string +type cliOptions struct { + intent cliIntent + configPath string } -func ParseCliOptions() (*CliOptions, error) { +func parseCliOptions() (*cliOptions, error) { flags := flag.NewFlagSet("", flag.ExitOnError) + flags.Usage = func() { + fmt.Println("Usage: glance [options] command") - checkConfig := flags.Bool("check-config", false, "Check whether the config is valid") + fmt.Println("\nOptions:") + flags.PrintDefaults() + + fmt.Println("\nCommands:") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" diagnose Run diagnostic checks") + } configPath := flags.String("config", "glance.yml", "Set config path") - err := flags.Parse(os.Args[1:]) - if err != nil { return nil, err } - intent := CliIntentServe + var intent cliIntent + var args = flags.Args() + unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " ")) - if *checkConfig { - intent = CliIntentCheckConfig + if len(args) == 0 { + intent = cliIntentServe + } else if len(args) == 1 { + if args[0] == "config:validate" { + intent = cliIntentConfigValidate + } else if args[0] == "config:print" { + intent = cliIntentConfigPrint + } else if args[0] == "diagnose" { + intent = cliIntentDiagnose + } else { + return nil, unknownCommandErr + } + } else { + return nil, unknownCommandErr } - return &CliOptions{ - Intent: intent, - ConfigPath: *configPath, + return &cliOptions{ + intent: intent, + configPath: *configPath, }, nil } diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go new file mode 100644 index 0000000..8aaac85 --- /dev/null +++ b/internal/glance/config-fields.go @@ -0,0 +1,221 @@ +package glance + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) + +const ( + hslHueMax = 360 + hslSaturationMax = 100 + hslLightnessMax = 100 +) + +type hslColorField struct { + Hue uint16 + Saturation uint8 + Lightness uint8 +} + +func (c *hslColorField) String() string { + return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) +} + +func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { + var value string + + if err := node.Decode(&value); err != nil { + return err + } + + matches := hslColorFieldPattern.FindStringSubmatch(value) + + if len(matches) != 4 { + return fmt.Errorf("invalid HSL color format: %s", value) + } + + hue, err := strconv.ParseUint(matches[1], 10, 16) + if err != nil { + return err + } + + if hue > hslHueMax { + return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) + } + + saturation, err := strconv.ParseUint(matches[2], 10, 8) + if err != nil { + return err + } + + if saturation > hslSaturationMax { + return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) + } + + lightness, err := strconv.ParseUint(matches[3], 10, 8) + if err != nil { + return err + } + + if lightness > hslLightnessMax { + return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) + } + + c.Hue = uint16(hue) + c.Saturation = uint8(saturation) + c.Lightness = uint8(lightness) + + 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 string + IsFlatIcon bool + // TODO: along with whether the icon is flat, we also need to know + // whether the icon is black or white by default in order to properly + // invert the color based on the theme being light or dark +} + +func newCustomIconField(value string) customIconField { + field := customIconField{} + + prefix, icon, found := strings.Cut(value, ":") + if !found { + field.URL = value + return field + } + + switch prefix { + case "si": + field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg" + field.IsFlatIcon = true + case "di", "sh": + // syntax: di:[.svg|.png] + // syntax: sh:[.svg|.png] + // if the icon name is specified without extension, it is assumed to be wanting the SVG icon + // otherwise, specify the extension of either .svg or .png to use either of the CDN offerings + // any other extension will be interpreted as .svg + basename, ext, found := strings.Cut(icon, ".") + if !found { + ext = "svg" + basename = icon + } + + if ext != "svg" && ext != "png" { + ext = "svg" + } + + if prefix == "di" { + field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext + } else { + field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/" + ext + "/" + basename + "." + ext + } + default: + field.URL = value + } + + return field +} + +func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { + var value string + if err := node.Decode(&value); err != nil { + return err + } + + *i = newCustomIconField(value) + 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 +} diff --git a/internal/glance/config.go b/internal/glance/config.go index 131ef7f..0ab79af 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -1,43 +1,96 @@ package glance import ( + "bytes" "fmt" - "io" + "html/template" + "log" + "maps" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + "github.com/fsnotify/fsnotify" "gopkg.in/yaml.v3" ) -type Config struct { - Server Server `yaml:"server"` - Theme Theme `yaml:"theme"` - Branding Branding `yaml:"branding"` - Pages []Page `yaml:"pages"` +type config struct { + Server struct { + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` + StartedAt time.Time `yaml:"-"` // used in custom css file + } `yaml:"server"` + + Document struct { + Head template.HTML `yaml:"head"` + } `yaml:"document"` + + Theme struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` + CustomCSSFile string `yaml:"custom-css-file"` + } `yaml:"theme"` + + Branding struct { + HideFooter bool `yaml:"hide-footer"` + CustomFooter template.HTML `yaml:"custom-footer"` + LogoText string `yaml:"logo-text"` + LogoURL string `yaml:"logo-url"` + FaviconURL string `yaml:"favicon-url"` + } `yaml:"branding"` + + Pages []page `yaml:"pages"` } -func NewConfigFromYml(contents io.Reader) (*Config, error) { - config := NewConfig() - - contentBytes, err := io.ReadAll(contents) +type page struct { + Title string `yaml:"name"` + Slug string `yaml:"slug"` + Width string `yaml:"width"` + ShowMobileHeader bool `yaml:"show-mobile-header"` + ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + Columns []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + } `yaml:"columns"` + PrimaryColumnIndex int8 `yaml:"-"` + mu sync.Mutex `yaml:"-"` +} +func newConfigFromYAML(contents []byte) (*config, error) { + contents, err := parseConfigEnvVariables(contents) if err != nil { return nil, err } - err = yaml.Unmarshal(contentBytes, config) + config := &config{} + config.Server.Port = 8080 + err = yaml.Unmarshal(contents, config) if err != nil { return nil, err } - if err = configIsValid(config); err != nil { + if err = isConfigStateValid(config); err != nil { return nil, err } for p := range config.Pages { for c := range config.Pages[p].Columns { for w := range config.Pages[p].Columns[c].Widgets { - if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { - return nil, err + if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil { + return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w]) } } } @@ -46,36 +99,253 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) { return config, nil } -func NewConfig() *Config { - config := &Config{} +var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`) - config.Server.Host = "" - config.Server.Port = 8080 +func parseConfigEnvVariables(contents []byte) ([]byte, error) { + var err error - return config + replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte { + if err != nil { + return nil + } + + groups := configEnvVariablePattern.FindSubmatch(match) + if len(groups) != 3 { + return match + } + + prefix, key := string(groups[1]), string(groups[2]) + if prefix == `\` { + if len(match) >= 2 { + return match[1:] + } else { + return nil + } + } + + value, found := os.LookupEnv(key) + if !found { + err = fmt.Errorf("environment variable %s not found", key) + return nil + } + + return []byte(prefix + value) + }) + + if err != nil { + return nil, err + } + + return replaced, nil } -func configIsValid(config *Config) error { +func formatWidgetInitError(err error, w widget) error { + return fmt.Errorf("%s widget: %v", w.GetType(), err) +} + +var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) + +func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { + mainFileContents, err := os.ReadFile(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("reading main YAML file: %w", err) + } + + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err) + } + mainFileDir := filepath.Dir(mainFileAbsPath) + + includes := make(map[string]struct{}) + var includesLastErr error + + mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { + if includesLastErr != nil { + return nil + } + + matches := includePattern.FindSubmatch(match) + if len(matches) != 3 { + includesLastErr = fmt.Errorf("invalid include match: %v", matches) + return nil + } + + indent := string(matches[1]) + includeFilePath := strings.TrimSpace(string(matches[2])) + if !filepath.IsAbs(includeFilePath) { + includeFilePath = filepath.Join(mainFileDir, includeFilePath) + } + + var fileContents []byte + var err error + + fileContents, err = os.ReadFile(includeFilePath) + if err != nil { + includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err) + return nil + } + + includes[includeFilePath] = struct{}{} + return []byte(prefixStringLines(indent, string(fileContents))) + }) + + if includesLastErr != nil { + return nil, nil, includesLastErr + } + + return mainFileContents, includes, nil +} + +func configFilesWatcher( + mainFilePath string, + lastContents []byte, + lastIncludes map[string]struct{}, + onChange func(newContents []byte), + onErr func(error), +) (func() error, error) { + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, fmt.Errorf("getting absolute path of main file: %w", err) + } + + // TODO: refactor, flaky + lastIncludes[mainFileAbsPath] = struct{}{} + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("creating watcher: %w", err) + } + + updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) { + for filePath := range previousWatched { + if _, ok := newWatched[filePath]; !ok { + watcher.Remove(filePath) + } + } + + for filePath := range newWatched { + if _, ok := previousWatched[filePath]; !ok { + if err := watcher.Add(filePath); err != nil { + log.Printf( + "Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v", + filePath, err, + ) + } + } + } + } + + updateWatchedFiles(nil, lastIncludes) + + // needed for lastContents and lastIncludes because they get updated in multiple goroutines + mu := sync.Mutex{} + + checkForContentChangesBeforeCallback := func() { + currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath) + if err != nil { + onErr(fmt.Errorf("parsing main file contents for comparison: %w", err)) + return + } + + // TODO: refactor, flaky + currentIncludes[mainFileAbsPath] = struct{}{} + + mu.Lock() + defer mu.Unlock() + + if !maps.Equal(currentIncludes, lastIncludes) { + updateWatchedFiles(lastIncludes, currentIncludes) + lastIncludes = currentIncludes + } + + if !bytes.Equal(lastContents, currentContents) { + lastContents = currentContents + onChange(currentContents) + } + } + + const debounceDuration = 500 * time.Millisecond + var debounceTimer *time.Timer + debouncedCallback := func() { + if debounceTimer != nil { + debounceTimer.Stop() + debounceTimer.Reset(debounceDuration) + } else { + debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback) + } + } + + go func() { + for { + select { + case event, isOpen := <-watcher.Events: + if !isOpen { + return + } + if event.Has(fsnotify.Write) { + debouncedCallback() + } else if event.Has(fsnotify.Remove) { + func() { + mu.Lock() + defer mu.Unlock() + fileAbsPath, _ := filepath.Abs(event.Name) + delete(lastIncludes, fileAbsPath) + }() + + debouncedCallback() + } + case err, isOpen := <-watcher.Errors: + if !isOpen { + return + } + onErr(fmt.Errorf("watcher error: %w", err)) + } + } + }() + + onChange(lastContents) + + return func() error { + if debounceTimer != nil { + debounceTimer.Stop() + } + + return watcher.Close() + }, nil +} + +func isConfigStateValid(config *config) error { + if len(config.Pages) == 0 { + return fmt.Errorf("no pages configured") + } + + if config.Server.AssetsPath != "" { + if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) { + return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath) + } + } + for i := range config.Pages { if config.Pages[i].Title == "" { - return fmt.Errorf("Page %d has no title", i+1) + return fmt.Errorf("page %d has no name", i+1) } if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { - return fmt.Errorf("Page %d: width can only be either wide or slim", i+1) + return fmt.Errorf("page %d: width can only be either wide or slim", i+1) } if len(config.Pages[i].Columns) == 0 { - return fmt.Errorf("Page %d has no columns", i+1) + return fmt.Errorf("page %d has no columns", i+1) } if config.Pages[i].Width == "slim" { if len(config.Pages[i].Columns) > 2 { - return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) + return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1) } } else { if len(config.Pages[i].Columns) > 3 { - return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) + return fmt.Errorf("page %d has more than 3 columns", i+1) } } @@ -83,7 +353,7 @@ func configIsValid(config *Config) error { for j := range config.Pages[i].Columns { if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { - return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1) + return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1) } columnSizesCount[config.Pages[i].Columns[j].Size]++ @@ -92,7 +362,7 @@ func configIsValid(config *Config) error { full := columnSizesCount["full"] if full > 2 || full == 0 { - return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1) + return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1) } } diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go new file mode 100644 index 0000000..892aa5f --- /dev/null +++ b/internal/glance/diagnose.go @@ -0,0 +1,205 @@ +package glance + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "runtime" + "strings" + "sync" + "time" +) + +const httpTestRequestTimeout = 10 * time.Second + +var diagnosticSteps = []diagnosticStep{ + { + name: "resolve cloudflare.com through Cloudflare DoH", + fn: func() (string, error) { + return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{ + "accept": "application/dns-json", + }, 200) + }, + }, + { + name: "resolve cloudflare.com through Google DoH", + fn: func() (string, error) { + return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200) + }, + }, + { + name: "resolve github.com", + fn: func() (string, error) { + return testDNSResolution("github.com") + }, + }, + { + name: "resolve reddit.com", + fn: func() (string, error) { + return testDNSResolution("reddit.com") + }, + }, + { + name: "resolve twitch.tv", + fn: func() (string, error) { + return testDNSResolution("twitch.tv") + }, + }, + { + name: "fetch data from YouTube RSS feed", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200) + }, + }, + { + name: "fetch data from Twitch.tv GQL", + fn: func() (string, error) { + // this should always return 0 bytes, we're mainly looking for a 200 status code + return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200) + }, + }, + { + name: "fetch data from GitHub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://api.github.com", 200) + }, + }, + { + name: "fetch data from Open-Meteo API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200) + }, + }, + { + name: "fetch data from Reddit API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.reddit.com/search.json", 200) + }, + }, + { + name: "fetch data from Yahoo finance API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200) + }, + }, + { + name: "fetch data from Hacker News Firebase API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200) + }, + }, + { + name: "fetch data from Docker Hub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200) + }, + }, +} + +func runDiagnostic() { + fmt.Println("```") + fmt.Println("Glance version: " + buildVersion) + fmt.Println("Go version: " + runtime.Version()) + fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) + fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no")) + + fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds())) + + var wg sync.WaitGroup + for i := range diagnosticSteps { + step := &diagnosticSteps[i] + wg.Add(1) + go func() { + defer wg.Done() + start := time.Now() + step.extraInfo, step.err = step.fn() + step.elapsed = time.Since(start) + }() + } + wg.Wait() + + for _, step := range diagnosticSteps { + var extraInfo string + + if step.extraInfo != "" { + extraInfo = "| " + step.extraInfo + " " + } + + fmt.Printf( + "%s %s %s| %dms\n", + boolToString(step.err == nil, "✓ Can", "✗ Can't"), + step.name, + extraInfo, + step.elapsed.Milliseconds(), + ) + + if step.err != nil { + fmt.Printf("└╴ error: %v\n", step.err) + } + } + fmt.Println("```") +} + +type diagnosticStep struct { + name string + fn func() (string, error) + extraInfo string + err error + elapsed time.Duration +} + +func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { + return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) +} + +func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout) + defer cancel() + + request, _ := http.NewRequestWithContext(ctx, method, url, nil) + for key, value := range headers { + request.Header.Add(key, value) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + printableBody := strings.ReplaceAll(string(body), "\n", "") + if len(printableBody) > 50 { + printableBody = printableBody[:50] + "..." + } + if len(printableBody) > 0 { + printableBody = ", " + printableBody + } + + extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody) + + if response.StatusCode != expectedStatusCode { + return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode) + } + + return extraInfo, nil +} + +func testDNSResolution(domain string) (string, error) { + ips, err := net.LookupIP(domain) + + var ipStrings []string + if err == nil { + for i := range ips { + ipStrings = append(ipStrings, ips[i].String()) + } + } + + return strings.Join(ipStrings, ", "), err +} diff --git a/internal/glance/embed.go b/internal/glance/embed.go new file mode 100644 index 0000000..7bb07c9 --- /dev/null +++ b/internal/glance/embed.go @@ -0,0 +1,62 @@ +package glance + +import ( + "crypto/md5" + "embed" + "encoding/hex" + "io" + "io/fs" + "log" + "strconv" + "time" +) + +//go:embed static +var _staticFS embed.FS + +//go:embed templates +var _templateFS embed.FS + +var staticFS, _ = fs.Sub(_staticFS, "static") +var templateFS, _ = fs.Sub(_templateFS, "templates") + +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) + } + + return hash +}() + +func computeFSHash(files fs.FS) (string, error) { + hash := md5.New() + + err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + file, err := files.Open(path) + if err != nil { + return err + } + + if _, err := io.Copy(hash, file); err != nil { + return err + } + + return nil + }) + + if err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil))[:10], nil +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index f47c66a..b1fcc37 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -5,80 +5,93 @@ import ( "context" "fmt" "html/template" - "log/slog" + "log" "net/http" "path/filepath" - "regexp" "strconv" "strings" "sync" "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/widget" ) -var buildVersion = "dev" +var ( + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") +) -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) +type application struct { + Version string + Config config + ParsedThemeStyle template.HTML -type Application struct { - Version string - Config Config - slugToPage map[string]*Page - widgetByID map[uint64]widget.Widget + slugToPage map[string]*page + widgetByID map[uint64]widget } -type Theme struct { - BackgroundColor *widget.HSLColorField `yaml:"background-color"` - PrimaryColor *widget.HSLColorField `yaml:"primary-color"` - PositiveColor *widget.HSLColorField `yaml:"positive-color"` - NegativeColor *widget.HSLColorField `yaml:"negative-color"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` +func newApplication(config *config) (*application, error) { + app := &application{ + Version: buildVersion, + Config: *config, + slugToPage: make(map[string]*page), + widgetByID: make(map[uint64]widget), + } + + app.slugToPage[""] = &config.Pages[0] + + providers := &widgetProviders{ + assetResolver: app.AssetPath, + } + + var err error + app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme) + if err != nil { + return nil, fmt.Errorf("parsing theme style: %v", err) + } + + for p := range config.Pages { + page := &config.Pages[p] + page.PrimaryColumnIndex = -1 + + if page.Slug == "" { + page.Slug = titleToSlug(page.Title) + } + + app.slugToPage[page.Slug] = page + + for c := range page.Columns { + column := &page.Columns[c] + + if page.PrimaryColumnIndex == -1 && column.Size == "full" { + page.PrimaryColumnIndex = int8(c) + } + + for w := range column.Widgets { + widget := column.Widgets[w] + app.widgetByID[widget.id()] = widget + + widget.setProviders(providers) + } + } + } + + config = &app.Config + + config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") + config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) + + if config.Branding.FaviconURL == "" { + config.Branding.FaviconURL = app.AssetPath("favicon.png") + } else { + config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) + } + + config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) + + return app, nil } -type Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` - AssetsHash string `yaml:"-"` - StartedAt time.Time `yaml:"-"` // used in custom css file -} - -type Branding struct { - HideFooter bool `yaml:"hide-footer"` - CustomFooter template.HTML `yaml:"custom-footer"` - LogoText string `yaml:"logo-text"` - LogoURL string `yaml:"logo-url"` - FaviconURL string `yaml:"favicon-url"` -} - -type Column struct { - Size string `yaml:"size"` - Widgets widget.Widgets `yaml:"widgets"` -} - -type templateData struct { - App *Application - Page *Page -} - -type Page struct { - Title string `yaml:"name"` - Slug string `yaml:"slug"` - Width string `yaml:"width"` - ShowMobileHeader bool `yaml:"show-mobile-header"` - HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` - CenterVertically bool `yaml:"center-vertically"` - Columns []Column `yaml:"columns"` - mu sync.Mutex -} - -func (p *Page) UpdateOutdatedWidgets() { +func (p *page) updateOutdatedWidgets() { now := time.Now() var wg sync.WaitGroup @@ -88,14 +101,14 @@ func (p *Page) UpdateOutdatedWidgets() { for w := range p.Columns[c].Widgets { widget := p.Columns[c].Widgets[w] - if !widget.RequiresUpdate(&now) { + if !widget.requiresUpdate(&now) { continue } wg.Add(1) go func() { defer wg.Done() - widget.Update(context) + widget.update(context) }() } } @@ -103,16 +116,7 @@ func (p *Page) UpdateOutdatedWidgets() { wg.Wait() } -// TODO: fix, currently very simple, lots of uncovered edge cases -func titleToSlug(s string) string { - s = strings.ToLower(s) - s = sequentialWhitespacePattern.ReplaceAllString(s, "-") - s = strings.Trim(s, "-") - - return s -} - -func (a *Application) TransformUserDefinedAssetPath(path string) string { +func (a *application) transformUserDefinedAssetPath(path string) string { if strings.HasPrefix(path, "/assets/") { return a.Config.Server.BaseURL + path } @@ -120,74 +124,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string { return path } -func NewApplication(config *Config) (*Application, error) { - if len(config.Pages) == 0 { - return nil, fmt.Errorf("no pages configured") - } - - app := &Application{ - Version: buildVersion, - Config: *config, - slugToPage: make(map[string]*Page), - widgetByID: make(map[uint64]widget.Widget), - } - - app.Config.Server.AssetsHash = assets.PublicFSHash - app.slugToPage[""] = &config.Pages[0] - - providers := &widget.Providers{ - AssetResolver: app.AssetPath, - } - - for p := range config.Pages { - if config.Pages[p].Slug == "" { - config.Pages[p].Slug = titleToSlug(config.Pages[p].Title) - } - - app.slugToPage[config.Pages[p].Slug] = &config.Pages[p] - - for c := range config.Pages[p].Columns { - for w := range config.Pages[p].Columns[c].Widgets { - widget := config.Pages[p].Columns[c].Widgets[w] - app.widgetByID[widget.GetID()] = widget - - widget.SetProviders(providers) - } - } - } - - config = &app.Config - - config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") - config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile) - - if config.Branding.FaviconURL == "" { - config.Branding.FaviconURL = app.AssetPath("favicon.png") - } else { - config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL) - } - - config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) - - return app, nil +type pageTemplateData struct { + App *application + Page *page } -func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, App: a, } var responseBytes bytes.Buffer - err := assets.PageTemplate.Execute(&responseBytes, pageData) - + err := pageTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -197,24 +153,28 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) w.Write(responseBytes.Bytes()) } -func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, } - page.mu.Lock() - defer page.mu.Unlock() - page.UpdateOutdatedWidgets() - + var err error var responseBytes bytes.Buffer - err := assets.PageContentTemplate.Execute(&responseBytes, pageData) + + func() { + page.mu.Lock() + defer page.mu.Unlock() + + page.updateOutdatedWidgets() + err = pageContentTemplate.Execute(&responseBytes, pageData) + }() if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -225,74 +185,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } -func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) { +func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) w.Write([]byte("Page not found")) } -func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { - server := http.FileServer(fs) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: fix always setting cache control even if the file doesn't exist - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) - server.ServeHTTP(w, r) - }) -} - -func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) - if err != nil { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } widget, exists := a.widgetByID[widgetID] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - widget.HandleRequest(w, r) + widget.handleRequest(w, r) } -func (a *Application) AssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset +func (a *application) AssetPath(asset string) string { + return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } -func (a *Application) Serve() error { +func (a *application) server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() - mux.HandleFunc("GET /{$}", a.HandlePageRequest) - mux.HandleFunc("GET /{page}", a.HandlePageRequest) + mux.HandleFunc("GET /{$}", a.handlePageRequest) + mux.HandleFunc("GET /{page}", a.handlePageRequest) - mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) - mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) + mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) + mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), - http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), + fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), ) + var absAssetsPath string if a.Config.Server.AssetsPath != "" { - absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) - - if err != nil { - return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath) - } - - slog.Info("Serving assets", "path", absAssetsPath) - assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) + absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) + assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) } @@ -301,8 +245,25 @@ func (a *Application) Serve() error { Handler: mux, } - a.Config.Server.StartedAt = time.Now() - slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL) + start := func() error { + a.Config.Server.StartedAt = time.Now() + log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", + a.Config.Server.Host, + a.Config.Server.Port, + a.Config.Server.BaseURL, + absAssetsPath, + ) - return server.ListenAndServe() + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil + } + + stop := func() error { + return server.Close() + } + + return start, stop } diff --git a/internal/glance/main.go b/internal/glance/main.go index 426c41f..35211a9 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -2,45 +2,174 @@ package glance import ( "fmt" + "io" + "log" + "net/http" "os" ) -func Main() int { - options, err := ParseCliOptions() +var buildVersion = "dev" +func Main() int { + options, err := parseCliOptions() if err != nil { fmt.Println(err) return 1 } - configFile, err := os.Open(options.ConfigPath) - - if err != nil { - fmt.Printf("failed opening config file: %v\n", err) - return 1 - } - - config, err := NewConfigFromYml(configFile) - configFile.Close() - - if err != nil { - fmt.Printf("failed parsing config file: %v\n", err) - return 1 - } - - if options.Intent == CliIntentServe { - app, err := NewApplication(config) + switch options.intent { + 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 + } + case cliIntentConfigValidate: + contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed creating application: %v\n", err) + fmt.Printf("Could not parse config file: %v\n", err) return 1 } - if err := app.Serve(); err != nil { - fmt.Printf("http server error: %v\n", err) + if _, err := newConfigFromYAML(contents); err != nil { + fmt.Printf("Config file is invalid: %v\n", err) return 1 } + case cliIntentConfigPrint: + contents, _, err := parseYAMLIncludes(options.configPath) + if err != nil { + fmt.Printf("Could not parse config file: %v\n", err) + return 1 + } + + fmt.Println(string(contents)) + case cliIntentDiagnose: + runDiagnostic() } return 0 } + +func serveApp(configPath string) error { + exitChannel := make(chan struct{}) + // the onChange method gets called at most once per 500ms due to debouncing so we shouldn't + // need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason + hadValidConfigOnStartup := false + var stopServer func() error + + onChange := func(newContents []byte) { + if stopServer != nil { + log.Println("Config file changed, reloading...") + } + + config, err := newConfigFromYAML(newContents) + if err != nil { + log.Printf("Config has errors: %v", err) + + if !hadValidConfigOnStartup { + close(exitChannel) + } + + return + } else if !hadValidConfigOnStartup { + hadValidConfigOnStartup = true + } + + app, err := newApplication(config) + if err != nil { + log.Printf("Failed to create application: %v", err) + return + } + + if stopServer != nil { + if err := stopServer(); err != nil { + log.Printf("Error while trying to stop server: %v", err) + } + } + + go func() { + var startServer func() error + startServer, stopServer = app.server() + + if err := startServer(); err != nil { + log.Printf("Failed to start server: %v", err) + } + }() + } + + onErr := func(err error) { + log.Printf("Error watching config files: %v", err) + } + + configContents, configIncludes, err := parseYAMLIncludes(configPath) + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr) + if err == nil { + defer stopWatching() + } else { + log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err) + + config, err := newConfigFromYAML(configContents) + if err != nil { + return fmt.Errorf("validating config file: %w", err) + } + + app, err := newApplication(config) + if err != nil { + return fmt.Errorf("creating application: %w", err) + } + + startServer, _ := app.server() + if err := startServer(); err != nil { + return fmt.Errorf("starting server: %w", err) + } + } + + <-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) + + // TODO: update - add link + fmt.Println("!!! WARNING !!!") + fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see 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/assets/static/app-icon.png b/internal/glance/static/app-icon.png similarity index 100% rename from internal/assets/static/app-icon.png rename to internal/glance/static/app-icon.png diff --git a/internal/assets/static/favicon.png b/internal/glance/static/favicon.png similarity index 100% rename from internal/assets/static/favicon.png rename to internal/glance/static/favicon.png diff --git a/internal/assets/static/fonts/JetBrainsMono-Regular.woff2 b/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/assets/static/fonts/JetBrainsMono-Regular.woff2 rename to internal/glance/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/assets/static/icons/codeberg.svg b/internal/glance/static/icons/codeberg.svg similarity index 100% rename from internal/assets/static/icons/codeberg.svg rename to internal/glance/static/icons/codeberg.svg diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/glance/static/icons/dockerhub.svg similarity index 100% rename from internal/assets/static/icons/dockerhub.svg rename to internal/glance/static/icons/dockerhub.svg diff --git a/internal/assets/static/icons/github.svg b/internal/glance/static/icons/github.svg similarity index 100% rename from internal/assets/static/icons/github.svg rename to internal/glance/static/icons/github.svg diff --git a/internal/assets/static/icons/gitlab.svg b/internal/glance/static/icons/gitlab.svg similarity index 100% rename from internal/assets/static/icons/gitlab.svg rename to internal/glance/static/icons/gitlab.svg diff --git a/internal/assets/static/js/main.js b/internal/glance/static/js/main.js similarity index 84% rename from internal/assets/static/js/main.js rename to internal/glance/static/js/main.js index ffa7eb7..58a8c2d 100644 --- a/internal/assets/static/js/main.js +++ b/internal/glance/static/js/main.js @@ -1,5 +1,6 @@ import { setupPopovers } from './popover.js'; -import { throttledDebounce, isElementVisible } from './utils.js'; +import { setupMasonries } from './masonry.js'; +import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js'; async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs @@ -48,29 +49,35 @@ function setupCarousels() { const minuteInSeconds = 60; const hourInSeconds = minuteInSeconds * 60; const dayInSeconds = hourInSeconds * 24; -const monthInSeconds = dayInSeconds * 30; -const yearInSeconds = monthInSeconds * 12; +const monthInSeconds = dayInSeconds * 30.4; +const yearInSeconds = dayInSeconds * 365; -function relativeTimeSince(timestamp) { - const delta = Math.round((Date.now() / 1000) - timestamp); +function timestampToRelativeTime(timestamp) { + let delta = Math.round((Date.now() / 1000) - timestamp); + let prefix = ""; + + if (delta < 0) { + delta = -delta; + prefix = "in "; + } if (delta < minuteInSeconds) { - return "1m"; + return prefix + "1m"; } if (delta < hourInSeconds) { - return Math.floor(delta / minuteInSeconds) + "m"; + return prefix + Math.floor(delta / minuteInSeconds) + "m"; } if (delta < dayInSeconds) { - return Math.floor(delta / hourInSeconds) + "h"; + return prefix + Math.floor(delta / hourInSeconds) + "h"; } if (delta < monthInSeconds) { - return Math.floor(delta / dayInSeconds) + "d"; + return prefix + Math.floor(delta / dayInSeconds) + "d"; } if (delta < yearInSeconds) { - return Math.floor(delta / monthInSeconds) + "mo"; + return prefix + Math.floor(delta / monthInSeconds) + "mo"; } - return Math.floor(delta / yearInSeconds) + "y"; + return prefix + Math.floor(delta / yearInSeconds) + "y"; } function updateRelativeTimeForElements(elements) @@ -83,7 +90,7 @@ function updateRelativeTimeForElements(elements) if (timestamp === undefined) continue - element.textContent = relativeTimeSince(timestamp); + element.textContent = timestampToRelativeTime(timestamp); } } @@ -104,6 +111,7 @@ function setupSearchBoxes() { const bangsMap = {}; const kbdElement = widget.getElementsByTagName("kbd")[0]; let currentBang = null; + let lastQuery = ""; for (let j = 0; j < bangs.length; j++) { const bang = bangs[j]; @@ -140,6 +148,14 @@ function setupSearchBoxes() { window.location.href = url; } + lastQuery = query; + inputElement.value = ""; + + return; + } + + if (event.key == "ArrowUp" && lastQuery.length > 0) { + inputElement.value = lastQuery; return; } }; @@ -245,8 +261,24 @@ function setupGroups() { for (let t = 0; t < titles.length; t++) { const title = titles[t]; + + if (title.dataset.titleUrl !== undefined) { + title.addEventListener("mousedown", (event) => { + if (event.button != 1) { + return; + } + + openURLInNewTab(title.dataset.titleUrl, false); + event.preventDefault(); + }); + } + title.addEventListener("click", () => { if (t == current) { + if (title.dataset.titleUrl !== undefined) { + openURLInNewTab(title.dataset.titleUrl); + } + return; } @@ -502,9 +534,34 @@ function timeInZone(now, zone) { timeInZone = now } - const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60); + const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60); - return { time: timeInZone, diffInHours: diffInHours }; + return { time: timeInZone, diffInMinutes: diffInMinutes }; +} + +function zoneDiffText(diffInMinutes) { + if (diffInMinutes == 0) { + return ""; + } + + const sign = diffInMinutes < 0 ? "-" : "+"; + const signText = diffInMinutes < 0 ? "behind" : "ahead"; + + diffInMinutes = Math.abs(diffInMinutes); + + const hours = Math.floor(diffInMinutes / 60); + const minutes = diffInMinutes % 60; + const hourSuffix = hours == 1 ? "" : "s"; + + if (minutes == 0) { + return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` }; + } + + if (hours == 0) { + return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` }; + } + + return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` }; } function setupClocks() { @@ -547,9 +604,11 @@ function setupClocks() { ); updateCallbacks.push((now) => { - const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone); + const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone); setZoneTime(time); - diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h'; + const { text, title } = zoneDiffText(diffInMinutes); + diffElement.textContent = text; + diffElement.title = title; }); } } @@ -566,6 +625,19 @@ function setupClocks() { updateClocks(); } +function setupTruncatedElementTitles() { + const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines"); + + if (elements.length == 0) { + return; + } + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element.title === "") element.title = element.textContent; + } +} + async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); @@ -581,6 +653,7 @@ async function setupPage() { setupCollapsibleLists(); setupCollapsibleGrids(); setupGroups(); + setupMasonries(); setupDynamicRelativeTime(); setupLazyImages(); } finally { @@ -590,6 +663,10 @@ async function setupPage() { contentReadyCallbacks[i](); } + setTimeout(() => { + setupTruncatedElementTitles(); + }, 50); + setTimeout(() => { document.body.classList.add("page-columns-transitioned"); }, 300); diff --git a/internal/glance/static/js/masonry.js b/internal/glance/static/js/masonry.js new file mode 100644 index 0000000..45680f4 --- /dev/null +++ b/internal/glance/static/js/masonry.js @@ -0,0 +1,53 @@ + +import { clamp } from "./utils.js"; + +export function setupMasonries() { + const masonryContainers = document.getElementsByClassName("masonry"); + + for (let i = 0; i < masonryContainers.length; i++) { + const container = masonryContainers[i]; + + const options = { + minColumnWidth: container.dataset.minColumnWidth || 330, + maxColumns: container.dataset.maxColumns || 6, + }; + + const items = Array.from(container.children); + let previousColumnsCount = 0; + + const render = function() { + const columnsCount = clamp( + Math.floor(container.offsetWidth / options.minColumnWidth), + 1, + Math.min(options.maxColumns, items.length) + ); + + if (columnsCount === previousColumnsCount) { + return; + } else { + container.textContent = ""; + previousColumnsCount = columnsCount; + } + + const columnsFragment = document.createDocumentFragment(); + + for (let i = 0; i < columnsCount; i++) { + const column = document.createElement("div"); + column.className = "masonry-column"; + columnsFragment.append(column); + } + + // poor man's masonry + // TODO: add an option that allows placing items in the + // shortest column instead of iterating the columns in order + for (let i = 0; i < items.length; i++) { + columnsFragment.children[i % columnsCount].appendChild(items[i]); + } + + container.append(columnsFragment); + }; + + const observer = new ResizeObserver(() => requestAnimationFrame(render)); + observer.observe(container); + } +} diff --git a/internal/assets/static/js/popover.js b/internal/glance/static/js/popover.js similarity index 94% rename from internal/assets/static/js/popover.js rename to internal/glance/static/js/popover.js index d6578ee..26d1850 100644 --- a/internal/assets/static/js/popover.js +++ b/internal/glance/static/js/popover.js @@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() { } function showPopover() { + if (pendingTarget === null) return; + activeTarget = pendingTarget; pendingTarget = null; @@ -109,9 +111,10 @@ function repositionContainer() { const containerBounds = containerElement.getBoundingClientRect(); const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline")); - const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5); + const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5); const position = activeTarget.dataset.popoverPosition || "below"; - const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2)); + const popoverOffest = activeTarget.dataset.popoverOffset || 0.5; + const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest)); if (left < 0) { containerElement.style.left = 0; @@ -120,11 +123,11 @@ function repositionContainer() { } else if (left + containerBounds.width > window.innerWidth) { containerElement.style.removeProperty("left"); containerElement.style.right = 0; - containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px"); + containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px"); } else { containerElement.style.removeProperty("right"); containerElement.style.left = left + "px"; - containerElement.style.removeProperty("--triangle-offset"); + containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px"); } const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget; diff --git a/internal/assets/static/js/utils.js b/internal/glance/static/js/utils.js similarity index 59% rename from internal/assets/static/js/utils.js rename to internal/glance/static/js/utils.js index af02086..1d1816a 100644 --- a/internal/assets/static/js/utils.js +++ b/internal/glance/static/js/utils.js @@ -23,3 +23,16 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { export function isElementVisible(element) { return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } + +export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +// NOTE: inconsistent behavior between browsers when it comes to +// whether the newly opened tab gets focused or not, potentially +// depending on the event that this function is called from +export function openURLInNewTab(url, focus = true) { + const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); + + if (focus && newWindow != null) newWindow.focus(); +} diff --git a/internal/assets/static/main.css b/internal/glance/static/main.css similarity index 95% rename from internal/assets/static/main.css rename to internal/glance/static/main.css index 5aa90e5..06f7b39 100644 --- a/internal/assets/static/main.css +++ b/internal/glance/static/main.css @@ -41,9 +41,10 @@ --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm)))); --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); + --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); + --color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%)); - --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); --font-size-h1: 1.7rem; @@ -273,6 +274,10 @@ background-color: var(--color-separator); } +pre { + font: inherit; +} + ::selection { background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); color: var(--color-text-highlight); @@ -326,7 +331,7 @@ html { scroll-behavior: smooth; } -html, body { +html, body, .body-content { height: 100%; } @@ -440,6 +445,17 @@ kbd:active { box-shadow: 0 0 0 0 var(--color-widget-background-highlight); } +.masonry { + display: flex; + gap: var(--widget-gap); +} + +.masonry-column { + flex: 1; + display: flex; + flex-direction: column; +} + .popover-container, [data-popover-html] { display: none; } @@ -728,19 +744,11 @@ details[open] .summary::after { .widget-error-icon { width: 2.4rem; height: 2.4rem; - border: 0.2rem solid var(--color-negative); - border-radius: 50%; - text-align: center; - line-height: 2rem; flex-shrink: 0; + stroke: var(--color-negative); opacity: 0.6; } -.widget-error-icon::before { - content: '!'; - color: var(--color-text-highlight); -} - .widget-content { container-type: inline-size; container-name: widget; @@ -796,7 +804,7 @@ details[open] .summary::after { .list-horizontal-text > *:not(:last-child)::after { content: '•'; color: var(--color-text-subdue); - margin: 0 0.5rem; + margin: 0 0.4rem; position: relative; top: 0.1rem; } @@ -928,15 +936,11 @@ details[open] .summary::after { border-radius: var(--border-radius) var(--border-radius) 0 0; } -.video-title { - margin-bottom: auto; - overflow: hidden; - display: block; - text-overflow: ellipsis; - line-clamp: 2; - -webkit-line-clamp: 2; - display: -webkit-box; - -webkit-box-orient: vertical; +.video-horizontal-list-thumbnail { + height: 4rem; + aspect-ratio: 16 / 8.9; + object-fit: cover; + border-radius: var(--border-radius); } .search-icon { @@ -1019,11 +1023,6 @@ details[open] .summary::after { display: none; } -.forum-post-list-item { - display: flex; - gap: 1.2rem; -} - .forum-post-list-thumbnail { flex-shrink: 0; width: 6rem; @@ -1038,6 +1037,12 @@ details[open] .summary::after { transform: translateY(-0.15rem); } +@container widget (max-width: 550px) { + .forum-post-autohide { + display: none; + } +} + .bookmarks-group { --bookmarks-group-color: var(--color-primary); } @@ -1065,7 +1070,7 @@ details[open] .summary::after { opacity: 0.8; } -:root:not(.light-scheme) .simple-icon { +:root:not(.light-scheme) .flat-icon { filter: invert(1); } @@ -1342,6 +1347,10 @@ details[open] .summary::after { transform: translate(-50%, -50%); } +.clock-time { + min-width: 8ch; +} + .clock-time span { color: var(--color-text-highlight); } @@ -1358,7 +1367,7 @@ details[open] .summary::after { transition: filter 0.3s, opacity 0.3s; } -.monitor-site-icon.simple-icon { +.monitor-site-icon.flat-icon { opacity: 0.7; } @@ -1366,7 +1375,7 @@ details[open] .summary::after { opacity: 1; } -.monitor-site:hover .monitor-site-icon:not(.simple-icon) { +.monitor-site:hover .monitor-site-icon:not(.flat-icon) { filter: grayscale(0); } @@ -1377,6 +1386,39 @@ details[open] .summary::after { height: 2rem; } +.monitor-site-status-icon-compact { + width: 1.8rem; + height: 1.8rem; + flex-shrink: 0; +} + +.docker-container-icon { + display: block; + filter: grayscale(0.4); + object-fit: contain; + aspect-ratio: 1 / 1; + width: 2.7rem; + opacity: 0.8; + transition: filter 0.3s, opacity 0.3s; +} + +.docker-container-icon.flat-icon { + opacity: 0.7; +} + +.docker-container:hover .docker-container-icon { + opacity: 1; +} + +.docker-container:hover .docker-container-icon:not(.flat-icon) { + filter: grayscale(0); +} + +.docker-container-status-icon { + width: 2rem; + height: 2rem; +} + .thumbnail { filter: grayscale(0.2) contrast(0.9); opacity: 0.8; @@ -1496,6 +1538,14 @@ details[open] .summary::after { border: 2px solid var(--color-widget-background); } +.twitch-stream-preview { + max-width: 100%; + width: 400px; + aspect-ratio: 16 / 9; + border-radius: var(--border-radius); + object-fit: cover; +} + .reddit-card-thumbnail { width: 100%; height: 100%; @@ -1660,6 +1710,11 @@ details[open] .summary::after { .weather-column-rain::before { background-size: 7px 7px; } + + .ios .search-input { + /* so that iOS Safari does not zoom the page when the input is focused */ + font-size: 16px; + } } @media (max-width: 1190px) and (display-mode: standalone) { @@ -1667,7 +1722,11 @@ details[open] .summary::after { --safe-area-inset-bottom: env(safe-area-inset-bottom, 0); } - .list-collapsible-label:has(.list-collapsible-input:checked) { + .ios .body-content { + height: 100dvh; + } + + .expand-toggle-button.container-expanded { bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); } @@ -1681,6 +1740,10 @@ details[open] .summary::after { transition: padding-bottom .3s; } + .mobile-navigation-offset { + height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); + } + .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) { padding-bottom: 0; } @@ -1739,6 +1802,7 @@ details[open] .summary::after { .size-h6 { font-size: var(--font-size-h6); } .color-highlight { color: var(--color-text-highlight); } +.color-paragraph { color: var(--color-text-paragraph); } .color-base { color: var(--color-text-base); } .color-subdue { color: var(--color-text-subdue); } .color-negative { color: var(--color-negative); } @@ -1757,7 +1821,6 @@ details[open] .summary::after { .shrink-0 { flex-shrink: 0; } .min-width-0 { min-width: 0; } .max-width-100 { max-width: 100%; } -.height-100 { height: 100%; } .block { display: block; } .inline-block { display: inline-block; } .overflow-hidden { overflow: hidden; } @@ -1778,12 +1841,14 @@ details[open] .summary::after { .gap-5 { gap: 0.5rem; } .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } +.gap-12 { gap: 1.2rem; } .gap-15 { gap: 1.5rem; } .gap-20 { gap: 2rem; } .gap-25 { gap: 2.5rem; } .gap-35 { gap: 3.5rem; } .gap-45 { gap: 4.5rem; } .gap-55 { gap: 5.5rem; } +.margin-left-auto { margin-left: auto; } .margin-top-3 { margin-top: 0.3rem; } .margin-top-5 { margin-top: 0.5rem; } .margin-top-7 { margin-top: 0.7rem; } @@ -1797,6 +1862,7 @@ details[open] .summary::after { .margin-block-3 { margin-block: 0.3rem; } .margin-block-5 { margin-block: 0.5rem; } .margin-block-7 { margin-block: 0.7rem; } +.margin-block-8 { margin-block: 0.8rem; } .margin-block-10 { margin-block: 1rem; } .margin-block-15 { margin-block: 1.5rem; } .margin-bottom-3 { margin-bottom: 0.3rem; } @@ -1810,6 +1876,7 @@ details[open] .summary::after { .list { --list-half-gap: 0rem; } .list-gap-2 { --list-half-gap: 0.1rem; } .list-gap-4 { --list-half-gap: 0.2rem; } +.list-gap-8 { --list-half-gap: 0.4rem; } .list-gap-10 { --list-half-gap: 0.5rem; } .list-gap-14 { --list-half-gap: 0.7rem; } .list-gap-20 { --list-half-gap: 1rem; } diff --git a/internal/assets/static/manifest.json b/internal/glance/static/manifest.json similarity index 100% rename from internal/assets/static/manifest.json rename to internal/glance/static/manifest.json diff --git a/internal/glance/templates.go b/internal/glance/templates.go new file mode 100644 index 0000000..fb2cb13 --- /dev/null +++ b/internal/glance/templates.go @@ -0,0 +1,61 @@ +package glance + +import ( + "html/template" + "math" + "strconv" + "time" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var intl = message.NewPrinter(language.English) + +var globalTemplateFunctions = template.FuncMap{ + "formatApproxNumber": formatApproxNumber, + "formatNumber": intl.Sprint, + "safeCSS": func(str string) template.CSS { + return template.CSS(str) + }, + "safeURL": func(str string) template.URL { + return template.URL(str) + }, + "absInt": func(i int) int { + return int(math.Abs(float64(i))) + }, + "formatPrice": func(price float64) string { + return intl.Sprintf("%.2f", price) + }, + "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { + return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`) + }, +} + +func mustParseTemplate(primary string, dependencies ...string) *template.Template { + t, err := template.New(primary). + Funcs(globalTemplateFunctions). + ParseFS(templateFS, append([]string{primary}, dependencies...)...) + + if err != nil { + panic(err) + } + + return t +} + +func formatApproxNumber(count int) string { + if count < 1_000 { + return strconv.Itoa(count) + } + + if count < 10_000 { + return strconv.FormatFloat(float64(count)/1_000, 'f', 1, 64) + "k" + } + + if count < 1_000_000 { + return strconv.Itoa(count/1_000) + "k" + } + + return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m" +} diff --git a/internal/assets/templates/bookmarks.html b/internal/glance/templates/bookmarks.html similarity index 58% rename from internal/assets/templates/bookmarks.html rename to internal/glance/templates/bookmarks.html index a4e2c97..f247bdb 100644 --- a/internal/assets/templates/bookmarks.html +++ b/internal/glance/templates/bookmarks.html @@ -3,17 +3,17 @@ {{ define "widget-content" }}
{{ range .Groups }} -
+
{{ if ne .Title "" }}
{{ .Title }}
{{ end }}
    {{ range .Links }}
  • - {{ if ne "" .Icon }} + {{ if ne "" .Icon.URL }}
    - +
    {{ end }} - {{ .Title }} + {{ .Title }}
  • {{ end }}
diff --git a/internal/assets/templates/calendar.html b/internal/glance/templates/calendar.html similarity index 83% rename from internal/assets/templates/calendar.html rename to internal/glance/templates/calendar.html index af15e5a..020d6ac 100644 --- a/internal/assets/templates/calendar.html +++ b/internal/glance/templates/calendar.html @@ -11,13 +11,18 @@
+ {{ if .StartSunday }} +
Su
+ {{ end }}
Mo
Tu
We
Th
Fr
Sa
-
Su
+ {{ if not .StartSunday }} +
Su
+ {{ end }}
diff --git a/internal/assets/templates/change-detection.html b/internal/glance/templates/change-detection.html similarity index 100% rename from internal/assets/templates/change-detection.html rename to internal/glance/templates/change-detection.html diff --git a/internal/assets/templates/clock.html b/internal/glance/templates/clock.html similarity index 100% rename from internal/assets/templates/clock.html rename to internal/glance/templates/clock.html diff --git a/internal/glance/templates/custom-api.html b/internal/glance/templates/custom-api.html new file mode 100644 index 0000000..e1f1f6f --- /dev/null +++ b/internal/glance/templates/custom-api.html @@ -0,0 +1,7 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }} + +{{ define "widget-content" }} +{{ .CompiledHTML }} +{{ end }} diff --git a/internal/assets/templates/dns-stats.html b/internal/glance/templates/dns-stats.html similarity index 91% rename from internal/assets/templates/dns-stats.html rename to internal/glance/templates/dns-stats.html index 5d83508..8128edf 100644 --- a/internal/assets/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -18,12 +18,14 @@
{{ else }}
-
{{ .Stats.DomainsBlocked | formatViewerCount }}
+
{{ .Stats.DomainsBlocked | formatApproxNumber }}
DOMAINS
{{ end }}
+ {{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }} + {{ if $showGraph }}
@@ -67,13 +69,14 @@ {{ end }}
+ {{ end }} - {{ if .Stats.TopBlockedDomains }} -
+ {{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }} +
Top blocked domains
    {{ range .Stats.TopBlockedDomains }} -
  • +
  • {{ .Domain }}
    {{ .PercentBlocked }}%
  • diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html new file mode 100644 index 0000000..66c79fd --- /dev/null +++ b/internal/glance/templates/docker-containers.html @@ -0,0 +1,64 @@ +{{ template "widget-base.html" . }} + +{{- define "widget-content" }} +
    + {{- range .Containers }} +
    +
    + +
    +
    {{ .Image }}
    +
    {{ .StateText }}
    + {{- if .Children }} +
      + {{- range .Children }} +
    • +
      {{ template "state-icon" .StateIcon }}
      +
      {{ .Title }} {{ .StateText }}
      +
    • + {{- end }} +
    + {{- end }} +
    +
    + +
    + {{- if .URL }} + {{ .Title }} + {{- else }} +
    {{ .Title }}
    + {{- end }} + {{- if .Description }} +
    {{ .Description }}
    + {{- end }} +
    + +
    + {{ template "state-icon" .StateIcon }} +
    +
    + {{- else }} +
    No containers available to show.
    + {{- end }} +
    +{{- end }} + +{{- define "state-icon" }} +{{- if eq . "ok" }} + + + +{{- else if eq . "warn" }} + + + +{{- else if eq . "paused" }} + + + +{{- else }} + + + +{{- end }} +{{- end }} diff --git a/internal/assets/templates/document.html b/internal/glance/templates/document.html similarity index 92% rename from internal/assets/templates/document.html rename to internal/glance/templates/document.html index c12a908..a26f854 100644 --- a/internal/assets/templates/document.html +++ b/internal/glance/templates/document.html @@ -3,6 +3,7 @@ {{ block "document-head-before" . }}{{ end }} {{ block "document-title" . }}{{ end }} + diff --git a/internal/glance/templates/extension.html b/internal/glance/templates/extension.html new file mode 100644 index 0000000..2973246 --- /dev/null +++ b/internal/glance/templates/extension.html @@ -0,0 +1,7 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content-classes" }}{{ if .Extension.Frameless }}widget-content-frameless{{ end }}{{ end }} + +{{ define "widget-content" }} +{{ .Extension.Content }} +{{ end }} diff --git a/internal/glance/templates/forum-posts.html b/internal/glance/templates/forum-posts.html new file mode 100644 index 0000000..05a57d8 --- /dev/null +++ b/internal/glance/templates/forum-posts.html @@ -0,0 +1,49 @@ +{{ template "widget-base.html" . }} + +{{- define "widget-content" }} +
      + {{- range .Posts }} +
    • +
      + {{- if $.ShowThumbnails }} + {{- if .IsCrosspost }} + + + + {{- else if .ThumbnailUrl }} + + {{- else if .TargetUrl }} + + + + {{- else }} + + + + {{- end }} + {{- end }} +
      + {{ .Title }} + {{- if .Tags }} + + {{- end }} +
        +
      • +
      • {{ .Score | formatApproxNumber }} points
      • +
      • {{ .CommentCount | formatApproxNumber }} comments
      • + {{- if .TargetUrl }} +
      • {{ .TargetUrlDomain }}
      • + {{- end }} +
      +
      +
      +
    • + {{- end }} +
    +{{- end }} diff --git a/internal/assets/templates/group.html b/internal/glance/templates/group.html similarity index 81% rename from internal/assets/templates/group.html rename to internal/glance/templates/group.html index fe296fe..646df2f 100644 --- a/internal/assets/templates/group.html +++ b/internal/glance/templates/group.html @@ -6,7 +6,7 @@
    {{ range $i, $widget := .Widgets }} - + {{ end }}
    diff --git a/internal/assets/templates/iframe.html b/internal/glance/templates/iframe.html similarity index 100% rename from internal/assets/templates/iframe.html rename to internal/glance/templates/iframe.html diff --git a/internal/assets/templates/markets.html b/internal/glance/templates/markets.html similarity index 100% rename from internal/assets/templates/markets.html rename to internal/glance/templates/markets.html diff --git a/internal/glance/templates/monitor-compact.html b/internal/glance/templates/monitor-compact.html new file mode 100644 index 0000000..dca5683 --- /dev/null +++ b/internal/glance/templates/monitor-compact.html @@ -0,0 +1,39 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{ if not (and .ShowFailingOnly (not .HasFailing)) }} +
      + {{ range .Sites }} + {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }} +
      + {{ template "site" . }} +
      + {{ end }} +
    +{{ else }} +
    +

    All sites are online

    + + + +
    +{{ end }} +{{ end }} + +{{ define "site" }} +{{ .Title }} +{{ if not .Status.TimedOut }}
    {{ .Status.ResponseTime.Milliseconds | formatNumber }}ms
    {{ end }} +{{ if eq .StatusStyle "ok" }} +
    + + + +
    +{{ else }} +
    + + + +
    +{{ end }} +{{ end }} diff --git a/internal/assets/templates/monitor.html b/internal/glance/templates/monitor.html similarity index 62% rename from internal/assets/templates/monitor.html rename to internal/glance/templates/monitor.html index b19f0e2..7e95b99 100644 --- a/internal/assets/templates/monitor.html +++ b/internal/glance/templates/monitor.html @@ -21,11 +21,11 @@ {{ end }} {{ define "site" }} -{{ if .IconUrl }} - +{{ if .Icon.URL }} + {{ end }}
    - {{ .Title }} + {{ .Title }}
      {{ if not .Status.Error }}
    • {{ .StatusText }}
    • @@ -39,14 +39,14 @@
    {{ if eq .StatusStyle "ok" }}
    - - + +
    {{ else }}
    - - + +
    {{ end }} diff --git a/internal/assets/templates/content.html b/internal/glance/templates/page-content.html similarity index 100% rename from internal/assets/templates/content.html rename to internal/glance/templates/page-content.html diff --git a/internal/assets/templates/page.html b/internal/glance/templates/page.html similarity index 89% rename from internal/assets/templates/page.html rename to internal/glance/templates/page.html index d2cee76..e740d03 100644 --- a/internal/assets/templates/page.html +++ b/internal/glance/templates/page.html @@ -14,10 +14,13 @@ {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }} {{ define "document-head-after" }} -{{ template "page-style-overrides.gotmpl" . }} +{{ .App.ParsedThemeStyle }} + {{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ end }} + +{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ end }} {{ define "navigation-links" }} @@ -27,7 +30,7 @@ {{ end }} {{ define "document-body" }} -
    +
    {{ if not .Page.HideDesktopNavigation }}
    @@ -44,9 +47,9 @@
    ↑ {{ range $i, $column := .Page.Columns }} - + {{ end }} - +
    diff --git a/internal/assets/templates/reddit-vertical-cards.html b/internal/glance/templates/reddit-vertical-cards.html similarity index 81% rename from internal/assets/templates/reddit-vertical-cards.html rename to internal/glance/templates/reddit-vertical-cards.html index 38ec9e8..747cc7e 100644 --- a/internal/assets/templates/reddit-vertical-cards.html +++ b/internal/glance/templates/reddit-vertical-cards.html @@ -17,10 +17,10 @@ {{ else }}
    /r/{{ $.Subreddit }}
    {{ end }} - {{ .Title }} + {{ .Title }}
    • -
    • {{ .Score | formatNumber }} points
    • +
    • {{ .Score | formatApproxNumber }} points
    diff --git a/internal/assets/templates/releases.html b/internal/glance/templates/releases.html similarity index 75% rename from internal/assets/templates/releases.html rename to internal/glance/templates/releases.html index 9bef5a0..3643524 100644 --- a/internal/assets/templates/releases.html +++ b/internal/glance/templates/releases.html @@ -1,13 +1,13 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -
      +
        {{ range .Releases }}
      • {{ .Name }} {{ if $.ShowSourceIcon }} - + {{ end }}
          diff --git a/internal/glance/templates/repository.html b/internal/glance/templates/repository.html new file mode 100644 index 0000000..d0d1b8e --- /dev/null +++ b/internal/glance/templates/repository.html @@ -0,0 +1,61 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{ .Repository.Name }} +
            +
          • {{ .Repository.Stars | formatNumber }} stars
          • +
          • {{ .Repository.Forks | formatNumber }} forks
          • +
          + +{{ if gt (len .Repository.Commits) 0 }} +
          +Last {{ .CommitsLimit }} commits +
          +
            + {{ range .Repository.Commits }} +
          • + {{ end }} +
          + +
          +{{ end }} + +{{ if gt (len .Repository.PullRequests) 0 }} +
          +Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total) +
          +
            + {{ range .Repository.PullRequests }} +
          • + {{ end }} +
          +
            + {{ range .Repository.PullRequests }} +
          • {{ .Title }}
          • + {{ end }} +
          +
          +{{ end }} + +{{ if gt (len .Repository.Issues) 0 }} +
          +Open issues ({{ .Repository.OpenIssues | formatNumber }} total) +
          +
            + {{ range .Repository.Issues }} +
          • + {{ end }} +
          +
            + {{ range .Repository.Issues }} +
          • {{ .Title }}
          • + {{ end }} +
          +
          +{{ end }} + +{{ end }} diff --git a/internal/assets/templates/rss-detailed-list.html b/internal/glance/templates/rss-detailed-list.html similarity index 100% rename from internal/assets/templates/rss-detailed-list.html rename to internal/glance/templates/rss-detailed-list.html diff --git a/internal/assets/templates/rss-horizontal-cards-2.html b/internal/glance/templates/rss-horizontal-cards-2.html similarity index 91% rename from internal/assets/templates/rss-horizontal-cards-2.html rename to internal/glance/templates/rss-horizontal-cards-2.html index 0404fce..496e56a 100644 --- a/internal/assets/templates/rss-horizontal-cards-2.html +++ b/internal/glance/templates/rss-horizontal-cards-2.html @@ -16,7 +16,7 @@ {{ end }}
          - {{ .Title }} + {{ .Title }}
          • {{ .ChannelName }}
          • diff --git a/internal/assets/templates/rss-horizontal-cards.html b/internal/glance/templates/rss-horizontal-cards.html similarity index 89% rename from internal/assets/templates/rss-horizontal-cards.html rename to internal/glance/templates/rss-horizontal-cards.html index 0f79b56..d8eef92 100644 --- a/internal/assets/templates/rss-horizontal-cards.html +++ b/internal/glance/templates/rss-horizontal-cards.html @@ -16,7 +16,7 @@ {{ end }}
            - {{ .Title }} + {{ .Title }}
            • {{ .ChannelName }}
            • diff --git a/internal/assets/templates/rss-list.html b/internal/glance/templates/rss-list.html similarity index 87% rename from internal/assets/templates/rss-list.html rename to internal/glance/templates/rss-list.html index 0b6222a..c14eb60 100644 --- a/internal/assets/templates/rss-list.html +++ b/internal/glance/templates/rss-list.html @@ -4,7 +4,7 @@
                {{ range .Items }}
              • - {{ .Title }} + {{ .Title }}
                • diff --git a/internal/assets/templates/search.html b/internal/glance/templates/search.html similarity index 87% rename from internal/assets/templates/search.html rename to internal/glance/templates/search.html index df84e9d..6e8fc43 100644 --- a/internal/assets/templates/search.html +++ b/internal/glance/templates/search.html @@ -16,7 +16,7 @@
            - +
            S diff --git a/internal/glance/templates/split-column.html b/internal/glance/templates/split-column.html new file mode 100644 index 0000000..63b4aea --- /dev/null +++ b/internal/glance/templates/split-column.html @@ -0,0 +1,11 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content-classes" }}widget-content-frameless{{ end }} + +{{ define "widget-content" }} +
            +{{ range .Widgets }} + {{ .Render }} +{{ end }} +
            +{{ end }} diff --git a/internal/glance/templates/theme-style.gotmpl b/internal/glance/templates/theme-style.gotmpl new file mode 100644 index 0000000..878ca0b --- /dev/null +++ b/internal/glance/templates/theme-style.gotmpl @@ -0,0 +1,14 @@ + diff --git a/internal/assets/templates/twitch-channels.html b/internal/glance/templates/twitch-channels.html similarity index 72% rename from internal/assets/templates/twitch-channels.html rename to internal/glance/templates/twitch-channels.html index aba7935..021a17a 100644 --- a/internal/assets/templates/twitch-channels.html +++ b/internal/glance/templates/twitch-channels.html @@ -5,9 +5,15 @@ {{ range .Channels }}
          • -
            +
            + {{ if .IsLive }} +
            + +

            {{ .StreamTitle }}

            +
            + {{ end }} {{ if .Exists }} - + {{ else }} @@ -25,7 +31,7 @@ {{ end }}
            • -
            • {{ .ViewersCount | formatViewerCount }} viewers
            • +
            • {{ .ViewersCount | formatApproxNumber }} viewers
            {{ else }}
            Offline
            diff --git a/internal/assets/templates/twitch-games-list.html b/internal/glance/templates/twitch-games-list.html similarity index 94% rename from internal/assets/templates/twitch-games-list.html rename to internal/glance/templates/twitch-games-list.html index 38ce758..94fc400 100644 --- a/internal/assets/templates/twitch-games-list.html +++ b/internal/glance/templates/twitch-games-list.html @@ -9,7 +9,7 @@
            {{ .Name }}
              -
            • {{ .ViewersCount | formatViewerCount }} viewers
            • +
            • {{ .ViewersCount | formatApproxNumber }} viewers
            • {{ if .IsNew }}
            • NEW
            • {{ end }} diff --git a/internal/glance/templates/v0.7-update-notice-page.html b/internal/glance/templates/v0.7-update-notice-page.html new file mode 100644 index 0000000..1f3f524 --- /dev/null +++ b/internal/glance/templates/v0.7-update-notice-page.html @@ -0,0 +1,44 @@ + + + + + + + Update notice + + + + + + +
              +

              UPDATE NOTICE

              +
              +

              + The default location of glance.yml in the Docker image has + changed since v0.7.0, please see the migration guide + for instructions or visit the release notes + to find out more about why this change was necessary. Sorry for the inconvenience. +

              + +

              Migration should take around 5 minutes.

              +
              +
              + + + diff --git a/internal/assets/templates/video-card-contents.html b/internal/glance/templates/video-card-contents.html similarity index 78% rename from internal/assets/templates/video-card-contents.html rename to internal/glance/templates/video-card-contents.html index 375fd08..c6340c5 100644 --- a/internal/assets/templates/video-card-contents.html +++ b/internal/glance/templates/video-card-contents.html @@ -1,7 +1,7 @@ {{ define "video-card-contents" }}
              - {{ .Title }} + {{ .Title }}
              • diff --git a/internal/assets/templates/videos-grid.html b/internal/glance/templates/videos-grid.html similarity index 100% rename from internal/assets/templates/videos-grid.html rename to internal/glance/templates/videos-grid.html diff --git a/internal/glance/templates/videos-vertical-list.html b/internal/glance/templates/videos-vertical-list.html new file mode 100644 index 0000000..b7ea6b2 --- /dev/null +++ b/internal/glance/templates/videos-vertical-list.html @@ -0,0 +1,20 @@ +{{ template "widget-base.html" . }} + +{{- define "widget-content" }} + +{{- end }} diff --git a/internal/assets/templates/videos.html b/internal/glance/templates/videos.html similarity index 100% rename from internal/assets/templates/videos.html rename to internal/glance/templates/videos.html diff --git a/internal/assets/templates/weather.html b/internal/glance/templates/weather.html similarity index 100% rename from internal/assets/templates/weather.html rename to internal/glance/templates/weather.html diff --git a/internal/assets/templates/widget-base.html b/internal/glance/templates/widget-base.html similarity index 60% rename from internal/assets/templates/widget-base.html rename to internal/glance/templates/widget-base.html index bdd30b9..0a8e3f2 100644 --- a/internal/assets/templates/widget-base.html +++ b/internal/glance/templates/widget-base.html @@ -1,7 +1,7 @@
                {{ if not .HideHeader}}
                - {{ if ne "" .TitleURL}}{{ .Title }}{{ else }}
                {{ .Title }}
                {{ end }} + {{ if ne "" .TitleURL }}{{ .Title }}{{ else }}
                {{ .Title }}
                {{ end }} {{ if and .Error .ContentAvailable }}
                {{ else if .Notice }} @@ -15,7 +15,9 @@ {{ else }}
                ERROR
                -
                + + +

                {{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}

                {{ end}} diff --git a/internal/feed/utils.go b/internal/glance/utils.go similarity index 51% rename from internal/feed/utils.go rename to internal/glance/utils.go index a6e3f8d..8455bfe 100644 --- a/internal/feed/utils.go +++ b/internal/glance/utils.go @@ -1,19 +1,19 @@ -package feed +package glance import ( - "errors" + "bytes" "fmt" + "html/template" + "net/http" "net/url" + "os" "regexp" "slices" "strings" "time" ) -var ( - ErrNoContent = errors.New("failed to retrieve any content") - ErrPartialContent = errors.New("failed to retrieve some of the content") -) +var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func percentChange(current, previous float64) float64 { return (current/previous - 1) * 100 @@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string { } parsed, err := url.Parse(u) - if err != nil { return "" } @@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string { return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") } -func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { +func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { if len(values) < 2 { return "" } @@ -86,6 +85,21 @@ func stripURLScheme(url string) string { return urlSchemePattern.ReplaceAllString(url, "") } +func isRunningInsideDockerContainer() bool { + _, err := os.Stat("/.dockerenv") + return err == nil +} + +func prefixStringLines(prefix string, s string) string { + lines := strings.Split(s, "\n") + + for i, line := range lines { + lines[i] = prefix + line + } + + return strings.Join(lines, "\n") +} + func limitStringLength(s string, max int) (string, bool) { asRunes := []rune(s) @@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) { func parseRFC3339Time(t string) time.Time { parsed, err := time.Parse(time.RFC3339, t) - if err != nil { return time.Now() } @@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time { return parsed } +func boolToString(b bool, trueValue, falseValue string) string { + if b { + return trueValue + } + + return falseValue +} + func normalizeVersionFormat(version string) string { version = strings.ToLower(strings.TrimSpace(version)) @@ -115,3 +136,53 @@ func normalizeVersionFormat(version string) string { return version } + +func titleToSlug(s string) string { + s = strings.ToLower(s) + s = sequentialWhitespacePattern.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + + return s +} + +func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { + server := http.FileServer(fs) + cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: fix always setting cache control even if the file doesn't exist + w.Header().Set("Cache-Control", cacheControlValue) + server.ServeHTTP(w, r) + }) +} + +func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) { + var b bytes.Buffer + + err := t.Execute(&b, data) + if err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return template.HTML(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 + } + + return items[index] +} + +func ternary[T any](condition bool, a, b T) T { + if condition { + return a + } + + return b +} diff --git a/internal/glance/widget-bookmarks.go b/internal/glance/widget-bookmarks.go new file mode 100644 index 0000000..4f3d26e --- /dev/null +++ b/internal/glance/widget-bookmarks.go @@ -0,0 +1,76 @@ +package glance + +import ( + "html/template" +) + +var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html") + +type bookmarksWidget struct { + widgetBase `yaml:",inline"` + cachedHTML template.HTML `yaml:"-"` + Groups []struct { + Title string `yaml:"title"` + Color *hslColorField `yaml:"color"` + SameTab bool `yaml:"same-tab"` + HideArrow bool `yaml:"hide-arrow"` + Target string `yaml:"target"` + Links []struct { + Title string `yaml:"title"` + URL string `yaml:"url"` + Icon customIconField `yaml:"icon"` + // we need a pointer to bool to know whether a value was provided, + // however there's no way to dereference a pointer in a template so + // {{ if not .SameTab }} would return true for any non-nil pointer + // which leaves us with no way of checking if the value is true or + // false, hence the duplicated fields below + SameTabRaw *bool `yaml:"same-tab"` + SameTab bool `yaml:"-"` + HideArrowRaw *bool `yaml:"hide-arrow"` + HideArrow bool `yaml:"-"` + Target string `yaml:"target"` + } `yaml:"links"` + } `yaml:"groups"` +} + +func (widget *bookmarksWidget) initialize() error { + widget.withTitle("Bookmarks").withError(nil) + + for g := range widget.Groups { + group := &widget.Groups[g] + for l := range group.Links { + link := &group.Links[l] + if link.SameTabRaw == nil { + link.SameTab = group.SameTab + } else { + link.SameTab = *link.SameTabRaw + } + + if link.HideArrowRaw == nil { + link.HideArrow = group.HideArrow + } else { + link.HideArrow = *link.HideArrowRaw + } + + if link.Target == "" { + if group.Target != "" { + link.Target = group.Target + } else { + if link.SameTab { + link.Target = "" + } else { + link.Target = "_blank" + } + } + } + } + } + + widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate) + + return nil +} + +func (widget *bookmarksWidget) Render() template.HTML { + return widget.cachedHTML +} diff --git a/internal/glance/widget-calendar.go b/internal/glance/widget-calendar.go new file mode 100644 index 0000000..518bc22 --- /dev/null +++ b/internal/glance/widget-calendar.go @@ -0,0 +1,86 @@ +package glance + +import ( + "context" + "html/template" + "time" +) + +var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html") + +type calendarWidget struct { + widgetBase `yaml:",inline"` + Calendar *calendar + StartSunday bool `yaml:"start-sunday"` +} + +func (widget *calendarWidget) initialize() error { + widget.withTitle("Calendar").withCacheOnTheHour() + + return nil +} + +func (widget *calendarWidget) update(ctx context.Context) { + widget.Calendar = newCalendar(time.Now(), widget.StartSunday) + widget.withError(nil).scheduleNextUpdate() +} + +func (widget *calendarWidget) Render() template.HTML { + return widget.renderTemplate(widget, calendarWidgetTemplate) +} + +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/feed/changedetection.go b/internal/glance/widget-changedetection.go similarity index 52% rename from internal/feed/changedetection.go rename to internal/glance/widget-changedetection.go index 793416d..8ca8803 100644 --- a/internal/feed/changedetection.go +++ b/internal/glance/widget-changedetection.go @@ -1,7 +1,9 @@ -package feed +package glance import ( + "context" "fmt" + "html/template" "log/slog" "net/http" "sort" @@ -9,7 +11,65 @@ import ( "time" ) -type ChangeDetectionWatch struct { +var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html") + +type changeDetectionWidget struct { + widgetBase `yaml:",inline"` + ChangeDetections changeDetectionWatchList `yaml:"-"` + WatchUUIDs []string `yaml:"watches"` + InstanceURL string `yaml:"instance-url"` + Token string `yaml:"token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` +} + +func (widget *changeDetectionWidget) initialize() error { + widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.InstanceURL == "" { + widget.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)) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.WatchUUIDs = uuids + } + + watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(watches) > widget.Limit { + watches = watches[:widget.Limit] + } + + widget.ChangeDetections = watches +} + +func (widget *changeDetectionWidget) Render() template.HTML { + return widget.renderTemplate(widget, changeDetectionWidgetTemplate) +} + +type changeDetectionWatch struct { Title string URL string LastChanged time.Time @@ -17,9 +77,9 @@ type ChangeDetectionWatch struct { PreviousHash string } -type ChangeDetectionWatches []ChangeDetectionWatch +type changeDetectionWatchList []changeDetectionWatch -func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches { +func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList { sort.Slice(r, func(i, j int) bool { return r[i].LastChanged.After(r[j].LastChanged) }) @@ -35,15 +95,14 @@ type changeDetectionResponseJson struct { PreviousHash string `json:"previous_md5"` } -func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) { +func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil) if token != "" { request.Header.Add("x-api-key", token) } - uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request) - + uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err) } @@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str return uuids, nil } -func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) { - watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs)) +func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) { + watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs)) if len(requestedWatchIDs) == 0 { return watches, nil @@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str requests[i] = request } - task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient) + task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient) job := newJob(task, requests).withWorkers(15) responses, errs, err := workerPoolDo(job) - if err != nil { return nil, err } @@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str for i := range responses { if errs[i] != nil { failed++ - slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL) + slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i]) continue } watchJson := responses[i] - watch := ChangeDetectionWatch{ + watch := changeDetectionWatch{ URL: watchJson.URL, DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), } @@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str } if len(watches) == 0 { - return nil, ErrNoContent + return nil, errNoContent } - watches.SortByNewest() + watches.sortByNewest() if failed > 0 { - return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed) + return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed) } return watches, nil diff --git a/internal/widget/clock.go b/internal/glance/widget-clock.go similarity index 51% rename from internal/widget/clock.go rename to internal/glance/widget-clock.go index efe8ccd..c69fc95 100644 --- a/internal/widget/clock.go +++ b/internal/glance/widget-clock.go @@ -1,15 +1,15 @@ -package widget +package glance import ( "errors" "fmt" "html/template" "time" - - "github.com/glanceapp/glance/internal/assets" ) -type Clock struct { +var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html") + +type clockWidget struct { widgetBase `yaml:",inline"` cachedHTML template.HTML `yaml:"-"` HourFormat string `yaml:"hour-format"` @@ -19,32 +19,30 @@ type Clock struct { } `yaml:"timezones"` } -func (widget *Clock) Initialize() error { +func (widget *clockWidget) initialize() error { widget.withTitle("Clock").withError(nil) if widget.HourFormat == "" { widget.HourFormat = "24h" } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" { - return errors.New("invalid hour format for clock widget, must be either 12h or 24h") + return errors.New("hour-format must be either 12h or 24h") } for t := range widget.Timezones { if widget.Timezones[t].Timezone == "" { - return errors.New("missing timezone value for clock widget") + return errors.New("missing timezone value") } - _, err := time.LoadLocation(widget.Timezones[t].Timezone) - - if err != nil { - return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err) + if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil { + return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err) } } - widget.cachedHTML = widget.render(widget, assets.ClockTemplate) + widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate) return nil } -func (widget *Clock) Render() template.HTML { +func (widget *clockWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-container.go b/internal/glance/widget-container.go new file mode 100644 index 0000000..4c9f33a --- /dev/null +++ b/internal/glance/widget-container.go @@ -0,0 +1,58 @@ +package glance + +import ( + "context" + "sync" + "time" +) + +type containerWidgetBase struct { + Widgets widgets `yaml:"widgets"` +} + +func (widget *containerWidgetBase) _initializeWidgets() error { + for i := range widget.Widgets { + if err := widget.Widgets[i].initialize(); err != nil { + return formatWidgetInitError(err, widget.Widgets[i]) + } + } + + return nil +} + +func (widget *containerWidgetBase) _update(ctx context.Context) { + var wg sync.WaitGroup + now := time.Now() + + for w := range widget.Widgets { + widget := widget.Widgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(ctx) + }() + } + + wg.Wait() +} + +func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) { + for i := range widget.Widgets { + widget.Widgets[i].setProviders(providers) + } +} + +func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool { + for i := range widget.Widgets { + if widget.Widgets[i].requiresUpdate(now) { + return true + } + } + + return false +} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go new file mode 100644 index 0000000..b7acce3 --- /dev/null +++ b/internal/glance/widget-custom-api.go @@ -0,0 +1,208 @@ +package glance + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "time" + + "github.com/tidwall/gjson" +) + +var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html") + +type customAPIWidget struct { + widgetBase `yaml:",inline"` + URL string `yaml:"url"` + Template string `yaml:"template"` + Frameless bool `yaml:"frameless"` + Headers map[string]string `yaml:"headers"` + APIRequest *http.Request `yaml:"-"` + compiledTemplate *template.Template `yaml:"-"` + CompiledHTML template.HTML `yaml:"-"` +} + +func (widget *customAPIWidget) initialize() error { + widget.withTitle("Custom API").withCacheDuration(1 * time.Hour) + + if widget.URL == "" { + return errors.New("URL is required") + } + + if widget.Template == "" { + return errors.New("template is required") + } + + compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template) + if err != nil { + return fmt.Errorf("parsing template: %w", err) + } + + widget.compiledTemplate = compiledTemplate + + req, err := http.NewRequest(http.MethodGet, widget.URL, nil) + if err != nil { + return err + } + + for key, value := range widget.Headers { + req.Header.Add(key, value) + } + + widget.APIRequest = req + + return nil +} + +func (widget *customAPIWidget) update(ctx context.Context) { + compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate) + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.CompiledHTML = compiledHTML +} + +func (widget *customAPIWidget) Render() template.HTML { + return widget.renderTemplate(widget, customAPIWidgetTemplate) +} + +func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { + emptyBody := template.HTML("") + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return emptyBody, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return emptyBody, err + } + + body := string(bodyBytes) + + if !gjson.Valid(body) { + truncatedBody, isTruncated := limitStringLength(body, 100) + if isTruncated { + truncatedBody += "... " + } + + slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody) + return emptyBody, errors.New("invalid response JSON") + } + + var templateBuffer bytes.Buffer + + data := CustomAPITemplateData{ + JSON: decoratedGJSONResult{gjson.Parse(body)}, + Response: resp, + } + + err = tmpl.Execute(&templateBuffer, &data) + if err != nil { + return emptyBody, err + } + + return template.HTML(templateBuffer.String()), nil +} + +type decoratedGJSONResult struct { + gjson.Result +} + +type CustomAPITemplateData struct { + JSON decoratedGJSONResult + Response *http.Response +} + +func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult { + decoratedResults := make([]decoratedGJSONResult, len(results)) + + for i, result := range results { + decoratedResults[i] = decoratedGJSONResult{result} + } + + return decoratedResults +} + +func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult { + if key == "" { + return gJsonResultArrayToDecoratedResultArray(r.Result.Array()) + } + + return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array()) +} + +func (r *decoratedGJSONResult) String(key string) string { + if key == "" { + return r.Result.String() + } + + return r.Get(key).String() +} + +func (r *decoratedGJSONResult) Int(key string) int64 { + if key == "" { + return r.Result.Int() + } + + return r.Get(key).Int() +} + +func (r *decoratedGJSONResult) Float(key string) float64 { + if key == "" { + return r.Result.Float() + } + + return r.Get(key).Float() +} + +func (r *decoratedGJSONResult) Bool(key string) bool { + if key == "" { + return r.Result.Bool() + } + + return r.Get(key).Bool() +} + +var customAPITemplateFuncs = func() template.FuncMap { + funcs := template.FuncMap{ + "toFloat": func(a int64) float64 { + return float64(a) + }, + "toInt": func(a float64) int64 { + return int64(a) + }, + "mathexpr": func(left float64, op string, right float64) float64 { + if right == 0 { + return 0 + } + + switch op { + case "+": + return left + right + case "-": + return left - right + case "*": + return left * right + case "/": + return left / right + default: + return 0 + } + }, + } + + for key, value := range globalTemplateFunctions { + funcs[key] = value + } + + return funcs +}() diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go new file mode 100644 index 0000000..833a80d --- /dev/null +++ b/internal/glance/widget-dns-stats.go @@ -0,0 +1,381 @@ +package glance + +import ( + "context" + "encoding/json" + "errors" + "html/template" + "log/slog" + "net/http" + "sort" + "strings" + "time" +) + +var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") + +type dnsStatsWidget struct { + widgetBase `yaml:",inline"` + + TimeLabels [8]string `yaml:"-"` + Stats *dnsStats `yaml:"-"` + + HourFormat string `yaml:"hour-format"` + HideGraph bool `yaml:"hide-graph"` + HideTopDomains bool `yaml:"hide-top-domains"` + Service string `yaml:"service"` + AllowInsecure bool `yaml:"allow-insecure"` + URL string `yaml:"url"` + Token string `yaml:"token"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +func makeDNSWidgetTimeLabels(format string) [8]string { + now := time.Now() + var labels [8]string + + for h := 24; h > 0; h -= 3 { + labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format)) + } + + return labels +} + +func (widget *dnsStatsWidget) initialize() error { + widget. + withTitle("DNS Stats"). + withTitleURL(string(widget.URL)). + withCacheDuration(10 * time.Minute) + + if widget.Service != "adguard" && widget.Service != "pihole" { + return errors.New("service must be either 'adguard' or 'pihole'") + } + + return nil +} + +func (widget *dnsStatsWidget) update(ctx context.Context) { + var stats *dnsStats + var err error + + if widget.Service == "adguard" { + stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) + } else { + stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + } + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.HourFormat == "24h" { + widget.TimeLabels = makeDNSWidgetTimeLabels("15:00") + } else { + widget.TimeLabels = makeDNSWidgetTimeLabels("3PM") + } + + widget.Stats = stats +} + +func (widget *dnsStatsWidget) Render() template.HTML { + return widget.renderTemplate(widget, dnsStatsWidgetTemplate) +} + +type dnsStats struct { + TotalQueries int + BlockedQueries int + BlockedPercent int + ResponseTime int + DomainsBlocked int + Series [8]dnsStatsSeries + TopBlockedDomains []dnsStatsBlockedDomain +} + +type dnsStatsSeries struct { + Queries int + Blocked int + PercentTotal int + PercentBlocked int +} + +type dnsStatsBlockedDomain struct { + Domain string + PercentBlocked int +} + +type adguardStatsResponse struct { + TotalQueries int `json:"num_dns_queries"` + QueriesSeries []int `json:"dns_queries"` + BlockedQueries int `json:"num_blocked_filtering"` + BlockedSeries []int `json:"blocked_filtering"` + ResponseTime float64 `json:"avg_processing_time"` + TopBlockedDomains []map[string]int `json:"top_blocked_domains"` +} + +func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string, noGraph bool) (*dnsStats, error) { + requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" + + request, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + + request.SetBasicAuth(username, password) + + var client requestDoer + if !allowInsecure { + client = defaultHTTPClient + } else { + client = defaultInsecureHTTPClient + } + + responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) + if err != nil { + return nil, err + } + + var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) + + stats := &dnsStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + ResponseTime: int(responseJson.ResponseTime * 1000), + TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount), + } + + if stats.TotalQueries <= 0 { + return stats, nil + } + + stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) + + for i := 0; i < topBlockedDomainsCount; i++ { + domain := responseJson.TopBlockedDomains[i] + var firstDomain string + + for k := range domain { + firstDomain = k + break + } + + if firstDomain == "" { + continue + } + + stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{ + Domain: firstDomain, + }) + + if stats.BlockedQueries > 0 { + stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) + } + } + + if noGraph { + return stats, nil + } + + queriesSeries := responseJson.QueriesSeries + blockedSeries := responseJson.BlockedSeries + + const bars = 8 + const hoursSpan = 24 + const hoursPerBar int = hoursSpan / bars + + if len(queriesSeries) > hoursSpan { + queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] + } else if len(queriesSeries) < hoursSpan { + queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) + } + + if len(blockedSeries) > hoursSpan { + blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] + } else if len(blockedSeries) < hoursSpan { + blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) + } + + maxQueriesInSeries := 0 + + for i := 0; i < bars; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < hoursPerBar; j++ { + queries += queriesSeries[i*hoursPerBar+j] + blocked += blockedSeries[i*hoursPerBar+j] + } + + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + } + + for i := 0; i < bars; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} + +type piholeStatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries piholeQueriesSeries `json:"domains_over_time"` + BlockedQueries int `json:"ads_blocked_today"` + BlockedSeries map[int64]int `json:"ads_over_time"` + BlockedPercentage float64 `json:"ads_percentage_today"` + TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` + DomainsBlocked int `json:"domains_being_blocked"` +} + +// If the user has query logging disabled it's possible for domains_over_time to be returned as an +// empty array rather than a map which will prevent unmashalling the rest of the data so we use +// custom unmarshal behavior to fallback to an empty map. +// See https://github.com/glanceapp/glance/issues/289 +type piholeQueriesSeries map[int64]int + +func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error { + temp := make(map[int64]int) + + err := json.Unmarshal(data, &temp) + if err != nil { + *p = make(piholeQueriesSeries) + } else { + *p = temp + } + + return nil +} + +// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array +// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling +type piholeTopBlockedDomains map[string]int + +func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { + // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow + // because of the UnmarshalJSON method getting called recursively + temp := make(map[string]int) + + err := json.Unmarshal(data, &temp) + if err != nil { + *p = make(piholeTopBlockedDomains) + } else { + *p = temp + } + + return nil +} + +func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { + if token == "" { + return nil, errors.New("missing API token") + } + + requestURL := strings.TrimRight(instanceURL, "/") + + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token + + request, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + + var client requestDoer + if !allowInsecure { + client = defaultHTTPClient + } else { + client = defaultInsecureHTTPClient + } + + responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request) + if err != nil { + return nil, err + } + + stats := &dnsStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + BlockedPercent: int(responseJson.BlockedPercentage), + DomainsBlocked: responseJson.DomainsBlocked, + } + + if len(responseJson.TopBlockedDomains) > 0 { + domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) + + for domain, count := range responseJson.TopBlockedDomains { + domains = append(domains, dnsStatsBlockedDomain{ + Domain: domain, + PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + if noGraph { + return stats, nil + } + + // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 + if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { + slog.Warn( + "DNS stats for pihole: did not get expected 144 data points", + "len(queries)", len(responseJson.QueriesSeries), + "len(blocked)", len(responseJson.BlockedSeries), + ) + return stats, nil + } + + var lowestTimestamp int64 = 0 + + for timestamp := range responseJson.QueriesSeries { + if lowestTimestamp == 0 || timestamp < lowestTimestamp { + lowestTimestamp = timestamp + } + } + + maxQueriesInSeries := 0 + + for i := 0; i < 8; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < 18; j++ { + index := lowestTimestamp + int64(i*10800+j*600) + + queries += responseJson.QueriesSeries[index] + blocked += responseJson.BlockedSeries[index] + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + } + + for i := 0; i < 8; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go new file mode 100644 index 0000000..e133dd8 --- /dev/null +++ b/internal/glance/widget-docker-containers.go @@ -0,0 +1,273 @@ +package glance + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net" + "net/http" + "sort" + "strings" + "time" +) + +var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html") + +type dockerContainersWidget struct { + widgetBase `yaml:",inline"` + HideByDefault bool `yaml:"hide-by-default"` + SockPath string `yaml:"sock-path"` + Containers dockerContainerList `yaml:"-"` +} + +func (widget *dockerContainersWidget) initialize() error { + widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute) + + if widget.SockPath == "" { + widget.SockPath = "/var/run/docker.sock" + } + + return nil +} + +func (widget *dockerContainersWidget) update(ctx context.Context) { + containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault) + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + containers.sortByStateIconThenTitle() + widget.Containers = containers +} + +func (widget *dockerContainersWidget) Render() template.HTML { + return widget.renderTemplate(widget, dockerContainersWidgetTemplate) +} + +const ( + dockerContainerLabelHide = "glance.hide" + dockerContainerLabelTitle = "glance.title" + dockerContainerLabelURL = "glance.url" + dockerContainerLabelDescription = "glance.description" + dockerContainerLabelSameTab = "glance.same-tab" + dockerContainerLabelIcon = "glance.icon" + dockerContainerLabelID = "glance.id" + dockerContainerLabelParent = "glance.parent" +) + +const ( + dockerContainerStateIconOK = "ok" + dockerContainerStateIconPaused = "paused" + dockerContainerStateIconWarn = "warn" + dockerContainerStateIconOther = "other" +) + +var dockerContainerStateIconPriorities = map[string]int{ + dockerContainerStateIconWarn: 0, + dockerContainerStateIconOther: 1, + dockerContainerStateIconPaused: 2, + dockerContainerStateIconOK: 3, +} + +type dockerContainerJsonResponse struct { + Names []string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + Labels dockerContainerLabels `json:"Labels"` +} + +type dockerContainerLabels map[string]string + +func (l *dockerContainerLabels) getOrDefault(label, def string) string { + if l == nil { + return def + } + + v, ok := (*l)[label] + if !ok { + return def + } + + if v == "" { + return def + } + + return v +} + +type dockerContainer struct { + Title string + URL string + SameTab bool + Image string + State string + StateText string + StateIcon string + Description string + Icon customIconField + Children dockerContainerList +} + +type dockerContainerList []dockerContainer + +func (containers dockerContainerList) sortByStateIconThenTitle() { + p := &dockerContainerStateIconPriorities + + sort.SliceStable(containers, func(a, b int) bool { + if containers[a].StateIcon != containers[b].StateIcon { + return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon] + } + + return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title) + }) +} + +func dockerContainerStateToStateIcon(state string) string { + switch state { + case "running": + return dockerContainerStateIconOK + case "paused": + return dockerContainerStateIconPaused + case "exited", "unhealthy", "dead": + return dockerContainerStateIconWarn + default: + return dockerContainerStateIconOther + } +} + +func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) { + containers, err := fetchAllDockerContainersFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching containers: %w", err) + } + + containers, children := groupDockerContainerChildren(containers, hideByDefault) + dockerContainers := make(dockerContainerList, 0, len(containers)) + + for i := range containers { + container := &containers[i] + + dc := dockerContainer{ + Title: deriveDockerContainerTitle(container), + URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""), + Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""), + SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), + Image: container.Image, + State: strings.ToLower(container.State), + StateText: strings.ToLower(container.Status), + Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")), + } + + if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { + if children, ok := children[idValue]; ok { + for i := range children { + child := &children[i] + dc.Children = append(dc.Children, dockerContainer{ + Title: deriveDockerContainerTitle(child), + StateText: child.Status, + StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), + }) + } + } + } + + dc.Children.sortByStateIconThenTitle() + + stateIconSupersededByChild := false + for i := range dc.Children { + if dc.Children[i].StateIcon == dockerContainerStateIconWarn { + dc.StateIcon = dockerContainerStateIconWarn + stateIconSupersededByChild = true + break + } + } + if !stateIconSupersededByChild { + dc.StateIcon = dockerContainerStateToStateIcon(dc.State) + } + + dockerContainers = append(dockerContainers, dc) + } + + return dockerContainers, nil +} + +func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string { + if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" { + return v + } + + return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/") +} + +func groupDockerContainerChildren( + containers []dockerContainerJsonResponse, + hideByDefault bool, +) ( + []dockerContainerJsonResponse, + map[string][]dockerContainerJsonResponse, +) { + parents := make([]dockerContainerJsonResponse, 0, len(containers)) + children := make(map[string][]dockerContainerJsonResponse) + + for i := range containers { + container := &containers[i] + + if isDockerContainerHidden(container, hideByDefault) { + continue + } + + isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != "" + parent := container.Labels.getOrDefault(dockerContainerLabelParent, "") + + if !isParent && parent != "" { + children[parent] = append(children[parent], *container) + } else { + parents = append(parents, *container) + } + } + + return parents, children +} + +func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool { + if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { + return stringToBool(v) + } + + return hideByDefault +} + +func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { + client := &http.Client{ + Timeout: 3 * time.Second, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + + request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("sending request to socket: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-200 response status: %s", response.Status) + } + + var containers []dockerContainerJsonResponse + if err := json.NewDecoder(response.Body).Decode(&containers); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return containers, nil +} diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go new file mode 100644 index 0000000..72a4a09 --- /dev/null +++ b/internal/glance/widget-extension.go @@ -0,0 +1,160 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "html" + "html/template" + "io" + "log/slog" + "net/http" + "net/url" + "time" +) + +var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html") + +const extensionWidgetDefaultTitle = "Extension" + +type extensionWidget struct { + widgetBase `yaml:",inline"` + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters map[string]string `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` + Extension extension `yaml:"-"` + cachedHTML template.HTML `yaml:"-"` +} + +func (widget *extensionWidget) initialize() error { + widget.withTitle(extensionWidgetDefaultTitle).withCacheDuration(time.Minute * 30) + + if widget.URL == "" { + return errors.New("URL is required") + } + + if _, err := url.Parse(widget.URL); err != nil { + return fmt.Errorf("parsing URL: %v", err) + } + + return nil +} + +func (widget *extensionWidget) update(ctx context.Context) { + extension, err := fetchExtension(extensionRequestOptions{ + URL: widget.URL, + FallbackContentType: widget.FallbackContentType, + Parameters: widget.Parameters, + AllowHtml: widget.AllowHtml, + }) + + widget.canContinueUpdateAfterHandlingErr(err) + + widget.Extension = extension + + if widget.Title == extensionWidgetDefaultTitle && extension.Title != "" { + widget.Title = extension.Title + } + + widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) +} + +func (widget *extensionWidget) Render() template.HTML { + return widget.cachedHTML +} + +type extensionType int + +const ( + extensionContentHTML extensionType = iota + extensionContentUnknown = iota +) + +var extensionStringToType = map[string]extensionType{ + "html": extensionContentHTML, +} + +const ( + extensionHeaderTitle = "Widget-Title" + extensionHeaderContentType = "Widget-Content-Type" + extensionHeaderContentFrameless = "Widget-Content-Frameless" +) + +type extensionRequestOptions struct { + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters map[string]string `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` +} + +type extension struct { + Title string + Content template.HTML + Frameless bool +} + +func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML { + switch contentType { + case extensionContentHTML: + if options.AllowHtml { + return template.HTML(content) + } + + fallthrough + default: + return template.HTML("
                " + html.EscapeString(string(content)) + "
                ") + } +} + +func fetchExtension(options extensionRequestOptions) (extension, error) { + request, _ := http.NewRequest("GET", options.URL, nil) + + query := url.Values{} + + for key, value := range options.Parameters { + query.Set(key, value) + } + + request.URL.RawQuery = query.Encode() + + 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) + } + + 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-group.go b/internal/glance/widget-group.go new file mode 100644 index 0000000..2ea3813 --- /dev/null +++ b/internal/glance/widget-group.go @@ -0,0 +1,52 @@ +package glance + +import ( + "context" + "errors" + "html/template" + "time" +) + +var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html") + +type groupWidget struct { + widgetBase `yaml:",inline"` + containerWidgetBase `yaml:",inline"` +} + +func (widget *groupWidget) initialize() error { + widget.withError(nil) + widget.HideHeader = true + + for i := range widget.Widgets { + widget.Widgets[i].setHideHeader(true) + + if widget.Widgets[i].GetType() == "group" { + return errors.New("nested groups are not supported") + } else if widget.Widgets[i].GetType() == "split-column" { + return errors.New("split columns inside of groups are not supported") + } + } + + if err := widget.containerWidgetBase._initializeWidgets(); err != nil { + return err + } + + return nil +} + +func (widget *groupWidget) update(ctx context.Context) { + widget.containerWidgetBase._update(ctx) +} + +func (widget *groupWidget) setProviders(providers *widgetProviders) { + widget.containerWidgetBase._setProviders(providers) +} + +func (widget *groupWidget) requiresUpdate(now *time.Time) bool { + return widget.containerWidgetBase._requiresUpdate(now) +} + +func (widget *groupWidget) Render() template.HTML { + return widget.renderTemplate(widget, groupWidgetTemplate) +} diff --git a/internal/glance/widget-hacker-news.go b/internal/glance/widget-hacker-news.go new file mode 100644 index 0000000..ad00df0 --- /dev/null +++ b/internal/glance/widget-hacker-news.go @@ -0,0 +1,152 @@ +package glance + +import ( + "context" + "fmt" + "html/template" + "log/slog" + "net/http" + "strconv" + "strings" + "time" +) + +type hackerNewsWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + Limit int `yaml:"limit"` + SortBy string `yaml:"sort-by"` + ExtraSortBy string `yaml:"extra-sort-by"` + CollapseAfter int `yaml:"collapse-after"` + CommentsUrlTemplate string `yaml:"comments-url-template"` + ShowThumbnails bool `yaml:"-"` +} + +func (widget *hackerNewsWidget) initialize() error { + widget. + withTitle("Hacker News"). + withTitleURL("https://news.ycombinator.com/"). + withCacheDuration(30 * time.Minute) + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { + widget.SortBy = "top" + } + + return nil +} + +func (widget *hackerNewsWidget) update(ctx context.Context) { + posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.ExtraSortBy == "engagement" { + posts.calculateEngagement() + posts.sortByEngagement() + } + + if widget.Limit < len(posts) { + posts = posts[:widget.Limit] + } + + widget.Posts = posts +} + +func (widget *hackerNewsWidget) Render() template.HTML { + return widget.renderTemplate(widget, forumPostsTemplate) +} + +type hackerNewsPostResponseJson struct { + Id int `json:"id"` + Score int `json:"score"` + Title string `json:"title"` + TargetUrl string `json:"url,omitempty"` + CommentCount int `json:"descendants"` + TimePosted int64 `json:"time"` +} + +func fetchHackerNewsPostIds(sort string) ([]int, error) { + request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) + response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request) + if err != nil { + return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent) + } + + return response, nil +} + +func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) { + requests := make([]*http.Request, len(postIds)) + + for i, id := range postIds { + request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil) + requests[i] = request + } + + task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient) + job := newJob(task, requests).withWorkers(30) + results, errs, err := workerPoolDo(job) + if err != nil { + return nil, err + } + + posts := make(forumPostList, 0, len(postIds)) + + for i := range results { + if errs[i] != nil { + slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) + continue + } + + var commentsUrl string + + if commentsUrlTemplate == "" { + commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) + } else { + commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) + } + + posts = append(posts, forumPost{ + Title: results[i].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), + }) + } + + if len(posts) == 0 { + return nil, errNoContent + } + + if len(posts) != len(postIds) { + return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent) + } + + return posts, nil +} + +func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) { + postIds, err := fetchHackerNewsPostIds(sort) + if err != nil { + return nil, err + } + + if len(postIds) > limit { + postIds = postIds[:limit] + } + + return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate) +} diff --git a/internal/widget/html.go b/internal/glance/widget-html.go similarity index 56% rename from internal/widget/html.go rename to internal/glance/widget-html.go index 6c66488..0e32a46 100644 --- a/internal/widget/html.go +++ b/internal/glance/widget-html.go @@ -1,20 +1,20 @@ -package widget +package glance import ( "html/template" ) -type HTML struct { +type htmlWidget struct { widgetBase `yaml:",inline"` Source template.HTML `yaml:"source"` } -func (widget *HTML) Initialize() error { +func (widget *htmlWidget) initialize() error { widget.withTitle("").withError(nil) return nil } -func (widget *HTML) Render() template.HTML { +func (widget *htmlWidget) Render() template.HTML { return widget.Source } diff --git a/internal/widget/iframe.go b/internal/glance/widget-iframe.go similarity index 50% rename from internal/widget/iframe.go rename to internal/glance/widget-iframe.go index 44d0822..830b383 100644 --- a/internal/widget/iframe.go +++ b/internal/glance/widget-iframe.go @@ -1,32 +1,30 @@ -package widget +package glance import ( "errors" "fmt" "html/template" "net/url" - - "github.com/glanceapp/glance/internal/assets" ) -type IFrame struct { +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 *IFrame) Initialize() error { +func (widget *iframeWidget) initialize() error { widget.withTitle("IFrame").withError(nil) if widget.Source == "" { - return errors.New("missing source for iframe") + return errors.New("source is required") } - _, err := url.Parse(widget.Source) - - if err != nil { - return fmt.Errorf("invalid source for iframe: %v", err) + if _, err := url.Parse(widget.Source); err != nil { + return fmt.Errorf("parsing URL: %v", err) } if widget.Height == 50 { @@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error { widget.Height = 50 } - widget.cachedHTML = widget.render(widget, assets.IFrameTemplate) + widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate) return nil } -func (widget *IFrame) Render() template.HTML { +func (widget *iframeWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go new file mode 100644 index 0000000..786d1df --- /dev/null +++ b/internal/glance/widget-lobsters.go @@ -0,0 +1,144 @@ +package glance + +import ( + "context" + "html/template" + "net/http" + "strings" + "time" +) + +type lobstersWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + InstanceURL string `yaml:"instance-url"` + CustomURL string `yaml:"custom-url"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + SortBy string `yaml:"sort-by"` + Tags []string `yaml:"tags"` + ShowThumbnails bool `yaml:"-"` +} + +func (widget *lobstersWidget) initialize() error { + widget.withTitle("Lobsters").withCacheDuration(time.Hour) + + if widget.InstanceURL == "" { + widget.withTitleURL("https://lobste.rs") + } else { + widget.withTitleURL(widget.InstanceURL) + } + + if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { + widget.SortBy = "hot" + } + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + return nil +} + +func (widget *lobstersWidget) update(ctx context.Context) { + posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.Limit < len(posts) { + posts = posts[:widget.Limit] + } + + widget.Posts = posts +} + +func (widget *lobstersWidget) Render() template.HTML { + return widget.renderTemplate(widget, forumPostsTemplate) +} + +type lobstersPostResponseJson struct { + CreatedAt string `json:"created_at"` + Title string `json:"title"` + URL string `json:"url"` + Score int `json:"score"` + CommentCount int `json:"comment_count"` + CommentsURL string `json:"comments_url"` + Tags []string `json:"tags"` +} + +type lobstersFeedResponseJson []lobstersPostResponseJson + +func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { + request, err := http.NewRequest("GET", feedUrl, nil) + if err != nil { + return nil, err + } + + feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request) + if err != nil { + return nil, err + } + + 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, + TimePosted: createdAt, + Tags: feed[i].Tags, + }) + } + + if len(posts) == 0 { + return nil, errNoContent + } + + return posts, nil +} + +func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) { + var feedUrl string + + if customURL != "" { + feedUrl = customURL + } else { + if instanceURL != "" { + instanceURL = strings.TrimRight(instanceURL, "/") + "/" + } else { + instanceURL = "https://lobste.rs/" + } + + if sortBy == "hot" { + sortBy = "hottest" + } else if sortBy == "new" { + sortBy = "newest" + } + + if len(tags) == 0 { + feedUrl = instanceURL + sortBy + ".json" + } else { + tags := strings.Join(tags, ",") + feedUrl = instanceURL + "t/" + tags + ".json" + } + } + + posts, err := fetchLobstersPostsFromFeed(feedUrl) + if err != nil { + return nil, err + } + + return posts, nil +} diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go new file mode 100644 index 0000000..63eda1a --- /dev/null +++ b/internal/glance/widget-markets.go @@ -0,0 +1,224 @@ +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 + 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"` + } `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) + 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 + } + + prices := response.Chart.Result[0].Indicators.Quote[0].Close + + if len(prices) > marketChartDays { + prices = prices[len(prices)-marketChartDays:] + } + + previous := response.Chart.Result[0].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[response.Chart.Result[0].Meta.Currency] + + if !exists { + currency = response.Chart.Result[0].Meta.Currency + } + + markets = append(markets, market{ + marketRequest: marketRequests[i], + Price: response.Chart.Result[0].Meta.RegularMarketPrice, + Currency: currency, + Name: ternary(marketRequests[i].CustomName == "", + response.Chart.Result[0].Meta.ShortName, + marketRequests[i].CustomName, + ), + PercentChange: percentChange( + response.Chart.Result[0].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 new file mode 100644 index 0000000..76f0d45 --- /dev/null +++ b/internal/glance/widget-monitor.go @@ -0,0 +1,182 @@ +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"` +} + +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 + } + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return siteStatus{ + Error: err, + }, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + request = request.WithContext(ctx) + 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/feed/reddit.go b/internal/glance/widget-reddit.go similarity index 50% rename from internal/feed/reddit.go rename to internal/glance/widget-reddit.go index 77a6e89..e7109fa 100644 --- a/internal/feed/reddit.go +++ b/internal/glance/widget-reddit.go @@ -1,14 +1,133 @@ -package feed +package glance import ( + "context" + "errors" "fmt" "html" + "html/template" "net/http" "net/url" "strings" "time" ) +var ( + redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html") + redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html") +) + +type redditWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + Subreddit string `yaml:"subreddit"` + Proxy proxyOptionsField `yaml:"proxy"` + Style string `yaml:"style"` + ShowThumbnails bool `yaml:"show-thumbnails"` + ShowFlairs bool `yaml:"show-flairs"` + SortBy string `yaml:"sort-by"` + TopPeriod string `yaml:"top-period"` + Search string `yaml:"search"` + ExtraSortBy string `yaml:"extra-sort-by"` + CommentsUrlTemplate string `yaml:"comments-url-template"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + RequestUrlTemplate string `yaml:"request-url-template"` +} + +func (widget *redditWidget) initialize() error { + if widget.Subreddit == "" { + return errors.New("subreddit is required") + } + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if !isValidRedditSortType(widget.SortBy) { + widget.SortBy = "hot" + } + + if !isValidRedditTopPeriod(widget.TopPeriod) { + widget.TopPeriod = "day" + } + + if widget.RequestUrlTemplate != "" { + if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { + return errors.New("no `{REQUEST-URL}` placeholder specified") + } + } + + widget. + withTitle("r/" + widget.Subreddit). + withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). + withCacheDuration(30 * time.Minute) + + return nil +} + +func isValidRedditSortType(sortBy string) bool { + return sortBy == "hot" || + sortBy == "new" || + sortBy == "top" || + sortBy == "rising" +} + +func isValidRedditTopPeriod(period string) bool { + return period == "hour" || + period == "day" || + period == "week" || + period == "month" || + period == "year" || + period == "all" +} + +func (widget *redditWidget) update(ctx context.Context) { + // TODO: refactor, use a struct to pass all of these + posts, err := fetchSubredditPosts( + widget.Subreddit, + widget.SortBy, + widget.TopPeriod, + widget.Search, + widget.CommentsUrlTemplate, + widget.RequestUrlTemplate, + widget.Proxy.client, + widget.ShowFlairs, + ) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(posts) > widget.Limit { + posts = posts[:widget.Limit] + } + + if widget.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) + +} + type subredditResponseJson struct { Data struct { Children []struct { @@ -44,7 +163,16 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str return template } -func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) { +func fetchSubredditPosts( + subreddit, + sort, + topPeriod, + search, + commentsUrlTemplate, + requestUrlTemplate string, + proxyClient *http.Client, + showFlairs bool, +) (forumPostList, error) { query := url.Values{} var requestUrl string @@ -63,20 +191,22 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode()) } + var client requestDoer = defaultHTTPClient + if requestUrlTemplate != "" { requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl) + } else if proxyClient != nil { + client = proxyClient } request, err := http.NewRequest("GET", requestUrl, nil) - if err != nil { return nil, err } // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests - addBrowserUserAgentHeader(request) - responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request) - + setBrowserUserAgentHeader(request) + responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request) if err != nil { return nil, err } @@ -85,7 +215,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate return nil, fmt.Errorf("no posts found") } - posts := make(ForumPosts, 0, len(responseJson.Data.Children)) + posts := make(forumPostList, 0, len(responseJson.Data.Children)) for i := range responseJson.Data.Children { post := &responseJson.Data.Children[i].Data @@ -102,7 +232,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink) } - forumPost := ForumPost{ + forumPost := forumPost{ Title: html.UnescapeString(post.Title), DiscussionUrl: commentsUrl, TargetUrlDomain: post.Domain, diff --git a/internal/glance/widget-releases.go b/internal/glance/widget-releases.go new file mode 100644 index 0000000..0b47783 --- /dev/null +++ b/internal/glance/widget-releases.go @@ -0,0 +1,391 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html") + +type releasesWidget struct { + widgetBase `yaml:",inline"` + Releases appReleaseList `yaml:"-"` + releaseRequests []*releaseRequest `yaml:"-"` + Repositories []string `yaml:"repositories"` + Token string `yaml:"token"` + GitLabToken string `yaml:"gitlab-token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ShowSourceIcon bool `yaml:"show-source-icon"` +} + +func (widget *releasesWidget) initialize() error { + widget.withTitle("Releases").withCacheDuration(2 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + for _, repository := range widget.Repositories { + parts := strings.SplitN(repository, ":", 2) + var request *releaseRequest + if len(parts) == 1 { + request = &releaseRequest{ + source: releaseSourceGithub, + repository: repository, + } + + if widget.Token != "" { + request.token = &widget.Token + } + } else if len(parts) == 2 { + if parts[0] == string(releaseSourceGitlab) { + request = &releaseRequest{ + source: releaseSourceGitlab, + repository: parts[1], + } + + if widget.GitLabToken != "" { + request.token = &widget.GitLabToken + } + } else if parts[0] == string(releaseSourceDockerHub) { + request = &releaseRequest{ + source: releaseSourceDockerHub, + repository: parts[1], + } + } else if parts[0] == string(releaseSourceCodeberg) { + request = &releaseRequest{ + source: releaseSourceCodeberg, + repository: parts[1], + } + } else { + return errors.New("invalid repository source " + parts[0]) + } + } + + widget.releaseRequests = append(widget.releaseRequests, request) + } + + return nil +} + +func (widget *releasesWidget) update(ctx context.Context) { + releases, err := fetchLatestReleases(widget.releaseRequests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(releases) > widget.Limit { + releases = releases[:widget.Limit] + } + + for i := range releases { + releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") + } + + widget.Releases = releases +} + +func (widget *releasesWidget) Render() template.HTML { + return widget.renderTemplate(widget, releasesWidgetTemplate) +} + +type releaseSource string + +const ( + releaseSourceCodeberg releaseSource = "codeberg" + releaseSourceGithub releaseSource = "github" + releaseSourceGitlab releaseSource = "gitlab" + releaseSourceDockerHub releaseSource = "dockerhub" +) + +type appRelease struct { + Source releaseSource + SourceIconURL string + Name string + Version string + NotesUrl string + TimeReleased time.Time + Downvotes int +} + +type appReleaseList []appRelease + +func (r appReleaseList) sortByNewest() appReleaseList { + sort.Slice(r, func(i, j int) bool { + return r[i].TimeReleased.After(r[j].TimeReleased) + }) + + return r +} + +type releaseRequest struct { + source releaseSource + repository string + token *string +} + +func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) { + job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) + results, errs, err := workerPoolDo(job) + if err != nil { + return nil, err + } + + var failed int + + releases := make(appReleaseList, 0, len(requests)) + + for i := range results { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i]) + continue + } + + releases = append(releases, *results[i]) + } + + if failed == len(requests) { + return nil, errNoContent + } + + releases.sortByNewest() + + if failed > 0 { + return releases, fmt.Errorf("%w: could not get %d releases", errPartialContent, failed) + } + + return releases, nil +} + +func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) { + switch request.source { + case releaseSourceCodeberg: + return fetchLatestCodebergRelease(request) + case releaseSourceGithub: + return fetchLatestGithubRelease(request) + case releaseSourceGitlab: + return fetchLatestGitLabRelease(request) + case releaseSourceDockerHub: + return fetchLatestDockerHubRelease(request) + } + + return nil, errors.New("unsupported source") +} + +type githubReleaseLatestResponseJson struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + HtmlUrl string `json:"html_url"` + Reactions struct { + Downvotes int `json:"-1"` + } `json:"reactions"` +} + +func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository), + nil, + ) + + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + + response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return &appRelease{ + Source: releaseSourceGithub, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.HtmlUrl, + TimeReleased: parseRFC3339Time(response.PublishedAt), + Downvotes: response.Reactions.Downvotes, + }, nil +} + +type dockerHubRepositoryTagsResponse struct { + Results []dockerHubRepositoryTagResponse `json:"results"` +} + +type dockerHubRepositoryTagResponse struct { + Name string `json:"name"` + LastPushed string `json:"tag_last_pushed"` +} + +const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" +const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" +const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" +const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" + +func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { + + nameParts := strings.Split(request.repository, "/") + + if len(nameParts) > 2 { + return nil, fmt.Errorf("invalid repository name: %s", request.repository) + } else if len(nameParts) == 1 { + nameParts = []string{"library", nameParts[0]} + } + + tagParts := strings.SplitN(nameParts[1], ":", 2) + + var requestURL string + + if len(tagParts) == 2 { + requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) + } else { + requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) + } + + httpRequest, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + + var tag *dockerHubRepositoryTagResponse + + if len(tagParts) == 1 { + response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + if len(response.Results) == 0 { + return nil, fmt.Errorf("no tags found for repository: %s", request.repository) + } + + tag = &response.Results[0] + } else { + response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + tag = &response + } + + var repo string + var displayName string + var notesURL string + + if len(tagParts) == 1 { + repo = nameParts[1] + } else { + repo = tagParts[0] + } + + if nameParts[0] == "library" { + displayName = repo + notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) + } else { + displayName = nameParts[0] + "/" + repo + notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) + } + + return &appRelease{ + Source: releaseSourceDockerHub, + NotesUrl: notesURL, + Name: displayName, + Version: tag.Name, + TimeReleased: parseRFC3339Time(tag.LastPushed), + }, nil +} + +type gitlabReleaseResponseJson struct { + TagName string `json:"tag_name"` + ReleasedAt string `json:"released_at"` + Links struct { + Self string `json:"self"` + } `json:"_links"` +} + +func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf( + "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", + url.QueryEscape(request.repository), + ), + nil, + ) + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("PRIVATE-TOKEN", *request.token) + } + + response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return &appRelease{ + Source: releaseSourceGitlab, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.Links.Self, + TimeReleased: parseRFC3339Time(response.ReleasedAt), + }, nil +} + +type codebergReleaseResponseJson struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + HtmlUrl string `json:"html_url"` +} + +func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf( + "https://codeberg.org/api/v1/repos/%s/releases/latest", + request.repository, + ), + nil, + ) + if err != nil { + return nil, err + } + + response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return &appRelease{ + Source: releaseSourceCodeberg, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.HtmlUrl, + TimeReleased: parseRFC3339Time(response.PublishedAt), + }, nil +} diff --git a/internal/feed/github.go b/internal/glance/widget-repository.go similarity index 50% rename from internal/feed/github.go rename to internal/glance/widget-repository.go index 9ec3733..1eeb8b4 100644 --- a/internal/feed/github.go +++ b/internal/glance/widget-repository.go @@ -1,93 +1,91 @@ -package feed +package glance import ( + "context" "fmt" + "html/template" "net/http" "strings" "sync" "time" ) -type githubReleaseLatestResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` - Reactions struct { - Downvotes int `json:"-1"` - } `json:"reactions"` +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 fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) { - var httpRequest *http.Request - var err error - var response githubReleaseLatestResponseJson +func (widget *repositoryWidget) initialize() error { + widget.withTitle("Repository").withCacheDuration(1 * time.Hour) - if request.IncludeGithubPreReleases { - httpRequest, err = http.NewRequest( - "GET", - fmt.Sprintf("https://api.github.com/repos/%s/releases", request.Repository), - nil, - ) - } else { - httpRequest, err = http.NewRequest( - "GET", - fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository), - nil, - ) + if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 { + widget.PullRequestsLimit = 3 } - if err != nil { - return nil, err + if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 { + widget.IssuesLimit = 3 } - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) + if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { + widget.CommitsLimit = -1 } - if request.IncludeGithubPreReleases { - releases, err := decodeJsonFromRequest[[]githubReleaseLatestResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - response = releases[0] - } else { - response, err = decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - } - - return &AppRelease{ - Source: ReleaseSourceGithub, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - Downvotes: response.Reactions.Downvotes, - }, nil + return nil } -type GithubTicket struct { +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 RepositoryDetails struct { - Name string - Stars int - Forks int - OpenPullRequests int - PullRequests []GithubTicket - OpenIssues int - Issues []GithubTicket - LastCommits int - Commits []CommitDetails +type githubCommitDetails struct { + Sha string + Author string + CreatedAt time.Time + Message string } -type githubRepositoryDetailsResponseJson struct { +type githubRepositoryResponseJson struct { Name string `json:"full_name"` Stars int `json:"stargazers_count"` Forks int `json:"forks_count"` @@ -102,13 +100,6 @@ type githubTicketResponseJson struct { } `json:"items"` } -type CommitDetails struct { - Sha string - Author string - CreatedAt time.Time - Message string -} - type gitHubCommitResponseJson struct { Sha string `json:"sha"` Commit struct { @@ -120,15 +111,15 @@ type gitHubCommitResponseJson struct { } `json:"commit"` } -func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) { - repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) +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 RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) + 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", repository, maxPRs), nil) - issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil) - CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil) + 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) @@ -138,7 +129,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in CommitsRequest.Header.Add("Authorization", token) } - var detailsResponse githubRepositoryDetailsResponseJson + var repositoryResponse githubRepositoryResponseJson var detailsErr error var PRsResponse githubTicketResponseJson var PRsErr error @@ -151,14 +142,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in wg.Add(1) go (func() { defer wg.Done() - detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest) + repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest) })() if maxPRs > 0 { wg.Add(1) go (func() { defer wg.Done() - PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest) + PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest) })() } @@ -166,7 +157,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in wg.Add(1) go (func() { defer wg.Done() - issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest) + issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest) })() } @@ -174,35 +165,35 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in wg.Add(1) go (func() { defer wg.Done() - commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest) + commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest) })() } wg.Wait() if detailsErr != nil { - return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr) + return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr) } - details := RepositoryDetails{ - Name: detailsResponse.Name, - Stars: detailsResponse.Stars, - Forks: detailsResponse.Forks, - PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), - Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), - Commits: make([]CommitDetails, 0, len(commitsResponse)), + 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) + 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{ + details.PullRequests = append(details.PullRequests, githubTicket{ Number: PRsResponse.Tickets[i].Number, CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), Title: PRsResponse.Tickets[i].Title, @@ -214,12 +205,12 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in if maxIssues > 0 { if issuesErr != nil { // TODO: fix, overwriting the previous error - err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr) + 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{ + details.Issues = append(details.Issues, githubTicket{ Number: issuesResponse.Tickets[i].Number, CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), Title: issuesResponse.Tickets[i].Title, @@ -230,10 +221,10 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in if maxCommits > 0 { if CommitsErr != nil { - err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr) + err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr) } else { for i := range commitsResponse { - details.Commits = append(details.Commits, CommitDetails{ + details.Commits = append(details.Commits, githubCommitDetails{ Sha: commitsResponse[i].Sha, Author: commitsResponse[i].Commit.Author.Name, CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), diff --git a/internal/feed/rss.go b/internal/glance/widget-rss.go similarity index 51% rename from internal/feed/rss.go rename to internal/glance/widget-rss.go index ec6ab2e..f24d828 100644 --- a/internal/feed/rss.go +++ b/internal/glance/widget-rss.go @@ -1,10 +1,13 @@ -package feed +package glance import ( "context" "fmt" "html" + "html/template" + "io" "log/slog" + "net/http" "net/url" "regexp" "sort" @@ -15,7 +18,87 @@ import ( gofeedext "github.com/mmcdole/gofeed/extensions" ) -type RSSFeedItem struct { +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") +) + +type rssWidget struct { + widgetBase `yaml:",inline"` + FeedRequests []rssFeedRequest `yaml:"feeds"` + Style string `yaml:"style"` + ThumbnailHeight float64 `yaml:"thumbnail-height"` + CardHeight float64 `yaml:"card-height"` + Items rssFeedItemList `yaml:"-"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + SingleLineTitles bool `yaml:"single-line-titles"` + NoItemsMessage string `yaml:"-"` +} + +func (widget *rssWidget) initialize() error { + widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 25 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.ThumbnailHeight < 0 { + widget.ThumbnailHeight = 0 + } + + if widget.CardHeight < 0 { + widget.CardHeight = 0 + } + + if widget.Style == "detailed-list" { + for i := range widget.FeedRequests { + widget.FeedRequests[i].IsDetailed = true + } + } + + widget.NoItemsMessage = "No items were returned from the feeds." + + return nil +} + +func (widget *rssWidget) update(ctx context.Context) { + items, err := fetchItemsFromRSSFeeds(widget.FeedRequests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + 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) + } + + return widget.renderTemplate(widget, rssWidgetTemplate) +} + +type rssFeedItem struct { ChannelName string ChannelURL string Title string @@ -28,7 +111,6 @@ type RSSFeedItem struct { // doesn't cover all cases but works the vast majority of the time var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`) -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func sanitizeFeedDescription(description string) string { if description == "" { @@ -56,18 +138,19 @@ func shortenFeedDescriptionLen(description string, maxLen int) string { return description } -type RSSFeedRequest struct { - Url string `yaml:"url"` - Title string `yaml:"title"` - HideCategories bool `yaml:"hide-categories"` - HideDescription bool `yaml:"hide-description"` - ItemLinkPrefix string `yaml:"item-link-prefix"` - IsDetailed bool `yaml:"-"` +type rssFeedRequest struct { + URL string `yaml:"url"` + Title string `yaml:"title"` + HideCategories bool `yaml:"hide-categories"` + HideDescription bool `yaml:"hide-description"` + ItemLinkPrefix string `yaml:"item-link-prefix"` + Headers map[string]string `yaml:"headers"` + IsDetailed bool `yaml:"-"` } -type RSSFeedItems []RSSFeedItem +type rssFeedItemList []rssFeedItem -func (f RSSFeedItems) SortByNewest() RSSFeedItems { +func (f rssFeedItemList) sortByNewest() rssFeedItemList { sort.Slice(f, func(i, j int) bool { return f[i].PublishedAt.After(f[j].PublishedAt) }) @@ -77,22 +160,42 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems { var feedParser = gofeed.NewParser() -func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - feed, err := feedParser.ParseURLWithContext(request.Url, ctx) - +func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { + req, err := http.NewRequest("GET", request.URL, nil) if err != nil { return nil, err } - items := make(RSSFeedItems, 0, len(feed.Items)) + for key, value := range request.Headers { + req.Header.Add(key, value) + } + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + feed, err := feedParser.ParseString(string(body)) + if err != nil { + return nil, err + } + + items := make(rssFeedItemList, 0, len(feed.Items)) for i := range feed.Items { item := feed.Items[i] - rssItem := RSSFeedItem{ + rssItem := rssFeedItem{ ChannelURL: feed.Link, } @@ -102,9 +205,8 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { rssItem.Link = item.Link } else { parsedUrl, err := url.Parse(feed.Link) - if err != nil { - parsedUrl, err = url.Parse(request.Url) + parsedUrl, err = url.Parse(request.URL) } if err == nil { @@ -121,7 +223,7 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { } if item.Title != "" { - rssItem.Title = item.Title + rssItem.Title = html.UnescapeString(item.Title) } else { rssItem.Title = shortenFeedDescriptionLen(item.Description, 100) } @@ -210,22 +312,21 @@ func findThumbnailInItemExtensions(item *gofeed.Item) string { return recursiveFindThumbnailInExtensions(media) } -func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) { - job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10) +func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) { + job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(30) feeds, errs, err := workerPoolDo(job) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) + return nil, fmt.Errorf("%w: %v", errNoContent, err) } failed := 0 - entries := make(RSSFeedItems, 0, len(feeds)*10) + entries := make(rssFeedItemList, 0, len(feeds)*10) for i := range feeds { if errs[i] != nil { failed++ - slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url) + slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i]) continue } @@ -233,13 +334,13 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) { } if failed == len(requests) { - return nil, ErrNoContent + return nil, errNoContent } - entries.SortByNewest() + entries.sortByNewest() if failed > 0 { - return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed) + return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed) } return entries, nil diff --git a/internal/widget/search.go b/internal/glance/widget-search.go similarity index 70% rename from internal/widget/search.go rename to internal/glance/widget-search.go index 19ca372..9d2b600 100644 --- a/internal/widget/search.go +++ b/internal/glance/widget-search.go @@ -1,26 +1,27 @@ -package widget +package glance import ( "fmt" "html/template" "strings" - - "github.com/glanceapp/glance/internal/assets" ) +var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html") + type SearchBang struct { Title string Shortcut string URL string } -type Search struct { +type searchWidget struct { widgetBase `yaml:",inline"` cachedHTML template.HTML `yaml:"-"` SearchEngine string `yaml:"search-engine"` Bangs []SearchBang `yaml:"bangs"` NewTab bool `yaml:"new-tab"` Autofocus bool `yaml:"autofocus"` + Placeholder string `yaml:"placeholder"` } func convertSearchUrl(url string) string { @@ -34,13 +35,17 @@ var searchEngines = map[string]string{ "google": "https://www.google.com/search?q={QUERY}", } -func (widget *Search) Initialize() error { +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 } @@ -49,20 +54,20 @@ func (widget *Search) Initialize() error { for i := range widget.Bangs { if widget.Bangs[i].Shortcut == "" { - return fmt.Errorf("Search bang %d has no shortcut", i+1) + 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) + return fmt.Errorf("search bang #%d has no URL", i+1) } widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL) } - widget.cachedHTML = widget.render(widget, assets.SearchTemplate) + widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate) return nil } -func (widget *Search) Render() template.HTML { +func (widget *searchWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-shared.go b/internal/glance/widget-shared.go new file mode 100644 index 0000000..45144ac --- /dev/null +++ b/internal/glance/widget-shared.go @@ -0,0 +1,64 @@ +package glance + +import ( + "math" + "sort" + "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 + DiscussionUrl string + TargetUrl string + TargetUrlDomain string + ThumbnailUrl string + CommentCount int + Score int + Engagement float64 + TimePosted time.Time + Tags []string + IsCrosspost bool +} + +type forumPostList []forumPost + +const depreciatePostsOlderThanHours = 7 +const maxDepreciation = 0.9 +const maxDepreciationAfterHours = 24 + +func (p forumPostList) calculateEngagement() { + var totalComments int + var totalScore int + + for i := range p { + totalComments += p[i].CommentCount + totalScore += p[i].Score + } + + numberOfPosts := float64(len(p)) + averageComments := float64(totalComments) / numberOfPosts + averageScore := float64(totalScore) / numberOfPosts + + for i := range p { + p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 + + elapsed := time.Since(p[i].TimePosted) + + if elapsed < time.Hour*depreciatePostsOlderThanHours { + continue + } + + p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation + } +} + +func (p forumPostList) sortByEngagement() { + sort.Slice(p, func(i, j int) bool { + return p[i].Engagement > p[j].Engagement + }) +} diff --git a/internal/glance/widget-split-column.go b/internal/glance/widget-split-column.go new file mode 100644 index 0000000..71747c9 --- /dev/null +++ b/internal/glance/widget-split-column.go @@ -0,0 +1,45 @@ +package glance + +import ( + "context" + "html/template" + "time" +) + +var splitColumnWidgetTemplate = mustParseTemplate("split-column.html", "widget-base.html") + +type splitColumnWidget struct { + widgetBase `yaml:",inline"` + containerWidgetBase `yaml:",inline"` + MaxColumns int `yaml:"max-columns"` +} + +func (widget *splitColumnWidget) initialize() error { + widget.withError(nil).withTitle("Split Column").setHideHeader(true) + + if err := widget.containerWidgetBase._initializeWidgets(); err != nil { + return err + } + + if widget.MaxColumns < 2 { + widget.MaxColumns = 2 + } + + return nil +} + +func (widget *splitColumnWidget) update(ctx context.Context) { + widget.containerWidgetBase._update(ctx) +} + +func (widget *splitColumnWidget) setProviders(providers *widgetProviders) { + widget.containerWidgetBase._setProviders(providers) +} + +func (widget *splitColumnWidget) requiresUpdate(now *time.Time) bool { + return widget.containerWidgetBase._requiresUpdate(now) +} + +func (widget *splitColumnWidget) Render() template.HTML { + return widget.renderTemplate(widget, splitColumnWidgetTemplate) +} diff --git a/internal/feed/twitch.go b/internal/glance/widget-twitch-channels.go similarity index 50% rename from internal/feed/twitch.go rename to internal/glance/widget-twitch-channels.go index 739d7d1..f3ab206 100644 --- a/internal/feed/twitch.go +++ b/internal/glance/widget-twitch-channels.go @@ -1,33 +1,69 @@ -package feed +package glance import ( + "context" "encoding/json" - "errors" "fmt" + "html/template" "log/slog" "net/http" - "slices" "sort" "strings" "time" ) -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:"-"` +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"` } -type TwitchChannel struct { +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 @@ -36,15 +72,15 @@ type TwitchChannel struct { ViewersCount int } -type TwitchChannels []TwitchChannel +type twitchChannelList []twitchChannel -func (channels TwitchChannels) SortByViewers() { +func (channels twitchChannelList) sortByViewers() { sort.Slice(channels, func(i, j int) bool { return channels[i].ViewersCount > channels[j].ViewersCount }) } -func (channels TwitchChannels) SortByLive() { +func (channels twitchChannelList) sortByLive() { sort.SliceStable(channels, func(i, j int) bool { return channels[i].IsLive && !channels[j].IsLive }) @@ -77,72 +113,16 @@ type twitchStreamMetadataOperationResponse struct { Name string `json:"name"` } `json:"game"` } `json:"stream"` + LastBroadcast *struct { + Title string `json:"title"` + } } `json:"user"` } -type twitchDirectoriesOperationResponse struct { - Data struct { - DirectoriesWithTags struct { - Edges []struct { - Node TwitchCategory `json:"node"` - } `json:"edges"` - } `json:"directoriesWithTags"` - } `json:"data"` -} - -const twitchGqlEndpoint = "https://gql.twitch.tv/gql" -const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" - -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](defaultClient, 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 -} - -const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]` +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 @@ -150,8 +130,8 @@ const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell // 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{ +func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { + result := twitchChannel{ Login: strings.ToLower(channel), } @@ -159,8 +139,7 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request) - + response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request) if err != nil { return result, err } @@ -175,16 +154,12 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { for i := range response { switch response[i].Extensions.OperationName { case "ChannelShell": - err = json.Unmarshal(response[i].Data, &channelShell) - - if err != nil { - return result, fmt.Errorf("failed to unmarshal channel shell: %w", err) + if err = json.Unmarshal(response[i].Data, &channelShell); err != nil { + return result, fmt.Errorf("unmarshalling channel shell: %w", err) } case "StreamMetadata": - err = json.Unmarshal(response[i].Data, &streamMetadata) - - if err != nil { - return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err) + 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) @@ -205,6 +180,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { 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 @@ -214,7 +193,7 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { if err == nil { result.LiveSince = startedAt } else { - slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) + slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) } } } @@ -222,12 +201,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { return result, nil } -func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { - result := make(TwitchChannels, 0, len(channelLogins)) +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 } @@ -237,7 +215,7 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { for i := range channels { if errs[i] != nil { failed++ - slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i]) + slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i]) continue } @@ -245,11 +223,11 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { } if failed == len(channelLogins) { - return result, ErrNoContent + return result, errNoContent } if failed > 0 { - return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed) + 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 new file mode 100644 index 0000000..4235bc9 --- /dev/null +++ b/internal/glance/widget-twitch-top-games.go @@ -0,0 +1,125 @@ +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/feed/requests.go b/internal/glance/widget-utils.go similarity index 80% rename from internal/feed/requests.go rename to internal/glance/widget-utils.go index 3ce5d9f..77a9d5c 100644 --- a/internal/feed/requests.go +++ b/internal/glance/widget-utils.go @@ -1,10 +1,11 @@ -package feed +package glance import ( "context" "crypto/tls" "encoding/json" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -12,66 +13,58 @@ import ( "time" ) +var ( + errNoContent = errors.New("failed to retrieve any content") + errPartialContent = errors.New("failed to retrieve some of the content") +) + const defaultClientTimeout = 5 * time.Second -var defaultClient = &http.Client{ +var defaultHTTPClient = &http.Client{ Timeout: defaultClientTimeout, } -var insecureClientTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, +var defaultInsecureHTTPClient = &http.Client{ + Timeout: defaultClientTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, } -var defaultInsecureClient = &http.Client{ - Timeout: defaultClientTimeout, - Transport: insecureClientTransport, -} - -type RequestDoer interface { +type requestDoer interface { Do(*http.Request) (*http.Response, error) } -func addBrowserUserAgentHeader(request *http.Request) { +func setBrowserUserAgentHeader(request *http.Request) { request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0") } -func truncateString(s string, maxLen int) string { - asRunes := []rune(s) - - if len(asRunes) > maxLen { - return string(asRunes[:maxLen]) - } - - return s -} - -func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { - response, err := client.Do(request) +func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) { var result T + response, err := client.Do(request) if err != nil { return result, err } - defer response.Body.Close() body, err := io.ReadAll(response.Body) - if err != nil { return result, err } if response.StatusCode != http.StatusOK { + truncatedBody, _ := limitStringLength(string(body), 256) + return result, fmt.Errorf( "unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, - truncateString(string(body), 256), + truncatedBody, ) } err = json.Unmarshal(body, &result) - if err != nil { return result, err } @@ -79,40 +72,39 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, return result, nil } -func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) { +func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) { return func(request *http.Request) (T, error) { return decodeJsonFromRequest[T](client, request) } } // TODO: tidy up, these are a copy of the above but with a line changed -func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { - response, err := client.Do(request) +func decodeXmlFromRequest[T any](client requestDoer, request *http.Request) (T, error) { var result T + response, err := client.Do(request) if err != nil { return result, err } - defer response.Body.Close() body, err := io.ReadAll(response.Body) - if err != nil { return result, err } if response.StatusCode != http.StatusOK { + truncatedBody, _ := limitStringLength(string(body), 256) + return result, fmt.Errorf( "unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, - truncateString(string(body), 256), + truncatedBody, ) } err = xml.Unmarshal(body, &result) - if err != nil { return result, err } @@ -120,7 +112,7 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, return result, nil } -func decodeXmlFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) { +func decodeXmlFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) { return func(request *http.Request) (T, error) { return decodeXmlFromRequest[T](client, request) } diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go new file mode 100644 index 0000000..bd36bee --- /dev/null +++ b/internal/glance/widget-videos.go @@ -0,0 +1,203 @@ +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"` + 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 + } + + 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/feed/openmeteo.go b/internal/glance/widget-weather.go similarity index 53% rename from internal/feed/openmeteo.go rename to internal/glance/widget-weather.go index 2bfa8f2..9d53cd6 100644 --- a/internal/feed/openmeteo.go +++ b/internal/glance/widget-weather.go @@ -1,7 +1,10 @@ -package feed +package glance import ( + "context" + "errors" "fmt" + "html/template" "math" "net/http" "net/url" @@ -12,11 +15,94 @@ import ( _ "time/tzdata" ) -type PlacesResponseJson struct { - Results []PlaceJson +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:"-"` } -type PlaceJson struct { +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 @@ -26,7 +112,7 @@ type PlaceJson struct { location *time.Location } -type WeatherResponseJson struct { +type openMeteoWeatherResponseJson struct { Daily struct { Sunrise []int64 `json:"sunrise"` Sunset []int64 `json:"sunset"` @@ -82,21 +168,20 @@ func parsePlaceName(name string) (string, string) { return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1]) } -func FetchPlaceFromName(location string) (*PlaceJson, error) { +func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) { location, area := parsePlaceName(location) requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location)) request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request) - + responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request) if err != nil { - return nil, fmt.Errorf("could not fetch places data: %v", err) + 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 *PlaceJson + var place *openMeteoPlaceResponseJson if area != "" { area = strings.ToLower(area) @@ -116,9 +201,8 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) { } loc, err := time.LoadLocation(place.Timezone) - if err != nil { - return nil, fmt.Errorf("could not load location: %v", err) + return nil, fmt.Errorf("loading location: %v", err) } place.location = loc @@ -126,12 +210,7 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) { return place, nil } -func barIndexFromHour(h int) int { - return h / 2 -} - -// TODO: bunch of spaget, refactor -func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { +func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) { query := url.Values{} var temperatureUnit string @@ -153,17 +232,16 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request) - + responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) + return nil, fmt.Errorf("%w: %v", errNoContent, err) } now := time.Now().In(place.location) bars := make([]weatherColumn, 0, 24) - currentBar := barIndexFromHour(now.Hour()) - sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) - sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1 + 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 @@ -205,7 +283,7 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { } } - return &Weather{ + return &weather{ Temperature: int(responseJson.Current.Temperature), ApparentTemperature: int(responseJson.Current.ApparentTemperature), WeatherCode: responseJson.Current.WeatherCode, @@ -215,3 +293,34 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { 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/widget/widget.go b/internal/glance/widget.go similarity index 65% rename from internal/widget/widget.go rename to internal/glance/widget.go index c452427..7e8a618 100644 --- a/internal/widget/widget.go +++ b/internal/glance/widget.go @@ -1,4 +1,4 @@ -package widget +package glance import ( "bytes" @@ -12,73 +12,77 @@ import ( "sync/atomic" "time" - "github.com/glanceapp/glance/internal/feed" - "gopkg.in/yaml.v3" ) -var uniqueID atomic.Uint64 +var widgetIDCounter atomic.Uint64 -func New(widgetType string) (Widget, error) { - var widget Widget +func newWidget(widgetType string) (widget, error) { + var w widget switch widgetType { case "calendar": - widget = &Calendar{} + w = &calendarWidget{} case "clock": - widget = &Clock{} + w = &clockWidget{} case "weather": - widget = &Weather{} + w = &weatherWidget{} case "bookmarks": - widget = &Bookmarks{} + w = &bookmarksWidget{} case "iframe": - widget = &IFrame{} + w = &iframeWidget{} case "html": - widget = &HTML{} + w = &htmlWidget{} case "hacker-news": - widget = &HackerNews{} + w = &hackerNewsWidget{} case "releases": - widget = &Releases{} + w = &releasesWidget{} case "videos": - widget = &Videos{} + w = &videosWidget{} case "markets", "stocks": - widget = &Markets{} + w = &marketsWidget{} case "reddit": - widget = &Reddit{} + w = &redditWidget{} case "rss": - widget = &RSS{} + w = &rssWidget{} case "monitor": - widget = &Monitor{} + w = &monitorWidget{} case "twitch-top-games": - widget = &TwitchGames{} + w = &twitchGamesWidget{} case "twitch-channels": - widget = &TwitchChannels{} + w = &twitchChannelsWidget{} case "lobsters": - widget = &Lobsters{} + w = &lobstersWidget{} case "change-detection": - widget = &ChangeDetection{} + w = &changeDetectionWidget{} case "repository": - widget = &Repository{} + w = &repositoryWidget{} case "search": - widget = &Search{} + w = &searchWidget{} case "extension": - widget = &Extension{} + w = &extensionWidget{} case "group": - widget = &Group{} + w = &groupWidget{} case "dns-stats": - widget = &DNSStats{} + w = &dnsStatsWidget{} + case "split-column": + w = &splitColumnWidget{} + case "custom-api": + w = &customAPIWidget{} + case "docker-containers": + w = &dockerContainersWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } - widget.SetID(uniqueID.Add(1)) + w.setID(widgetIDCounter.Add(1)) - return widget, nil + return w, nil } -type Widgets []Widget +type widgets []widget -func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { +func (w *widgets) UnmarshalYAML(node *yaml.Node) error { var nodes []yaml.Node if err := node.Decode(&nodes); err != nil { @@ -94,8 +98,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { return err } - widget, err := New(meta.Type) - + widget, err := newWidget(meta.Type) if err != nil { return err } @@ -110,17 +113,19 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { return nil } -type Widget interface { - Initialize() error - RequiresUpdate(*time.Time) bool - SetProviders(*Providers) - Update(context.Context) +type widget interface { + // These need to be exported because they get called in templates Render() template.HTML GetType() string - GetID() uint64 - SetID(uint64) - HandleRequest(w http.ResponseWriter, r *http.Request) - SetHideHeader(bool) + + initialize() error + requiresUpdate(*time.Time) bool + setProviders(*widgetProviders) + update(context.Context) + setID(uint64) + id() uint64 + handleRequest(w http.ResponseWriter, r *http.Request) + setHideHeader(bool) } type cacheType int @@ -132,29 +137,29 @@ const ( ) type widgetBase struct { - ID uint64 `yaml:"-"` - Providers *Providers `yaml:"-"` - Type string `yaml:"type"` - Title string `yaml:"title"` - TitleURL string `yaml:"title-url"` - CSSClass string `yaml:"css-class"` - CustomCacheDuration DurationField `yaml:"cache"` - ContentAvailable 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:"-"` - HideHeader bool `yaml:"-"` + ID uint64 `yaml:"-"` + Providers *widgetProviders `yaml:"-"` + Type string `yaml:"type"` + Title string `yaml:"title"` + TitleURL string `yaml:"title-url"` + CSSClass string `yaml:"css-class"` + CustomCacheDuration durationField `yaml:"cache"` + ContentAvailable 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:"-"` + HideHeader bool `yaml:"-"` } -type Providers struct { - AssetResolver func(string) string +type widgetProviders struct { + assetResolver func(string) string } -func (w *widgetBase) RequiresUpdate(now *time.Time) bool { +func (w *widgetBase) requiresUpdate(now *time.Time) bool { if w.cacheType == cacheTypeInfinite { return false } @@ -166,23 +171,23 @@ func (w *widgetBase) RequiresUpdate(now *time.Time) bool { return now.After(w.nextUpdate) } -func (w *widgetBase) Update(ctx context.Context) { +func (w *widgetBase) update(ctx context.Context) { } -func (w *widgetBase) GetID() uint64 { +func (w *widgetBase) id() uint64 { return w.ID } -func (w *widgetBase) SetID(id uint64) { +func (w *widgetBase) setID(id uint64) { w.ID = id } -func (w *widgetBase) SetHideHeader(value bool) { +func (w *widgetBase) setHideHeader(value bool) { w.HideHeader = value } -func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) { +func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { http.Error(w, "not implemented", http.StatusNotImplemented) } @@ -190,19 +195,18 @@ func (w *widgetBase) GetType() string { return w.Type } -func (w *widgetBase) SetProviders(providers *Providers) { +func (w *widgetBase) setProviders(providers *widgetProviders) { w.Providers = providers } -func (w *widgetBase) render(data any, t *template.Template) template.HTML { +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) + slog.Error("Failed to render template", "error", err) // need to immediately re-render with the error, // otherwise risk breaking the page since the widget @@ -211,7 +215,7 @@ func (w *widgetBase) render(data any, t *template.Template) template.HTML { err2 := t.Execute(&w.templateBuffer, data) if err2 != nil { - slog.Error("failed to render error within widget", "error", err2, "initial_error", err) + 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 @@ -288,7 +292,7 @@ func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool { if err != nil { w.scheduleEarlyUpdate() - if !errors.Is(err, feed.ErrPartialContent) { + if !errors.Is(err, errPartialContent) { w.withError(err) w.withNotice(nil) return false diff --git a/internal/widget/bookmarks.go b/internal/widget/bookmarks.go deleted file mode 100644 index 962d540..0000000 --- a/internal/widget/bookmarks.go +++ /dev/null @@ -1,47 +0,0 @@ -package widget - -import ( - "html/template" - - "github.com/glanceapp/glance/internal/assets" -) - -type Bookmarks struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - Groups []struct { - Title string `yaml:"title"` - Color *HSLColorField `yaml:"color"` - Links []struct { - Title string `yaml:"title"` - URL string `yaml:"url"` - Icon string `yaml:"icon"` - IsSimpleIcon bool `yaml:"-"` - SameTab bool `yaml:"same-tab"` - HideArrow bool `yaml:"hide-arrow"` - } `yaml:"links"` - } `yaml:"groups"` -} - -func (widget *Bookmarks) Initialize() error { - widget.withTitle("Bookmarks").withError(nil) - - for g := range widget.Groups { - for l := range widget.Groups[g].Links { - if widget.Groups[g].Links[l].Icon == "" { - continue - } - - link := &widget.Groups[g].Links[l] - link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon) - } - } - - widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate) - - return nil -} - -func (widget *Bookmarks) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/widget/calendar.go b/internal/widget/calendar.go deleted file mode 100644 index a126353..0000000 --- a/internal/widget/calendar.go +++ /dev/null @@ -1,30 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Calendar struct { - widgetBase `yaml:",inline"` - Calendar *feed.Calendar -} - -func (widget *Calendar) Initialize() error { - widget.withTitle("Calendar").withCacheOnTheHour() - - return nil -} - -func (widget *Calendar) Update(ctx context.Context) { - widget.Calendar = feed.NewCalendar(time.Now()) - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *Calendar) Render() template.HTML { - return widget.render(widget, assets.CalendarTemplate) -} diff --git a/internal/widget/changedetection.go b/internal/widget/changedetection.go deleted file mode 100644 index 26c080a..0000000 --- a/internal/widget/changedetection.go +++ /dev/null @@ -1,66 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type ChangeDetection struct { - widgetBase `yaml:",inline"` - ChangeDetections feed.ChangeDetectionWatches `yaml:"-"` - WatchUUIDs []string `yaml:"watches"` - InstanceURL string `yaml:"instance-url"` - Token OptionalEnvString `yaml:"token"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *ChangeDetection) Initialize() error { - widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.InstanceURL == "" { - widget.InstanceURL = "https://www.changedetection.io" - } - - return nil -} - -func (widget *ChangeDetection) Update(ctx context.Context) { - if len(widget.WatchUUIDs) == 0 { - uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token)) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.WatchUUIDs = uuids - } - - watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(watches) > widget.Limit { - watches = watches[:widget.Limit] - } - - widget.ChangeDetections = watches -} - -func (widget *ChangeDetection) Render() template.HTML { - return widget.render(widget, assets.ChangeDetectionTemplate) -} diff --git a/internal/widget/dns-stats.go b/internal/widget/dns-stats.go deleted file mode 100644 index 91757b1..0000000 --- a/internal/widget/dns-stats.go +++ /dev/null @@ -1,77 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "strings" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type DNSStats struct { - widgetBase `yaml:",inline"` - - TimeLabels [8]string `yaml:"-"` - Stats *feed.DNSStats `yaml:"-"` - - HourFormat string `yaml:"hour-format"` - Service string `yaml:"service"` - URL OptionalEnvString `yaml:"url"` - Token OptionalEnvString `yaml:"token"` - Username OptionalEnvString `yaml:"username"` - Password OptionalEnvString `yaml:"password"` -} - -func makeDNSTimeLabels(format string) [8]string { - now := time.Now() - var labels [8]string - - for i := 24; i > 0; i -= 3 { - labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format)) - } - - return labels -} - -func (widget *DNSStats) Initialize() error { - widget. - withTitle("DNS Stats"). - withTitleURL(string(widget.URL)). - withCacheDuration(10 * time.Minute) - - if widget.Service != "adguard" && widget.Service != "pihole" { - return errors.New("DNS stats service must be either 'adguard' or 'pihole'") - } - - return nil -} - -func (widget *DNSStats) Update(ctx context.Context) { - var stats *feed.DNSStats - var err error - - if widget.Service == "adguard" { - stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password)) - } else { - stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token)) - } - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.HourFormat == "24h" { - widget.TimeLabels = makeDNSTimeLabels("15:00") - } else { - widget.TimeLabels = makeDNSTimeLabels("3PM") - } - - widget.Stats = stats -} - -func (widget *DNSStats) Render() template.HTML { - return widget.render(widget, assets.DNSStatsTemplate) -} diff --git a/internal/widget/extension.go b/internal/widget/extension.go deleted file mode 100644 index 547bbfe..0000000 --- a/internal/widget/extension.go +++ /dev/null @@ -1,59 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "net/url" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Extension struct { - widgetBase `yaml:",inline"` - URL string `yaml:"url"` - Parameters map[string]string `yaml:"parameters"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` - Extension feed.Extension `yaml:"-"` - cachedHTML template.HTML `yaml:"-"` -} - -func (widget *Extension) Initialize() error { - widget.withTitle("Extension").withCacheDuration(time.Minute * 30) - - if widget.URL == "" { - return errors.New("no extension URL specified") - } - - _, err := url.Parse(widget.URL) - - if err != nil { - return err - } - - return nil -} - -func (widget *Extension) Update(ctx context.Context) { - extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{ - URL: widget.URL, - Parameters: widget.Parameters, - AllowHtml: widget.AllowHtml, - }) - - widget.canContinueUpdateAfterHandlingErr(err) - - widget.Extension = extension - - if extension.Title != "" { - widget.Title = extension.Title - } - - widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate) -} - -func (widget *Extension) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/widget/fields.go b/internal/widget/fields.go deleted file mode 100644 index 72ade15..0000000 --- a/internal/widget/fields.go +++ /dev/null @@ -1,168 +0,0 @@ -package widget - -import ( - "fmt" - "html/template" - "os" - "regexp" - "strconv" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) -var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`) - -const ( - HSLHueMax = 360 - HSLSaturationMax = 100 - HSLLightnessMax = 100 -) - -type HSLColorField struct { - Hue uint16 - Saturation uint8 - Lightness uint8 -} - -func (c *HSLColorField) String() string { - return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) -} - -func (c *HSLColorField) AsCSSValue() template.CSS { - return template.CSS(c.String()) -} - -func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { - var value string - - if err := node.Decode(&value); err != nil { - return err - } - - matches := HSLColorPattern.FindStringSubmatch(value) - - if len(matches) != 4 { - return fmt.Errorf("invalid HSL color format: %s", value) - } - - hue, err := strconv.ParseUint(matches[1], 10, 16) - - if err != nil { - return err - } - - if hue > HSLHueMax { - return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax) - } - - saturation, err := strconv.ParseUint(matches[2], 10, 8) - - if err != nil { - return err - } - - if saturation > HSLSaturationMax { - return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax) - } - - lightness, err := strconv.ParseUint(matches[3], 10, 8) - - if err != nil { - return err - } - - if lightness > HSLLightnessMax { - return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax) - } - - c.Hue = uint16(hue) - c.Saturation = uint8(saturation) - c.Lightness = uint8(lightness) - - return nil -} - -var DurationPattern = 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 := DurationPattern.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 OptionalEnvString string - -func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { - var value string - - err := node.Decode(&value) - - if err != nil { - return err - } - - matches := EnvFieldPattern.FindStringSubmatch(value) - - if len(matches) != 2 { - *f = OptionalEnvString(value) - - return nil - } - - value, found := os.LookupEnv(matches[1]) - - if !found { - return fmt.Errorf("environment variable %s not found", matches[1]) - } - - *f = OptionalEnvString(value) - - return nil -} - -func (f *OptionalEnvString) String() string { - return string(*f) -} - -func toSimpleIconIfPrefixed(icon string) (string, bool) { - if !strings.HasPrefix(icon, "si:") { - return icon, false - } - - icon = strings.TrimPrefix(icon, "si:") - icon = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg" - - return icon, true -} diff --git a/internal/widget/group.go b/internal/widget/group.go deleted file mode 100644 index 9a15510..0000000 --- a/internal/widget/group.go +++ /dev/null @@ -1,76 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "sync" - "time" - - "github.com/glanceapp/glance/internal/assets" -) - -type Group struct { - widgetBase `yaml:",inline"` - Widgets Widgets `yaml:"widgets"` -} - -func (widget *Group) Initialize() error { - widget.withError(nil) - widget.HideHeader = true - - for i := range widget.Widgets { - widget.Widgets[i].SetHideHeader(true) - - if widget.Widgets[i].GetType() == "group" { - return errors.New("nested groups are not allowed") - } - - if err := widget.Widgets[i].Initialize(); err != nil { - return err - } - } - - return nil -} - -func (widget *Group) Update(ctx context.Context) { - var wg sync.WaitGroup - now := time.Now() - - for w := range widget.Widgets { - widget := widget.Widgets[w] - - if !widget.RequiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.Update(ctx) - }() - } - - wg.Wait() -} - -func (widget *Group) SetProviders(providers *Providers) { - for i := range widget.Widgets { - widget.Widgets[i].SetProviders(providers) - } -} - -func (widget *Group) RequiresUpdate(now *time.Time) bool { - for i := range widget.Widgets { - if widget.Widgets[i].RequiresUpdate(now) { - return true - } - } - - return false -} - -func (widget *Group) Render() template.HTML { - return widget.render(widget, assets.GroupTemplate) -} diff --git a/internal/widget/hacker-news.go b/internal/widget/hacker-news.go deleted file mode 100644 index f2db6e3..0000000 --- a/internal/widget/hacker-news.go +++ /dev/null @@ -1,65 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type HackerNews struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - Limit int `yaml:"limit"` - SortBy string `yaml:"sort-by"` - ExtraSortBy string `yaml:"extra-sort-by"` - CollapseAfter int `yaml:"collapse-after"` - CommentsUrlTemplate string `yaml:"comments-url-template"` - ShowThumbnails bool `yaml:"-"` -} - -func (widget *HackerNews) Initialize() error { - widget. - withTitle("Hacker News"). - withTitleURL("https://news.ycombinator.com/"). - withCacheDuration(30 * time.Minute) - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { - widget.SortBy = "top" - } - - return nil -} - -func (widget *HackerNews) Update(ctx context.Context) { - posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.ExtraSortBy == "engagement" { - posts.CalculateEngagement() - posts.SortByEngagement() - } - - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] - } - - widget.Posts = posts -} - -func (widget *HackerNews) Render() template.HTML { - return widget.render(widget, assets.ForumPostsTemplate) -} diff --git a/internal/widget/lobsters.go b/internal/widget/lobsters.go deleted file mode 100644 index a783c31..0000000 --- a/internal/widget/lobsters.go +++ /dev/null @@ -1,64 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Lobsters struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - InstanceURL string `yaml:"instance-url"` - CustomURL string `yaml:"custom-url"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` - Tags []string `yaml:"tags"` - ShowThumbnails bool `yaml:"-"` -} - -func (widget *Lobsters) Initialize() error { - widget.withTitle("Lobsters").withCacheDuration(time.Hour) - - if widget.InstanceURL == "" { - widget.withTitleURL("https://lobste.rs") - } else { - widget.withTitleURL(widget.InstanceURL) - } - - if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { - widget.SortBy = "hot" - } - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - return nil -} - -func (widget *Lobsters) Update(ctx context.Context) { - posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] - } - - widget.Posts = posts -} - -func (widget *Lobsters) Render() template.HTML { - return widget.render(widget, assets.ForumPostsTemplate) -} diff --git a/internal/widget/markets.go b/internal/widget/markets.go deleted file mode 100644 index 0d80973..0000000 --- a/internal/widget/markets.go +++ /dev/null @@ -1,46 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Markets struct { - widgetBase `yaml:",inline"` - StocksRequests []feed.MarketRequest `yaml:"stocks"` - MarketRequests []feed.MarketRequest `yaml:"markets"` - Sort string `yaml:"sort-by"` - Markets feed.Markets `yaml:"-"` -} - -func (widget *Markets) Initialize() error { - widget.withTitle("Markets").withCacheDuration(time.Hour) - - if len(widget.MarketRequests) == 0 { - widget.MarketRequests = widget.StocksRequests - } - - return nil -} - -func (widget *Markets) Update(ctx context.Context) { - markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Sort == "absolute-change" { - markets.SortByAbsChange() - } - - widget.Markets = markets -} - -func (widget *Markets) Render() template.HTML { - return widget.render(widget, assets.MarketsTemplate) -} diff --git a/internal/widget/monitor.go b/internal/widget/monitor.go deleted file mode 100644 index 06d7303..0000000 --- a/internal/widget/monitor.go +++ /dev/null @@ -1,103 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "strconv" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -func statusCodeToText(status int) string { - if status == 200 { - return "OK" - } - if status == 404 { - return "Not Found" - } - if status == 403 { - return "Forbidden" - } - if status == 401 { - return "Unauthorized" - } - if status >= 400 { - return "Client Error" - } - if status >= 500 { - return "Server Error" - } - - return strconv.Itoa(status) -} - -func statusCodeToStyle(status int) string { - if status == 200 { - return "ok" - } - - return "error" -} - -type Monitor struct { - widgetBase `yaml:",inline"` - Sites []struct { - *feed.SiteStatusRequest `yaml:",inline"` - Status *feed.SiteStatus `yaml:"-"` - Title string `yaml:"title"` - IconUrl string `yaml:"icon"` - IsSimpleIcon bool `yaml:"-"` - SameTab bool `yaml:"same-tab"` - StatusText string `yaml:"-"` - StatusStyle string `yaml:"-"` - } `yaml:"sites"` - ShowFailingOnly bool `yaml:"show-failing-only"` - HasFailing bool `yaml:"-"` -} - -func (widget *Monitor) Initialize() error { - widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) - - for i := range widget.Sites { - widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl) - } - - return nil -} - -func (widget *Monitor) Update(ctx context.Context) { - requests := make([]*feed.SiteStatusRequest, len(widget.Sites)) - - for i := range widget.Sites { - requests[i] = widget.Sites[i].SiteStatusRequest - } - - statuses, err := feed.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 status.Code >= 400 || status.TimedOut || status.Error != nil { - widget.HasFailing = true - } - - if !status.TimedOut { - site.StatusText = statusCodeToText(status.Code) - site.StatusStyle = statusCodeToStyle(status.Code) - } - } -} - -func (widget *Monitor) Render() template.HTML { - return widget.render(widget, assets.MonitorTemplate) -} diff --git a/internal/widget/reddit.go b/internal/widget/reddit.go deleted file mode 100644 index b1ddf0a..0000000 --- a/internal/widget/reddit.go +++ /dev/null @@ -1,121 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "strings" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Reddit struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - Subreddit string `yaml:"subreddit"` - Style string `yaml:"style"` - ShowThumbnails bool `yaml:"show-thumbnails"` - ShowFlairs bool `yaml:"show-flairs"` - SortBy string `yaml:"sort-by"` - TopPeriod string `yaml:"top-period"` - Search string `yaml:"search"` - ExtraSortBy string `yaml:"extra-sort-by"` - CommentsUrlTemplate string `yaml:"comments-url-template"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - RequestUrlTemplate string `yaml:"request-url-template"` -} - -func (widget *Reddit) Initialize() error { - if widget.Subreddit == "" { - return errors.New("no subreddit specified") - } - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if !isValidRedditSortType(widget.SortBy) { - widget.SortBy = "hot" - } - - if !isValidRedditTopPeriod(widget.TopPeriod) { - widget.TopPeriod = "day" - } - - if widget.RequestUrlTemplate != "" { - if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { - return errors.New("no `{REQUEST-URL}` placeholder specified") - } - } - - widget. - withTitle("/r/" + widget.Subreddit). - withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). - withCacheDuration(30 * time.Minute) - - return nil -} - -func isValidRedditSortType(sortBy string) bool { - return sortBy == "hot" || - sortBy == "new" || - sortBy == "top" || - sortBy == "rising" -} - -func isValidRedditTopPeriod(period string) bool { - return period == "hour" || - period == "day" || - period == "week" || - period == "month" || - period == "year" || - period == "all" -} - -func (widget *Reddit) Update(ctx context.Context) { - // TODO: refactor, use a struct to pass all of these - posts, err := feed.FetchSubredditPosts( - widget.Subreddit, - widget.SortBy, - widget.TopPeriod, - widget.Search, - widget.CommentsUrlTemplate, - widget.RequestUrlTemplate, - widget.ShowFlairs, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(posts) > widget.Limit { - posts = posts[:widget.Limit] - } - - if widget.ExtraSortBy == "engagement" { - posts.CalculateEngagement() - posts.SortByEngagement() - } - - widget.Posts = posts -} - -func (widget *Reddit) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.render(widget, assets.RedditCardsHorizontalTemplate) - } - - if widget.Style == "vertical-cards" { - return widget.render(widget, assets.RedditCardsVerticalTemplate) - } - - return widget.render(widget, assets.ForumPostsTemplate) - -} diff --git a/internal/widget/repository-overview.go b/internal/widget/repository-overview.go deleted file mode 100644 index 9d4cab3..0000000 --- a/internal/widget/repository-overview.go +++ /dev/null @@ -1,58 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Repository struct { - widgetBase `yaml:",inline"` - RequestedRepository string `yaml:"repository"` - Token OptionalEnvString `yaml:"token"` - PullRequestsLimit int `yaml:"pull-requests-limit"` - IssuesLimit int `yaml:"issues-limit"` - CommitsLimit int `yaml:"commits-limit"` - RepositoryDetails feed.RepositoryDetails -} - -func (widget *Repository) 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 *Repository) Update(ctx context.Context) { - details, err := feed.FetchRepositoryDetailsFromGithub( - widget.RequestedRepository, - string(widget.Token), - widget.PullRequestsLimit, - widget.IssuesLimit, - widget.CommitsLimit, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.RepositoryDetails = details -} - -func (widget *Repository) Render() template.HTML { - return widget.render(widget, assets.RepositoryTemplate) -} diff --git a/internal/widget/rss.go b/internal/widget/rss.go deleted file mode 100644 index 282b150..0000000 --- a/internal/widget/rss.go +++ /dev/null @@ -1,83 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type RSS struct { - widgetBase `yaml:",inline"` - FeedRequests []feed.RSSFeedRequest `yaml:"feeds"` - Style string `yaml:"style"` - ThumbnailHeight float64 `yaml:"thumbnail-height"` - CardHeight float64 `yaml:"card-height"` - Items feed.RSSFeedItems `yaml:"-"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - SingleLineTitles bool `yaml:"single-line-titles"` - NoItemsMessage string `yaml:"-"` -} - -func (widget *RSS) Initialize() error { - widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 25 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.ThumbnailHeight < 0 { - widget.ThumbnailHeight = 0 - } - - if widget.CardHeight < 0 { - widget.CardHeight = 0 - } - - if widget.Style == "detailed-list" { - for i := range widget.FeedRequests { - widget.FeedRequests[i].IsDetailed = true - } - } - - widget.NoItemsMessage = "No items were returned from the feeds." - - return nil -} - -func (widget *RSS) Update(ctx context.Context) { - items, err := feed.GetItemsFromRSSFeeds(widget.FeedRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(items) > widget.Limit { - items = items[:widget.Limit] - } - - widget.Items = items -} - -func (widget *RSS) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.render(widget, assets.RSSHorizontalCardsTemplate) - } - - if widget.Style == "horizontal-cards-2" { - return widget.render(widget, assets.RSSHorizontalCards2Template) - } - - if widget.Style == "detailed-list" { - return widget.render(widget, assets.RSSDetailedListTemplate) - } - - return widget.render(widget, assets.RSSListTemplate) -} diff --git a/internal/widget/twitch-channels.go b/internal/widget/twitch-channels.go deleted file mode 100644 index b06c986..0000000 --- a/internal/widget/twitch-channels.go +++ /dev/null @@ -1,55 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type TwitchChannels struct { - widgetBase `yaml:",inline"` - ChannelsRequest []string `yaml:"channels"` - Channels []feed.TwitchChannel `yaml:"-"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` -} - -func (widget *TwitchChannels) 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 *TwitchChannels) Update(ctx context.Context) { - channels, err := feed.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 *TwitchChannels) Render() template.HTML { - return widget.render(widget, assets.TwitchChannelsTemplate) -} diff --git a/internal/widget/twitch-top-games.go b/internal/widget/twitch-top-games.go deleted file mode 100644 index 85933a6..0000000 --- a/internal/widget/twitch-top-games.go +++ /dev/null @@ -1,49 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type TwitchGames struct { - widgetBase `yaml:",inline"` - Categories []feed.TwitchCategory `yaml:"-"` - Exclude []string `yaml:"exclude"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *TwitchGames) 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 *TwitchGames) Update(ctx context.Context) { - categories, err := feed.FetchTopGamesFromTwitch(widget.Exclude, widget.Limit) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Categories = categories -} - -func (widget *TwitchGames) Render() template.HTML { - return widget.render(widget, assets.TwitchGamesListTemplate) -} diff --git a/internal/widget/videos.go b/internal/widget/videos.go deleted file mode 100644 index 8943603..0000000 --- a/internal/widget/videos.go +++ /dev/null @@ -1,57 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Videos struct { - widgetBase `yaml:",inline"` - Videos feed.Videos `yaml:"-"` - VideoUrlTemplate string `yaml:"video-url-template"` - Style string `yaml:"style"` - CollapseAfterRows int `yaml:"collapse-after-rows"` - Channels []string `yaml:"channels"` - Limit int `yaml:"limit"` - IncludeShorts bool `yaml:"include-shorts"` -} - -func (widget *Videos) 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 - } - - return nil -} - -func (widget *Videos) Update(ctx context.Context) { - videos, err := feed.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 *Videos) Render() template.HTML { - if widget.Style == "grid-cards" { - return widget.render(widget, assets.VideosGridTemplate) - } - - return widget.render(widget, assets.VideosTemplate) -} diff --git a/internal/widget/weather.go b/internal/widget/weather.go deleted file mode 100644 index ac207d4..0000000 --- a/internal/widget/weather.go +++ /dev/null @@ -1,74 +0,0 @@ -package widget - -import ( - "context" - "fmt" - "html/template" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Weather 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 *feed.PlaceJson `yaml:"-"` - Weather *feed.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 *Weather) Initialize() error { - widget.withTitle("Weather").withCacheOnTheHour() - - if widget.Location == "" { - return fmt.Errorf("location must be specified for weather widget") - } - - if widget.HourFormat == "" || widget.HourFormat == "12h" { - widget.TimeLabels = timeLabels12h - } else if widget.HourFormat == "24h" { - widget.TimeLabels = timeLabels24h - } else { - return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat) - } - - if widget.Units == "" { - widget.Units = "metric" - } else if widget.Units != "metric" && widget.Units != "imperial" { - return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units) - } - - return nil -} - -func (widget *Weather) Update(ctx context.Context) { - if widget.Place == nil { - place, err := feed.FetchPlaceFromName(widget.Location) - - if err != nil { - widget.withError(err).scheduleEarlyUpdate() - return - } - - widget.Place = place - } - - weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Weather = weather -} - -func (widget *Weather) Render() template.HTML { - return widget.render(widget, assets.WeatherTemplate) -}