Merge remote-tracking branch 'upstream/release/v0.7.0'

This commit is contained in:
develoopeer 2024-12-29 23:37:00 +03:00
commit 83c1368f98
137 changed files with 5607 additions and 3860 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/assets /assets
/build /build
/playground /playground
/.idea
glance*.yml glance*.yml

View file

@ -1,4 +1,4 @@
FROM golang:1.22.5-alpine3.20 AS builder FROM golang:1.23.1-alpine3.20 AS builder
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
@ -9,5 +9,8 @@ FROM alpine:3.20
WORKDIR /app WORKDIR /app
COPY --from=builder /app/glance . 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 EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"] ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

View file

@ -3,6 +3,8 @@ FROM alpine:3.20
WORKDIR /app WORKDIR /app
COPY glance . 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"]

View file

@ -20,6 +20,7 @@
* Twitch channels & top games * Twitch channels & top games
* GitHub releases * GitHub releases
* Repository overview * Repository overview
* Docker containers
* Site monitor * Site monitor
* Search box * Search box
@ -51,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
``` ```
#### Docker #### Docker
<!-- TODO: update -->
> [!IMPORTANT] > [!IMPORTANT]
> >
> Make sure you have a valid `glance.yml` file in the same directory before running the container. > Make sure you have a valid `glance.yml` file in the same directory before running the container.

View file

