Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
|
@ -5,7 +5,6 @@
|
||||||
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||||
!/build/
|
!/build/
|
||||||
!/internal/
|
!/internal/
|
||||||
!/pkg/
|
|
||||||
!/go.mod
|
!/go.mod
|
||||||
!/go.sum
|
!/go.sum
|
||||||
!main.go
|
!main.go
|
||||||
|
|
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -1,37 +0,0 @@
|
||||||
name: Bug report
|
|
||||||
description: Let us know if something isn't working as expected
|
|
||||||
labels: ["bug report"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
> [!NOTE]
|
|
||||||
>
|
|
||||||
> Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
|
|
||||||
|
|
||||||
If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
|
|
||||||
|
|
||||||
Please include only the information you think is relevant to the bug:
|
|
||||||
|
|
||||||
* How did you install Glance? (Docker container, manual binary install, etc)
|
|
||||||
* Which version of Glance are you using?
|
|
||||||
* Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
|
|
||||||
* Include any relevant logs or screenshots if applicable
|
|
||||||
* Is the issue specific to a certain browser or OS?
|
|
||||||
* Steps to reliably reproduce the issue
|
|
||||||
* Are you hosting Glance on a VPS?
|
|
||||||
* Anything else you think might be relevant
|
|
||||||
|
|
||||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thank you for taking the time to submit a bug report.
|
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
|
@ -1,8 +0,0 @@
|
||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Discussions
|
|
||||||
url: https://github.com/glanceapp/glance/discussions
|
|
||||||
about: For help, feedback, guides, resources and more
|
|
||||||
- name: Discord
|
|
||||||
url: https://discord.com/invite/7KQ7Xa9kJd
|
|
||||||
about: Much like the discussions but more chatty
|
|
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
@ -1,33 +0,0 @@
|
||||||
name: Feature request
|
|
||||||
description: Share your ideas for new features or improvements
|
|
||||||
labels: ["feature request"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
> [!NOTE]
|
|
||||||
>
|
|
||||||
> Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
|
|
||||||
|
|
||||||
Please provide a detailed description of what the feature would do and what it would look like:
|
|
||||||
|
|
||||||
* What problem would this feature solve?
|
|
||||||
* Are there any potential downsides to this feature?
|
|
||||||
* If applicable, what would the configuration for this feature look like?
|
|
||||||
* Are there any existing examples of this feature in other software?
|
|
||||||
* If applicable, include any external documentation required to implement this feature
|
|
||||||
* Anything else you think might be relevant
|
|
||||||
|
|
||||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thank you for taking the time to submit your idea.
|
|
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1 +1,7 @@
|
||||||
<!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->
|
<!--
|
||||||
|
|
||||||
|
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
|
||||||
|
|
||||||
|
Documentation updates (including new themes) can be submitted to the main branch.
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
3
.gitignore
vendored
|
@ -1,5 +1,4 @@
|
||||||
/assets
|
/assets
|
||||||
/build
|
/build
|
||||||
/playground
|
/playground
|
||||||
/.idea
|
glance*.yml
|
||||||
/glance*.yml
|
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
FROM golang:1.24.2-alpine3.21 AS builder
|
FROM golang:1.22.5-alpine3.20 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN CGO_ENABLED=0 go build .
|
RUN CGO_ENABLED=0 go build .
|
||||||
|
|
||||||
FROM alpine:3.21
|
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", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
FROM alpine:3.21
|
FROM alpine:3.20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY glance .
|
COPY 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", "--config", "/app/config/glance.yml"]
|
|
||||||
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|
451
README.md
|
@ -1,436 +1,111 @@
|
||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><em>What if you could see everything at a...</em></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">Glance</h1>
|
||||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> • <a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
|
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
|
||||||
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a></p>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
### Features
|
||||||
### Various widgets
|
#### Various widgets
|
||||||
* RSS feeds
|
* RSS feeds
|
||||||
* Subreddit posts
|
* Subreddit posts
|
||||||
* Hacker News posts
|
* Weather
|
||||||
* Weather forecasts
|
* Bookmarks
|
||||||
* YouTube channel uploads
|
* Hacker News
|
||||||
* Twitch channels
|
* Lobsters
|
||||||
* Market prices
|
* Latest YouTube videos from specific channels
|
||||||
* Docker containers status
|
* Clock
|
||||||
* Server stats
|
* Calendar
|
||||||
* Custom widgets
|
* Stocks
|
||||||
* [and many more...](docs/configuration.md)
|
* iframe
|
||||||
|
* Twitch channels & top games
|
||||||
|
* GitHub releases
|
||||||
|
* Repository overview
|
||||||
|
* Site monitor
|
||||||
|
* Search box
|
||||||
|
|
||||||
### Fast and lightweight
|
#### Themeable
|
||||||
* Low memory usage
|

|
||||||
* Few dependencies
|
|
||||||
* Minimal vanilla JS
|
|
||||||
* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
|
|
||||||
* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
|
||||||
|
|
||||||
### Tons of customizability
|
#### Optimized for mobile devices
|
||||||
* Different layouts
|

|
||||||
* As many pages/tabs as you need
|
|
||||||
* Numerous configuration options for each widget
|
|
||||||
* Multiple styles for some widgets
|
|
||||||
* Custom CSS
|
|
||||||
|
|
||||||
### Optimized for mobile devices
|
#### Fast and lightweight
|
||||||
Because you'll want to take it with you on the go.
|
* Minimal JS, no bloated frameworks
|
||||||
|
* Very few dependencies
|
||||||
|
* Single, easily distributed <15mb binary and just as small docker container
|
||||||
|
* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||||
|
|
||||||

|
### Configuration
|
||||||
|
Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
|
||||||
|
|
||||||
### Themeable
|
### Installation
|
||||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
> [!CAUTION]
|
||||||
|
>
|
||||||
|
> The project is under active development, expect things to break every once in a while.
|
||||||
|
|
||||||

|
#### Manual
|
||||||
|
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||||
<br>
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Preview example configuration file</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pages:
|
|
||||||
- name: Home
|
|
||||||
columns:
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: calendar
|
|
||||||
first-day-of-week: monday
|
|
||||||
|
|
||||||
- type: rss
|
|
||||||
limit: 10
|
|
||||||
collapse-after: 3
|
|
||||||
cache: 12h
|
|
||||||
feeds:
|
|
||||||
- url: https://selfh.st/rss/
|
|
||||||
title: selfh.st
|
|
||||||
limit: 4
|
|
||||||
- url: https://ciechanow.ski/atom.xml
|
|
||||||
- url: https://www.joshwcomeau.com/rss.xml
|
|
||||||
title: Josh Comeau
|
|
||||||
- url: https://samwho.dev/rss.xml
|
|
||||||
- url: https://ishadeed.com/feed.xml
|
|
||||||
title: Ahmad Shadeed
|
|
||||||
|
|
||||||
- type: twitch-channels
|
|
||||||
channels:
|
|
||||||
- theprimeagen
|
|
||||||
- j_blow
|
|
||||||
- piratesoftware
|
|
||||||
- cohhcarnage
|
|
||||||
- christitustech
|
|
||||||
- EJ_SA
|
|
||||||
|
|
||||||
- size: full
|
|
||||||
widgets:
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: hacker-news
|
|
||||||
- type: lobsters
|
|
||||||
|
|
||||||
- type: videos
|
|
||||||
channels:
|
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
|
||||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
|
||||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
|
||||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
|
||||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
|
||||||
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: reddit
|
|
||||||
subreddit: technology
|
|
||||||
show-thumbnails: true
|
|
||||||
- type: reddit
|
|
||||||
subreddit: selfhosted
|
|
||||||
show-thumbnails: true
|
|
||||||
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: weather
|
|
||||||
location: London, United Kingdom
|
|
||||||
units: metric
|
|
||||||
hour-format: 12h
|
|
||||||
|
|
||||||
- type: markets
|
|
||||||
markets:
|
|
||||||
- symbol: SPY
|
|
||||||
name: S&P 500
|
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
|
|
||||||
- type: releases
|
|
||||||
cache: 1d
|
|
||||||
repositories:
|
|
||||||
- glanceapp/glance
|
|
||||||
- go-gitea/gitea
|
|
||||||
- immich-app/immich
|
|
||||||
- syncthing/syncthing
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Choose one of the following methods:
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Create a new directory called `glance` as well as the template files within it by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
|
|
||||||
```
|
|
||||||
|
|
||||||
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
|
|
||||||
|
|
||||||
Then, edit the following files as desired:
|
|
||||||
* `docker-compose.yml` to configure the port, volumes and other containery things
|
|
||||||
* `config/home.yml` to configure the widgets or layout of the home page
|
|
||||||
* `config/glance.yml` if you want to change the theme or add more pages
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Other files you may want to edit</summary>
|
|
||||||
|
|
||||||
* `.env` to configure environment variables that will be available inside configuration files
|
|
||||||
* `assets/user.css` to add custom CSS
|
|
||||||
</details>
|
|
||||||
|
|
||||||
When ready, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
If you encounter any issues, you can check the logs by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Docker compose manual</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Create a `docker-compose.yml` file with the following contents:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
glance:
|
|
||||||
container_name: glance
|
|
||||||
image: glanceapp/glance
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./config:/app/config
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Feel free to edit the `glance.yml` file to your liking, and when ready run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
If you encounter any issues, you can check the logs by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs glance
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Manual binary installation</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
/opt/glance/glance --config /etc/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
To grab a starting template for the config file, run:
|
#### Docker
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
docker run -d -p 8080:8080 \
|
||||||
|
-v ./glance.yml:/app/glance.yml \
|
||||||
|
-v /etc/timezone:/etc/timezone:ro \
|
||||||
|
-v /etc/localtime:/etc/localtime:ro \
|
||||||
|
glanceapp/glance
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
Or if you prefer docker compose:
|
||||||
|
|
||||||
Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Other</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Glance can also be installed through the following 3rd party channels:
|
|
||||||
* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
|
|
||||||
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
|
||||||
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Common issues
|
|
||||||
<details>
|
|
||||||
<summary><strong>Requests timing out</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
|
||||||
|
|
||||||
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
podman:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
|
||||||
|
|
||||||
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
<details>
|
|
||||||
<summary><strong>Does the information on the page update automatically?</strong></summary>
|
|
||||||
No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>How frequently do widgets update?</strong></summary>
|
|
||||||
No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Can I create my own widgets?</strong></summary>
|
|
||||||
|
|
||||||
Yes, there are multiple ways to create custom widgets:
|
|
||||||
* `iframe` widget - allows you to embed things from other websites
|
|
||||||
* `html` widget - allows you to insert your own static HTML
|
|
||||||
* `extension` widget - fetch HTML from a URL
|
|
||||||
* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Can I change the title of a widget?</strong></summary>
|
|
||||||
|
|
||||||
Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- type: rss
|
services:
|
||||||
title: My custom title
|
glance:
|
||||||
|
image: glanceapp/glance
|
||||||
- type: markets
|
volumes:
|
||||||
title: My custom title
|
- ./glance.yml:/app/glance.yml
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- type: videos
|
- /etc/localtime:/etc/localtime:ro
|
||||||
title: My custom title
|
ports:
|
||||||
|
- 8080:8080
|
||||||
# and so on for all widgets...
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
### Building from source
|
||||||
|
|
||||||
## Feature requests
|
Requirements: [Go](https://go.dev/dl/) >= v1.22
|
||||||
|
|
||||||
New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
|
To build:
|
||||||
|
|
||||||
Feature requests are tagged with one of the following:
|
|
||||||
|
|
||||||
* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
|
|
||||||
* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
|
|
||||||
* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Building from source
|
|
||||||
|
|
||||||
Choose one of the following methods:
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Build binary with Go</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
|
||||||
|
|
||||||
To build the project for your current OS and architecture, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o build/glance .
|
go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
To build for a specific OS and architecture, run:
|
To run:
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
|
||||||
```
|
|
||||||
|
|
||||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
|
||||||
|
|
||||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
### Building Docker image
|
||||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
Build the image:
|
||||||
|
|
||||||
To build the project and image using just Docker, run:
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
*(replace `owner` with your name or organization)*
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t owner/glance:latest .
|
docker build -t owner/glance:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
Push the image to your registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker push owner/glance:latest
|
docker push owner/glance:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Contributing guidelines
|
|
||||||
|
|
||||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
|
||||||
* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
|
|
||||||
* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
|
|
||||||
* Avoid introducing new dependencies
|
|
||||||
* Avoid making backwards-incompatible configuration changes
|
|
||||||
* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
|
|
||||||
* For icons, try to use [heroicons](https://heroicons.com/) where applicable
|
|
||||||
* Provide a screenshot of the changes if UI related where possible
|
|
||||||
* No `package.json`
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
|
|
||||||
|
|
||||||
[1] The feature likely already has work put into it that may conflict with your implementation
|
|
||||||
|
|
||||||
[2] The demand, implementation or functionality for this feature is not yet clear
|
|
||||||
|
|
||||||
[3] No plans to add this feature for the time being
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Thank you
|
|
||||||
|
|
||||||
To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.
|
|
||||||
|
|
|
@ -1,415 +0,0 @@
|
||||||
[Jump to function definitions](#functions)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "My Title",
|
|
||||||
"content": "My Content",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To access the two fields in the JSON response, you would use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ .JSON.String "title" }}</div>
|
|
||||||
<div>{{ .JSON.String "content" }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>My Title</div>
|
|
||||||
<div>My Content</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"author": "John Doe",
|
|
||||||
"posts": [
|
|
||||||
{
|
|
||||||
"title": "My Title",
|
|
||||||
"content": "My Content"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "My Title 2",
|
|
||||||
"content": "My Content 2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To loop through the array of posts, you would use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSON.Array "posts" }}
|
|
||||||
<div>{{ .String "title" }}</div>
|
|
||||||
<div>{{ .String "content" }}</div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>My Title</div>
|
|
||||||
<div>My Content</div>
|
|
||||||
<div>My Title 2</div>
|
|
||||||
<div>My Content 2</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
|
|
||||||
|
|
||||||
If you want to access the top-level context within the range, you can use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSON.Array "posts" }}
|
|
||||||
<div>{{ .String "title" }}</div>
|
|
||||||
<div>{{ .String "content" }}</div>
|
|
||||||
<div>{{ $.JSON.String "author" }}</div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>My Title</div>
|
|
||||||
<div>My Content</div>
|
|
||||||
<div>John Doe</div>
|
|
||||||
<div>My Title 2</div>
|
|
||||||
<div>My Content 2</div>
|
|
||||||
<div>John Doe</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
"Apple",
|
|
||||||
"Banana",
|
|
||||||
"Cherry",
|
|
||||||
"Watermelon"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSON.Array "" }}
|
|
||||||
<div>{{ .String "" }}</div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>Apple</div>
|
|
||||||
<div>Banana</div>
|
|
||||||
<div>Cherry</div>
|
|
||||||
<div>Watermelon</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
To access an item at a specific index, you could use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ .JSON.String "0" }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>Apple</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user": {
|
|
||||||
"address": {
|
|
||||||
"city": "New York",
|
|
||||||
"state": "NY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To easily access deeply nested objects, you can use the following dot notation:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ .JSON.String "user.address.city" }}</div>
|
|
||||||
<div>{{ .JSON.String "user.address.state" }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>New York</div>
|
|
||||||
<div>NY</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Using indexes anywhere in the path is also supported:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"name": "John Doe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jane Doe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ .JSON.String "users.0.name" }}</div>
|
|
||||||
<div>{{ .JSON.String "users.1.name" }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>John Doe</div>
|
|
||||||
<div>Jane Doe</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user": {
|
|
||||||
"name": "John Doe",
|
|
||||||
"age": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To check if a field exists, you can use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ if .JSON.Exists "user.age" }}
|
|
||||||
<div>{{ .JSON.Int "user.age" }}</div>
|
|
||||||
{{ else }}
|
|
||||||
<div>Age not provided</div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>30</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"price": 100,
|
|
||||||
"discount": 10
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>90</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Other operations include `add`, `mul`, and `div`.
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"posts": [
|
|
||||||
{
|
|
||||||
"title": "Exploring the Depths of Quantum Computing",
|
|
||||||
"date": "2023-10-27T10:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "A Beginner's Guide to Sustainable Living",
|
|
||||||
"date": "2023-11-15T14:30:00+01:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "The Art of Baking Sourdough Bread",
|
|
||||||
"date": "2023-12-03T08:45:22-08:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{ range .JSON.Array "posts" }}
|
|
||||||
<div>{{ .String "title" }}</div>
|
|
||||||
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>Exploring the Depths of Quantum Computing</div>
|
|
||||||
<div data-dynamic-relative-time="1698400800"></div>
|
|
||||||
|
|
||||||
<div>A Beginner's Guide to Sustainable Living</div>
|
|
||||||
<div data-dynamic-relative-time="1700055000"></div>
|
|
||||||
|
|
||||||
<div>The Art of Baking Sourdough Bread</div>
|
|
||||||
<div data-dynamic-relative-time="1701621922"></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
|
||||||
|
|
||||||
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
In some instances, you may want to know the status code of the response. This can be done using the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ if eq .Response.StatusCode 200 }}
|
|
||||||
<p>Success!</p>
|
|
||||||
{{ else }}
|
|
||||||
<p>Failed to fetch data</p>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also access the response headers:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"name": "Steve", "age": 30}
|
|
||||||
{"name": "Alex", "age": 25}
|
|
||||||
{"name": "John", "age": 35}
|
|
||||||
```
|
|
||||||
|
|
||||||
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- type: custom-api
|
|
||||||
skip-json-validation: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, to iterate over each object you can use `.JSONLines`:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSONLines }}
|
|
||||||
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p>Steve is 30 years old</p>
|
|
||||||
<p>Alex is 25 years old</p>
|
|
||||||
<p>John is 35 years old</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSON.Array "..#.name" }}
|
|
||||||
<p>{{ .String "" }}</p>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p>Steve</p>
|
|
||||||
<p>Alex</p>
|
|
||||||
<p>John</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
The following functions are available on the `JSON` object:
|
|
||||||
|
|
||||||
- `String(key string) string`: Returns the value of the key as a string.
|
|
||||||
- `Int(key string) int`: Returns the value of the key as an integer.
|
|
||||||
- `Float(key string) float`: Returns the value of the key as a float.
|
|
||||||
- `Bool(key string) bool`: Returns the value of the key as a boolean.
|
|
||||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
|
||||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
|
||||||
|
|
||||||
The following helper functions provided by Glance are available:
|
|
||||||
|
|
||||||
- `toFloat(i int) float`: Converts an integer to a float.
|
|
||||||
- `toInt(f float) int`: Converts a float to an integer.
|
|
||||||
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
|
||||||
- `now() time.Time`: Returns the current time.
|
|
||||||
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
|
||||||
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
|
||||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
|
||||||
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
|
||||||
- `add(a, b float) float`: Adds two numbers.
|
|
||||||
- `sub(a, b float) float`: Subtracts two numbers.
|
|
||||||
- `mul(a, b float) float`: Multiplies two numbers.
|
|
||||||
- `div(a, b float) float`: Divides two numbers.
|
|
||||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
|
||||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
|
||||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
|
||||||
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
|
||||||
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
|
||||||
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
|
||||||
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
|
||||||
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
|
||||||
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
|
||||||
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
|
||||||
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
|
||||||
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
|
||||||
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
|
||||||
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
|
||||||
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
|
||||||
|
|
||||||
The following helper functions provided by Go's `text/template` are available:
|
|
||||||
|
|
||||||
- `eq(a, b any) bool`: Compares two values for equality.
|
|
||||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
|
||||||
- `lt(a, b any) bool`: Compares two values for less than.
|
|
||||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
|
||||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
|
||||||
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
|
|
||||||
- `and(a, b bool) bool`: Returns true if both values are true.
|
|
||||||
- `or(a, b bool) bool`: Returns true if either value is true.
|
|
||||||
- `not(a bool) bool`: Returns the opposite of the value.
|
|
||||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
|
||||||
- `len(a any) int`: Returns the length of an array.
|
|
||||||
- `printf(format string, a ...any) string`: Returns a formatted string.
|
|
|
@ -26,15 +26,9 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
|
||||||
### `Widget-Title`
|
### `Widget-Title`
|
||||||
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||||
|
|
||||||
### `Widget-Title-URL`
|
|
||||||
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
|
|
||||||
|
|
||||||
### `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]
|
||||||
|
|
105
docs/glance.yml
|
@ -1,105 +0,0 @@
|
||||||
pages:
|
|
||||||
- name: Home
|
|
||||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
|
||||||
# hide-desktop-navigation: true
|
|
||||||
columns:
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: calendar
|
|
||||||
first-day-of-week: monday
|
|
||||||
|
|
||||||
- type: rss
|
|
||||||
limit: 10
|
|
||||||
collapse-after: 3
|
|
||||||
cache: 12h
|
|
||||||
feeds:
|
|
||||||
- url: https://selfh.st/rss/
|
|
||||||
title: selfh.st
|
|
||||||
limit: 4
|
|
||||||
- url: https://ciechanow.ski/atom.xml
|
|
||||||
- url: https://www.joshwcomeau.com/rss.xml
|
|
||||||
title: Josh Comeau
|
|
||||||
- url: https://samwho.dev/rss.xml
|
|
||||||
- url: https://ishadeed.com/feed.xml
|
|
||||||
title: Ahmad Shadeed
|
|
||||||
|
|
||||||
- type: twitch-channels
|
|
||||||
channels:
|
|
||||||
- theprimeagen
|
|
||||||
- j_blow
|
|
||||||
- piratesoftware
|
|
||||||
- cohhcarnage
|
|
||||||
- christitustech
|
|
||||||
- EJ_SA
|
|
||||||
|
|
||||||
- size: full
|
|
||||||
widgets:
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: hacker-news
|
|
||||||
- type: lobsters
|
|
||||||
|
|
||||||
- type: videos
|
|
||||||
channels:
|
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
|
||||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
|
||||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
|
||||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
|
||||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
|
||||||
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: reddit
|
|
||||||
subreddit: technology
|
|
||||||
show-thumbnails: true
|
|
||||||
- type: reddit
|
|
||||||
subreddit: selfhosted
|
|
||||||
show-thumbnails: true
|
|
||||||
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: weather
|
|
||||||
location: London, United Kingdom
|
|
||||||
units: metric # alternatively "imperial"
|
|
||||||
hour-format: 12h # alternatively "24h"
|
|
||||||
# Optionally hide the location from being displayed in the widget
|
|
||||||
# hide-location: true
|
|
||||||
|
|
||||||
- type: markets
|
|
||||||
markets:
|
|
||||||
- symbol: SPY
|
|
||||||
name: S&P 500
|
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
|
|
||||||
- type: releases
|
|
||||||
cache: 1d
|
|
||||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
|
||||||
# read-only token from your Github account settings and use it here to increase the limit.
|
|
||||||
# token: ...
|
|
||||||
repositories:
|
|
||||||
- glanceapp/glance
|
|
||||||
- go-gitea/gitea
|
|
||||||
- immich-app/immich
|
|
||||||
- syncthing/syncthing
|
|
||||||
|
|
||||||
# Add more pages here:
|
|
||||||
# - name: Your page name
|
|
||||||
# columns:
|
|
||||||
# - size: small
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
||||||
|
|
||||||
# - size: full
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
||||||
|
|
||||||
# - size: small
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 343 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 181 KiB |
Before Width: | Height: | Size: 325 KiB |
Before Width: | Height: | Size: 339 KiB |
Before Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 77 KiB |
|
@ -4,10 +4,6 @@ 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
|
||||||
|
|
||||||