@ -15,6 +15,8 @@
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget) - [Search](#search-widget)
- [Group](#group) - [Group](#group)
- [Split Column](#split-column)
- [Custom API](#custom-api)
- [Extension](#extension) - [Extension](#extension)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
@ -30,8 +32,10 @@
- [Twitch Top Games](#twitch-top-games) - [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe) - [iframe](#iframe)
- [HTML](#html) - [HTML](#html)
- [Docker](#docker)
## Intro ## Intro
<!-- TODO: update -->
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. 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 ## 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! Configure the widgets, add more of them, add extra pages, etc. Make it your own!
<!-- TODO: update - add information about top level document key -->
## Server ## Server
Server configuration is done through a top level `server` property. Example: Server configuration is done through a top level `server` property. Example:
@ -281,6 +287,7 @@ theme:
> .widget-type-rss a { > .widget-type-rss a {
> font-size: 1.5rem; > font-size: 1.5rem;
> } > }
> ```
> >
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets. > In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
@ -313,6 +320,7 @@ pages:
| width | string | no | | | width | string | no | |
| center-vertically | boolean | no | false | | center-vertically | boolean | no | false |
| hide-desktop-navigation | boolean | no | false | | hide-desktop-navigation | boolean | no | false |
| expand-mobile-page-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false | | show-mobile-header | boolean | no | false |
| columns | array | yes | | | columns | array | yes | |
@ -339,6 +347,9 @@ When set to `true`, vertically centers the content on the page. Has no effect if
#### `hide-desktop-navigation` #### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop. 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` #### `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. 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.
@ -525,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-categories | boolean | no | false | Only applicable for `detailed-list` style |
| hide-description | 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 | | | | item-link-prefix | string | no | | |
| headers | key (string) & value (string) | no | | |
###### `item-link-prefix` ###### `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. 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` ##### `limit`
The maximum number of articles to show. The maximum number of articles to show.
@ -565,7 +588,15 @@ Preview:
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels` ##### `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) ![](images/videos-channel-description-example.png)
@ -828,6 +859,7 @@ Preview:
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty | | <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty | | <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
| <kbd>Escape</kbd> | Leave focus | Search input is focused | | <kbd>Escape</kbd> | Leave focus | Search input is focused |
| <kbd>Up</kbd> | Insert the last search query since the page was opened into the input field | Search input is focused |
> [!TIP] > [!TIP]
> >
@ -890,7 +922,7 @@ url: https://www.amazon.com/s?k={QUERY}
``` ```
### Group ### 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: Example:
@ -933,6 +965,67 @@ Example:
<<: *shared-properties <<: *shared-properties
``` ```
### Split Column
<!-- TODO: update -->
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:
<details>
<summary>View config</summary>
```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
```
</details>
<br>
Preview:
![](images/split-column-widget-preview.png)
### Custom API
<!-- TODO -->
### Extension ### 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). 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).
@ -948,11 +1041,15 @@ Display a widget provided by an external source (3rd party). If you want to lear
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| url | string | yes | | | url | string | yes | |
| fallback-content-type | string | no | |
| allow-potentially-dangerous-html | boolean | no | false | | allow-potentially-dangerous-html | boolean | no | false |
| parameters | key & value | no | | | parameters | key & value | no | |
##### `url` ##### `url`
The URL of the extension. 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` ##### `allow-potentially-dangerous-html`
Whether to allow the extension to display HTML. Whether to allow the extension to display HTML.
@ -1065,11 +1162,19 @@ You can hover over the "ERROR" text to view more information.
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| sites | array | yes | | | sites | array | yes | |
| style | string | no | |
| show-failing-only | boolean | no | false | | show-failing-only | boolean | no | false |
##### `show-failing-only` ##### `show-failing-only`
Shows only a list of failing sites when set to `true`. 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` ##### `sites`
Properties for each site: Properties for each site:
@ -1082,6 +1187,7 @@ Properties for each site:
| icon | string | no | | | icon | string | no | |
| allow-insecure | boolean | no | false | | allow-insecure | boolean | no | false |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
| alt-status-codes | array | no | |
`title` `title`
@ -1097,7 +1203,7 @@ The URL which will be requested and its response will determine the status of th
`icon` `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 ```yaml
icon: si:jellyfin icon: si:jellyfin
@ -1107,7 +1213,7 @@ icon: si:adguard
> [!WARNING] > [!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` `allow-insecure`
@ -1117,6 +1223,15 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab. 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 ### Releases
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub. Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
@ -1238,15 +1353,21 @@ Preview:
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| service | string | no | pihole | | service | string | no | pihole |
| allow-insecure | bool | no | false |
| url | string | yes | | | url | string | yes | |
| username | string | when service is `adguard` | | | username | string | when service is `adguard` | |
| password | string | when service is `adguard` | | | password | string | when service is `adguard` | |
| token | string | when service is `pihole` | | | token | string | when service is `pihole` | |
| hide-graph | bool | no | false |
| hide-top-domains | bool | no | false |
| hour-format | string | no | 12h | | hour-format | string | no | 12h |
##### `service` ##### `service`
Either `adguard` or `pihole`. Either `adguard` or `pihole`.
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
##### `url` ##### `url`
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
@ -1259,6 +1380,12 @@ Only required when using AdGuard Home. The password used to log into the admin d
##### `token` ##### `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}`. 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` ##### `hour-format`
Whether to display the relative time in the graph in `12h` or `24h` format. Whether to display the relative time in the graph in `12h` or `24h` format.
@ -1363,6 +1490,12 @@ An array of groups which can optionally have a title and a custom color.
| title | string | no | | | title | string | no | |
| color | HSL | no | the primary color of the theme | | color | HSL | no | the primary color of the theme |
| links | array | yes | | | links | array | yes | |
| same-tab | boolean | no | false |
| hide-arrow | boolean | no | false |
> [!TIP]
>
> You can set `same-tab` and `hide-arrow` 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 ###### Properties for each link
| Name | Type | Required | Default | | Name | Type | Required | Default |
@ -1375,7 +1508,7 @@ An array of groups which can optionally have a title and a custom color.
`icon` `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 ```yaml
icon: si:gmail icon: si:gmail
@ -1385,7 +1518,7 @@ icon: si:reddit
> [!WARNING] > [!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` `same-tab`
@ -1494,15 +1627,25 @@ Example:
```yaml ```yaml
- type: calendar - type: calendar
start-sunday: false
``` ```
Preview: Preview:
![](images/calendar-widget-preview.png) ![](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] > [!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 ### Markets
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
@ -1539,7 +1682,7 @@ Preview:
An array of markets for which to display information about. An array of markets for which to display information about.
##### `sort-by` ##### `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 ###### Properties for each stock
| Name | Type | Required | | Name | Type | Required |
@ -1674,3 +1817,75 @@ Example:
``` ```
Note the use of `|` after `source:`, this allows you to insert a multi-line string. Note the use of `|` after `source:`, this allows you to insert a multi-line string.
### Docker Containers
<!-- TODO: update -->
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)

View file

@ -29,6 +29,9 @@ Used to specify the title of the widget. If not provided, the widget's title wil
### `Widget-Content-Type` ### `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. 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 ## Content Types
> [!NOTE] > [!NOTE]

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

View file

@ -4,6 +4,10 @@ Don't want to spend time configuring pages from scratch? No problem! Simply copy
Pull requests with your page configurations are welcome! Pull requests with your page configurations are welcome!
> [!NOTE]
>
> Pages must be placed under a top level `pages:` key, you can read more about that [here](configuration.md#pages).
## Startpage ## Startpage
![](images/startpage-preview.png) ![](images/startpage-preview.png)

15
go.mod
View file

@ -1,20 +1,25 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.5 go 1.23.1
require ( require (
github.com/fsnotify/fsnotify v1.8.0
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.16.0 github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.21.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/arran4/golang-ical v0.3.1 // indirect github.com/arran4/golang-ical v0.3.1 // 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/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.27.0 // 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
) )

63
go.sum
View file

@ -1,13 +1,15 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= 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/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk= github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk=
github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk= github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -32,21 +34,45 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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= 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-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.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.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.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-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-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.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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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-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.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.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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -54,21 +80,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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.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-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.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.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.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.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.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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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-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.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.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.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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -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)

View file

@ -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))
}

View file

@ -1,5 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}

View file

@ -1,14 +0,0 @@
<style>
:root {
{{ if .App.Config.Theme.BackgroundColor }}
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View file

@ -1,61 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .RepositoryDetails.Commits) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}

View file

@ -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
}

View file

@ -1,105 +0,0 @@
package feed
import (
"log"
"net/http"
"os"
"strings"
"time"
ics "github.com/arran4/golang-ical"
)
// 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, icsurl string) *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([]CalendayDay, 21)
events, _ := ReadPublicIcs(icsurl)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
month := now.Month()
var dayEvent CalendarEvent
if day < 1 {
day = previousMonthDays + day
month -= 1
} else if day > currentMonthDays {
day = day - currentMonthDays
month += 1
}
if events != nil {
for _, event := range events {
startAt, err := event.GetStartAt()
if err != nil {
log.Panic(err)
}
if startAt.Day() == day && startAt.Month() == month && startAt.Year() == year {
dayEvent.StartedDay = startAt
dayEvent.EventHover = event.GetProperty("SUMMARY").Value
days[i].IsEvent = true
}
}
}
days[i].Day = day
days[i].Event = dayEvent
}
return &Calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func ParseEventsFromFile(file string) []*ics.VEvent {
eventString, err := os.ReadFile(file)
if err != nil {
log.Panic(err)
}
cal, err := ics.ParseCalendar(strings.NewReader(string(eventString)))
if err != nil {
log.Panic(err)
}
events := cal.Events()
return events
}
func ReadPublicIcs(url string) ([]*ics.VEvent, error) {
response, err := http.Get(url)
if err != nil {
return nil, err
}
defer response.Body.Close()
cal, err := ics.ParseCalendar(response.Body)
if err != nil {
return nil, err
}
events := cal.Events()
return events, nil
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -1,250 +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 CalendarEvent struct {
StartedDay time.Time
EventHover string
}
type CalendayDay struct {
Day int
IsEvent bool
Event CalendarEvent
}
type Calendar struct {
CurrentDay int
CurrentWeekNumber int
CurrentMonthName string
CurrentYear int
Days []CalendayDay
}
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
}

View file

@ -1,72 +0,0 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceCodeberg ReleaseSource = "codeberg"
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
}
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(AppReleases, 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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -2,41 +2,66 @@ package glance
import ( import (
"flag" "flag"
"fmt"
"os" "os"
"strings"
) )
type CliIntent uint8 type cliIntent uint8
const ( const (
CliIntentServe CliIntent = iota cliIntentServe cliIntent = iota
CliIntentCheckConfig = iota cliIntentConfigValidate = iota
cliIntentConfigPrint = iota
cliIntentDiagnose = iota
) )
type CliOptions struct { type cliOptions struct {
Intent CliIntent intent cliIntent
ConfigPath string configPath string
} }
func ParseCliOptions() (*CliOptions, error) { func parseCliOptions() (*cliOptions, error) {
flags := flag.NewFlagSet("", flag.ExitOnError) 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") configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:]) err := flags.Parse(os.Args[1:])
if err != nil { if err != nil {
return nil, err return nil, err
} }
intent := CliIntentServe var intent cliIntent
var args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if *checkConfig { if len(args) == 0 {
intent = CliIntentCheckConfig 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{ return &cliOptions{
Intent: intent, intent: intent,
ConfigPath: *configPath, configPath: *configPath,
}, nil }, nil
} }

View file

@ -0,0 +1,171 @@
package glance
import (
"fmt"
"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:<icon_name>[.svg|.png]
// syntax: sh:<icon_name>[.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
}

View file

@ -1,43 +1,96 @@
package glance package glance
import ( import (
"bytes"
"fmt" "fmt"
"io" "html/template"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type config struct {
Server Server `yaml:"server"` Server struct {
Theme Theme `yaml:"theme"` Host string `yaml:"host"`
Branding Branding `yaml:"branding"` Port uint16 `yaml:"port"`
Pages []Page `yaml:"pages"` 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) { type page struct {
config := NewConfig() Title string `yaml:"name"`
Slug string `yaml:"slug"`
contentBytes, err := io.ReadAll(contents) 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 { if err != nil {
return nil, err return nil, err
} }
err = yaml.Unmarshal(contentBytes, config) config := &config{}
config.Server.Port = 8080
err = yaml.Unmarshal(contents, config)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = configIsValid(config); err != nil { if err = isConfigStateValid(config); err != nil {
return nil, err return nil, err
} }
for p := range config.Pages { for p := range config.Pages {
for c := range config.Pages[p].Columns { for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets { for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
return nil, err 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 return config, nil
} }
func NewConfig() *Config { var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
config := &Config{}
config.Server.Host = "" func parseConfigEnvVariables(contents []byte) ([]byte, error) {
config.Server.Port = 8080 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 { for i := range config.Pages {
if config.Pages[i].Title == "" { 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") { 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 { 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 config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 { 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 { } else {
if len(config.Pages[i].Columns) > 3 { 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 { for j := range config.Pages[i].Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { 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]++ columnSizesCount[config.Pages[i].Columns[j].Size]++
@ -92,7 +362,7 @@ func configIsValid(config *Config) error {
full := columnSizesCount["full"] full := columnSizesCount["full"]
if full > 2 || full == 0 { 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)
} }
} }

205
internal/glance/diagnose.go Normal file
View file

@ -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
}

62
internal/glance/embed.go Normal file
View file

@ -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
}

View file

@ -5,80 +5,93 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "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 { slugToPage map[string]*page
Version string widgetByID map[uint64]widget
Config Config
slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
} }
type Theme struct { func newApplication(config *config) (*application, error) {
BackgroundColor *widget.HSLColorField `yaml:"background-color"` app := &application{
PrimaryColor *widget.HSLColorField `yaml:"primary-color"` Version: buildVersion,
PositiveColor *widget.HSLColorField `yaml:"positive-color"` Config: *config,
NegativeColor *widget.HSLColorField `yaml:"negative-color"` slugToPage: make(map[string]*page),
Light bool `yaml:"light"` widgetByID: make(map[uint64]widget),
ContrastMultiplier float32 `yaml:"contrast-multiplier"` }
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"` 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 { func (p *page) updateOutdatedWidgets() {
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() {
now := time.Now() now := time.Now()
var wg sync.WaitGroup var wg sync.WaitGroup
@ -88,14 +101,14 @@ func (p *Page) UpdateOutdatedWidgets() {
for w := range p.Columns[c].Widgets { for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w] widget := p.Columns[c].Widgets[w]
if !widget.RequiresUpdate(&now) { if !widget.requiresUpdate(&now) {
continue continue
} }
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
widget.Update(context) widget.update(context)
}() }()
} }
} }
@ -103,16 +116,7 @@ func (p *Page) UpdateOutdatedWidgets() {
wg.Wait() wg.Wait()
} }
// TODO: fix, currently very simple, lots of uncovered edge cases func (a *application) transformUserDefinedAssetPath(path string) string {
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 {
if strings.HasPrefix(path, "/assets/") { if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path return a.Config.Server.BaseURL + path
} }
@ -120,74 +124,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
return path return path
} }
func NewApplication(config *Config) (*Application, error) { type pageTemplateData struct {
if len(config.Pages) == 0 { App *application
return nil, fmt.Errorf("no pages configured") Page *page
}
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
} }
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")] page, exists := a.slugToPage[r.PathValue("page")]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
pageData := templateData{ pageData := pageTemplateData{
Page: page, Page: page,
App: a, App: a,
} }
var responseBytes bytes.Buffer var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData) err := pageTemplate.Execute(&responseBytes, pageData)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -197,24 +153,28 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
w.Write(responseBytes.Bytes()) 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")] page, exists := a.slugToPage[r.PathValue("page")]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
pageData := templateData{ pageData := pageTemplateData{
Page: page, Page: page,
} }
page.mu.Lock() var err error
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
var responseBytes bytes.Buffer 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 { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -225,74 +185,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes()) 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 // TODO: add proper not found page
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page not found")) w.Write([]byte("Page not found"))
} }
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
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) {
widgetValue := r.PathValue("widget") widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64) widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil { if err != nil {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
widget, exists := a.widgetByID[widgetID] widget, exists := a.widgetByID[widgetID]
if !exists { if !exists {
a.HandleNotFound(w, r) a.handleNotFound(w, r)
return return
} }
widget.HandleRequest(w, r) widget.handleRequest(w, r)
} }
func (a *Application) AssetPath(asset string) string { func (a *application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset 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 gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support // TODO: add HTTPS support
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest) mux.HandleFunc("GET /{$}", a.handlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest) mux.HandleFunc("GET /{page}", a.handlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
mux.Handle( mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
) )
var absAssetsPath string
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
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)
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
} }
@ -301,8 +245,25 @@ func (a *Application) Serve() error {
Handler: mux, Handler: mux,
} }
a.Config.Server.StartedAt = time.Now() start := func() error {
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL) 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
} }