|

|
||||||
|
|
|
@ -53,16 +53,6 @@ theme:
|
||||||
primary-color: 97 13 80
|
primary-color: 97 13 80
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gruvbox Dark
|
|
||||||

|
|
||||||
```yaml
|
|
||||||
theme:
|
|
||||||
background-color: 0 0 16
|
|
||||||
primary-color: 43 59 81
|
|
||||||
positive-color: 61 66 44
|
|
||||||
negative-color: 6 96 59
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kanagawa Dark
|
### Kanagawa Dark
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
|
@ -82,17 +72,6 @@ theme:
|
||||||
negative-color: 209 88 54
|
negative-color: 209 88 54
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dracula
|
|
||||||

|
|
||||||
```yaml
|
|
||||||
theme:
|
|
||||||
background-color: 231 15 21
|
|
||||||
primary-color: 265 89 79
|
|
||||||
contrast-multiplier: 1.2
|
|
||||||
positive-color: 135 94 66
|
|
||||||
negative-color: 0 100 67
|
|
||||||
```
|
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
## Upgrading to v0.7.0 from previous versions
|
|
||||||
|
|
||||||
In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
|
|
||||||
|
|
||||||
### Before
|
|
||||||
|
|
||||||
Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
glance:
|
|
||||||
image: glanceapp/glance
|
|
||||||
volumes:
|
|
||||||
- ./glance.yml:/app/glance.yml
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
And expected you to have the following directory structure:
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
glance/
|
|
||||||
docker-compose.yml
|
|
||||||
glance.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### After
|
|
||||||
|
|
||||||
With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
glance:
|
|
||||||
container_name: glance
|
|
||||||
image: glanceapp/glance
|
|
||||||
volumes:
|
|
||||||
- ./config:/app/config
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
And expects you to have the following directory structure:
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
glance/
|
|
||||||
docker-compose.yml
|
|
||||||
config/
|
|
||||||
glance.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why this change was necessary
|
|
||||||
|
|
||||||
1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
|
|
||||||
2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
|
|
||||||
3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
|
|
||||||
|
|
||||||
Taking all of these into account, it felt like the right time to implement the change.
|
|
23
go.mod
|
@ -1,32 +1,19 @@
|
||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.24.2
|
go 1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3
|
golang.org/x/text v0.16.0
|
||||||
github.com/tidwall/gjson v1.18.0
|
|
||||||
golang.org/x/text v0.24.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.2 // indirect
|
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // 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
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
|
||||||
golang.org/x/net v0.39.0 // indirect
|
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
108
go.sum
|
@ -1,31 +1,13 @@
|
||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
|
||||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
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=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
|
||||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
|
@ -37,111 +19,47 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
|
||||||
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=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
|
||||||
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
|
||||||
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
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-20201204225414-ed752295db88/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=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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.1.0/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
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=
|
||||||
|
|
56
internal/assets/files.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
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)
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 802 B After Width: | Height: | Size: 802 B |
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 553 B |
|
@ -1,6 +1,27 @@
|
||||||
import { setupPopovers } from './popover.js';
|
import { setupPopovers } from './popover.js';
|
||||||
import { setupMasonries } from './masonry.js';
|
|
||||||
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||||
|
let debounceTimeout;
|
||||||
|
let timesDebounced = 0;
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
if (timesDebounced == maxDebounceTimes) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced++;
|
||||||
|
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
}, debounceDelay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
async function fetchPageContent(pageData) {
|
async function fetchPageContent(pageData) {
|
||||||
// TODO: handle non 200 status codes/time outs
|
// TODO: handle non 200 status codes/time outs
|
||||||
|
@ -49,35 +70,29 @@ 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.4;
|
const monthInSeconds = dayInSeconds * 30;
|
||||||
const yearInSeconds = dayInSeconds * 365;
|
const yearInSeconds = monthInSeconds * 12;
|
||||||
|
|
||||||
function timestampToRelativeTime(timestamp) {
|
function relativeTimeSince(timestamp) {
|
||||||
let delta = Math.round((Date.now() / 1000) - timestamp);
|
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||||
let prefix = "";
|
|
||||||
|
|
||||||
if (delta < 0) {
|
|
||||||
delta = -delta;
|
|
||||||
prefix = "in ";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta < minuteInSeconds) {
|
if (delta < minuteInSeconds) {
|
||||||
return prefix + "1m";
|
return "1m";
|
||||||
}
|
}
|
||||||
if (delta < hourInSeconds) {
|
if (delta < hourInSeconds) {
|
||||||
return prefix + Math.floor(delta / minuteInSeconds) + "m";
|
return Math.floor(delta / minuteInSeconds) + "m";
|
||||||
}
|
}
|
||||||
if (delta < dayInSeconds) {
|
if (delta < dayInSeconds) {
|
||||||
return prefix + Math.floor(delta / hourInSeconds) + "h";
|
return Math.floor(delta / hourInSeconds) + "h";
|
||||||
}
|
}
|
||||||
if (delta < monthInSeconds) {
|
if (delta < monthInSeconds) {
|
||||||
return prefix + Math.floor(delta / dayInSeconds) + "d";
|
return Math.floor(delta / dayInSeconds) + "d";
|
||||||
}
|
}
|
||||||
if (delta < yearInSeconds) {
|
if (delta < yearInSeconds) {
|
||||||
return prefix + Math.floor(delta / monthInSeconds) + "mo";
|
return Math.floor(delta / monthInSeconds) + "mo";
|
||||||
}
|
}
|
||||||
|
|
||||||
return prefix + Math.floor(delta / yearInSeconds) + "y";
|
return Math.floor(delta / yearInSeconds) + "y";
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRelativeTimeForElements(elements)
|
function updateRelativeTimeForElements(elements)
|
||||||
|
@ -90,7 +105,7 @@ function updateRelativeTimeForElements(elements)
|
||||||
if (timestamp === undefined)
|
if (timestamp === undefined)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
element.textContent = timestampToRelativeTime(timestamp);
|
element.textContent = relativeTimeSince(timestamp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +126,6 @@ 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];
|
||||||
|
@ -148,14 +162,6 @@ 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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -261,32 +267,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < titles.length; i++) {
|
for (let i = 0; i < titles.length; i++) {
|
||||||
titles[i].classList.remove("widget-group-title-current");
|
titles[i].classList.remove("widget-group-title-current");
|
||||||
titles[i].setAttribute("aria-selected", "false");
|
|
||||||
tabs[i].classList.remove("widget-group-content-current");
|
tabs[i].classList.remove("widget-group-content-current");
|
||||||
tabs[i].setAttribute("aria-hidden", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current < t) {
|
if (current < t) {
|
||||||
|
@ -298,9 +286,7 @@ function setupGroups() {
|
||||||
current = t;
|
current = t;
|
||||||
|
|
||||||
title.classList.add("widget-group-title-current");
|
title.classList.add("widget-group-title-current");
|
||||||
title.setAttribute("aria-selected", "true");
|
|
||||||
tabs[t].classList.add("widget-group-content-current");
|
tabs[t].classList.add("widget-group-content-current");
|
||||||
tabs[t].setAttribute("aria-hidden", "false");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -441,9 +427,9 @@ function setupCollapsibleGrids() {
|
||||||
|
|
||||||
const button = attachExpandToggleButton(gridElement);
|
const button = attachExpandToggleButton(gridElement);
|
||||||
|
|
||||||
let cardsPerRow;
|
let cardsPerRow = 2;
|
||||||
|
|
||||||
const resolveCollapsibleItems = () => requestAnimationFrame(() => {
|
const resolveCollapsibleItems = () => {
|
||||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||||
|
|
||||||
if (hideItemsAfterIndex >= gridElement.children.length) {
|
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||||
|
@ -469,13 +455,14 @@ function setupCollapsibleGrids() {
|
||||||
child.style.removeProperty("animation-delay");
|
child.style.removeProperty("animation-delay");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
afterContentReady(() => {
|
||||||
|
cardsPerRow = getCardsPerRow();
|
||||||
|
resolveCollapsibleItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
window.addEventListener("resize", () => {
|
||||||
if (!isElementVisible(gridElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCardsPerRow = getCardsPerRow();
|
const newCardsPerRow = getCardsPerRow();
|
||||||
|
|
||||||
if (cardsPerRow == newCardsPerRow) {
|
if (cardsPerRow == newCardsPerRow) {
|
||||||
|
@ -485,8 +472,6 @@ function setupCollapsibleGrids() {
|
||||||
cardsPerRow = newCardsPerRow;
|
cardsPerRow = newCardsPerRow;
|
||||||
resolveCollapsibleItems();
|
resolveCollapsibleItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterContentReady(() => observer.observe(gridElement));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -538,34 +523,9 @@ function timeInZone(now, zone) {
|
||||||
timeInZone = now
|
timeInZone = now
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
|
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||||
|
|
||||||
return { time: timeInZone, diffInMinutes: diffInMinutes };
|
return { time: timeInZone, diffInHours: diffInHours };
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
||||||
|
@ -608,11 +568,9 @@ function setupClocks() {
|
||||||
);
|
);
|
||||||
|
|
||||||
updateCallbacks.push((now) => {
|
updateCallbacks.push((now) => {
|
||||||
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||||
setZoneTime(time);
|
setZoneTime(time);
|
||||||
const { text, title } = zoneDiffText(diffInMinutes);
|
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||||
diffElement.textContent = text;
|
|
||||||
diffElement.title = title;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -629,30 +587,6 @@ function setupClocks() {
|
||||||
updateClocks();
|
updateClocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupCalendars() {
|
|
||||||
const elems = document.getElementsByClassName("calendar");
|
|
||||||
if (elems.length == 0) return;
|
|
||||||
|
|
||||||
// TODO: implement prefetching, currently loads as a nasty waterfall of requests
|
|
||||||
const calendar = await import ('./calendar.js');
|
|
||||||
|
|
||||||
for (let i = 0; i < elems.length; i++)
|
|
||||||
calendar.default(elems[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.getAttribute("title") === null) 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");
|
||||||
|
@ -663,27 +597,20 @@ async function setupPage() {
|
||||||
try {
|
try {
|
||||||
setupPopovers();
|
setupPopovers();
|
||||||
setupClocks()
|
setupClocks()
|
||||||
await setupCalendars();
|
|
||||||
setupCarousels();
|
setupCarousels();
|
||||||
setupSearchBoxes();
|
setupSearchBoxes();
|
||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
setupCollapsibleGrids();
|
setupCollapsibleGrids();
|
||||||
setupGroups();
|
setupGroups();
|
||||||
setupMasonries();
|
|
||||||
setupDynamicRelativeTime();
|
setupDynamicRelativeTime();
|
||||||
setupLazyImages();
|
setupLazyImages();
|
||||||
} finally {
|
} finally {
|
||||||
pageElement.classList.add("content-ready");
|
pageElement.classList.add("content-ready");
|
||||||
pageElement.setAttribute("aria-busy", "false");
|
|
||||||
|
|
||||||
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
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);
|
|
@ -25,8 +25,7 @@ frameElement.append(contentElement);
|
||||||
containerElement.append(frameElement);
|
containerElement.append(frameElement);
|
||||||
document.body.append(containerElement);
|
document.body.append(containerElement);
|
||||||
|
|
||||||
const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
|
const observer = new ResizeObserver(repositionContainer);
|
||||||
const observer = new ResizeObserver(queueRepositionContainer);
|
|
||||||
|
|
||||||
function handleMouseEnter(event) {
|
function handleMouseEnter(event) {
|
||||||
clearTogglePopoverTimeout();
|
clearTogglePopoverTimeout();
|
||||||
|
@ -57,8 +56,6 @@ function clearTogglePopoverTimeout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPopover() {
|
function showPopover() {
|
||||||
if (pendingTarget === null) return;
|
|
||||||
|
|
||||||
activeTarget = pendingTarget;
|
activeTarget = pendingTarget;
|
||||||
pendingTarget = null;
|
pendingTarget = null;
|
||||||
|
|
||||||
|
@ -98,25 +95,23 @@ function showPopover() {
|
||||||
}
|
}
|
||||||
|
|
||||||
contentElement.style.maxWidth = contentMaxWidth;
|
contentElement.style.maxWidth = contentMaxWidth;
|
||||||
|
containerElement.style.display = "block";
|
||||||
activeTarget.classList.add("popover-active");
|
activeTarget.classList.add("popover-active");
|
||||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
window.addEventListener("resize", queueRepositionContainer);
|
window.addEventListener("resize", repositionContainer);
|
||||||
observer.observe(containerElement);
|
observer.observe(containerElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositionContainer() {
|
function repositionContainer() {
|
||||||
containerElement.style.display = "block";
|
|
||||||
|
|
||||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||||
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
||||||
: activeTarget.getBoundingClientRect();
|
: activeTarget.getBoundingClientRect();
|
||||||
|
|
||||||
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.popoverTargetOffset || 0.5);
|
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
|
||||||
const position = activeTarget.dataset.popoverPosition || "below";
|
const position = activeTarget.dataset.popoverPosition || "below";
|
||||||
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
|
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
|
||||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
|
|
||||||
|
|
||||||
if (left < 0) {
|
if (left < 0) {
|
||||||
containerElement.style.left = 0;
|
containerElement.style.left = 0;
|
||||||
|
@ -125,11 +120,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) + -1 + "px");
|
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
|
||||||
} else {
|
} else {
|
||||||
containerElement.style.removeProperty("right");
|
containerElement.style.removeProperty("right");
|
||||||
containerElement.style.left = left + "px";
|
containerElement.style.left = left + "px";
|
||||||
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px");
|
containerElement.style.removeProperty("--triangle-offset");
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
||||||
|
@ -158,7 +153,7 @@ function hidePopover() {
|
||||||
activeTarget.classList.remove("popover-active");
|
activeTarget.classList.remove("popover-active");
|
||||||
containerElement.style.display = "none";
|
containerElement.style.display = "none";
|
||||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
window.removeEventListener("resize", queueRepositionContainer);
|
window.removeEventListener("resize", repositionContainer);
|
||||||
observer.unobserve(containerElement);
|
observer.unobserve(containerElement);
|
||||||
|
|
||||||
if (cleanupOnHidePopover !== null) {
|
if (cleanupOnHidePopover !== null) {
|
|
@ -17,7 +17,7 @@
|
||||||
--cm: 1;
|
--cm: 1;
|
||||||
--tsm: 1;
|
--tsm: 1;
|
||||||
|
|
||||||
--widget-gap: 23px;
|
--widget-gap: 25px;
|
||||||
--widget-content-vertical-padding: 15px;
|
--widget-content-vertical-padding: 15px;
|
||||||
--widget-content-horizontal-padding: 17px;
|
--widget-content-horizontal-padding: 17px;
|
||||||
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||||
|
@ -37,14 +37,13 @@
|
||||||
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
||||||
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
||||||
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
||||||
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 30% * var(--cm))));
|
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
|
||||||
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
||||||
|
|
||||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
|
||||||
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
|
|
||||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||||
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
||||||
|
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||||
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||||
|
|
||||||
--font-size-h1: 1.7rem;
|
--font-size-h1: 1.7rem;
|
||||||
|
@ -110,7 +109,7 @@
|
||||||
.visited-indicator:not(.text-truncate)::after,
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
.visited-indicator.text-truncate::before,
|
.visited-indicator.text-truncate::before,
|
||||||
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
content: '↗' / "";
|
content: '↗';
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -189,7 +188,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-toggle-button-icon::before {
|
.expand-toggle-button-icon::before {
|
||||||
content: '' / "";
|
content: '';
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
@ -274,10 +273,6 @@
|
||||||
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);
|
||||||
|
@ -294,12 +289,6 @@ pre {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 0.1rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
@ -337,23 +326,10 @@ html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, .body-content {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5 {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
clip-path: inset(50%);
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: absolute;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
@ -464,17 +440,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
@ -549,11 +514,6 @@ kbd:active {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.details[open] .summary {
|
.details[open] .summary {
|
||||||
|
@ -575,12 +535,8 @@ 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;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -751,7 +707,6 @@ 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 {
|
||||||
|
@ -767,11 +722,19 @@ 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;
|
||||||
|
@ -813,20 +776,6 @@ details[open] .summary::after {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-beta-icon {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform .45s, opacity .45s, stroke .45s;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
|
|
||||||
fill: var(--color-text-highlight);
|
|
||||||
transform: translateY(-10%) scale(1.3);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget + .widget {
|
.widget + .widget {
|
||||||
margin-top: var(--widget-gap);
|
margin-top: var(--widget-gap);
|
||||||
}
|
}
|
||||||
|
@ -839,9 +788,9 @@ details[open] .summary::after {
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-horizontal-text > *:not(:last-child)::after {
|
.list-horizontal-text > *:not(:last-child)::after {
|
||||||
content: '•' / "";
|
content: '•';
|
||||||
color: var(--color-text-subdue);
|
color: var(--color-text-subdue);
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0.1rem;
|
top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
@ -902,7 +851,6 @@ details[open] .summary::after {
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
transition: color .3s, border-color .3s;
|
transition: color .3s, border-color .3s;
|
||||||
font-size: var(--font-size-h3);
|
font-size: var(--font-size-h3);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:not(.nav-item-current):hover {
|
.nav-item:not(.nav-item-current):hover {
|
||||||
|
@ -973,11 +921,15 @@ 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-horizontal-list-thumbnail {
|
.video-title {
|
||||||
height: 4rem;
|
margin-bottom: auto;
|
||||||
aspect-ratio: 16 / 8.9;
|
overflow: hidden;
|
||||||
object-fit: cover;
|
display: block;
|
||||||
border-radius: var(--border-radius);
|
text-overflow: ellipsis;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
|
@ -1060,6 +1012,11 @@ details[open] .summary::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forum-post-list-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.forum-post-list-thumbnail {
|
.forum-post-list-thumbnail {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
|
@ -1074,12 +1031,6 @@ details[open] .summary::after {
|
||||||
transform: translateY(-0.15rem);
|
transform: translateY(-0.15rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@container widget (max-width: 550px) {
|
|
||||||
.forum-post-autohide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarks-group {
|
.bookmarks-group {
|
||||||
--bookmarks-group-color: var(--color-primary);
|
--bookmarks-group-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
@ -1098,7 +1049,6 @@ details[open] .summary::after {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmarks-icon {
|
.bookmarks-icon {
|
||||||
|
@ -1107,82 +1057,23 @@ details[open] .summary::after {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root:not(.light-scheme) .flat-icon {
|
:root:not(.light-scheme) .simple-icon {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.old-calendar-day {
|
.calendar-day {
|
||||||
width: calc(100% / 7);
|
width: calc(100% / 7);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.6rem 0;
|
padding: 0.6rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.old-calendar-day-today {
|
|
||||||
|
.calendar-day-today {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-dates {
|
|
||||||
text-align: center;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-date {
|
|
||||||
padding: 0.4rem 0;
|
|
||||||
color: var(--color-text-base);
|
|
||||||
position: relative;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-current-date {
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background-color: var(--color-popover-border);
|
|
||||||
color: var(--color-text-highlight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-spillover-date {
|
|
||||||
color: var(--color-text-subdue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-header-button {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
z-index: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-header-button::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -0.2rem;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background-color: var(--color-text-subdue);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-header-button:hover::before {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-undo-button {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: text-top;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
margin-left: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dns-stats-totals {
|
.dns-stats-totals {
|
||||||
transition: opacity .3s;
|
transition: opacity .3s;
|
||||||
transition-delay: 50ms;
|
transition-delay: 50ms;
|
||||||
|
@ -1202,6 +1093,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1228,6 +1120,7 @@ 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;
|
||||||
|
@ -1273,7 +1166,6 @@ details[open] .summary::after {
|
||||||
|
|
||||||
.dns-stats-graph-bar > .blocked {
|
.dns-stats-graph-bar > .blocked {
|
||||||
background-color: var(--color-negative);
|
background-color: var(--color-negative);
|
||||||
flex-basis: calc(var(--percent) - 1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||||
|
@ -1370,6 +1262,7 @@ 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 {
|
||||||
|
@ -1444,10 +1337,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
@ -1464,7 +1353,7 @@ details[open] .summary::after {
|
||||||
transition: filter 0.3s, opacity 0.3s;
|
transition: filter 0.3s, opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitor-site-icon.flat-icon {
|
.monitor-site-icon.simple-icon {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1472,7 +1361,7 @@ details[open] .summary::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
|
||||||
filter: grayscale(0);
|
filter: grayscale(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1483,170 +1372,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-type-server-info {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server + .server {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server {
|
|
||||||
gap: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-info {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.5rem;
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-details {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-icon {
|
|
||||||
height: 3rem;
|
|
||||||
width: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-spicy-cpu-icon {
|
|
||||||
height: 1em;
|
|
||||||
align-self: center;
|
|
||||||
margin-left: 0.4em;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-stat-unavailable {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
border: 1px solid var(--color-progress-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 2px;
|
|
||||||
height: 1.5rem;
|
|
||||||
margin-inline: -3px; /* naughty, but oh so beautiful */
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-combined {
|
|
||||||
height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-active > .progress-bar {
|
|
||||||
transition: border-color .3s;
|
|
||||||
border-color: var(--color-text-subdue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value {
|
|
||||||
--half-border-radius: calc(var(--border-radius) / 2);
|
|
||||||
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
|
|
||||||
background: var(--color-progress-value);
|
|
||||||
width: calc(var(--percent) * 1%);
|
|
||||||
min-width: 1px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value:first-child {
|
|
||||||
border-top-left-radius: var(--half-border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value:last-child {
|
|
||||||
border-bottom-left-radius: var(--half-border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value-notice {
|
|
||||||
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-separator {
|
|
||||||
min-width: 2rem;
|
|
||||||
margin-inline: 0.8rem;
|
|
||||||
flex: 1;
|
|
||||||
height: calc(1em * 1.1);
|
|
||||||
border-bottom: 1px dotted var(--color-text-subdue);
|
|
||||||
}
|
|
||||||
|
|
||||||
@container widget (min-width: 650px) {
|
|
||||||
.server {
|
|
||||||
gap: 2rem;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server + .server {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-info {
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
justify-content: unset;
|
|
||||||
margin-right: auto;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-stats {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: right;
|
|
||||||
min-width: 450px;
|
|
||||||
margin-top: 0;
|
|
||||||
gap: 2rem;
|
|
||||||
padding-bottom: 0.8rem;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-stats > * {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
filter: grayscale(0.2) contrast(0.9);
|
filter: grayscale(0.2) contrast(0.9);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
@ -1766,14 +1491,6 @@ 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%;
|
||||||
|
@ -1839,7 +1556,7 @@ details[open] .summary::after {
|
||||||
transform: translateY(calc(100% - var(--mobile-navigation-height)));
|
transform: translateY(calc(100% - var(--mobile-navigation-height)));
|
||||||
left: var(--content-bounds-padding);
|
left: var(--content-bounds-padding);
|
||||||
right: var(--content-bounds-padding);
|
right: var(--content-bounds-padding);
|
||||||
z-index: 11;
|
z-index: 10;
|
||||||
background-color: var(--color-widget-background);
|
background-color: var(--color-widget-background);
|
||||||
border: 1px solid var(--color-widget-content-border);
|
border: 1px solid var(--color-widget-content-border);
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
|
@ -1938,11 +1655,6 @@ 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) {
|
||||||
|
@ -1950,11 +1662,7 @@ details[open] .summary::after {
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios .body-content {
|
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1968,10 +1676,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
@ -1985,7 +1689,7 @@ details[open] .summary::after {
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@media (max-width: 550px) {
|
||||||
:root {
|
:root {
|
||||||
font-size: 9.4px;
|
font-size: 9px;
|
||||||
--widget-gap: 15px;
|
--widget-gap: 15px;
|
||||||
--widget-content-vertical-padding: 10px;
|
--widget-content-vertical-padding: 10px;
|
||||||
--widget-content-horizontal-padding: 10px;
|
--widget-content-horizontal-padding: 10px;
|
||||||
|
@ -2030,7 +1734,6 @@ details[open] .summary::after {
|
||||||
.size-h6 { font-size: var(--font-size-h6); }
|
.size-h6 { font-size: var(--font-size-h6); }
|
||||||
|
|
||||||
.color-highlight { color: var(--color-text-highlight); }
|
.color-highlight { color: var(--color-text-highlight); }
|
||||||
.color-paragraph { color: var(--color-text-paragraph); }
|
|
||||||
.color-base { color: var(--color-text-base); }
|
.color-base { color: var(--color-text-base); }
|
||||||
.color-subdue { color: var(--color-text-subdue); }
|
.color-subdue { color: var(--color-text-subdue); }
|
||||||
.color-negative { color: var(--color-negative); }
|
.color-negative { color: var(--color-negative); }
|
||||||
|
@ -2038,25 +1741,23 @@ details[open] .summary::after {
|
||||||
.color-primary { color: var(--color-primary); }
|
.color-primary { color: var(--color-primary); }
|
||||||
|
|
||||||
.cursor-help { cursor: help; }
|
.cursor-help { cursor: help; }
|
||||||
.rounded { border-radius: var(--border-radius); }
|
|
||||||
.break-all { word-break: break-all; }
|
.break-all { word-break: break-all; }
|
||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
.text-elevate { margin-top: -0.2em; }
|
.text-elevate { margin-top: -0.2em; }
|
||||||
.text-compact { word-spacing: -0.18em; }
|
.text-compact { word-spacing: -0.18em; }
|
||||||
.text-very-compact { word-spacing: -0.35em; }
|
|
||||||
.rtl { direction: rtl; }
|
.rtl { direction: rtl; }
|
||||||
.shrink { flex-shrink: 1; }
|
.shrink { flex-shrink: 1; }
|
||||||
.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; }
|
||||||
.relative { position: relative; }
|
.relative { position: relative; }
|
||||||
.flex { display: flex; }
|
.flex { display: flex; }
|
||||||
.flex-1 { flex: 1; }
|
|
||||||
.flex-wrap { flex-wrap: wrap; }
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
.flex-nowrap { flex-wrap: nowrap; }
|
.flex-nowrap { flex-wrap: nowrap; }
|
||||||
.justify-between { justify-content: space-between; }
|
.justify-between { justify-content: space-between; }
|
||||||
|
@ -2069,18 +1770,15 @@ details[open] .summary::after {
|
||||||
.flex-column { flex-direction: column; }
|
.flex-column { flex-direction: column; }
|
||||||
.items-center { align-items: center; }
|
.items-center { align-items: center; }
|
||||||
.items-start { align-items: start; }
|
.items-start { align-items: start; }
|
||||||
.items-end { align-items: end; }
|
|
||||||
.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; }
|
||||||
|
@ -2094,7 +1792,6 @@ 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; }
|
||||||
|
@ -2108,7 +1805,6 @@ 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; }
|
109
internal/assets/templates.go
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
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))
|
||||||
|
}
|
|
@ -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.String | safeCSS }}"{{ end }}>
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ 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.URL }}
|
{{ if ne "" .Icon }}
|
||||||
<div class="bookmarks-icon-container">
|
<div class="bookmarks-icon-container">
|
||||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<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>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
|
@ -11,23 +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">Mo</div>
|
||||||
<div class="old-calendar-day">Su</div>
|
<div class="calendar-day">Tu</div>
|
||||||
{{ end }}
|
<div class="calendar-day">We</div>
|
||||||
<div class="old-calendar-day">Mo</div>
|
<div class="calendar-day">Th</div>
|
||||||
<div class="old-calendar-day">Tu</div>
|
<div class="calendar-day">Fr</div>
|
||||||
<div class="old-calendar-day">We</div>
|
<div class="calendar-day">Sa</div>
|
||||||
<div class="old-calendar-day">Th</div>
|
<div class="calendar-day">Su</div>
|
||||||
<div class="old-calendar-day">Fr</div>
|
|
||||||
<div class="old-calendar-day">Sa</div>
|
|
||||||
{{ if not .StartSunday }}
|
|
||||||
<div class="old-calendar-day">Su</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
{{ range .Calendar.Days }}
|
{{ range .Calendar.Days }}
|
||||||
<div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
|
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -18,14 +18,12 @@
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
|
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
|
||||||
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatApproxNumber }}</div>
|
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
|
||||||
<div class="size-h6">DOMAINS</div>
|
<div class="size-h6">DOMAINS</div>
|
||||||
</div>
|
</div>
|
||||||
{{ 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">
|
||||||
|
@ -59,8 +57,8 @@
|
||||||
{{ if ne $column.Queries $column.Blocked }}
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
<div class="queries"></div>
|
<div class="queries"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if gt $column.PercentBlocked 0 }}
|
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
||||||
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
|
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -69,14 +67,13 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }}
|
{{ if .Stats.TopBlockedDomains }}
|
||||||
<details class="details {{ if $showGraph }}margin-top-40{{ else }}margin-top-15{{ end }}">
|
<details class="details margin-top-40">
|
||||||
<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 gap-10">
|
<li class="flex justify-between align-center">
|
||||||
<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>
|
|
@ -3,7 +3,6 @@
|
||||||
<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">
|
5
internal/assets/templates/extension.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ .Extension.Content }}
|
||||||
|
{{ end }}
|
49
internal/assets/templates/forum-posts.html
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Posts }}
|
||||||
|
<li>
|
||||||
|
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||||
|
{{ if $.ShowThumbnails }}
|
||||||
|
{{ if .IsCrosspost }}
|
||||||
|
<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="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||||
|
</svg>
|
||||||
|
{{ else if ne .ThumbnailUrl "" }}
|
||||||
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
|
{{ else if .HasTargetUrl }}
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
{{ else }}
|
||||||
|
<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="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
{{ if gt (len .Tags) 0 }}
|
||||||
|
<div class="inline-block forum-post-tags-container">
|
||||||
|
<ul class="attachments">
|
||||||
|
{{ range .Tags }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
|
{{ if .HasTargetUrl }}
|
||||||
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
20
internal/assets/templates/group.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="widget-group-header">
|
||||||
|
<div class="widget-header gap-20">
|
||||||
|
{{ range $i, $widget := .Widgets }}
|
||||||
|
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget-group-contents">
|
||||||
|
{{ range $i, $widget := .Widgets }}
|
||||||
|
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ end }}
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
<div class="market-values shrink-0">
|
<div class="market-values shrink-0">
|
||||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
|
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -21,11 +21,11 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "site" }}
|
{{ define "site" }}
|
||||||
{{ if .Icon.URL }}
|
{{ if .IconUrl }}
|
||||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" 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 | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ 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 fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="monitor-site-status-icon">
|
<div class="monitor-site-status-icon">
|
||||||
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||||
<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" />
|
<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" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<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>
|
|
@ -14,31 +14,28 @@
|
||||||
{{ 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" }}
|
||||||
{{ .App.ParsedThemeStyle }}
|
{{ template "page-style-overrides.gotmpl" . }}
|
||||||
|
|
||||||
{{ 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" }}
|
||||||
{{ range .App.Config.Pages }}
|
{{ range .App.Config.Pages }}
|
||||||
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}>{{ .Title }}</a>
|
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "document-body" }}
|
{{ define "document-body" }}
|
||||||
<div class="flex flex-column body-content">
|
<div class="flex flex-column height-100">
|
||||||
{{ 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">
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||||
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
||||||
<nav class="nav flex grow">
|
<div class="nav flex grow">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -47,9 +44,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 $i $.Page.PrimaryColumnIndex }} 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 "full" $column.Size }} 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"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><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" . }}
|
||||||
|
@ -57,19 +54,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow">
|
<div class="content-bounds grow">
|
||||||
<main class="page" id="page" aria-live="polite" aria-busy="true">
|
<div class="page" id="page">
|
||||||
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
|
||||||
<div class="page-content" id="page-content"></div>
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
<!-- TODO: add a bigger/better loading indicator -->
|
<!-- TODO: add a bigger/better loading indicator -->
|
||||||
<div class="visually-hidden">Loading</div>
|
<div class="loading-icon"></div>
|
||||||
<div class="loading-icon" aria-hidden="true"></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if not .App.Config.Branding.HideFooter }}
|
{{ if not .App.Config.Branding.HideFooter }}
|
||||||
<footer class="footer flex items-center flex-column">
|
<div class="footer flex items-center flex-column">
|
||||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||||
<div>
|
<div>
|
||||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
@ -77,7 +72,7 @@
|
||||||
{{ else }}
|
{{ else }}
|
||||||
{{ .App.Config.Branding.CustomFooter }}
|
{{ .App.Config.Branding.CustomFooter }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</footer>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div class="mobile-navigation-offset"></div>
|
<div class="mobile-navigation-offset"></div>
|
|
@ -18,10 +18,10 @@
|
||||||
{{ 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 }}" 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 }}" title="{{ .Title }}" 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 | formatApproxNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -17,10 +17,10 @@
|
||||||
{{ 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 }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" 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 | formatApproxNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -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" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-10 collapsible-container single-line-titles" 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="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
|
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
61
internal/assets/templates/repository.html
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
{{ 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 }}
|
|
@ -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 }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" 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>
|
|
@ -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 }}" 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 }}" title="{{ .Title }}" 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>
|
|
@ -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 }}</a>
|
<a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .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">
|
|
@ -16,7 +16,7 @@
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input class="search-input" type="text" placeholder="{{ .Placeholder }}" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||||
|
|
||||||
<div class="search-bang"></div>
|
<div class="search-bang"></div>
|
||||||
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
|
@ -5,15 +5,9 @@
|
||||||
{{ 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"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ 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 }}" target="_blank" rel="noreferrer">
|
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" 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 }}
|
||||||
|
@ -31,7 +25,7 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div>Offline</div>
|
<div>Offline</div>
|
|
@ -9,7 +9,7 @@
|
||||||
<div class="min-width-0">
|
<div class="min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
{{ if .IsNew }}
|
{{ if .IsNew }}
|
||||||
<li class="color-primary">NEW</li>
|
<li class="color-primary">NEW</li>
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -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="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .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">
|
23
internal/assets/templates/widget-base.html
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||||
|
{{ if not .HideHeader}}
|
||||||
|
<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 and .Error .ContentAvailable }}
|
||||||
|
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||||
|
{{ else if .Notice }}
|
||||||
|
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||||
|
{{ if .ContentAvailable }}
|
||||||
|
{{ block "widget-content" . }}{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<div class="widget-error-header">
|
||||||
|
<div class="color-negative size-h3">ERROR</div>
|
||||||
|
<div class="widget-error-icon"></div>
|
||||||
|
</div>
|
||||||
|
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||||
|
{{ end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
99
internal/feed/adguard.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &DNSStats{
|
||||||
|
TotalQueries: responseJson.TotalQueries,
|
||||||
|
BlockedQueries: responseJson.BlockedQueries,
|
||||||
|
ResponseTime: int(responseJson.ResponseTime * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalQueries <= 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||||
|
|
||||||
|
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
|
||||||
|
|
||||||
|
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,
|
||||||
|
PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adguard _should_ return data for the last 24 hours in a 1 hour interval
|
||||||
|
if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
queries := 0
|
||||||
|
blocked := 0
|
||||||
|
|
||||||
|
for j := 0; j < 3; j++ {
|
||||||
|
queries += responseJson.QueriesSeries[i*3+j]
|
||||||
|
blocked += responseJson.BlockedSeries[i*3+j]
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Series[i] = DNSStatsSeries{
|
||||||
|
Queries: queries,
|
||||||
|
Blocked: blocked,
|
||||||
|
PercentBlocked: int(float64(blocked) / float64(queries) * 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries > maxQueriesInSeries {
|
||||||
|
maxQueriesInSeries = queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
53
internal/feed/calendar.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TODO: very inflexible, refactor to allow more customizability
|
||||||
|
// TODO: allow changing first day of week
|
||||||
|
// TODO: allow changing between showing the previous and next week and the entire month
|
||||||
|
func NewCalendar(now time.Time) *Calendar {
|
||||||
|
year, week := now.ISOWeek()
|
||||||
|
weekday := now.Weekday()
|
||||||
|
|
||||||
|
if weekday == 0 {
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMonthDays := daysInMonth(now.Month(), year)
|
||||||
|
|
||||||
|
var previousMonthDays int
|
||||||
|
|
||||||
|
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||||
|
previousMonthDays = daysInMonth(12, year-1)
|
||||||
|
} else {
|
||||||
|
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
startDaysFrom := now.Day() - int(weekday+6)
|
||||||
|
|
||||||
|
days := make([]int, 21)
|
||||||
|
|
||||||
|
for i := 0; i < 21; i++ {
|
||||||
|
day := startDaysFrom + i
|
||||||
|
|
||||||
|
if day < 1 {
|
||||||
|
day = previousMonthDays + day
|
||||||
|
} else if day > currentMonthDays {
|
||||||
|
day = day - currentMonthDays
|
||||||
|
}
|
||||||
|
|
||||||
|
days[i] = day
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Calendar{
|
||||||
|
CurrentDay: now.Day(),
|
||||||
|
CurrentWeekNumber: week,
|
||||||
|
CurrentMonthName: now.Month().String(),
|
||||||
|
CurrentYear: year,
|
||||||
|
Days: days,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysInMonth(m time.Month, year int) int {
|
||||||
|
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -11,65 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
|
type ChangeDetectionWatch struct {
|
||||||
|
|
||||||
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
|
||||||
|
@ -77,9 +17,9 @@ type changeDetectionWatch struct {
|
||||||
PreviousHash string
|
PreviousHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
type changeDetectionWatchList []changeDetectionWatch
|
type ChangeDetectionWatches []ChangeDetectionWatch
|
||||||
|
|
||||||
func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
|
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
|
||||||
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)
|
||||||
})
|
})
|
||||||
|
@ -95,14 +35,15 @@ 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{}](defaultHTTPClient, request)
|
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, 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)
|
||||||
}
|
}
|
||||||
|
@ -116,8 +57,8 @@ func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
|
||||||
return uuids, nil
|
return uuids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
|
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
|
||||||
watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
|
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
|
||||||
|
|
||||||
if len(requestedWatchIDs) == 0 {
|
if len(requestedWatchIDs) == 0 {
|
||||||
return watches, nil
|
return watches, nil
|
||||||
|
@ -135,9 +76,10 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
|
||||||
requests[i] = request
|
requests[i] = request
|
||||||
}
|
}
|
||||||
|
|
||||||
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
|
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -147,13 +89,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", "url", requests[i].URL, "error", errs[i])
|
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
|
||||||
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),
|
||||||
}
|
}
|
||||||
|
@ -184,13 +126,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
|
102
internal/feed/dockerhub.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
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
|
||||||
|
}
|
97
internal/feed/extension.go
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,91 +1,72 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html")
|
type githubReleaseLatestResponseJson struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
type repositoryWidget struct {
|
PublishedAt string `json:"published_at"`
|
||||||
widgetBase `yaml:",inline"`
|
HtmlUrl string `json:"html_url"`
|
||||||
RequestedRepository string `yaml:"repository"`
|
Reactions struct {
|
||||||
Token string `yaml:"token"`
|
Downvotes int `json:"-1"`
|
||||||
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
} `json:"reactions"`
|
||||||
IssuesLimit int `yaml:"issues-limit"`
|
|
||||||
CommitsLimit int `yaml:"commits-limit"`
|
|
||||||
Repository repository `yaml:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *repositoryWidget) initialize() error {
|
func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
|
||||||
widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
|
httpRequest, err := http.NewRequest(
|
||||||
|
"GET",
|
||||||
if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
|
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
|
||||||
widget.PullRequestsLimit = 3
|
nil,
|
||||||
}
|
|
||||||
|
|
||||||
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
|
|
||||||
widget.IssuesLimit = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
|
|
||||||
widget.CommitsLimit = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *repositoryWidget) update(ctx context.Context) {
|
|
||||||
details, err := fetchRepositoryDetailsFromGithub(
|
|
||||||
widget.RequestedRepository,
|
|
||||||
string(widget.Token),
|
|
||||||
widget.PullRequestsLimit,
|
|
||||||
widget.IssuesLimit,
|
|
||||||
widget.CommitsLimit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if err != nil {
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.Repository = details
|
if request.Token != nil {
|
||||||
|
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *repositoryWidget) Render() template.HTML {
|
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
|
||||||
return widget.renderTemplate(widget, repositoryWidgetTemplate)
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type repository struct {
|
return &AppRelease{
|
||||||
Name string
|
Source: ReleaseSourceGithub,
|
||||||
Stars int
|
Name: request.Repository,
|
||||||
Forks int
|
Version: normalizeVersionFormat(response.TagName),
|
||||||
OpenPullRequests int
|
NotesUrl: response.HtmlUrl,
|
||||||
PullRequests []githubTicket
|
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||||
OpenIssues int
|
Downvotes: response.Reactions.Downvotes,
|
||||||
Issues []githubTicket
|
}, nil
|
||||||
LastCommits int
|
|
||||||
Commits []githubCommitDetails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubTicket struct {
|
type GithubTicket struct {
|
||||||
Number int
|
Number int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubCommitDetails struct {
|
type RepositoryDetails struct {
|
||||||
Sha string
|
Name string
|
||||||
Author string
|
Stars int
|
||||||
CreatedAt time.Time
|
Forks int
|
||||||
Message string
|
OpenPullRequests int
|
||||||
|
PullRequests []GithubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []GithubTicket
|
||||||
|
LastCommits int
|
||||||
|
Commits []CommitDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubRepositoryResponseJson struct {
|
type githubRepositoryDetailsResponseJson struct {
|
||||||
Name string `json:"full_name"`
|
Name string `json:"full_name"`
|
||||||
Stars int `json:"stargazers_count"`
|
Stars int `json:"stargazers_count"`
|
||||||
Forks int `json:"forks_count"`
|
Forks int `json:"forks_count"`
|
||||||
|
@ -100,6 +81,13 @@ type githubTicketResponseJson struct {
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommitDetails struct {
|
||||||
|
Sha string
|
||||||
|
Author string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
type gitHubCommitResponseJson struct {
|
type gitHubCommitResponseJson struct {
|
||||||
Sha string `json:"sha"`
|
Sha string `json:"sha"`
|
||||||
Commit struct {
|
Commit struct {
|
||||||
|
@ -111,15 +99,15 @@ type gitHubCommitResponseJson struct {
|
||||||
} `json:"commit"`
|
} `json:"commit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) {
|
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
|
||||||
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
|
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil)
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||||
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||||
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)
|
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
|
||||||
|
|
||||||
if token != "" {
|
if token != "" {
|
||||||
token = fmt.Sprintf("Bearer %s", token)
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
|
@ -129,7 +117,7 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
CommitsRequest.Header.Add("Authorization", token)
|
CommitsRequest.Header.Add("Authorization", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repositoryResponse githubRepositoryResponseJson
|
var detailsResponse githubRepositoryDetailsResponseJson
|
||||||
var detailsErr error
|
var detailsErr error
|
||||||
var PRsResponse githubTicketResponseJson
|
var PRsResponse githubTicketResponseJson
|
||||||
var PRsErr error
|
var PRsErr error
|
||||||
|
@ -142,14 +130,14 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest)
|
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if maxPRs > 0 {
|
if maxPRs > 0 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest)
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,7 +145,7 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest)
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,35 +153,35 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest)
|
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if detailsErr != nil {
|
if detailsErr != nil {
|
||||||
return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr)
|
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
details := repository{
|
details := RepositoryDetails{
|
||||||
Name: repositoryResponse.Name,
|
Name: detailsResponse.Name,
|
||||||
Stars: repositoryResponse.Stars,
|
Stars: detailsResponse.Stars,
|
||||||
Forks: repositoryResponse.Forks,
|
Forks: detailsResponse.Forks,
|
||||||
PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)),
|
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)),
|
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
Commits: make([]githubCommitDetails, 0, len(commitsResponse)),
|
Commits: make([]CommitDetails, 0, len(commitsResponse)),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = nil
|
err = nil
|
||||||
|
|
||||||
if maxPRs > 0 {
|
if maxPRs > 0 {
|
||||||
if PRsErr != nil {
|
if PRsErr != nil {
|
||||||
err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr)
|
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||||
} else {
|
} else {
|
||||||
details.OpenPullRequests = PRsResponse.Count
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
for i := range PRsResponse.Tickets {
|
for i := range PRsResponse.Tickets {
|
||||||
details.PullRequests = append(details.PullRequests, githubTicket{
|
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||||
Number: PRsResponse.Tickets[i].Number,
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
|
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
|
||||||
Title: PRsResponse.Tickets[i].Title,
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
|
@ -205,12 +193,12 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
if maxIssues > 0 {
|
if maxIssues > 0 {
|
||||||
if issuesErr != nil {
|
if issuesErr != nil {
|
||||||
// TODO: fix, overwriting the previous error
|
// TODO: fix, overwriting the previous error
|
||||||
err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr)
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||||
} else {
|
} else {
|
||||||
details.OpenIssues = issuesResponse.Count
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
for i := range issuesResponse.Tickets {
|
for i := range issuesResponse.Tickets {
|
||||||
details.Issues = append(details.Issues, githubTicket{
|
details.Issues = append(details.Issues, GithubTicket{
|
||||||
Number: issuesResponse.Tickets[i].Number,
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
|
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
|
||||||
Title: issuesResponse.Tickets[i].Title,
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
|
@ -221,10 +209,10 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max
|
||||||
|
|
||||||
if maxCommits > 0 {
|
if maxCommits > 0 {
|
||||||
if CommitsErr != nil {
|
if CommitsErr != nil {
|
||||||
err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr)
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
|
||||||
} else {
|
} else {
|
||||||
for i := range commitsResponse {
|
for i := range commitsResponse {
|
||||||
details.Commits = append(details.Commits, githubCommitDetails{
|
details.Commits = append(details.Commits, CommitDetails{
|
||||||
Sha: commitsResponse[i].Sha,
|
Sha: commitsResponse[i].Sha,
|
||||||
Author: commitsResponse[i].Commit.Author.Name,
|
Author: commitsResponse[i].Commit.Author.Name,
|
||||||
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
|
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
|
48
internal/feed/gitlab.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
|
}
|
98
internal/feed/hacker-news.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
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)
|
||||||
|
}
|
91
internal/feed/lobsters.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
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
|
||||||
|
}
|
77
internal/feed/monitor.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,10 +1,7 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -15,94 +12,11 @@ import (
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html")
|
type PlacesResponseJson struct {
|
||||||
|
Results []PlaceJson
|
||||||
type weatherWidget struct {
|
|
||||||
widgetBase `yaml:",inline"`
|
|
||||||
Location string `yaml:"location"`
|
|
||||||
ShowAreaName bool `yaml:"show-area-name"`
|
|
||||||
HideLocation bool `yaml:"hide-location"`
|
|
||||||
HourFormat string `yaml:"hour-format"`
|
|
||||||
Units string `yaml:"units"`
|
|
||||||
Place *openMeteoPlaceResponseJson `yaml:"-"`
|
|
||||||
Weather *weather `yaml:"-"`
|
|
||||||
TimeLabels [12]string `yaml:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
type PlaceJson struct {
|
||||||
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) initialize() error {
|
|
||||||
widget.withTitle("Weather").withCacheOnTheHour()
|
|
||||||
|
|
||||||
if widget.Location == "" {
|
|
||||||
return fmt.Errorf("location is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.HourFormat == "" || widget.HourFormat == "12h" {
|
|
||||||
widget.TimeLabels = timeLabels12h
|
|
||||||
} else if widget.HourFormat == "24h" {
|
|
||||||
widget.TimeLabels = timeLabels24h
|
|
||||||
} else {
|
|
||||||
return errors.New("hour-format must be either 12h or 24h")
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.Units == "" {
|
|
||||||
widget.Units = "metric"
|
|
||||||
} else if widget.Units != "metric" && widget.Units != "imperial" {
|
|
||||||
return errors.New("units must be either metric or imperial")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) update(ctx context.Context) {
|
|
||||||
if widget.Place == nil {
|
|
||||||
place, err := fetchOpenMeteoPlaceFromName(widget.Location)
|
|
||||||
if err != nil {
|
|
||||||
widget.withError(err).scheduleEarlyUpdate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.Place = place
|
|
||||||
}
|
|
||||||
|
|
||||||
weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units)
|
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.Weather = weather
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) Render() template.HTML {
|
|
||||||
return widget.renderTemplate(widget, weatherWidgetTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
type weather struct {
|
|
||||||
Temperature int
|
|
||||||
ApparentTemperature int
|
|
||||||
WeatherCode int
|
|
||||||
CurrentColumn int
|
|
||||||
SunriseColumn int
|
|
||||||
SunsetColumn int
|
|
||||||
Columns []weatherColumn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *weather) WeatherCodeAsString() string {
|
|
||||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
|
||||||
return weatherCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type openMeteoPlacesResponseJson struct {
|
|
||||||
Results []openMeteoPlaceResponseJson
|
|
||||||
}
|
|
||||||
|
|
||||||
type openMeteoPlaceResponseJson struct {
|
|
||||||
Name string
|
Name string
|
||||||
Area string `json:"admin1"`
|
Area string `json:"admin1"`
|
||||||
Latitude float64
|
Latitude float64
|
||||||
|
@ -112,7 +26,7 @@ type openMeteoPlaceResponseJson struct {
|
||||||
location *time.Location
|
location *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type openMeteoWeatherResponseJson struct {
|
type WeatherResponseJson struct {
|
||||||
Daily struct {
|
Daily struct {
|
||||||
Sunrise []int64 `json:"sunrise"`
|
Sunrise []int64 `json:"sunrise"`
|
||||||
Sunset []int64 `json:"sunset"`
|
Sunset []int64 `json:"sunset"`
|
||||||
|
@ -168,20 +82,21 @@ func parsePlaceName(name string) (string, string) {
|
||||||
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
|
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||||
location, area := parsePlaceName(location)
|
location, area := parsePlaceName(location)
|
||||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
|
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetching places data: %v", err)
|
return nil, fmt.Errorf("could not fetch places data: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(responseJson.Results) == 0 {
|
if len(responseJson.Results) == 0 {
|
||||||
return nil, fmt.Errorf("no places found for %s", location)
|
return nil, fmt.Errorf("no places found for %s", location)
|
||||||
}
|
}
|
||||||
|
|
||||||
var place *openMeteoPlaceResponseJson
|
var place *PlaceJson
|
||||||
|
|
||||||
if area != "" {
|
if area != "" {
|
||||||
area = strings.ToLower(area)
|
area = strings.ToLower(area)
|
||||||
|
@ -201,8 +116,9 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson,
|
||||||
}
|
}
|
||||||
|
|
||||||
loc, err := time.LoadLocation(place.Timezone)
|
loc, err := time.LoadLocation(place.Timezone)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading location: %v", err)
|
return nil, fmt.Errorf("could not load location: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
place.location = loc
|
place.location = loc
|
||||||
|
@ -210,7 +126,12 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson,
|
||||||
return place, nil
|
return place, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) {
|
func barIndexFromHour(h int) int {
|
||||||
|
return h / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: bunch of spaget, refactor
|
||||||
|
func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
var temperatureUnit string
|
var temperatureUnit string
|
||||||
|
|
||||||
|
@ -232,16 +153,17 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
|
|
||||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request)
|
responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().In(place.location)
|
now := time.Now().In(place.location)
|
||||||
bars := make([]weatherColumn, 0, 24)
|
bars := make([]weatherColumn, 0, 24)
|
||||||
currentBar := now.Hour() / 2
|
currentBar := barIndexFromHour(now.Hour())
|
||||||
sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2
|
sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour())
|
||||||
sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2
|
sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1
|
||||||
|
|
||||||
if sunsetBar < 0 {
|
if sunsetBar < 0 {
|
||||||
sunsetBar = 0
|
sunsetBar = 0
|
||||||
|
@ -267,23 +189,16 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
minT := slices.Min(temperatures)
|
minT := slices.Min(temperatures)
|
||||||
maxT := slices.Max(temperatures)
|
maxT := slices.Max(temperatures)
|
||||||
|
|
||||||
temperaturesRange := float64(maxT - minT)
|
|
||||||
|
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
bars = append(bars, weatherColumn{
|
bars = append(bars, weatherColumn{
|
||||||
Temperature: temperatures[i],
|
Temperature: temperatures[i],
|
||||||
|
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||||
HasPrecipitation: precipitations[i],
|
HasPrecipitation: precipitations[i],
|
||||||
})
|
})
|
||||||
|
|
||||||
if temperaturesRange > 0 {
|
|
||||||
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
|
|
||||||
} else {
|
|
||||||
bars[i].Scale = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &weather{
|
return &Weather{
|
||||||
Temperature: int(responseJson.Current.Temperature),
|
Temperature: int(responseJson.Current.Temperature),
|
||||||
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
|
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
|
||||||
WeatherCode: responseJson.Current.WeatherCode,
|
WeatherCode: responseJson.Current.WeatherCode,
|
||||||
|
@ -293,34 +208,3 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
Columns: bars,
|
Columns: bars,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var weatherCodeTable = map[int]string{
|
|
||||||
0: "Clear Sky",
|
|
||||||
1: "Mainly Clear",
|
|
||||||
2: "Partly Cloudy",
|
|
||||||
3: "Overcast",
|
|
||||||
45: "Fog",
|
|
||||||
48: "Rime Fog",
|
|
||||||
51: "Drizzle",
|
|
||||||
53: "Drizzle",
|
|
||||||
55: "Drizzle",
|
|
||||||
56: "Drizzle",
|
|
||||||
57: "Drizzle",
|
|
||||||
61: "Rain",
|
|
||||||
63: "Moderate Rain",
|
|
||||||
65: "Heavy Rain",
|
|
||||||
66: "Freezing Rain",
|
|
||||||
67: "Freezing Rain",
|
|
||||||
71: "Snow",
|
|
||||||
73: "Moderate Snow",
|
|
||||||
75: "Heavy Snow",
|
|
||||||
77: "Snow Grains",
|
|
||||||
80: "Rain",
|
|
||||||
81: "Moderate Rain",
|
|
||||||
82: "Heavy Rain",
|
|
||||||
85: "Snow",
|
|
||||||
86: "Snow",
|
|
||||||
95: "Thunderstorm",
|
|
||||||
96: "Thunderstorm",
|
|
||||||
99: "Thunderstorm",
|
|
||||||
}
|
|
109
internal/feed/pihole.go
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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 map[string]int `json:"top_ads"`
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
241
internal/feed/primitives.go
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ForumPost struct {
|
||||||
|
Title string
|
||||||
|
DiscussionUrl string
|
||||||
|
TargetUrl string
|
||||||
|
TargetUrlDomain string
|
||||||
|
ThumbnailUrl string
|
||||||
|
CommentCount int
|
||||||
|
Score int
|
||||||
|
Engagement float64
|
||||||
|
TimePosted time.Time
|
||||||
|
Tags []string
|
||||||
|
IsCrosspost bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumPosts []ForumPost
|
||||||
|
|
||||||
|
type Calendar struct {
|
||||||
|
CurrentDay int
|
||||||
|
CurrentWeekNumber int
|
||||||
|
CurrentMonthName string
|
||||||
|
CurrentYear int
|
||||||
|
Days []int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Weather struct {
|
||||||
|
Temperature int
|
||||||
|
ApparentTemperature int
|
||||||
|
WeatherCode int
|
||||||
|
CurrentColumn int
|
||||||
|
SunriseColumn int
|
||||||
|
SunsetColumn int
|
||||||
|
Columns []weatherColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppRelease struct {
|
||||||
|
Source ReleaseSource
|
||||||
|
SourceIconURL string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
NotesUrl string
|
||||||
|
TimeReleased time.Time
|
||||||
|
Downvotes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppReleases []AppRelease
|
||||||
|
|
||||||
|
type Video struct {
|
||||||
|
ThumbnailUrl string
|
||||||
|
Title string
|
||||||
|
Url string
|
||||||
|
Author string
|
||||||
|
AuthorUrl string
|
||||||
|
TimePosted time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Videos []Video
|
||||||
|
|
||||||
|
var currencyToSymbol = map[string]string{
|
||||||
|
"USD": "$",
|
||||||
|
"EUR": "€",
|
||||||
|
"JPY": "¥",
|
||||||
|
"CAD": "C$",
|
||||||
|
"AUD": "A$",
|
||||||
|
"GBP": "£",
|
||||||
|
"CHF": "Fr",
|
||||||
|
"NZD": "N$",
|
||||||
|
"INR": "₹",
|
||||||
|
"BRL": "R$",
|
||||||
|
"RUB": "₽",
|
||||||
|
"TRY": "₺",
|
||||||
|
"ZAR": "R",
|
||||||
|
"CNY": "¥",
|
||||||
|
"KRW": "₩",
|
||||||
|
"HKD": "HK$",
|
||||||
|
"SGD": "S$",
|
||||||
|
"SEK": "kr",
|
||||||
|
"NOK": "kr",
|
||||||
|
"DKK": "kr",
|
||||||
|
"PLN": "zł",
|
||||||
|
"PHP": "₱",
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSStats struct {
|
||||||
|
TotalQueries int
|
||||||
|
BlockedQueries int
|
||||||
|
BlockedPercent int
|
||||||
|
ResponseTime int
|
||||||
|
DomainsBlocked int
|
||||||
|
Series [8]DNSStatsSeries
|
||||||
|
TopBlockedDomains []DNSStatsBlockedDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSStatsSeries struct {
|
||||||
|
Queries int
|
||||||
|
Blocked int
|
||||||
|
PercentTotal int
|
||||||
|
PercentBlocked int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSStatsBlockedDomain struct {
|
||||||
|
Domain string
|
||||||
|
PercentBlocked int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketRequest struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
ChartLink string `yaml:"chart-link"`
|
||||||
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Market struct {
|
||||||
|
MarketRequest
|
||||||
|
Currency string `yaml:"-"`
|
||||||
|
Price float64 `yaml:"-"`
|
||||||
|
PercentChange float64 `yaml:"-"`
|
||||||
|
SvgChartPoints string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Markets []Market
|
||||||
|
|
||||||
|
func (t Markets) SortByAbsChange() {
|
||||||
|
sort.Slice(t, func(i, j int) bool {
|
||||||
|
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var weatherCodeTable = map[int]string{
|
||||||
|
0: "Clear Sky",
|
||||||
|
1: "Mainly Clear",
|
||||||
|
2: "Partly Cloudy",
|
||||||
|
3: "Overcast",
|
||||||
|
45: "Fog",
|
||||||
|
48: "Rime Fog",
|
||||||
|
51: "Drizzle",
|
||||||
|
53: "Drizzle",
|
||||||
|
55: "Drizzle",
|
||||||
|
56: "Drizzle",
|
||||||
|
57: "Drizzle",
|
||||||
|
61: "Rain",
|
||||||
|
63: "Moderate Rain",
|
||||||
|
65: "Heavy Rain",
|
||||||
|
66: "Freezing Rain",
|
||||||
|
67: "Freezing Rain",
|
||||||
|
71: "Snow",
|
||||||
|
73: "Moderate Snow",
|
||||||
|
75: "Heavy Snow",
|
||||||
|
77: "Snow Grains",
|
||||||
|
80: "Rain",
|
||||||
|
81: "Moderate Rain",
|
||||||
|
82: "Heavy Rain",
|
||||||
|
85: "Snow",
|
||||||
|
86: "Snow",
|
||||||
|
95: "Thunderstorm",
|
||||||
|
96: "Thunderstorm",
|
||||||
|
99: "Thunderstorm",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Weather) WeatherCodeAsString() string {
|
||||||
|
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
||||||
|
return weatherCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const depreciatePostsOlderThanHours = 7
|
||||||
|
const maxDepreciation = 0.9
|
||||||
|
const maxDepreciationAfterHours = 24
|
||||||
|
|
||||||
|
func (p ForumPosts) CalculateEngagement() {
|
||||||
|
var totalComments int
|
||||||
|
var totalScore int
|
||||||
|
|
||||||
|
for i := range p {
|
||||||
|
totalComments += p[i].CommentCount
|
||||||
|
totalScore += p[i].Score
|
||||||
|
}
|
||||||
|
|
||||||
|
numberOfPosts := float64(len(p))
|
||||||
|
averageComments := float64(totalComments) / numberOfPosts
|
||||||
|
averageScore := float64(totalScore) / numberOfPosts
|
||||||
|
|
||||||
|
for i := range p {
|
||||||
|
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
|
||||||
|
|
||||||
|
elapsed := time.Since(p[i].TimePosted)
|
||||||
|
|
||||||
|
if elapsed < time.Hour*depreciatePostsOlderThanHours {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ForumPosts) SortByEngagement() {
|
||||||
|
sort.Slice(p, func(i, j int) bool {
|
||||||
|
return p[i].Engagement > p[j].Engagement
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumPost) HasTargetUrl() bool {
|
||||||
|
return s.TargetUrl != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
|
||||||
|
recent := make([]ForumPost, 0, len(p))
|
||||||
|
|
||||||
|
for i := range p {
|
||||||
|
if time.Since(p[i].TimePosted) < postedBefore {
|
||||||
|
recent = append(recent, p[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r AppReleases) SortByNewest() AppReleases {
|
||||||
|
sort.Slice(r, func(i, j int) bool {
|
||||||
|
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Videos) SortByNewest() Videos {
|
||||||
|
sort.Slice(v, func(i, j int) bool {
|
||||||
|
return v[i].TimePosted.After(v[j].TimePosted)
|
||||||
|
})
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
146
internal/feed/reddit.go
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subredditResponseJson struct {
|
||||||
|
Data struct {
|
||||||
|
Children []struct {
|
||||||
|
Data struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Upvotes int `json:"ups"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Time float64 `json:"created"`
|
||||||
|
CommentsCount int `json:"num_comments"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
Stickied bool `json:"stickied"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
IsSelf bool `json:"is_self"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Flair string `json:"link_flair_text"`
|
||||||
|
ParentList []struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Subreddit string `json:"subreddit"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
} `json:"crosspost_parent_list"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
|
||||||
|
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
|
||||||
|
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
||||||
|
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
||||||
|
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
var requestUrl string
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
query.Set("q", search+" subreddit:"+subreddit)
|
||||||
|
query.Set("sort", sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort == "top" {
|
||||||
|
query.Set("t", topPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||||
|
} else {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestUrlTemplate != "" {
|
||||||
|
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||||
|
addBrowserUserAgentHeader(request)
|
||||||
|
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(responseJson.Data.Children) == 0 {
|
||||||
|
return nil, fmt.Errorf("no posts found")
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
||||||
|
|
||||||
|
for i := range responseJson.Data.Children {
|
||||||
|
post := &responseJson.Data.Children[i].Data
|
||||||
|
|
||||||
|
if post.Stickied || post.Pinned {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||||
|
} else {
|
||||||
|
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
||||||
|
}
|
||||||
|
|
||||||
|
forumPost := ForumPost{
|
||||||
|
Title: html.UnescapeString(post.Title),
|
||||||
|
DiscussionUrl: commentsUrl,
|
||||||
|
TargetUrlDomain: post.Domain,
|
||||||
|
CommentCount: post.CommentsCount,
|
||||||
|
Score: post.Upvotes,
|
||||||
|
TimePosted: time.Unix(int64(post.Time), 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
|
||||||
|
forumPost.ThumbnailUrl = post.Thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
if !post.IsSelf {
|
||||||
|
forumPost.TargetUrl = post.Url
|
||||||
|
}
|
||||||
|
|
||||||
|
if showFlairs && post.Flair != "" {
|
||||||
|
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(post.ParentList) > 0 {
|
||||||
|
forumPost.IsCrosspost = true
|
||||||
|
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
||||||
|
} else {
|
||||||
|
forumPost.TargetUrl = templateRedditCommentsURL(
|
||||||
|
commentsUrlTemplate,
|
||||||
|
post.ParentList[0].Subreddit,
|
||||||
|
post.ParentList[0].Id,
|
||||||
|
post.ParentList[0].Permalink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posts = append(posts, forumPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
69
internal/feed/releases.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReleaseSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
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 ReleaseSourceGithub:
|
||||||
|
return fetchLatestGithubRelease(request)
|
||||||
|
case ReleaseSourceGitlab:
|
||||||
|
return fetchLatestGitLabRelease(request)
|
||||||
|
case ReleaseSourceDockerHub:
|
||||||
|
return fetchLatestDockerHubRelease(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("unsupported source")
|
||||||
|
}
|
|
@ -1,80 +1,77 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
errNoContent = errors.New("failed to retrieve any content")
|
|
||||||
errPartialContent = errors.New("failed to retrieve some of the content")
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultClientTimeout = 5 * time.Second
|
const defaultClientTimeout = 5 * time.Second
|
||||||
|
|
||||||
var defaultHTTPClient = &http.Client{
|
var defaultClient = &http.Client{
|
||||||
Timeout: defaultClientTimeout,
|
Timeout: defaultClientTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultInsecureHTTPClient = &http.Client{
|
var insecureClientTransport = &http.Transport{
|
||||||
Timeout: defaultClientTimeout,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type requestDoer interface {
|
var defaultInsecureClient = &http.Client{
|
||||||
|
Timeout: defaultClientTimeout,
|
||||||
|
Transport: insecureClientTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestDoer interface {
|
||||||
Do(*http.Request) (*http.Response, error)
|
Do(*http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var userAgentPersistentVersion atomic.Int32
|
func addBrowserUserAgentHeader(request *http.Request) {
|
||||||
|
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
||||||
func setBrowserUserAgentHeader(request *http.Request) {
|
|
||||||
if rand.IntN(2000) == 0 {
|
|
||||||
userAgentPersistentVersion.Store(rand.Int32N(5))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
func truncateString(s string, maxLen int) string {
|
||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0")
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > maxLen {
|
||||||
|
return string(asRunes[:maxLen])
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||||
|
response, err := client.Do(request)
|
||||||
var result T
|
var result T
|
||||||
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(response.Body)
|
body, err := io.ReadAll(response.Body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
truncatedBody, _ := limitStringLength(string(body), 256)
|
|
||||||
|
|
||||||
return result, fmt.Errorf(
|
return result, fmt.Errorf(
|
||||||
"unexpected status code %d for %s, response: %s",
|
"unexpected status code %d for %s, response: %s",
|
||||||
response.StatusCode,
|
response.StatusCode,
|
||||||
request.URL,
|
request.URL,
|
||||||
truncatedBody,
|
truncateString(string(body), 256),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, &result)
|
err = json.Unmarshal(body, &result)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
@ -82,39 +79,40 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
|
func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
|
||||||
return func(request *http.Request) (T, error) {
|
return func(request *http.Request) (T, error) {
|
||||||
return decodeJsonFromRequest[T](client, request)
|
return decodeJsonFromRequest[T](client, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: tidy up, these are a copy of the above but with a line changed
|
// TODO: tidy up, these are a copy of the above but with a line changed
|
||||||
func decodeXmlFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||||
|
response, err := client.Do(request)
|
||||||
var result T
|
var result T
|
||||||
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(response.Body)
|
body, err := io.ReadAll(response.Body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
truncatedBody, _ := limitStringLength(string(body), 256)
|
|
||||||
|
|
||||||
return result, fmt.Errorf(
|
return result, fmt.Errorf(
|
||||||
"unexpected status code %d for %s, response: %s",
|
"unexpected status code %d for %s, response: %s",
|
||||||
response.StatusCode,
|
response.StatusCode,
|
||||||
request.URL,
|
request.URL,
|
||||||
truncatedBody,
|
truncateString(string(body), 256),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = xml.Unmarshal(body, &result)
|
err = xml.Unmarshal(body, &result)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
@ -122,7 +120,7 @@ func decodeXmlFromRequest[T any](client requestDoer, request *http.Request) (T,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeXmlFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
|
func decodeXmlFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
|
||||||
return func(request *http.Request) (T, error) {
|
return func(request *http.Request) (T, error) {
|
||||||
return decodeXmlFromRequest[T](client, request)
|
return decodeXmlFromRequest[T](client, request)
|
||||||
}
|
}
|
||||||
|
@ -181,11 +179,6 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error)
|
||||||
return results, errs, nil
|
return results, errs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(job.data) == 1 {
|
|
||||||
results[0], errs[0] = job.task(job.data[0])
|
|
||||||
return results, errs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tasksQueue := make(chan *workerPoolTask[I, O])
|
tasksQueue := make(chan *workerPoolTask[I, O])
|
||||||
resultsQueue := make(chan *workerPoolTask[I, O])
|
resultsQueue := make(chan *workerPoolTask[I, O])
|
||||||
|
|
246
internal/feed/rss.go
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
gofeedext "github.com/mmcdole/gofeed/extensions"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RSSFeedItem struct {
|
||||||
|
ChannelName string
|
||||||
|
ChannelURL string
|
||||||
|
Title string
|
||||||
|
Link string
|
||||||
|
ImageURL string
|
||||||
|
Categories []string
|
||||||
|
Description string
|
||||||
|
PublishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// doesn't cover all cases but works the vast majority of the time
|
||||||
|
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||||
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
func sanitizeFeedDescription(description string) string {
|
||||||
|
if description == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||||
|
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
description = html.UnescapeString(description)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||||
|
description, _ = limitStringLength(description, 1000)
|
||||||
|
description = sanitizeFeedDescription(description)
|
||||||
|
description, limited := limitStringLength(description, maxLen)
|
||||||
|
|
||||||
|
if limited {
|
||||||
|
description += "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
type RSSFeedRequest struct {
|
||||||
|
Url string `yaml:"url"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
HideCategories bool `yaml:"hide-categories"`
|
||||||
|
HideDescription bool `yaml:"hide-description"`
|
||||||
|
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||||
|
IsDetailed bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RSSFeedItems []RSSFeedItem
|
||||||
|
|
||||||
|
func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
||||||
|
sort.Slice(f, func(i, j int) bool {
|
||||||
|
return f[i].PublishedAt.After(f[j].PublishedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedParser = gofeed.NewParser()
|
||||||
|
|
||||||
|
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make(RSSFeedItems, 0, len(feed.Items))
|
||||||
|
|
||||||
|
for i := range feed.Items {
|
||||||
|
item := feed.Items[i]
|
||||||
|
|
||||||
|
rssItem := RSSFeedItem{
|
||||||
|
ChannelURL: feed.Link,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.ItemLinkPrefix != "" {
|
||||||
|
rssItem.Link = request.ItemLinkPrefix + item.Link
|
||||||
|
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
|
||||||
|
rssItem.Link = item.Link
|
||||||
|
} else {
|
||||||
|
parsedUrl, err := url.Parse(feed.Link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
parsedUrl, err = url.Parse(request.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
var link string
|
||||||
|
|
||||||
|
if len(item.Link) > 0 && item.Link[0] == '/' {
|
||||||
|
link = item.Link
|
||||||
|
} else {
|
||||||
|
link = "/" + item.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Title != "" {
|
||||||
|
rssItem.Title = item.Title
|
||||||
|
} else {
|
||||||
|
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.IsDetailed {
|
||||||
|
if !request.HideDescription && item.Description != "" && item.Title != "" {
|
||||||
|
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.HideCategories {
|
||||||
|
var categories = make([]string, 0, 6)
|
||||||
|
|
||||||
|
for _, category := range item.Categories {
|
||||||
|
if len(categories) == 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(category) == 0 || len(category) > 30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Categories = categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Title != "" {
|
||||||
|
rssItem.ChannelName = request.Title
|
||||||
|
} else {
|
||||||
|
rssItem.ChannelName = feed.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Image != nil {
|
||||||
|
rssItem.ImageURL = item.Image.URL
|
||||||
|
} else if url := findThumbnailInItemExtensions(item); url != "" {
|
||||||
|
rssItem.ImageURL = url
|
||||||
|
} else if feed.Image != nil {
|
||||||
|
if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
|
||||||
|
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
|
||||||
|
} else {
|
||||||
|
rssItem.ImageURL = feed.Image.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
rssItem.PublishedAt = *item.PublishedParsed
|
||||||
|
} else {
|
||||||
|
rssItem.PublishedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, rssItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
|
||||||
|
for _, exts := range extensions {
|
||||||
|
for _, ext := range exts {
|
||||||
|
if ext.Name == "thumbnail" || ext.Name == "image" {
|
||||||
|
if url, ok := ext.Attrs["url"]; ok {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.Children != nil {
|
||||||
|
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
||||||
|
media, ok := item.Extensions["media"]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return recursiveFindThumbnailInExtensions(media)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||||
|
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||||
|
feeds, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
entries := make(RSSFeedItems, 0, len(feeds)*10)
|
||||||
|
|
||||||
|
for i := range feeds {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, feeds[i]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed == len(requests) {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.SortByNewest()
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
|
@ -1,69 +1,33 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var twitchChannelsWidgetTemplate = mustParseTemplate("twitch-channels.html", "widget-base.html")
|
type TwitchCategory struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
type twitchChannelsWidget struct {
|
Name string `json:"name"`
|
||||||
widgetBase `yaml:",inline"`
|
AvatarUrl string `json:"avatarURL"`
|
||||||
ChannelsRequest []string `yaml:"channels"`
|
ViewersCount int `json:"viewersCount"`
|
||||||
Channels []twitchChannel `yaml:"-"`
|
Tags []struct {
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
Name string `json:"tagName"`
|
||||||
SortBy string `yaml:"sort-by"`
|
} `json:"tags"`
|
||||||
|
GameReleaseDate string `json:"originalReleaseDate"`
|
||||||
|
IsNew bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *twitchChannelsWidget) initialize() error {
|
type TwitchChannel struct {
|
||||||
widget.
|
|
||||||
withTitle("Twitch Channels").
|
|
||||||
withTitleURL("https://www.twitch.tv/directory/following").
|
|
||||||
withCacheDuration(time.Minute * 10)
|
|
||||||
|
|
||||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
|
||||||
widget.CollapseAfter = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.SortBy != "viewers" && widget.SortBy != "live" {
|
|
||||||
widget.SortBy = "viewers"
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *twitchChannelsWidget) update(ctx context.Context) {
|
|
||||||
channels, err := fetchChannelsFromTwitch(widget.ChannelsRequest)
|
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.SortBy == "viewers" {
|
|
||||||
channels.sortByViewers()
|
|
||||||
} else if widget.SortBy == "live" {
|
|
||||||
channels.sortByLive()
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.Channels = channels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *twitchChannelsWidget) Render() template.HTML {
|
|
||||||
return widget.renderTemplate(widget, twitchChannelsWidgetTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
type twitchChannel struct {
|
|
||||||
Login string
|
Login string
|
||||||
Exists bool
|
Exists bool
|
||||||
Name string
|
Name string
|
||||||
StreamTitle string
|
|
||||||
AvatarUrl string
|
AvatarUrl string
|
||||||
IsLive bool
|
IsLive bool
|
||||||
LiveSince time.Time
|
LiveSince time.Time
|
||||||
|
@ -72,15 +36,15 @@ type twitchChannel struct {
|
||||||
ViewersCount int
|
ViewersCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type twitchChannelList []twitchChannel
|
type TwitchChannels []TwitchChannel
|
||||||
|
|
||||||
func (channels twitchChannelList) sortByViewers() {
|
func (channels TwitchChannels) SortByViewers() {
|
||||||
sort.Slice(channels, func(i, j int) bool {
|
sort.Slice(channels, func(i, j int) bool {
|
||||||
return channels[i].ViewersCount > channels[j].ViewersCount
|
return channels[i].ViewersCount > channels[j].ViewersCount
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channels twitchChannelList) sortByLive() {
|
func (channels TwitchChannels) SortByLive() {
|
||||||
sort.SliceStable(channels, func(i, j int) bool {
|
sort.SliceStable(channels, func(i, j int) bool {
|
||||||
return channels[i].IsLive && !channels[j].IsLive
|
return channels[i].IsLive && !channels[j].IsLive
|
||||||
})
|
})
|
||||||
|
@ -113,16 +77,72 @@ type twitchStreamMetadataOperationResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"game"`
|
} `json:"game"`
|
||||||
} `json:"stream"`
|
} `json:"stream"`
|
||||||
LastBroadcast *struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const twitchChannelStatusOperationRequestBody = `[
|
type twitchDirectoriesOperationResponse struct {
|
||||||
{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
|
Data struct {
|
||||||
{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
|
DirectoriesWithTags struct {
|
||||||
]`
|
Edges []struct {
|
||||||
|
Node TwitchCategory `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"directoriesWithTags"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
|
||||||
|
const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||||
|
|
||||||
|
const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
|
||||||
|
|
||||||
|
func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
|
||||||
|
reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
|
||||||
|
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||||
|
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||||
|
response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response) == 0 {
|
||||||
|
return nil, errors.New("no categories could be retrieved")
|
||||||
|
}
|
||||||
|
|
||||||
|
edges := (response)[0].Data.DirectoriesWithTags.Edges
|
||||||
|
categories := make([]TwitchCategory, 0, len(edges))
|
||||||
|
|
||||||
|
for i := range edges {
|
||||||
|
if slices.Contains(exclude, edges[i].Node.Slug) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
category := &edges[i].Node
|
||||||
|
category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
|
||||||
|
|
||||||
|
if len(category.Tags) > 2 {
|
||||||
|
category.Tags = category.Tags[:2]
|
||||||
|
}
|
||||||
|
|
||||||
|
gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if time.Since(gameReleasedDate) < 14*24*time.Hour {
|
||||||
|
category.IsNew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, *category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(categories) > limit {
|
||||||
|
categories = categories[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||||
|
|
||||||
// TODO: rework
|
// TODO: rework
|
||||||
// The operations for multiple channels can all be sent in a single request
|
// The operations for multiple channels can all be sent in a single request
|
||||||
|
@ -130,8 +150,8 @@ const twitchChannelStatusOperationRequestBody = `[
|
||||||
// what the limit is for max operations per request and batch operations in
|
// what the limit is for max operations per request and batch operations in
|
||||||
// multiple requests if number of channels exceeds allowed limit.
|
// multiple requests if number of channels exceeds allowed limit.
|
||||||
|
|
||||||
func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
||||||
result := twitchChannel{
|
result := TwitchChannel{
|
||||||
Login: strings.ToLower(channel),
|
Login: strings.ToLower(channel),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,7 +159,8 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
||||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||||
|
|
||||||
response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request)
|
response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
@ -154,12 +175,16 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
||||||
for i := range response {
|
for i := range response {
|
||||||
switch response[i].Extensions.OperationName {
|
switch response[i].Extensions.OperationName {
|
||||||
case "ChannelShell":
|
case "ChannelShell":
|
||||||
if err = json.Unmarshal(response[i].Data, &channelShell); err != nil {
|
err = json.Unmarshal(response[i].Data, &channelShell)
|
||||||
return result, fmt.Errorf("unmarshalling channel shell: %w", err)
|
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
|
||||||
}
|
}
|
||||||
case "StreamMetadata":
|
case "StreamMetadata":
|
||||||
if err = json.Unmarshal(response[i].Data, &streamMetadata); err != nil {
|
err = json.Unmarshal(response[i].Data, &streamMetadata)
|
||||||
return result, fmt.Errorf("unmarshalling stream metadata: %w", err)
|
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
|
return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
|
||||||
|
@ -180,10 +205,6 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
||||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||||
|
|
||||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
||||||
if streamMetadata.UserOrNull.LastBroadcast != nil {
|
|
||||||
result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamMetadata.UserOrNull.Stream.Game != nil {
|
if streamMetadata.UserOrNull.Stream.Game != nil {
|
||||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||||
|
@ -193,23 +214,20 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.LiveSince = startedAt
|
result.LiveSince = startedAt
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// This prevents live channels with 0 viewers from being
|
|
||||||
// incorrectly sorted lower than offline channels
|
|
||||||
result.ViewersCount = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) {
|
func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
|
||||||
result := make(twitchChannelList, 0, len(channelLogins))
|
result := make(TwitchChannels, 0, len(channelLogins))
|
||||||
|
|
||||||
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
|
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
|
||||||
channels, errs, err := workerPoolDo(job)
|
channels, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
@ -219,7 +237,7 @@ func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error)
|
||||||
for i := range channels {
|
for i := range channels {
|
||||||
if errs[i] != nil {
|
if errs[i] != nil {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i])
|
slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,11 +245,11 @@ func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed == len(channelLogins) {
|
if failed == len(channelLogins) {
|
||||||
return result, errNoContent
|
return result, ErrNoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed)
|
return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|