View file

@ -2,45 +2,174 @@ package glance
import ( import (
"fmt" "fmt"
"io"
"log"
"net/http"
"os" "os"
) )
func Main() int { var buildVersion = "dev"
options, err := ParseCliOptions()
func Main() int {
options, err := parseCliOptions()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return 1 return 1
} }
configFile, err := os.Open(options.ConfigPath) switch options.intent {
case cliIntentServe:
if err != nil { // remove in v0.10.0
fmt.Printf("failed opening config file: %v\n", err) if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
return 1 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)
if err := serveApp(options.configPath); err != nil {
fmt.Println(err)
return 1
}
case cliIntentConfigValidate:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil { if err != nil {
fmt.Printf("failed creating application: %v\n", err) fmt.Printf("Could not parse config file: %v\n", err)
return 1 return 1
} }
if err := app.Serve(); err != nil { if _, err := newConfigFromYAML(contents); err != nil {
fmt.Printf("http server error: %v\n", err) fmt.Printf("Config file is invalid: %v\n", err)
return 1 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 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 <link> 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
}

View file

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 802 B

After

Width:  |  Height:  |  Size: 802 B

View file

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 553 B

View file

@ -1,5 +1,6 @@
import { setupPopovers } from './popover.js'; 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) { async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs // TODO: handle non 200 status codes/time outs
@ -48,29 +49,35 @@ function setupCarousels() {
const minuteInSeconds = 60; const minuteInSeconds = 60;
const hourInSeconds = minuteInSeconds * 60; const hourInSeconds = minuteInSeconds * 60;
const dayInSeconds = hourInSeconds * 24; const dayInSeconds = hourInSeconds * 24;
const monthInSeconds = dayInSeconds * 30; const monthInSeconds = dayInSeconds * 30.4;
const yearInSeconds = monthInSeconds * 12; const yearInSeconds = dayInSeconds * 365;
function relativeTimeSince(timestamp) { function timestampToRelativeTime(timestamp) {
const delta = Math.round((Date.now() / 1000) - timestamp); let delta = Math.round((Date.now() / 1000) - timestamp);
let prefix = "";
if (delta < 0) {
delta = -delta;
prefix = "in ";
}
if (delta < minuteInSeconds) { if (delta < minuteInSeconds) {
return "1m"; return prefix + "1m";
} }
if (delta < hourInSeconds) { if (delta < hourInSeconds) {
return Math.floor(delta / minuteInSeconds) + "m"; return prefix + Math.floor(delta / minuteInSeconds) + "m";
} }
if (delta < dayInSeconds) { if (delta < dayInSeconds) {
return Math.floor(delta / hourInSeconds) + "h"; return prefix + Math.floor(delta / hourInSeconds) + "h";
} }
if (delta < monthInSeconds) { if (delta < monthInSeconds) {
return Math.floor(delta / dayInSeconds) + "d"; return prefix + Math.floor(delta / dayInSeconds) + "d";
} }
if (delta < yearInSeconds) { 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) function updateRelativeTimeForElements(elements)
@ -83,7 +90,7 @@ function updateRelativeTimeForElements(elements)
if (timestamp === undefined) if (timestamp === undefined)
continue continue
element.textContent = relativeTimeSince(timestamp); element.textContent = timestampToRelativeTime(timestamp);
} }
} }
@ -104,6 +111,7 @@ function setupSearchBoxes() {
const bangsMap = {}; const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0]; const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null; let currentBang = null;
let lastQuery = "";
for (let j = 0; j < bangs.length; j++) { for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j]; const bang = bangs[j];
@ -140,6 +148,14 @@ function setupSearchBoxes() {
window.location.href = url; window.location.href = url;
} }
lastQuery = query;
inputElement.value = "";
return;
}
if (event.key == "ArrowUp" && lastQuery.length > 0) {
inputElement.value = lastQuery;
return; return;
} }
}; };
@ -245,8 +261,24 @@ function setupGroups() {
for (let t = 0; t < titles.length; t++) { for (let t = 0; t < titles.length; t++) {
const title = titles[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", () => { title.addEventListener("click", () => {
if (t == current) { if (t == current) {
if (title.dataset.titleUrl !== undefined) {
openURLInNewTab(title.dataset.titleUrl);
}
return; return;
} }
@ -502,9 +534,34 @@ function timeInZone(now, zone) {
timeInZone = now 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() { function setupClocks() {
@ -547,9 +604,11 @@ function setupClocks() {
); );
updateCallbacks.push((now) => { updateCallbacks.push((now) => {
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone); const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time); 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(); 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() { async function setupPage() {
const pageElement = document.getElementById("page"); const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content"); const pageContentElement = document.getElementById("page-content");
@ -581,6 +653,7 @@ async function setupPage() {
setupCollapsibleLists(); setupCollapsibleLists();
setupCollapsibleGrids(); setupCollapsibleGrids();
setupGroups(); setupGroups();
setupMasonries();
setupDynamicRelativeTime(); setupDynamicRelativeTime();
setupLazyImages(); setupLazyImages();
} finally { } finally {
@ -590,6 +663,10 @@ async function setupPage() {
contentReadyCallbacks[i](); contentReadyCallbacks[i]();
} }
setTimeout(() => {
setupTruncatedElementTitles();
}, 50);
setTimeout(() => { setTimeout(() => {
document.body.classList.add("page-columns-transitioned"); document.body.classList.add("page-columns-transitioned");
}, 300); }, 300);

View file

@ -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);
}
}

View file

@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
} }
function showPopover() { function showPopover() {
if (pendingTarget === null) return;
activeTarget = pendingTarget; activeTarget = pendingTarget;
pendingTarget = null; pendingTarget = null;
@ -109,9 +111,10 @@ function repositionContainer() {
const containerBounds = containerElement.getBoundingClientRect(); const containerBounds = containerElement.getBoundingClientRect();
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline")); 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 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) { if (left < 0) {
containerElement.style.left = 0; containerElement.style.left = 0;
@ -120,11 +123,11 @@ function repositionContainer() {
} else if (left + containerBounds.width > window.innerWidth) { } else if (left + containerBounds.width > window.innerWidth) {
containerElement.style.removeProperty("left"); containerElement.style.removeProperty("left");
containerElement.style.right = 0; 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 { } else {
containerElement.style.removeProperty("right"); containerElement.style.removeProperty("right");
containerElement.style.left = left + "px"; 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; const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;

View file

@ -23,3 +23,16 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
export function isElementVisible(element) { export function isElementVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); 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();
}

View file

@ -273,6 +273,10 @@
background-color: var(--color-separator); background-color: var(--color-separator);
} }
pre {
font: inherit;
}
::selection { ::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight); color: var(--color-text-highlight);
@ -326,7 +330,7 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
html, body { html, body, .body-content {
height: 100%; height: 100%;
} }
@ -440,6 +444,17 @@ kbd:active {
box-shadow: 0 0 0 0 var(--color-widget-background-highlight); 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] { .popover-container, [data-popover-html] {
display: none; display: none;
} }
@ -514,6 +529,7 @@ kbd:active {
list-style: none; list-style: none;
position: relative; position: relative;
display: flex; display: flex;
z-index: 1;
} }
.details[open] .summary { .details[open] .summary {
@ -535,6 +551,10 @@ kbd:active {
opacity: 1; opacity: 1;
} }
.details:not([open]) .list-with-transition {
display: none;
}
.summary::after { .summary::after {
content: "◀"; content: "◀";
font-size: 1.2em; font-size: 1.2em;
@ -707,6 +727,7 @@ details[open] .summary::after {
justify-content: space-between; justify-content: space-between;
position: relative; position: relative;
margin-bottom: 1.8rem; margin-bottom: 1.8rem;
z-index: 1;
} }
.widget-error-header::before { .widget-error-header::before {
@ -722,19 +743,11 @@ details[open] .summary::after {
.widget-error-icon { .widget-error-icon {
width: 2.4rem; width: 2.4rem;
height: 2.4rem; height: 2.4rem;
border: 0.2rem solid var(--color-negative);
border-radius: 50%;
text-align: center;
line-height: 2rem;
flex-shrink: 0; flex-shrink: 0;
stroke: var(--color-negative);
opacity: 0.6; opacity: 0.6;
} }
.widget-error-icon::before {
content: '!';
color: var(--color-text-highlight);
}
.widget-content { .widget-content {
container-type: inline-size; container-type: inline-size;
container-name: widget; container-name: widget;
@ -922,17 +935,6 @@ details[open] .summary::after {
border-radius: var(--border-radius) var(--border-radius) 0 0; 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;
}
.search-icon { .search-icon {
width: 2.3rem; width: 2.3rem;
} }
@ -1059,7 +1061,7 @@ details[open] .summary::after {
opacity: 0.8; opacity: 0.8;
} }
:root:not(.light-scheme) .simple-icon { :root:not(.light-scheme) .flat-icon {
filter: invert(1); filter: invert(1);
} }
@ -1141,7 +1143,6 @@ details[open] .summary::after {
.dns-stats-graph-gridlines-container { .dns-stats-graph-gridlines-container {
position: absolute; position: absolute;
z-index: -1;
inset: 0; inset: 0;
} }
@ -1168,7 +1169,6 @@ details[open] .summary::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 1px 0; inset: 1px 0;
z-index: -1;
opacity: 0; opacity: 0;
background: var(--color-text-base); background: var(--color-text-base);
transition: opacity .2s; transition: opacity .2s;
@ -1310,7 +1310,6 @@ details[open] .summary::after {
overflow: hidden; overflow: hidden;
mask-image: linear-gradient(0deg, transparent 40%, #000); mask-image: linear-gradient(0deg, transparent 40%, #000);
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
z-index: -1;
} }
.weather-column-rain::before { .weather-column-rain::before {
@ -1385,6 +1384,10 @@ details[open] .summary::after {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.clock-time {
min-width: 8ch;
}
.clock-time span { .clock-time span {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
@ -1401,7 +1404,7 @@ details[open] .summary::after {
transition: filter 0.3s, opacity 0.3s; transition: filter 0.3s, opacity 0.3s;
} }
.monitor-site-icon.simple-icon { .monitor-site-icon.flat-icon {
opacity: 0.7; opacity: 0.7;
} }
@ -1409,7 +1412,7 @@ details[open] .summary::after {
opacity: 1; opacity: 1;
} }
.monitor-site:hover .monitor-site-icon:not(.simple-icon) { .monitor-site:hover .monitor-site-icon:not(.flat-icon) {
filter: grayscale(0); filter: grayscale(0);
} }
@ -1420,6 +1423,39 @@ details[open] .summary::after {
height: 2rem; 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 { .thumbnail {
filter: grayscale(0.2) contrast(0.9); filter: grayscale(0.2) contrast(0.9);
opacity: 0.8; opacity: 0.8;
@ -1539,6 +1575,14 @@ details[open] .summary::after {
border: 2px solid var(--color-widget-background); 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 { .reddit-card-thumbnail {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -1703,6 +1747,11 @@ details[open] .summary::after {
.weather-column-rain::before { .weather-column-rain::before {
background-size: 7px 7px; 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) { @media (max-width: 1190px) and (display-mode: standalone) {
@ -1710,7 +1759,11 @@ details[open] .summary::after {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0); --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)); bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
} }
@ -1724,6 +1777,10 @@ details[open] .summary::after {
transition: padding-bottom .3s; 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) { .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
padding-bottom: 0; padding-bottom: 0;
} }
@ -1800,7 +1857,6 @@ details[open] .summary::after {
.shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; } .min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; } .max-width-100 { max-width: 100%; }
.height-100 { height: 100%; }
.block { display: block; } .block { display: block; }
.inline-block { display: inline-block; } .inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; } .overflow-hidden { overflow: hidden; }
@ -1821,12 +1877,14 @@ details[open] .summary::after {
.gap-5 { gap: 0.5rem; } .gap-5 { gap: 0.5rem; }
.gap-7 { gap: 0.7rem; } .gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; } .gap-10 { gap: 1rem; }
.gap-12 { gap: 1.2rem; }
.gap-15 { gap: 1.5rem; } .gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; } .gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; } .gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; } .gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; } .gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; } .gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-top-3 { margin-top: 0.3rem; } .margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; } .margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; } .margin-top-7 { margin-top: 0.7rem; }
@ -1840,6 +1898,7 @@ details[open] .summary::after {
.margin-block-3 { margin-block: 0.3rem; } .margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; } .margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; } .margin-block-7 { margin-block: 0.7rem; }
.margin-block-8 { margin-block: 0.8rem; }
.margin-block-10 { margin-block: 1rem; } .margin-block-10 { margin-block: 1rem; }
.margin-block-15 { margin-block: 1.5rem; } .margin-block-15 { margin-block: 1.5rem; }
.margin-bottom-3 { margin-bottom: 0.3rem; } .margin-bottom-3 { margin-bottom: 0.3rem; }
@ -1853,6 +1912,7 @@ details[open] .summary::after {
.list { --list-half-gap: 0rem; } .list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; } .list-gap-2 { --list-half-gap: 0.1rem; }
.list-gap-4 { --list-half-gap: 0.2rem; } .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-10 { --list-half-gap: 0.5rem; }
.list-gap-14 { --list-half-gap: 0.7rem; } .list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; } .list-gap-20 { --list-half-gap: 1rem; }

View file

@ -0,0 +1,62 @@
package glance
import (
"fmt"
"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{
"formatViewerCount": formatViewerCount,
"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 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)
}

View file

@ -3,17 +3,17 @@
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="dynamic-columns list-gap-24 list-with-separator"> <div class="dynamic-columns list-gap-24 list-with-separator">
{{ range .Groups }} {{ range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}> <div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }} {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .Links }} {{ range .Links }}
<li class="flex items-center gap-10"> <li class="flex items-center gap-10">
{{ if ne "" .Icon }} {{ if ne "" .Icon.URL }}
<div class="bookmarks-icon-container"> <div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy"> <img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div> </div>
{{ end }} {{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>

View file

@ -11,13 +11,18 @@
</div> </div>
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue"> <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
{{ if .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
<div class="calendar-day">Mo</div> <div class="calendar-day">Mo</div>
<div class="calendar-day">Tu</div> <div class="calendar-day">Tu</div>
<div class="calendar-day">We</div> <div class="calendar-day">We</div>
<div class="calendar-day">Th</div> <div class="calendar-day">Th</div>
<div class="calendar-day">Fr</div> <div class="calendar-day">Fr</div>
<div class="calendar-day">Sa</div> <div class="calendar-day">Sa</div>
<div class="calendar-day">Su</div> {{ if not .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
</div> </div>
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">

View file

@ -0,0 +1,7 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
{{ define "widget-content" }}
{{ .CompiledHTML }}
{{ end }}

View file

@ -24,6 +24,8 @@
{{ end }} {{ end }}
</div> </div>
{{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }}
{{ if $showGraph }}
<div class="dns-stats-graph margin-top-15"> <div class="dns-stats-graph margin-top-15">
<div class="dns-stats-graph-gridlines-container"> <div class="dns-stats-graph-gridlines-container">
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none"> <svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
@ -67,13 +69,14 @@
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ end }}
{{ if .Stats.TopBlockedDomains }} {{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }}
<details class="details margin-top-40"> <details class="details {{ if $showGraph }}margin-top-40{{ else }}margin-top-15{{ end }}">
<summary class="summary">Top blocked domains</summary> <summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5"> <ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }} {{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between align-center"> <li class="flex justify-between">
<div class="text-truncate rtl">{{ .Domain }}</div> <div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div> <div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li> </li>

View file

@ -0,0 +1,64 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Containers }}
<div class="docker-container flex items-center gap-15">
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem">
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
<div data-popover-html>
<div class="color-highlight text-truncate block">{{ .Image }}</div>
<div>{{ .StateText }}</div>
{{ if .Children }}
<ul class="list list-gap-4 margin-top-10">
{{ range .Children }}
<li class="flex gap-7 items-center">
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
</li>
{{ end }}
</ul>
{{ end }}
</div>
</div>
<div class="min-width-0">
{{ if .URL }}
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ else }}
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
{{ end }}
{{ if .Description }}
<div class="text-truncate">{{ .Description }}</div>
{{ end }}
</div>
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
{{ template "state-icon" .StateIcon }}
</div>
</div>
{{ else }}
<div class="text-center">No containers available to show.</div>
{{ end }}
</div>
{{ end }}
{{ define "state-icon" }}
{{ if eq . "ok" }}
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
{{ else if eq . "warn" }}
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{ else if eq . "paused" }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
</svg>
{{ else }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{ end }}
{{ end }}

View file

@ -3,6 +3,7 @@
<head> <head>
{{ block "document-head-before" . }}{{ end }} {{ block "document-head-before" . }}{{ end }}
<title>{{ block "document-title" . }}{{ end }}</title> <title>{{ block "document-title" . }}{{ end }}</title>
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">

View file

@ -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 }}

View file

@ -12,7 +12,7 @@
</svg> </svg>
{{ else if ne .ThumbnailUrl "" }} {{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy"> <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }} {{ else if ne "" .TargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)"> <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg> </svg>
@ -37,7 +37,7 @@
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li> <li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }} {{ if ne "" .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li> <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }} {{ end }}
</ul> </ul>

View file

@ -6,7 +6,7 @@
<div class="widget-group-header"> <div class="widget-group-header">
<div class="widget-header gap-20"> <div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }} {{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button> <button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
{{ end }} {{ end }}
</div> </div>
</div> </div>

View file

@ -0,0 +1,39 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="dynamic-columns list-gap-8">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
<div class="flex items-center gap-12">
{{ template "site" . }}
</div>
{{ end }}
</ul>
{{ else }}
<div class="flex items-center justify-center gap-10 padding-block-5">
<p>All sites are online</p>
<svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}
{{ define "site" }}
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}

View file

@ -21,11 +21,11 @@
{{ end }} {{ end }}
{{ define "site" }} {{ define "site" }}
{{ if .IconUrl }} {{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy"> <img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }} {{ end }}
<div class="min-width-0"> <div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
{{ if not .Status.Error }} {{ if not .Status.Error }}
<li title="{{ .Status.Code }}">{{ .StatusText }}</li> <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
@ -39,14 +39,14 @@
</div> </div>
{{ if eq .StatusStyle "ok" }} {{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon"> <div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)"> <svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg> </svg>
</div> </div>
{{ else }} {{ else }}
<div class="monitor-site-status-icon"> <div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)"> <svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg> </svg>
</div> </div>
{{ end }} {{ end }}

View file

@ -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-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" }} {{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }} {{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}"> <link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }} {{ end }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
{{ end }} {{ end }}
{{ define "navigation-links" }} {{ define "navigation-links" }}
@ -27,7 +30,7 @@
{{ end }} {{ end }}
{{ define "document-body" }} {{ define "document-body" }}
<div class="flex flex-column height-100"> <div class="flex flex-column body-content">
{{ if not .Page.HideDesktopNavigation }} {{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds"> <div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame"> <div class="header flex padding-inline-widget widget-content-frame">
@ -44,9 +47,9 @@
<div class="mobile-navigation-icons"> <div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a> <a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }} {{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label> <label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }} {{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label> <label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
</div> </div>
<div class="mobile-navigation-page-links"> <div class="mobile-navigation-page-links">
{{ template "navigation-links" . }} {{ template "navigation-links" . }}

View file

@ -18,7 +18,7 @@
{{ else }} {{ else }}
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div> <div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>

View file

@ -17,7 +17,7 @@
{{ else }} {{ else }}
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div> <div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>

View file

@ -1,13 +1,13 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}"> <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Releases }} {{ range .Releases }}
<li> <li>
<div class="flex items-center gap-10"> <div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a> <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }} {{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy"> <img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }} {{ end }}
</div> </div>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">

View file

@ -0,0 +1,61 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .Repository.Stars | formatNumber }} stars</li>
<li>{{ .Repository.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .Repository.Commits) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .Repository.PullRequests) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}

View file

@ -16,7 +16,7 @@
</svg> </svg>
{{ end }} {{ end }}
<div class="rss-card-2-content padding-inline-widget"> <div class="rss-card-2-content padding-inline-widget">
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .Link }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-5"> <ul class="list-horizontal-text flex-nowrap margin-top-5">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li> <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>

View file

@ -16,7 +16,7 @@
</svg> </svg>
{{ end }} {{ end }}
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow"> <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .Link }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7"> <ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li> <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>

View file

@ -4,7 +4,7 @@
<ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}"> <ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }} {{ range .Items }}
<li> <li>
<a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a> <a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap"> <ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li> <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0"> <li class="min-width-0">

View file

@ -0,0 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}

View file

@ -0,0 +1,14 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.String | safeCSS }};{{ end }}
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
}
</style>

View file

@ -5,9 +5,15 @@
{{ range .Channels }} {{ range .Channels }}
<li> <li>
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent"> <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container"> <div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
{{ if .IsLive }}
<div data-popover-html>
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
</div>
{{ end }}
{{ if .Exists }} {{ if .Exists }}
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer"> <a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy"> <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a> </a>
{{ else }} {{ else }}

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="static/main.css">
<title>Update notice</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
}
.content-bounds {
max-width: 700px;
margin-top: -10rem;
}
.comfy-line-height {
line-height: 1.9;
}
</style>
</head>
<body>
<!-- TODO: update - add links -->
<div class="content-bounds color-highlight">
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
<div class="widget-content-frame margin-top-10 padding-widget">
<p class="comfy-line-height">
The default location of glance.yml in the Docker image has
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
for instructions or visit the <a class="color-primary" href="#">release notes</a>
to find out more about why this change was necessary. Sorry for the inconvenience.
</p>
<p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
</div>
</div>
</body>
</html>

View file

@ -1,7 +1,7 @@
{{ define "video-card-contents" }} {{ define "video-card-contents" }}
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt=""> <img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget"> <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a> <a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7"> <ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0"> <li class="min-width-0">

View file

@ -1,7 +1,7 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}"> <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}} {{ if not .HideHeader}}
<div class="widget-header"> <div class="widget-header">
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }} {{ if ne "" .TitleURL }}<a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ if and .Error .ContentAvailable }} {{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div> <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }} {{ else if .Notice }}
@ -15,7 +15,9 @@
{{ else }} {{ else }}
<div class="widget-error-header"> <div class="widget-error-header">
<div class="color-negative size-h3">ERROR</div> <div class="color-negative size-h3">ERROR</div>
<div class="widget-error-icon"></div> <svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div> </div>
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p> <p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
{{ end}} {{ end}}

View file

@ -1,19 +1,19 @@
package feed package glance
import ( import (
"errors" "bytes"
"fmt" "fmt"
"html/template"
"net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
) )
var ( var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
ErrNoContent = errors.New("failed to retrieve any content")
ErrPartialContent = errors.New("failed to retrieve some of the content")
)
func percentChange(current, previous float64) float64 { func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100 return (current/previous - 1) * 100
@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
} }
parsed, err := url.Parse(u) parsed, err := url.Parse(u)
if err != nil { if err != nil {
return "" return ""
} }
@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") 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 { if len(values) < 2 {
return "" return ""
} }
@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "") 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) { func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s) asRunes := []rune(s)
@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
func parseRFC3339Time(t string) time.Time { func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t) parsed, err := time.Parse(time.RFC3339, t)
if err != nil { if err != nil {
return time.Now() return time.Now()
} }
@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
return parsed return parsed
} }
func boolToString(b bool, trueValue, falseValue string) string {
if b {
return trueValue
}
return falseValue
}
func normalizeVersionFormat(version string) string { func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version)) version = strings.ToLower(strings.TrimSpace(version))
@ -115,3 +136,45 @@ func normalizeVersionFormat(version string) string {
return version 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]
}

View file

@ -0,0 +1,62 @@
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"`
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:"-"`
} `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
}
}
}
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
return nil
}
func (widget *bookmarksWidget) Render() template.HTML {
return widget.cachedHTML
}

View file

@ -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()
}

View file

@ -1,7 +1,9 @@
package feed package glance
import ( import (
"context"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"sort" "sort"
@ -9,7 +11,65 @@ import (
"time" "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 Title string
URL string URL string
LastChanged time.Time LastChanged time.Time
@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
PreviousHash string 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 { sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged) return r[i].LastChanged.After(r[j].LastChanged)
}) })
@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
PreviousHash string `json:"previous_md5"` 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) request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" { if token != "" {
request.Header.Add("x-api-key", 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 { if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err) 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 return uuids, nil
} }
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) { func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs)) watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 { if len(requestedWatchIDs) == 0 {
return watches, nil return watches, nil
@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
requests[i] = request requests[i] = request
} }
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient) task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(15) job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job) responses, errs, err := workerPoolDo(job)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
for i := range responses { for i := range responses {
if errs[i] != nil { if errs[i] != nil {
failed++ 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 continue
} }
watchJson := responses[i] watchJson := responses[i]
watch := ChangeDetectionWatch{ watch := changeDetectionWatch{
URL: watchJson.URL, URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), 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 { if len(watches) == 0 {
return nil, ErrNoContent return nil, errNoContent
} }
watches.SortByNewest() watches.sortByNewest()
if failed > 0 { 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 return watches, nil

View file

@ -1,15 +1,15 @@
package widget package glance
import ( import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"time" "time"
"github.com/glanceapp/glance/internal/assets"
) )
type Clock struct { var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
type clockWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"` HourFormat string `yaml:"hour-format"`
@ -19,32 +19,30 @@ type Clock struct {
} `yaml:"timezones"` } `yaml:"timezones"`
} }
func (widget *Clock) Initialize() error { func (widget *clockWidget) initialize() error {
widget.withTitle("Clock").withError(nil) widget.withTitle("Clock").withError(nil)
if widget.HourFormat == "" { if widget.HourFormat == "" {
widget.HourFormat = "24h" widget.HourFormat = "24h"
} else if widget.HourFormat != "12h" && 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 { for t := range widget.Timezones {
if widget.Timezones[t].Timezone == "" { 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 := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
if err != nil {
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
} }
} }
widget.cachedHTML = widget.render(widget, assets.ClockTemplate) widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
return nil return nil
} }
func (widget *Clock) Render() template.HTML { func (widget *clockWidget) Render() template.HTML {
return widget.cachedHTML return widget.cachedHTML
} }

View file

@ -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
}

View file

@ -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 += "... <truncated>"
}
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
}()

View file

@ -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
}

View file

@ -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
}

View file

@ -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("<pre>" + html.EscapeString(string(content)) + "</pre>")
}
}
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
}

View file

@ -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)
}

View file

@ -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)
}

Some files were not shown because too many files have changed in this diff Show more