diff --git a/.gitignore b/.gitignore index 2cd84fc0..4338ed4a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /playground /.idea /glance*.yml +/.env \ No newline at end of file diff --git a/README.md b/README.md index 4ed527a3..009ca825 100644 --- a/README.md +++ b/README.md @@ -1,436 +1,14 @@ -

What if you could see everything at a...

-

Glance

-

InstallConfigurationDiscordSponsor

-

Community widgetsPreconfigured pagesThemes

+# Pulse - AI-based activity feed aggregator -![](docs/images/readme-main-image.png) +![Screenshot 2025-05-19 at 13 27 14](https://github.com/user-attachments/assets/2bbce011-2098-4aca-9ff3-b8d6d5cc1a5c) -## Features -### Various widgets -* RSS feeds -* Subreddit posts -* Hacker News posts -* Weather forecasts -* YouTube channel uploads -* Twitch channels -* Market prices -* Docker containers status -* Server stats -* Custom widgets -- [and many more...](docs/configuration.md#configuring-glance) +## Usage -### Fast and lightweight -* Low memory usage -* Few dependencies -* Minimal vanilla JS -* Single <20mb binary available for multiple OSs & architectures and just as small Docker container -* Uncached pages usually load within ~1s (depending on internet speed and number of widgets) - -### Tons of customizability -* Different layouts -* As many pages/tabs as you need -* Numerous configuration options for each widget -* Multiple styles for some widgets -* Custom CSS - -### Optimized for mobile devices -Because you'll want to take it with you on the go. - -![](docs/images/mobile-preview.png) - -### Themeable -Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md). - -![](docs/images/themes-example.png) - -
- -## 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#configuring-glance). -
-Preview example configuration file -
- -```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 -``` -
- -
- -## Installation - -Choose one of the following methods: - -
-Docker compose using provided directory structure (recommended) -
- -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 - -
-Other files you may want to edit - -* `.env` to configure environment variables that will be available inside configuration files -* `assets/user.css` to add custom CSS -
- -When ready, run: - -```bash -docker compose up -d -``` - -If you encounter any issues, you can check the logs by running: - -```bash -docker compose logs -``` - -
-
- -
-Docker compose manual -
- -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 -``` - -
-
- -
-Manual binary installation -
- -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 -/opt/glance/glance --config /etc/glance.yml -``` - -To grab a starting template for the config file, run: - -```bash -wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml -``` - -### Windows - -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. - - - -
-
- -
-Other -
- -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/) - -
-
- -
- -## Common issues -
-Requests timing out - -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 -``` -
- -
-Broken layout for markets, bookmarks or other widgets - -This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted. -
- -
-cannot unmarshal !!map into []glance.page - -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. - -
- -
- -## FAQ -
-Does the information on the page update automatically? -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. -
- -
-How frequently do widgets update? -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. -
- -
-Can I create my own widgets? - -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 -
- -
-Can I change the title of a widget? - -Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration: - -```yaml -- type: rss - title: My custom title - -- type: markets - title: My custom title - -- type: videos - title: My custom title - -# and so on for all widgets... -``` -
- -
- -## Feature requests - -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). - -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 - -
- -## Building from source - -Choose one of the following methods: - -
-Build binary with Go -
- -Requirements: [Go](https://go.dev/dl/) >= v1.23 - -To build the project for your current OS and architecture, run: - -```bash -go build -o build/glance . -``` - -To build for a specific OS and architecture, 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: +Required env variables: +- `OPENAI_API_KEY` +- `GITHUB_TOKEN` +To start the application with a default config, run: ```bash -go run . +go run main.go --config config/root.yml ``` -
-
- -
-Build project and Docker image with Docker -
- -Requirements: [Docker](https://docs.docker.com/engine/install/) - -To build the project and image using just Docker, run: - -*(replace `owner` with your name or organization)* - -```bash -docker build -t owner/glance:latest . -``` - -If you wish to push the image to a registry (by default Docker Hub), run: - -```bash -docker push owner/glance:latest -``` - -
-
- -
- -## 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[1], backlog[2] or icebox[3] -* 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` - -
-[1] [2] [3] - -[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 - -
- -
- -## 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. diff --git a/config/dev-blogs.yml b/config/dev-blogs.yml new file mode 100644 index 00000000..6bac7a00 --- /dev/null +++ b/config/dev-blogs.yml @@ -0,0 +1,17 @@ +- name: Dev Blogs + columns: + - size: full + widgets: + - type: rss + limit: 10 + collapse-after: 3 + cache: 12h + feeds: + - url: https://ghuntley.com/rss + - url: https://www.latent.space/feed + - url: https://danluu.com/atom.xml + - url: https://thesephist.com/index.xml + - url: https://simonwillison.net/atom/everything + - url: https://lilianweng.github.io/index.xml + - url: https://karpathy.bearblog.dev/feed + - url: https://eugeneyan.com/rss diff --git a/config/llms.yml b/config/llms.yml new file mode 100644 index 00000000..e4cfe671 --- /dev/null +++ b/config/llms.yml @@ -0,0 +1,99 @@ +- name: Large Language Models + # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look + # hide-desktop-navigation: true + columns: + - size: full + widgets: + - type: rss + limit: 10 + collapse-after: 3 + cache: 12h + feeds: + - url: https://magazine.sebastianraschka.com/feed + - url: https://sub.thursdai.news/feed + - url: https://www.emergentmind.com/feeds/rss + - url: https://huggingface.co/blog/feed.xml + - url: https://openai.com/news/rss.xml + # AlphaSignal feed: https://kill-the-newsletter.com/feeds/md66niepzpco8rn4lo4k + - url: https://kill-the-newsletter.com/feeds/md66niepzpco8rn4lo4k.xml + + - type: group + widgets: + - type: hacker-news + - type: lobsters + - type: mastodon + instance-url: https://mastodon.social + accounts: + - huggingface + - openai + - anthropic + hashtags: + - ai + - llm + - machinelearning + limit: 15 + collapse-after: 5 + + - type: group + widgets: + - type: reddit + subreddit: LLM + show-thumbnails: true + - type: reddit + subreddit: LLMDevs + show-thumbnails: true + - type: reddit + subreddit: OpenAI + show-thumbnails: true + - type: reddit + subreddit: mcp + show-thumbnails: true + - type: reddit + subreddit: Anthropic + show-thumbnails: true + - type: reddit + subreddit: LocalLLaMA + show-thumbnails: true + + - size: full + widgets: + + - 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: + - modelcontextprotocol/specification + - modelcontextprotocol/servers + - browserbase/stagehand + - google/A2A + - letta-ai/letta + - FoundationAgents/OpenManus + - FoundationAgents/MetaGPT + - langchain-ai/agent-protocol + - agent-network-protocol/AgentNetworkProtocol + - mem0ai/mem0 + - type: issues + cache: 30m + # 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: + - modelcontextprotocol/specification + - modelcontextprotocol/servers + - browserbase/stagehand + - google/A2A + - letta-ai/letta + - FoundationAgents/OpenManus + - FoundationAgents/MetaGPT + - langchain-ai/agent-protocol + - agent-network-protocol/AgentNetworkProtocol + - mem0ai/mem0 + activity-types: + - opened + - closed + - commented + limit: 10 + collapse-after: 5 + show-source-icon: true \ No newline at end of file diff --git a/config/root.yml b/config/root.yml new file mode 100644 index 00000000..d4dab64e --- /dev/null +++ b/config/root.yml @@ -0,0 +1,5 @@ +pages: + # It's not necessary to create a new file for each page and include it, you can simply + # put its contents here, though multiple pages are easier to manage when separated + !include: llms.yml + !include: dev-blogs.yml diff --git a/go.mod b/go.mod index ccea58ca..cdc2a805 100644 --- a/go.mod +++ b/go.mod @@ -7,27 +7,40 @@ require ( github.com/mmcdole/gofeed v1.3.0 github.com/shirou/gopsutil/v4 v4.25.4 github.com/tidwall/gjson v1.18.0 + github.com/tmc/langchaingo v0.1.13 golang.org/x/crypto v0.38.0 + golang.org/x/sync v0.14.0 golang.org/x/text v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect +) + require ( github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 + github.com/google/uuid v1.6.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 80c2d6c6..df36a16a 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,38 @@ -github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= -github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= -github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= -github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= +github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 h1:BYLNYdZaepitbZreRIa9xeCQZocWmy/wj4cGIH0qyw0= +github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612/go.mod h1:wgqthQa8SAYs0yyljVeCOQlZ027VW5CmLsbi9jWC08c= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 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/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 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/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= @@ -36,18 +42,19 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= +github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= 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/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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -57,14 +64,12 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT 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/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA= +github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg= 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= @@ -74,10 +79,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY 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/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -92,10 +97,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.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/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -105,6 +106,8 @@ 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/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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= @@ -119,10 +122,6 @@ 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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -143,10 +142,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.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/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -158,5 +153,10 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go deleted file mode 100644 index 1ee1bc32..00000000 --- a/internal/glance/diagnose.go +++ /dev/null @@ -1,207 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "runtime" - "strings" - "sync" - "time" -) - -const httpTestRequestTimeout = 10 * time.Second - -var diagnosticSteps = []diagnosticStep{ - { - name: "resolve cloudflare.com through Cloudflare DoH", - fn: func() (string, error) { - return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{ - "accept": "application/dns-json", - }, 200) - }, - }, - { - name: "resolve cloudflare.com through Google DoH", - fn: func() (string, error) { - return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200) - }, - }, - { - name: "resolve github.com", - fn: func() (string, error) { - return testDNSResolution("github.com") - }, - }, - { - name: "resolve reddit.com", - fn: func() (string, error) { - return testDNSResolution("reddit.com") - }, - }, - { - name: "resolve twitch.tv", - fn: func() (string, error) { - return testDNSResolution("twitch.tv") - }, - }, - { - name: "fetch data from YouTube RSS feed", - fn: func() (string, error) { - return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200) - }, - }, - { - name: "fetch data from Twitch.tv GQL", - fn: func() (string, error) { - // this should always return 0 bytes, we're mainly looking for a 200 status code - return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200) - }, - }, - { - name: "fetch data from GitHub API", - fn: func() (string, error) { - return testHttpRequest("GET", "https://api.github.com", 200) - }, - }, - { - name: "fetch data from Open-Meteo API", - fn: func() (string, error) { - return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200) - }, - }, - { - name: "fetch data from Reddit API", - fn: func() (string, error) { - return testHttpRequest("GET", "https://www.reddit.com/search.json", 200) - }, - }, - { - name: "fetch data from Yahoo finance API", - fn: func() (string, error) { - return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0", - }, 200) - }, - }, - { - name: "fetch data from Hacker News Firebase API", - fn: func() (string, error) { - return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200) - }, - }, - { - name: "fetch data from Docker Hub API", - fn: func() (string, error) { - return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200) - }, - }, -} - -func runDiagnostic() { - fmt.Println("```") - fmt.Println("Glance version: " + buildVersion) - fmt.Println("Go version: " + runtime.Version()) - fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) - fmt.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no")) - - fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds())) - - var wg sync.WaitGroup - for i := range diagnosticSteps { - step := &diagnosticSteps[i] - wg.Add(1) - go func() { - defer wg.Done() - start := time.Now() - step.extraInfo, step.err = step.fn() - step.elapsed = time.Since(start) - }() - } - wg.Wait() - - for _, step := range diagnosticSteps { - var extraInfo string - - if step.extraInfo != "" { - extraInfo = "| " + step.extraInfo + " " - } - - fmt.Printf( - "%s %s %s| %dms\n", - ternary(step.err == nil, "✓ Can", "✗ Can't"), - step.name, - extraInfo, - step.elapsed.Milliseconds(), - ) - - if step.err != nil { - fmt.Printf("└╴ error: %v\n", step.err) - } - } - fmt.Println("```") -} - -type diagnosticStep struct { - name string - fn func() (string, error) - extraInfo string - err error - elapsed time.Duration -} - -func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { - return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) -} - -func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout) - defer cancel() - - request, _ := http.NewRequestWithContext(ctx, method, url, nil) - for key, value := range headers { - request.Header.Add(key, value) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - return "", err - } - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - - printableBody := strings.ReplaceAll(string(body), "\n", "") - if len(printableBody) > 50 { - printableBody = printableBody[:50] + "..." - } - if len(printableBody) > 0 { - printableBody = ", " + printableBody - } - - extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody) - - if response.StatusCode != expectedStatusCode { - return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode) - } - - return extraInfo, nil -} - -func testDNSResolution(domain string) (string, error) { - ips, err := net.LookupIP(domain) - - var ipStrings []string - if err == nil { - for i := range ips { - ipStrings = append(ipStrings, ips[i].String()) - } - } - - return strings.Join(ipStrings, ", "), err -} diff --git a/internal/glance/templates/bookmarks.html b/internal/glance/templates/bookmarks.html deleted file mode 100644 index 1952cdb2..00000000 --- a/internal/glance/templates/bookmarks.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
- {{- range .Groups }} -
- {{- if ne .Title "" }} -
{{ .Title }}
- {{- end }} - -
- {{- end }} -
-{{ end }} diff --git a/internal/glance/templates/calendar.html b/internal/glance/templates/calendar.html deleted file mode 100644 index b3c4a694..00000000 --- a/internal/glance/templates/calendar.html +++ /dev/null @@ -1,7 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-
-
-{{ end }} diff --git a/internal/glance/templates/change-detection.html b/internal/glance/templates/change-detection.html deleted file mode 100644 index 22b7a181..00000000 --- a/internal/glance/templates/change-detection.html +++ /dev/null @@ -1,17 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/clock.html b/internal/glance/templates/clock.html deleted file mode 100644 index 1bc0bf52..00000000 --- a/internal/glance/templates/clock.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-
-
-
-
-
-
-
-
-
-
- {{ if gt (len .Timezones) 0 }} -
- - {{ end }} -
-{{ end }} diff --git a/internal/glance/templates/custom-api.html b/internal/glance/templates/custom-api.html deleted file mode 100644 index e1f1f6f5..00000000 --- a/internal/glance/templates/custom-api.html +++ /dev/null @@ -1,7 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }} - -{{ define "widget-content" }} -{{ .CompiledHTML }} -{{ end }} diff --git a/internal/glance/templates/dns-stats.html b/internal/glance/templates/dns-stats.html deleted file mode 100644 index bb4222c9..00000000 --- a/internal/glance/templates/dns-stats.html +++ /dev/null @@ -1,88 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-
-
-
{{ .Stats.TotalQueries | formatNumber }}
-
QUERIES
-
-
-
{{ .Stats.BlockedPercent }}%
-
BLOCKED
-
- {{ if gt .Stats.ResponseTime 0 }} -
-
{{ .Stats.ResponseTime | formatNumber }}ms
-
LATENCY
-
- {{ else }} -
-
{{ .Stats.DomainsBlocked | formatApproxNumber }}
-
DOMAINS
-
- {{ end }} -
- - {{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }} - {{ if $showGraph }} -
-
- - - - - - - - - -
- -
- {{ range $i, $column := .Stats.Series }} -
-
-
-
-
{{ $column.Queries | formatNumber }}
-
QUERIES
-
-
-
{{ $column.PercentBlocked }}%
-
BLOCKED
-
-
-
- {{ if gt $column.PercentTotal 0}} -
- {{ if ne $column.Queries $column.Blocked }} -
- {{ end }} - {{ if gt $column.PercentBlocked 0 }} -
- {{ end }} -
- {{ end }} -
{{ index $.TimeLabels $i }}
-
- {{ end }} -
-
- {{ end }} - - {{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }} -
- Top blocked domains - -
- {{ end }} -
-{{ end }} diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html deleted file mode 100644 index afbbb171..00000000 --- a/internal/glance/templates/docker-containers.html +++ /dev/null @@ -1,66 +0,0 @@ -{{ template "widget-base.html" . }} - -{{- define "widget-content" }} - -{{- end }} - -{{- define "state-icon" }} -{{- if eq . "ok" }} - -{{- else if eq . "warn" }} - -{{- else if eq . "paused" }} - -{{- else }} - -{{- end }} -{{- end }} diff --git a/internal/glance/templates/iframe.html b/internal/glance/templates/iframe.html deleted file mode 100644 index 4d9519cc..00000000 --- a/internal/glance/templates/iframe.html +++ /dev/null @@ -1,7 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/markets.html b/internal/glance/templates/markets.html deleted file mode 100644 index cd47cab3..00000000 --- a/internal/glance/templates/markets.html +++ /dev/null @@ -1,25 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
- {{ range .Markets }} -
-
- {{ .Symbol }} -
{{ .Name }}
-
- - - - - - - -
-
{{ printf "%+.2f" .PercentChange }}%
-
{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}
-
-
- {{ end }} -
-{{ end }} diff --git a/internal/glance/templates/monitor-compact.html b/internal/glance/templates/monitor-compact.html deleted file mode 100644 index dca56839..00000000 --- a/internal/glance/templates/monitor-compact.html +++ /dev/null @@ -1,39 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -{{ if not (and .ShowFailingOnly (not .HasFailing)) }} - -{{ else }} -
-

All sites are online

- - - -
-{{ end }} -{{ end }} - -{{ define "site" }} -{{ .Title }} -{{ if not .Status.TimedOut }}
{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms
{{ end }} -{{ if eq .StatusStyle "ok" }} -
- - - -
-{{ else }} -
- - - -
-{{ end }} -{{ end }} diff --git a/internal/glance/templates/monitor.html b/internal/glance/templates/monitor.html deleted file mode 100644 index 63989219..00000000 --- a/internal/glance/templates/monitor.html +++ /dev/null @@ -1,53 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -{{ if not (and .ShowFailingOnly (not .HasFailing)) }} - -{{ else }} -
-

All sites are online

- - - -
-{{ end }} -{{ end }} - -{{ define "site" }} -{{ if .Icon.URL }} - -{{ end }} -
- {{ .Title }} - -
-{{ if eq .StatusStyle "ok" }} -
- - - -
-{{ else }} -
- - - -
-{{ end }} -{{ end }} diff --git a/internal/glance/templates/old-calendar.html b/internal/glance/templates/old-calendar.html deleted file mode 100644 index b43d43da..00000000 --- a/internal/glance/templates/old-calendar.html +++ /dev/null @@ -1,34 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-
-
{{ .Calendar.CurrentMonthName }}
- -
- -
- {{ if .StartSunday }} -
Su
- {{ end }} -
Mo
-
Tu
-
We
-
Th
-
Fr
-
Sa
- {{ if not .StartSunday }} -
Su
- {{ end }} -
- -
- {{ range .Calendar.Days }} -
{{ . }}
- {{ end }} -
-
-{{ end }} diff --git a/internal/glance/templates/reddit-horizontal-cards.html b/internal/glance/templates/reddit-horizontal-cards.html deleted file mode 100644 index 9ee31b38..00000000 --- a/internal/glance/templates/reddit-horizontal-cards.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/reddit-vertical-cards.html b/internal/glance/templates/reddit-vertical-cards.html deleted file mode 100644 index 747cc7e8..00000000 --- a/internal/glance/templates/reddit-vertical-cards.html +++ /dev/null @@ -1,29 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} -
- {{ range .Posts }} -
- {{ if ne "" .ThumbnailUrl }} -
- -
- {{ end }} -
- {{ if ne "" .TargetUrl }} - {{ .TargetUrlDomain }} - {{ else }} -
/r/{{ $.Subreddit }}
- {{ end }} - {{ .Title }} -
    -
  • -
  • {{ .Score | formatApproxNumber }} points
  • -
-
-
- {{ end }} -
-{{ end }} diff --git a/internal/glance/templates/releases.html b/internal/glance/templates/releases.html deleted file mode 100644 index 3643524b..00000000 --- a/internal/glance/templates/releases.html +++ /dev/null @@ -1,23 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/repository.html b/internal/glance/templates/repository.html deleted file mode 100644 index 1542a073..00000000 --- a/internal/glance/templates/repository.html +++ /dev/null @@ -1,61 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -{{ .Repository.Name }} - - -{{ if gt (len .Repository.Commits) 0 }} -
-Last {{ .CommitsLimit }} commits -
- - -
-{{ end }} - -{{ if gt (len .Repository.PullRequests) 0 }} -
-Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total) -
- - -
-{{ end }} - -{{ if gt (len .Repository.Issues) 0 }} -
-Open issues ({{ .Repository.OpenIssues | formatNumber }} total) -
- - -
-{{ end }} - -{{ end }} diff --git a/internal/glance/templates/rss-detailed-list.html b/internal/glance/templates/rss-detailed-list.html deleted file mode 100644 index 311923da..00000000 --- a/internal/glance/templates/rss-detailed-list.html +++ /dev/null @@ -1,40 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/rss-horizontal-cards-2.html b/internal/glance/templates/rss-horizontal-cards-2.html deleted file mode 100644 index 496e56ab..00000000 --- a/internal/glance/templates/rss-horizontal-cards-2.html +++ /dev/null @@ -1,32 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} -{{ if gt (len .Items) 0 }} - -{{ else }} -
{{ .NoItemsMessage }}
-{{ end }} -{{ end }} diff --git a/internal/glance/templates/rss-horizontal-cards.html b/internal/glance/templates/rss-horizontal-cards.html deleted file mode 100644 index d8eef927..00000000 --- a/internal/glance/templates/rss-horizontal-cards.html +++ /dev/null @@ -1,32 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} -{{ if gt (len .Items) 0 }} - -{{ else }} -
{{ .NoItemsMessage }}
-{{ end }} -{{ end }} diff --git a/internal/glance/templates/rss-list.html b/internal/glance/templates/rss-list.html deleted file mode 100644 index c14eb609..00000000 --- a/internal/glance/templates/rss-list.html +++ /dev/null @@ -1,19 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/search.html b/internal/glance/templates/search.html deleted file mode 100644 index ae981c63..00000000 --- a/internal/glance/templates/search.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/server-stats.html b/internal/glance/templates/server-stats.html deleted file mode 100644 index 4eaa4343..00000000 --- a/internal/glance/templates/server-stats.html +++ /dev/null @@ -1,140 +0,0 @@ -{{ template "widget-base.html" . }} - -{{- define "widget-content" }} -{{- range .Servers }} -
-
-
-
{{ if .Name }}{{ .Name }}{{ else }}{{ .Info.Hostname }}{{ end }}
-
- {{- if .IsReachable }} - {{ if .Info.HostInfoIsAvailable }}{{ else }}unknown{{ end }} uptime - {{- else }} - unreachable - {{- end }} -
-
-
- {{- if .IsReachable }} -
-
PLATFORM
-
{{ if .Info.HostInfoIsAvailable }}{{ .Info.Platform }}{{ else }}Unknown{{ end }}
-
- {{- end }} - - - -
-
-
-
-
-
CPU
- {{- if and .Info.CPU.TemperatureIsAvailable (ge .Info.CPU.TemperatureC 80) }} - - - - {{- end }} -
{{ if .Info.CPU.LoadIsAvailable }}{{ .Info.CPU.Load1Percent }} %{{ else }}n/a{{ end }}
-
- - {{- if .Info.CPU.LoadIsAvailable }} -
-
-
1M AVG
-
-
{{ .Info.CPU.Load1Percent }} %
-
-
-
15M AVG
-
-
{{ .Info.CPU.Load15Percent }} %
-
- {{- if .Info.CPU.TemperatureIsAvailable }} -
-
TEMP C
-
-
{{ .Info.CPU.TemperatureC }} °
-
- {{- end }} -
- {{- end }} -
- {{- if .Info.CPU.LoadIsAvailable }} -
-
- {{- end }} -
-
-
-
-
-
RAM
-
{{ if .Info.Memory.IsAvailable }}{{ .Info.Memory.UsedPercent }} %{{ else }}n/a{{ end }}
-
- - {{- if .Info.Memory.IsAvailable }} -
-
-
RAM
-
-
- {{ .Info.Memory.UsedMB | formatServerMegabytes }} / {{ .Info.Memory.TotalMB | formatServerMegabytes }} -
-
- {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }} -
-
SWAP
-
-
- {{ .Info.Memory.SwapUsedMB | formatServerMegabytes }} / {{ .Info.Memory.SwapTotalMB | formatServerMegabytes }} -
-
- {{- end }} -
- {{- end }} -
- {{- if .Info.Memory.IsAvailable }} -
- {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }} -
- {{- end }} - {{- end }} -
-
-
-
-
-
DISK
-
{{ if .Info.Mountpoints }}{{ (index .Info.Mountpoints 0).UsedPercent }} %{{ else }}n/a{{ end }}
-
- - {{- if .Info.Mountpoints }} -
-
    - {{- range .Info.Mountpoints }} -
  • -
    {{ if .Name }}{{ .Name }}{{ else }}{{ .Path }}{{ end }}
    -
    -
    - {{ .UsedMB | formatServerMegabytes }} / {{ .TotalMB | formatServerMegabytes }} -
    -
  • - {{- end }} -
-
- {{- end }} -
- {{- if .Info.Mountpoints }} -
- {{- if ge (len .Info.Mountpoints) 2 }} -
- {{- end }} - {{- end }} -
-
- - - -{{- end }} -{{- end }} diff --git a/internal/glance/templates/todo.html b/internal/glance/templates/todo.html deleted file mode 100644 index d90c0056..00000000 --- a/internal/glance/templates/todo.html +++ /dev/null @@ -1,5 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-{{ end }} diff --git a/internal/glance/templates/twitch-channels.html b/internal/glance/templates/twitch-channels.html deleted file mode 100644 index 021a17a7..00000000 --- a/internal/glance/templates/twitch-channels.html +++ /dev/null @@ -1,47 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/twitch-games-list.html b/internal/glance/templates/twitch-games-list.html deleted file mode 100644 index 94fc4004..00000000 --- a/internal/glance/templates/twitch-games-list.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/v0.7-update-notice-page.html b/internal/glance/templates/v0.7-update-notice-page.html deleted file mode 100644 index fa976fb4..00000000 --- a/internal/glance/templates/v0.7-update-notice-page.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - Update notice - - - - -
-

UPDATE NOTICE

-
-

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

- -

Migration should take around 5 minutes.

-
-
- - - diff --git a/internal/glance/templates/video-card-contents.html b/internal/glance/templates/video-card-contents.html deleted file mode 100644 index da944c0e..00000000 --- a/internal/glance/templates/video-card-contents.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "video-card-contents" }} - -
- {{ .Title }} - -
-{{ end }} diff --git a/internal/glance/templates/videos-grid.html b/internal/glance/templates/videos-grid.html deleted file mode 100644 index 2819fe8c..00000000 --- a/internal/glance/templates/videos-grid.html +++ /dev/null @@ -1,13 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} -
- {{ range .Videos }} -
- {{ template "video-card-contents" . }} -
- {{ end }} -
-{{ end }} diff --git a/internal/glance/templates/videos-vertical-list.html b/internal/glance/templates/videos-vertical-list.html deleted file mode 100644 index cd760a4d..00000000 --- a/internal/glance/templates/videos-vertical-list.html +++ /dev/null @@ -1,20 +0,0 @@ -{{ template "widget-base.html" . }} - -{{- define "widget-content" }} - -{{- end }} diff --git a/internal/glance/templates/videos.html b/internal/glance/templates/videos.html deleted file mode 100644 index 16e7261e..00000000 --- a/internal/glance/templates/videos.html +++ /dev/null @@ -1,15 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}widget-content-frameless{{ end }} - -{{ define "widget-content" }} - -{{ end }} diff --git a/internal/glance/templates/weather.html b/internal/glance/templates/weather.html deleted file mode 100644 index f271b74a..00000000 --- a/internal/glance/templates/weather.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
-
{{ .Weather.WeatherCodeAsString }}
-
Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}
- -
- {{ range $i, $column := .Weather.Columns }} -
- {{ if $column.HasPrecipitation }} -
- {{ end }} - {{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }} -
- {{ end }} -
{{ $column.Temperature | absInt }}
-
-
{{ index $.TimeLabels $i }}
-
- {{ end }} -
- - {{ if not .HideLocation }} -
-
-
{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}
-
- {{ end }} -
-{{ end }} diff --git a/internal/glance/widget-bookmarks.go b/internal/glance/widget-bookmarks.go deleted file mode 100644 index 2245e2e1..00000000 --- a/internal/glance/widget-bookmarks.go +++ /dev/null @@ -1,77 +0,0 @@ -package glance - -import ( - "html/template" -) - -var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html") - -type bookmarksWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - Groups []struct { - Title string `yaml:"title"` - Color *hslColorField `yaml:"color"` - SameTab bool `yaml:"same-tab"` - HideArrow bool `yaml:"hide-arrow"` - Target string `yaml:"target"` - Links []struct { - Title string `yaml:"title"` - URL string `yaml:"url"` - Description string `yaml:"description"` - Icon customIconField `yaml:"icon"` - // we need a pointer to bool to know whether a value was provided, - // however there's no way to dereference a pointer in a template so - // {{ if not .SameTab }} would return true for any non-nil pointer - // which leaves us with no way of checking if the value is true or - // false, hence the duplicated fields below - SameTabRaw *bool `yaml:"same-tab"` - SameTab bool `yaml:"-"` - HideArrowRaw *bool `yaml:"hide-arrow"` - HideArrow bool `yaml:"-"` - Target string `yaml:"target"` - } `yaml:"links"` - } `yaml:"groups"` -} - -func (widget *bookmarksWidget) initialize() error { - widget.withTitle("Bookmarks").withError(nil) - - for g := range widget.Groups { - group := &widget.Groups[g] - for l := range group.Links { - link := &group.Links[l] - if link.SameTabRaw == nil { - link.SameTab = group.SameTab - } else { - link.SameTab = *link.SameTabRaw - } - - if link.HideArrowRaw == nil { - link.HideArrow = group.HideArrow - } else { - link.HideArrow = *link.HideArrowRaw - } - - if link.Target == "" { - if group.Target != "" { - link.Target = group.Target - } else { - if link.SameTab { - link.Target = "" - } else { - link.Target = "_blank" - } - } - } - } - } - - widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate) - - return nil -} - -func (widget *bookmarksWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-calendar.go b/internal/glance/widget-calendar.go deleted file mode 100644 index 9537e537..00000000 --- a/internal/glance/widget-calendar.go +++ /dev/null @@ -1,45 +0,0 @@ -package glance - -import ( - "errors" - "html/template" - "time" -) - -var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html") - -var calendarWeekdaysToInt = map[string]time.Weekday{ - "sunday": time.Sunday, - "monday": time.Monday, - "tuesday": time.Tuesday, - "wednesday": time.Wednesday, - "thursday": time.Thursday, - "friday": time.Friday, - "saturday": time.Saturday, -} - -type calendarWidget struct { - widgetBase `yaml:",inline"` - FirstDayOfWeek string `yaml:"first-day-of-week"` - FirstDay int `yaml:"-"` - cachedHTML template.HTML `yaml:"-"` -} - -func (widget *calendarWidget) initialize() error { - widget.withTitle("Calendar").withError(nil) - - if widget.FirstDayOfWeek == "" { - widget.FirstDayOfWeek = "monday" - } else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok { - return errors.New("invalid first day of week") - } - - widget.FirstDay = int(calendarWeekdaysToInt[widget.FirstDayOfWeek]) - widget.cachedHTML = widget.renderTemplate(widget, calendarWidgetTemplate) - - return nil -} - -func (widget *calendarWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-clock.go b/internal/glance/widget-clock.go deleted file mode 100644 index c69fc95a..00000000 --- a/internal/glance/widget-clock.go +++ /dev/null @@ -1,48 +0,0 @@ -package glance - -import ( - "errors" - "fmt" - "html/template" - "time" -) - -var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html") - -type clockWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - HourFormat string `yaml:"hour-format"` - Timezones []struct { - Timezone string `yaml:"timezone"` - Label string `yaml:"label"` - } `yaml:"timezones"` -} - -func (widget *clockWidget) initialize() error { - widget.withTitle("Clock").withError(nil) - - if widget.HourFormat == "" { - widget.HourFormat = "24h" - } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" { - return errors.New("hour-format must be either 12h or 24h") - } - - for t := range widget.Timezones { - if widget.Timezones[t].Timezone == "" { - return errors.New("missing timezone value") - } - - if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil { - return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err) - } - } - - widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate) - - return nil -} - -func (widget *clockWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go deleted file mode 100644 index 3d94eb6f..00000000 --- a/internal/glance/widget-custom-api.go +++ /dev/null @@ -1,714 +0,0 @@ -package glance - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "html/template" - "io" - "log/slog" - "math" - "net/http" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/tidwall/gjson" -) - -var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html") - -// Needs to be exported for the YAML unmarshaler to work -type CustomAPIRequest struct { - URL string `yaml:"url"` - AllowInsecure bool `yaml:"allow-insecure"` - Headers map[string]string `yaml:"headers"` - Parameters queryParametersField `yaml:"parameters"` - Method string `yaml:"method"` - BodyType string `yaml:"body-type"` - Body any `yaml:"body"` - SkipJSONValidation bool `yaml:"skip-json-validation"` - bodyReader io.ReadSeeker `yaml:"-"` - httpRequest *http.Request `yaml:"-"` -} - -type customAPIWidget struct { - widgetBase `yaml:",inline"` - *CustomAPIRequest `yaml:",inline"` // the primary request - Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"` - Options customAPIOptions `yaml:"options"` - Template string `yaml:"template"` - Frameless bool `yaml:"frameless"` - compiledTemplate *template.Template `yaml:"-"` - CompiledHTML template.HTML `yaml:"-"` -} - -func (widget *customAPIWidget) initialize() error { - widget.withTitle("Custom API").withCacheDuration(1 * time.Hour) - - if err := widget.CustomAPIRequest.initialize(); err != nil { - return fmt.Errorf("initializing primary request: %v", err) - } - - for key := range widget.Subrequests { - if err := widget.Subrequests[key].initialize(); err != nil { - return fmt.Errorf("initializing subrequest %q: %v", key, err) - } - } - - if widget.Template == "" { - return errors.New("template is required") - } - - compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template) - if err != nil { - return fmt.Errorf("parsing template: %w", err) - } - - widget.compiledTemplate = compiledTemplate - - return nil -} - -func (widget *customAPIWidget) update(ctx context.Context) { - compiledHTML, err := fetchAndRenderCustomAPIRequest( - widget.CustomAPIRequest, widget.Subrequests, widget.Options, widget.compiledTemplate, - ) - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.CompiledHTML = compiledHTML -} - -func (widget *customAPIWidget) Render() template.HTML { - return widget.renderTemplate(widget, customAPIWidgetTemplate) -} - -type customAPIOptions map[string]any - -func (o *customAPIOptions) StringOr(key, defaultValue string) string { - return customAPIGetOptionOrDefault(*o, key, defaultValue) -} - -func (o *customAPIOptions) IntOr(key string, defaultValue int) int { - return customAPIGetOptionOrDefault(*o, key, defaultValue) -} - -func (o *customAPIOptions) FloatOr(key string, defaultValue float64) float64 { - return customAPIGetOptionOrDefault(*o, key, defaultValue) -} - -func (o *customAPIOptions) BoolOr(key string, defaultValue bool) bool { - return customAPIGetOptionOrDefault(*o, key, defaultValue) -} - -func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T { - if value, exists := o[key]; exists { - if typedValue, ok := value.(T); ok { - return typedValue - } - } - return defaultValue -} - -func (req *CustomAPIRequest) initialize() error { - if req == nil || req.URL == "" { - return nil - } - - if req.Body != nil { - if req.Method == "" { - req.Method = http.MethodPost - } - - if req.BodyType == "" { - req.BodyType = "json" - } - - if req.BodyType != "json" && req.BodyType != "string" { - return errors.New("invalid body type, must be either 'json' or 'string'") - } - - switch req.BodyType { - case "json": - encoded, err := json.Marshal(req.Body) - if err != nil { - return fmt.Errorf("marshaling body: %v", err) - } - - req.bodyReader = bytes.NewReader(encoded) - case "string": - bodyAsString, ok := req.Body.(string) - if !ok { - return errors.New("body must be a string when body-type is 'string'") - } - - req.bodyReader = strings.NewReader(bodyAsString) - } - - } else if req.Method == "" { - req.Method = http.MethodGet - } - - httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader) - if err != nil { - return err - } - - if len(req.Parameters) > 0 { - httpReq.URL.RawQuery = req.Parameters.toQueryString() - } - - if req.BodyType == "json" { - httpReq.Header.Set("Content-Type", "application/json") - } - - for key, value := range req.Headers { - httpReq.Header.Add(key, value) - } - - req.httpRequest = httpReq - - return nil -} - -type customAPIResponseData struct { - JSON decoratedGJSONResult - Response *http.Response -} - -type customAPITemplateData struct { - *customAPIResponseData - subrequests map[string]*customAPIResponseData - Options customAPIOptions -} - -func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult { - result := make([]decoratedGJSONResult, 0, 5) - - gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool { - result = append(result, decoratedGJSONResult{line}) - return true - }) - - return result -} - -func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData { - req, exists := data.subrequests[key] - if !exists { - // We have to panic here since there's nothing sensible we can return and the - // lack of an error would cause requested data to return zero values which - // would be confusing from the user's perspective. Go's template module - // handles recovering from panics and will return the panic message as an - // error during template execution. - panic(fmt.Sprintf("subrequest with key %q has not been defined", key)) - } - - return req -} - -func fetchCustomAPIResponse(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) { - if req == nil || req.URL == "" { - return &customAPIResponseData{ - JSON: decoratedGJSONResult{gjson.Result{}}, - Response: &http.Response{}, - }, nil - } - - if req.bodyReader != nil { - req.bodyReader.Seek(0, io.SeekStart) - } - - client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) - resp, err := client.Do(req.httpRequest.WithContext(ctx)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - body := strings.TrimSpace(string(bodyBytes)) - - if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) { - if 200 <= resp.StatusCode && resp.StatusCode < 300 { - truncatedBody, isTruncated := limitStringLength(body, 100) - if isTruncated { - truncatedBody += "... " - } - - slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody) - return nil, errors.New("invalid response JSON") - } - - return nil, fmt.Errorf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) - - } - - return &customAPIResponseData{ - JSON: decoratedGJSONResult{gjson.Parse(body)}, - Response: resp, - }, nil -} - -func fetchAndRenderCustomAPIRequest( - primaryReq *CustomAPIRequest, - subReqs map[string]*CustomAPIRequest, - options customAPIOptions, - tmpl *template.Template, -) (template.HTML, error) { - var primaryData *customAPIResponseData - subData := make(map[string]*customAPIResponseData, len(subReqs)) - var err error - - if len(subReqs) == 0 { - // If there are no subrequests, we can fetch the primary request in a much simpler way - primaryData, err = fetchCustomAPIResponse(context.Background(), primaryReq) - } else { - // If there are subrequests, we need to fetch them concurrently - // and cancel all requests if any of them fail. There's probably - // a more elegant way to do this, but this works for now. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var wg sync.WaitGroup - var mu sync.Mutex // protects subData and err - - wg.Add(1) - go func() { - defer wg.Done() - var localErr error - primaryData, localErr = fetchCustomAPIResponse(ctx, primaryReq) - mu.Lock() - if localErr != nil && err == nil { - err = localErr - cancel() - } - mu.Unlock() - }() - - for key, req := range subReqs { - wg.Add(1) - go func() { - defer wg.Done() - var localErr error - var data *customAPIResponseData - data, localErr = fetchCustomAPIResponse(ctx, req) - mu.Lock() - if localErr == nil { - subData[key] = data - } else if err == nil { - err = localErr - cancel() - } - mu.Unlock() - }() - } - - wg.Wait() - } - - emptyBody := template.HTML("") - - if err != nil { - return emptyBody, err - } - - data := customAPITemplateData{ - customAPIResponseData: primaryData, - subrequests: subData, - Options: options, - } - - var templateBuffer bytes.Buffer - err = tmpl.Execute(&templateBuffer, &data) - if err != nil { - return emptyBody, err - } - - return template.HTML(templateBuffer.String()), nil -} - -type decoratedGJSONResult struct { - gjson.Result -} - -func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult { - decoratedResults := make([]decoratedGJSONResult, len(results)) - - for i, result := range results { - decoratedResults[i] = decoratedGJSONResult{result} - } - - return decoratedResults -} - -func (r *decoratedGJSONResult) Exists(key string) bool { - return r.Result.Get(key).Exists() -} - -func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult { - if key == "" { - return gJsonResultArrayToDecoratedResultArray(r.Result.Array()) - } - - return gJsonResultArrayToDecoratedResultArray(r.Result.Get(key).Array()) -} - -func (r *decoratedGJSONResult) String(key string) string { - if key == "" { - return r.Result.String() - } - - return r.Result.Get(key).String() -} - -func (r *decoratedGJSONResult) Int(key string) int { - if key == "" { - return int(r.Result.Int()) - } - - return int(r.Result.Get(key).Int()) -} - -func (r *decoratedGJSONResult) Float(key string) float64 { - if key == "" { - return r.Result.Float() - } - - return r.Result.Get(key).Float() -} - -func (r *decoratedGJSONResult) Bool(key string) bool { - if key == "" { - return r.Result.Bool() - } - - return r.Result.Get(key).Bool() -} - -func (r *decoratedGJSONResult) Get(key string) *decoratedGJSONResult { - return &decoratedGJSONResult{r.Result.Get(key)} -} - -func customAPIDoMathOp[T int | float64](a, b T, op string) T { - switch op { - case "add": - return a + b - case "sub": - return a - b - case "mul": - return a * b - case "div": - if b == 0 { - return 0 - } - return a / b - } - return 0 -} - -var customAPITemplateFuncs = func() template.FuncMap { - var regexpCacheMu sync.Mutex - var regexpCache = make(map[string]*regexp.Regexp) - - getCachedRegexp := func(pattern string) *regexp.Regexp { - regexpCacheMu.Lock() - defer regexpCacheMu.Unlock() - - regex, exists := regexpCache[pattern] - if !exists { - regex = regexp.MustCompile(pattern) - regexpCache[pattern] = regex - } - - return regex - } - - doMathOpWithAny := func(a, b any, op string) any { - switch at := a.(type) { - case int: - switch bt := b.(type) { - case int: - return customAPIDoMathOp(at, bt, op) - case float64: - return customAPIDoMathOp(float64(at), bt, op) - default: - return math.NaN() - } - case float64: - switch bt := b.(type) { - case int: - return customAPIDoMathOp(at, float64(bt), op) - case float64: - return customAPIDoMathOp(at, bt, op) - default: - return math.NaN() - } - default: - return math.NaN() - } - } - - funcs := template.FuncMap{ - "toFloat": func(a int) float64 { - return float64(a) - }, - "toInt": func(a float64) int { - return int(a) - }, - "add": func(a, b any) any { - return doMathOpWithAny(a, b, "add") - }, - "sub": func(a, b any) any { - return doMathOpWithAny(a, b, "sub") - }, - "mul": func(a, b any) any { - return doMathOpWithAny(a, b, "mul") - }, - "div": func(a, b any) any { - return doMathOpWithAny(a, b, "div") - }, - "now": func() time.Time { - return time.Now() - }, - "offsetNow": func(offset string) time.Time { - d, err := time.ParseDuration(offset) - if err != nil { - return time.Now() - } - return time.Now().Add(d) - }, - "duration": func(str string) time.Duration { - d, err := time.ParseDuration(str) - if err != nil { - return 0 - } - - return d - }, - "parseTime": func(layout, value string) time.Time { - return customAPIFuncParseTimeInLocation(layout, value, time.UTC) - }, - "formatTime": customAPIFuncFormatTime, - "parseLocalTime": func(layout, value string) time.Time { - return customAPIFuncParseTimeInLocation(layout, value, time.Local) - }, - "toRelativeTime": dynamicRelativeTimeAttrs, - "parseRelativeTime": func(layout, value string) template.HTMLAttr { - // Shorthand to do both of the above with a single function call - return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC)) - }, - "startOfDay": func(t time.Time) time.Time { - return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) - }, - "endOfDay": func(t time.Time) time.Time { - return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) - }, - // The reason we flip the parameter order is so that you can chain multiple calls together like this: - // {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }} - // instead of doing this: - // {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }} - // since the piped value gets passed as the last argument to the function. - "trimPrefix": func(prefix, s string) string { - return strings.TrimPrefix(s, prefix) - }, - "trimSuffix": func(suffix, s string) string { - return strings.TrimSuffix(s, suffix) - }, - "trimSpace": strings.TrimSpace, - "replaceAll": func(old, new, s string) string { - return strings.ReplaceAll(s, old, new) - }, - "replaceMatches": func(pattern, replacement, s string) string { - if s == "" { - return "" - } - - return getCachedRegexp(pattern).ReplaceAllString(s, replacement) - }, - "findMatch": func(pattern, s string) string { - if s == "" { - return "" - } - - return getCachedRegexp(pattern).FindString(s) - }, - "findSubmatch": func(pattern, s string) string { - if s == "" { - return "" - } - - regex := getCachedRegexp(pattern) - return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "") - }, - "percentChange": percentChange, - "sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { - sort.Slice(results, func(a, b int) bool { - if order == "asc" { - return results[a].String(key) < results[b].String(key) - } - - return results[a].String(key) > results[b].String(key) - }) - - return results - }, - "sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { - sort.Slice(results, func(a, b int) bool { - if order == "asc" { - return results[a].Int(key) < results[b].Int(key) - } - - return results[a].Int(key) > results[b].Int(key) - }) - - return results - }, - "sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { - sort.Slice(results, func(a, b int) bool { - if order == "asc" { - return results[a].Float(key) < results[b].Float(key) - } - - return results[a].Float(key) > results[b].Float(key) - }) - - return results - }, - "sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult { - sort.Slice(results, func(a, b int) bool { - timeA := customAPIFuncParseTimeInLocation(layout, results[a].String(key), time.UTC) - timeB := customAPIFuncParseTimeInLocation(layout, results[b].String(key), time.UTC) - - if order == "asc" { - return timeA.Before(timeB) - } - - return timeA.After(timeB) - }) - - return results - }, - "concat": func(items ...string) string { - return strings.Join(items, "") - }, - "unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult { - seen := make(map[string]struct{}) - out := make([]decoratedGJSONResult, 0, len(results)) - for _, result := range results { - val := result.String(key) - if _, ok := seen[val]; !ok { - seen[val] = struct{}{} - out = append(out, result) - } - } - return out - }, - "newRequest": func(url string) *CustomAPIRequest { - return &CustomAPIRequest{ - URL: url, - } - }, - "withHeader": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest { - if req.Headers == nil { - req.Headers = make(map[string]string) - } - req.Headers[key] = value - return req - }, - "withParameter": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest { - if req.Parameters == nil { - req.Parameters = make(queryParametersField) - } - req.Parameters[key] = append(req.Parameters[key], value) - return req - }, - "withStringBody": func(body string, req *CustomAPIRequest) *CustomAPIRequest { - req.Body = body - req.BodyType = "string" - return req - }, - "getResponse": func(req *CustomAPIRequest) *customAPIResponseData { - err := req.initialize() - if err != nil { - panic(fmt.Sprintf("initializing request: %v", err)) - } - - data, err := fetchCustomAPIResponse(context.Background(), req) - if err != nil { - slog.Error("Could not fetch response within custom API template", "error", err) - return &customAPIResponseData{ - JSON: decoratedGJSONResult{gjson.Result{}}, - Response: &http.Response{ - Status: err.Error(), - }, - } - } - - return data - }, - } - - for key, value := range globalTemplateFunctions { - if _, exists := funcs[key]; !exists { - funcs[key] = value - } - } - - return funcs -}() - -func customAPIFuncFormatTime(layout string, t time.Time) string { - switch strings.ToLower(layout) { - case "unix": - return strconv.FormatInt(t.Unix(), 10) - case "rfc3339": - layout = time.RFC3339 - case "rfc3339nano": - layout = time.RFC3339Nano - case "datetime": - layout = time.DateTime - case "dateonly": - layout = time.DateOnly - } - - return t.Format(layout) -} - -func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time { - switch strings.ToLower(layout) { - case "unix": - asInt, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return time.Unix(0, 0) - } - - return time.Unix(asInt, 0) - case "rfc3339": - layout = time.RFC3339 - case "rfc3339nano": - layout = time.RFC3339Nano - case "datetime": - layout = time.DateTime - case "dateonly": - layout = time.DateOnly - } - - parsed, err := time.ParseInLocation(layout, value, loc) - if err != nil { - return time.Unix(0, 0) - } - - return parsed -} diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go deleted file mode 100644 index 7311b1bc..00000000 --- a/internal/glance/widget-dns-stats.go +++ /dev/null @@ -1,820 +0,0 @@ -package glance - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "html/template" - "io" - "log/slog" - "net/http" - "sort" - "strings" - "sync" - "time" -) - -var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") - -const ( - dnsStatsBars = 8 - dnsStatsHoursSpan = 24 - dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars -) - -type dnsStatsWidget struct { - widgetBase `yaml:",inline"` - - TimeLabels [8]string `yaml:"-"` - Stats *dnsStats `yaml:"-"` - piholeSessionID string `yaml:"-"` - - HourFormat string `yaml:"hour-format"` - HideGraph bool `yaml:"hide-graph"` - HideTopDomains bool `yaml:"hide-top-domains"` - Service string `yaml:"service"` - AllowInsecure bool `yaml:"allow-insecure"` - URL string `yaml:"url"` - Token string `yaml:"token"` - Username string `yaml:"username"` - Password string `yaml:"password"` -} - -const ( - dnsServiceAdguard = "adguard" - dnsServicePihole = "pihole" - dnsServiceTechnitium = "technitium" - dnsServicePiholeV6 = "pihole-v6" -) - -func makeDNSWidgetTimeLabels(format string) [8]string { - now := time.Now() - var labels [dnsStatsBars]string - - for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar { - labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format)) - } - - return labels -} - -func (widget *dnsStatsWidget) initialize() error { - titleURL := strings.TrimRight(widget.URL, "/") - switch widget.Service { - case dnsServicePihole, dnsServicePiholeV6: - titleURL = titleURL + "/admin" - } - - widget. - withTitle("DNS Stats"). - withTitleURL(titleURL). - withCacheDuration(10 * time.Minute) - - switch widget.Service { - case dnsServiceAdguard: - case dnsServicePiholeV6: - case dnsServicePihole: - case dnsServiceTechnitium: - default: - return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium) - } - - return nil -} - -func (widget *dnsStatsWidget) update(ctx context.Context) { - var stats *dnsStats - var err error - - switch widget.Service { - case dnsServiceAdguard: - stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) - case dnsServicePihole: - stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) - case dnsServiceTechnitium: - stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) - case dnsServicePiholeV6: - var newSessionID string - stats, newSessionID, err = fetchPiholeStats( - widget.URL, - widget.AllowInsecure, - widget.Password, - widget.piholeSessionID, - !widget.HideGraph, - !widget.HideTopDomains, - ) - if err == nil { - widget.piholeSessionID = newSessionID - } - } - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.HourFormat == "24h" { - widget.TimeLabels = makeDNSWidgetTimeLabels("15:00") - } else { - widget.TimeLabels = makeDNSWidgetTimeLabels("3PM") - } - - widget.Stats = stats -} - -func (widget *dnsStatsWidget) Render() template.HTML { - return widget.renderTemplate(widget, dnsStatsWidgetTemplate) -} - -type dnsStats struct { - TotalQueries int - BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later? - BlockedPercent int - ResponseTime int - DomainsBlocked int - Series [dnsStatsBars]dnsStatsSeries - TopBlockedDomains []dnsStatsBlockedDomain -} - -type dnsStatsSeries struct { - Queries int - Blocked int - PercentTotal int - PercentBlocked int -} - -type dnsStatsBlockedDomain struct { - Domain string - PercentBlocked int -} - -type adguardStatsResponse struct { - TotalQueries int `json:"num_dns_queries"` - QueriesSeries []int `json:"dns_queries"` - BlockedQueries int `json:"num_blocked_filtering"` - BlockedSeries []int `json:"blocked_filtering"` - ResponseTime float64 `json:"avg_processing_time"` - TopBlockedDomains []map[string]int `json:"top_blocked_domains"` -} - -func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string, noGraph bool) (*dnsStats, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" - - request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return nil, err - } - - request.SetBasicAuth(username, password) - - var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) - - stats := &dnsStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - ResponseTime: int(responseJson.ResponseTime * 1000), - TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount), - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - - for i := range topBlockedDomainsCount { - domain := responseJson.TopBlockedDomains[i] - var firstDomain string - - for k := range domain { - firstDomain = k - break - } - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) - } - } - - if noGraph { - return stats, nil - } - - queriesSeries := responseJson.QueriesSeries - blockedSeries := responseJson.BlockedSeries - - if len(queriesSeries) > dnsStatsHoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:] - } else if len(queriesSeries) < dnsStatsHoursSpan { - queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > dnsStatsHoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:] - } else if len(blockedSeries) < dnsStatsHoursSpan { - blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := range dnsStatsBars { - queries := 0 - blocked := 0 - - for j := range dnsStatsHoursPerBar { - queries += queriesSeries[i*dnsStatsHoursPerBar+j] - blocked += blockedSeries[i*dnsStatsHoursPerBar+j] - } - - stats.Series[i] = dnsStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := range dnsStatsBars { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} - -// Legacy Pi-hole stats response (before v6) -type pihole5StatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries pihole5QueriesSeries `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 pihole5TopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// If the user has query logging disabled it's possible for domains_over_time to be returned as an -// empty array rather than a map which will prevent unmashalling the rest of the data so we use -// custom unmarshal behavior to fallback to an empty map. -// See https://github.com/glanceapp/glance/issues/289 -type pihole5QueriesSeries map[int64]int - -func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error { - temp := make(map[int64]int) - - err := json.Unmarshal(data, &temp) - if err != nil { - *p = make(pihole5QueriesSeries) - } else { - *p = temp - } - - return nil -} - -// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array -// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type pihole5TopBlockedDomains map[string]int - -func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error { - // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow - // because of the UnmarshalJSON method getting called recursively - temp := make(map[string]int) - - err := json.Unmarshal(data, &temp) - if err != nil { - *p = make(pihole5TopBlockedDomains) - } else { - *p = temp - } - - return nil -} - -func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - - request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return nil, err - } - - var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) - responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request) - if err != nil { - return nil, err - } - - stats := &dnsStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - BlockedPercent: int(responseJson.BlockedPercentage), - DomainsBlocked: responseJson.DomainsBlocked, - } - - if len(responseJson.TopBlockedDomains) > 0 { - domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) - - for domain, count := range responseJson.TopBlockedDomains { - domains = append(domains, dnsStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - if noGraph { - return stats, nil - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { - slog.Warn( - "DNS stats for pihole: did not get expected 144 data points", - "len(queries)", len(responseJson.QueriesSeries), - "len(blocked)", len(responseJson.BlockedSeries), - ) - return stats, nil - } - - var lowestTimestamp int64 = 0 - for timestamp := range responseJson.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - - maxQueriesInSeries := 0 - - for i := range dnsStatsBars { - queries := 0 - blocked := 0 - - for j := range 18 { - 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 := range dnsStatsBars { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} - -func fetchPiholeStats( - instanceURL string, - allowInsecure bool, - password string, - sessionID string, - includeGraph bool, - includeTopDomains bool, -) (*dnsStats, string, error) { - instanceURL = strings.TrimRight(instanceURL, "/") - var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) - - fetchNewSessionID := func() error { - newSessionID, err := fetchPiholeSessionID(instanceURL, client, password) - if err != nil { - return err - } - sessionID = newSessionID - return nil - } - - if sessionID == "" { - if err := fetchNewSessionID(); err != nil { - slog.Error("Failed to fetch Pihole v6 session ID", "error", err) - return nil, "", fmt.Errorf("fetching session ID: %v", err) - } - } else { - isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID) - if err != nil { - slog.Error("Failed to check Pihole v6 session ID validity", "error", err) - return nil, "", fmt.Errorf("checking session ID: %v", err) - } - - if !isValid { - if err := fetchNewSessionID(); err != nil { - slog.Error("Failed to renew Pihole v6 session ID", "error", err) - return nil, "", fmt.Errorf("renewing session ID: %v", err) - } - } - } - - var wg sync.WaitGroup - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - type statsResponseJson struct { - Queries struct { - Total int `json:"total"` - Blocked int `json:"blocked"` - PercentBlocked float64 `json:"percent_blocked"` - } `json:"queries"` - Gravity struct { - DomainsBlocked int `json:"domains_being_blocked"` - } `json:"gravity"` - } - - statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil) - statsRequest.Header.Set("x-ftl-sid", sessionID) - - var statsResponse statsResponseJson - var statsErr error - - wg.Add(1) - go func() { - defer wg.Done() - statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest) - if statsErr != nil { - cancel() - } - }() - - type seriesResponseJson struct { - History []struct { - Timestamp int64 `json:"timestamp"` - Total int `json:"total"` - Blocked int `json:"blocked"` - } `json:"history"` - } - - var seriesResponse seriesResponseJson - var seriesErr error - - if includeGraph { - seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil) - seriesRequest.Header.Set("x-ftl-sid", sessionID) - - wg.Add(1) - go func() { - defer wg.Done() - seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest) - }() - } - - type topDomainsResponseJson struct { - Domains []struct { - Domain string `json:"domain"` - Count int `json:"count"` - } `json:"domains"` - TotalQueries int `json:"total_queries"` - BlockedQueries int `json:"blocked_queries"` - Took float64 `json:"took"` - } - - var topDomainsResponse topDomainsResponseJson - var topDomainsErr error - - if includeTopDomains { - topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil) - topDomainsRequest.Header.Set("x-ftl-sid", sessionID) - - wg.Add(1) - go func() { - defer wg.Done() - topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest) - }() - } - - wg.Wait() - partialContent := false - - if statsErr != nil { - return nil, "", fmt.Errorf("fetching stats: %v", statsErr) - } - - if includeGraph && seriesErr != nil { - slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr) - partialContent = true - } - - if includeTopDomains && topDomainsErr != nil { - slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr) - partialContent = true - } - - stats := &dnsStats{ - TotalQueries: statsResponse.Queries.Total, - BlockedQueries: statsResponse.Queries.Blocked, - BlockedPercent: int(statsResponse.Queries.PercentBlocked), - DomainsBlocked: statsResponse.Gravity.DomainsBlocked, - } - - if includeGraph && seriesErr == nil { - if len(seriesResponse.History) != 145 { - slog.Error( - "Pihole v6 graph data has unexpected length", - "length", len(seriesResponse.History), - "expected", 145, - ) - partialContent = true - } else { - // The API from v5 used to return 144 data points, but v6 returns 145. - // We only show data from the last 24 hours hours, Pihole returns data - // points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra - // data point? I don't know, but we'll just ignore the first one since it's - // the oldest data point. - history := seriesResponse.History[1:] - - const interval = 10 - const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval) - - maxQueriesInSeries := 0 - - for i := range dnsStatsBars { - queries := 0 - blocked := 0 - for j := range dataPointsPerBar { - index := i*dataPointsPerBar + j - queries += history[index].Total - blocked += history[index].Blocked - } - 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 := range dnsStatsBars { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - } - } - - if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 { - domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains)) - for i := range topDomainsResponse.Domains { - d := &topDomainsResponse.Domains[i] - domains = append(domains, dnsStatsBlockedDomain{ - Domain: d.Domain, - PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - return stats, sessionID, ternary(partialContent, errPartialContent, nil) -} - -func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) { - requestBody := []byte(`{"password":"` + password + `"}`) - - request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody)) - if err != nil { - return "", fmt.Errorf("creating authentication request: %v", err) - } - request.Header.Set("Content-Type", "application/json") - - response, err := client.Do(request) - if err != nil { - return "", fmt.Errorf("sending authentication request: %v", err) - } - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - return "", fmt.Errorf("reading authentication response: %v", err) - } - - var jsonResponse struct { - Session struct { - SID string `json:"sid"` - Message string `json:"message"` - } `json:"session"` - } - - if err := json.Unmarshal(body, &jsonResponse); err != nil { - return "", fmt.Errorf("parsing authentication response: %v", err) - } - - if response.StatusCode != http.StatusOK { - return "", fmt.Errorf( - "authentication request returned status %s with message '%s'", - response.Status, jsonResponse.Session.Message, - ) - } - - if jsonResponse.Session.SID == "" { - return "", fmt.Errorf( - "authentication response returned empty session ID, status code %d, message '%s'", - response.StatusCode, jsonResponse.Session.Message, - ) - } - - return jsonResponse.Session.SID, nil -} - -func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) { - request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil) - if err != nil { - return false, fmt.Errorf("creating session ID check request: %v", err) - } - request.Header.Set("x-ftl-sid", sessionID) - - response, err := client.Do(request) - if err != nil { - return false, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized { - return false, fmt.Errorf("session ID check request returned status %s", response.Status) - } - - return response.StatusCode == http.StatusOK, nil -} - -type technitiumStatsResponse struct { - Response struct { - Stats struct { - TotalQueries int `json:"totalQueries"` - BlockedQueries int `json:"totalBlocked"` - BlockedZones int `json:"blockedZones"` - BlockListZones int `json:"blockListZones"` - } `json:"stats"` - MainChartData struct { - Datasets []struct { - Label string `json:"label"` - Data []int `json:"data"` - } `json:"datasets"` - } `json:"mainChartData"` - TopBlockedDomains []struct { - Domain string `json:"name"` - Count int `json:"hits"` - } - } `json:"response"` -} - -func fetchTechnitiumStats(instanceUrl string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceUrl, "/") + "/api/dashboard/stats/get?token=" + token + "&type=LastDay" - - request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return nil, err - } - - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - - responseJson, err := decodeJsonFromRequest[technitiumStatsResponse](client, request) - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.Response.TopBlockedDomains), 5) - - stats := &dnsStats{ - TotalQueries: responseJson.Response.Stats.TotalQueries, - BlockedQueries: responseJson.Response.Stats.BlockedQueries, - TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount), - DomainsBlocked: responseJson.Response.Stats.BlockedZones + responseJson.Response.Stats.BlockListZones, - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.Response.Stats.BlockedQueries) / float64(responseJson.Response.Stats.TotalQueries) * 100) - - for i := 0; i < topBlockedDomainsCount; i++ { - domain := responseJson.Response.TopBlockedDomains[i] - firstDomain := domain.Domain - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain.Count) / float64(responseJson.Response.Stats.BlockedQueries) * 100) - } - } - - if noGraph { - return stats, nil - } - - var queriesSeries, blockedSeries []int - - for _, label := range responseJson.Response.MainChartData.Datasets { - switch label.Label { - case "Total": - queriesSeries = label.Data - case "Blocked": - blockedSeries = label.Data - } - } - - if len(queriesSeries) > dnsStatsHoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:] - } else if len(queriesSeries) < dnsStatsHoursSpan { - queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > dnsStatsHoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:] - } else if len(blockedSeries) < dnsStatsHoursSpan { - blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := 0; i < dnsStatsBars; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < dnsStatsHoursPerBar; j++ { - queries += queriesSeries[i*dnsStatsHoursPerBar+j] - blocked += blockedSeries[i*dnsStatsHoursPerBar+j] - } - - stats.Series[i] = dnsStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := 0; i < dnsStatsBars; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go deleted file mode 100644 index 702a34b0..00000000 --- a/internal/glance/widget-docker-containers.go +++ /dev/null @@ -1,379 +0,0 @@ -package glance - -import ( - "context" - "encoding/json" - "fmt" - "html/template" - "net" - "net/http" - "net/url" - "sort" - "strings" - "time" -) - -var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html") - -type dockerContainersWidget struct { - widgetBase `yaml:",inline"` - HideByDefault bool `yaml:"hide-by-default"` - RunningOnly bool `yaml:"running-only"` - Category string `yaml:"category"` - SockPath string `yaml:"sock-path"` - FormatContainerNames bool `yaml:"format-container-names"` - Containers dockerContainerList `yaml:"-"` - LabelOverrides map[string]map[string]string `yaml:"containers"` -} - -func (widget *dockerContainersWidget) initialize() error { - widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute) - - if widget.SockPath == "" { - widget.SockPath = "/var/run/docker.sock" - } - - return nil -} - -func (widget *dockerContainersWidget) update(ctx context.Context) { - containers, err := fetchDockerContainers( - widget.SockPath, - widget.HideByDefault, - widget.Category, - widget.RunningOnly, - widget.FormatContainerNames, - widget.LabelOverrides, - ) - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - containers.sortByStateIconThenTitle() - widget.Containers = containers -} - -func (widget *dockerContainersWidget) Render() template.HTML { - return widget.renderTemplate(widget, dockerContainersWidgetTemplate) -} - -const ( - dockerContainerLabelHide = "glance.hide" - dockerContainerLabelName = "glance.name" - dockerContainerLabelURL = "glance.url" - dockerContainerLabelDescription = "glance.description" - dockerContainerLabelSameTab = "glance.same-tab" - dockerContainerLabelIcon = "glance.icon" - dockerContainerLabelID = "glance.id" - dockerContainerLabelParent = "glance.parent" - dockerContainerLabelCategory = "glance.category" -) - -const ( - dockerContainerStateIconOK = "ok" - dockerContainerStateIconPaused = "paused" - dockerContainerStateIconWarn = "warn" - dockerContainerStateIconOther = "other" -) - -var dockerContainerStateIconPriorities = map[string]int{ - dockerContainerStateIconWarn: 0, - dockerContainerStateIconOther: 1, - dockerContainerStateIconPaused: 2, - dockerContainerStateIconOK: 3, -} - -type dockerContainerJsonResponse struct { - Names []string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Status string `json:"Status"` - Labels dockerContainerLabels `json:"Labels"` -} - -type dockerContainerLabels map[string]string - -func (l *dockerContainerLabels) getOrDefault(label, def string) string { - if l == nil { - return def - } - - v, ok := (*l)[label] - if !ok { - return def - } - - if v == "" { - return def - } - - return v -} - -type dockerContainer struct { - Name string - URL string - SameTab bool - Image string - State string - StateText string - StateIcon string - Description string - Icon customIconField - Children dockerContainerList -} - -type dockerContainerList []dockerContainer - -func (containers dockerContainerList) sortByStateIconThenTitle() { - p := &dockerContainerStateIconPriorities - - sort.SliceStable(containers, func(a, b int) bool { - if containers[a].StateIcon != containers[b].StateIcon { - return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon] - } - - return strings.ToLower(containers[a].Name) < strings.ToLower(containers[b].Name) - }) -} - -func dockerContainerStateToStateIcon(state string) string { - switch state { - case "running": - return dockerContainerStateIconOK - case "paused": - return dockerContainerStateIconPaused - case "exited", "unhealthy", "dead": - return dockerContainerStateIconWarn - default: - return dockerContainerStateIconOther - } -} - -func fetchDockerContainers( - socketPath string, - hideByDefault bool, - category string, - runningOnly bool, - formatNames bool, - labelOverrides map[string]map[string]string, -) (dockerContainerList, error) { - containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides) - if err != nil { - return nil, fmt.Errorf("fetching containers: %w", err) - } - - containers, children := groupDockerContainerChildren(containers, hideByDefault) - dockerContainers := make(dockerContainerList, 0, len(containers)) - - for i := range containers { - container := &containers[i] - - dc := dockerContainer{ - Name: deriveDockerContainerName(container, formatNames), - URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""), - Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""), - SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), - Image: container.Image, - State: strings.ToLower(container.State), - StateText: strings.ToLower(container.Status), - Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")), - } - - if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { - if children, ok := children[idValue]; ok { - for i := range children { - child := &children[i] - dc.Children = append(dc.Children, dockerContainer{ - Name: deriveDockerContainerName(child, formatNames), - StateText: child.Status, - StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), - }) - } - } - } - - dc.Children.sortByStateIconThenTitle() - - stateIconSupersededByChild := false - for i := range dc.Children { - if dc.Children[i].StateIcon == dockerContainerStateIconWarn { - dc.StateIcon = dockerContainerStateIconWarn - stateIconSupersededByChild = true - break - } - } - if !stateIconSupersededByChild { - dc.StateIcon = dockerContainerStateToStateIcon(dc.State) - } - - dockerContainers = append(dockerContainers, dc) - } - - return dockerContainers, nil -} - -func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string { - if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" { - return v - } - - if len(container.Names) == 0 || container.Names[0] == "" { - return "n/a" - } - - name := strings.TrimLeft(container.Names[0], "/") - - if formatNames { - name = strings.ReplaceAll(name, "_", " ") - name = strings.ReplaceAll(name, "-", " ") - - words := strings.Split(name, " ") - for i := range words { - if len(words[i]) > 0 { - words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] - } - } - name = strings.Join(words, " ") - } - - return name -} - -func groupDockerContainerChildren( - containers []dockerContainerJsonResponse, - hideByDefault bool, -) ( - []dockerContainerJsonResponse, - map[string][]dockerContainerJsonResponse, -) { - parents := make([]dockerContainerJsonResponse, 0, len(containers)) - children := make(map[string][]dockerContainerJsonResponse) - - for i := range containers { - container := &containers[i] - - if isDockerContainerHidden(container, hideByDefault) { - continue - } - - isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != "" - parent := container.Labels.getOrDefault(dockerContainerLabelParent, "") - - if !isParent && parent != "" { - children[parent] = append(children[parent], *container) - } else { - parents = append(parents, *container) - } - } - - return parents, children -} - -func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool { - if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { - return stringToBool(v) - } - - return hideByDefault -} - - -func fetchDockerContainersFromSource( - source string, - category string, - runningOnly bool, - labelOverrides map[string]map[string]string, -) ([]dockerContainerJsonResponse, error) { - var hostname string - - var client *http.Client - if strings.HasPrefix(source, "tcp://") || strings.HasPrefix(source, "http://") { - client = &http.Client{} - parsed, err := url.Parse(source) - if err != nil { - return nil, fmt.Errorf("parsing URL: %w", err) - } - - port := parsed.Port() - if port == "" { - port = "80" - } - - hostname = parsed.Hostname() + ":" + port - } else { - hostname = "docker" - client = &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", source) - }, - }, - } - } - - - fetchAll := ternary(runningOnly, "false", "true") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - request, err := http.NewRequestWithContext(ctx, "GET", "http://"+hostname+"/containers/json?all="+fetchAll, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - response, err := client.Do(request) - if err != nil { - return nil, fmt.Errorf("sending request to socket: %w", err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-200 response status: %s", response.Status) - } - - var containers []dockerContainerJsonResponse - if err := json.NewDecoder(response.Body).Decode(&containers); err != nil { - return nil, fmt.Errorf("decoding response: %w", err) - } - - for i := range containers { - container := &containers[i] - name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/") - - if name == "" { - continue - } - - overrides, ok := labelOverrides[name] - if !ok { - continue - } - - if container.Labels == nil { - container.Labels = make(dockerContainerLabels) - } - - for label, value := range overrides { - container.Labels["glance."+label] = value - } - } - - // We have to filter here instead of using the `filters` parameter of Docker's API - // because the user may define a category override within their config - if category != "" { - filtered := make([]dockerContainerJsonResponse, 0, len(containers)) - - for i := range containers { - container := &containers[i] - - if container.Labels.getOrDefault(dockerContainerLabelCategory, "") == category { - filtered = append(filtered, *container) - } - } - - containers = filtered - } - - return containers, nil -} diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go deleted file mode 100644 index c2b11f55..00000000 --- a/internal/glance/widget-extension.go +++ /dev/null @@ -1,172 +0,0 @@ -package glance - -import ( - "context" - "errors" - "fmt" - "html" - "html/template" - "io" - "log/slog" - "net/http" - "net/url" - "time" -) - -var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html") - -const extensionWidgetDefaultTitle = "Extension" - -type extensionWidget struct { - widgetBase `yaml:",inline"` - URL string `yaml:"url"` - FallbackContentType string `yaml:"fallback-content-type"` - Parameters queryParametersField `yaml:"parameters"` - Headers map[string]string `yaml:"headers"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` - Extension extension `yaml:"-"` - cachedHTML template.HTML `yaml:"-"` -} - -func (widget *extensionWidget) initialize() error { - widget.withTitle(extensionWidgetDefaultTitle).withCacheDuration(time.Minute * 30) - - if widget.URL == "" { - return errors.New("URL is required") - } - - if _, err := url.Parse(widget.URL); err != nil { - return fmt.Errorf("parsing URL: %v", err) - } - - return nil -} - -func (widget *extensionWidget) update(ctx context.Context) { - extension, err := fetchExtension(extensionRequestOptions{ - URL: widget.URL, - FallbackContentType: widget.FallbackContentType, - Parameters: widget.Parameters, - Headers: widget.Headers, - AllowHtml: widget.AllowHtml, - }) - - widget.canContinueUpdateAfterHandlingErr(err) - - widget.Extension = extension - - if widget.Title == extensionWidgetDefaultTitle && extension.Title != "" { - widget.Title = extension.Title - } - - if widget.TitleURL == "" && extension.TitleURL != "" { - widget.TitleURL = extension.TitleURL - } - - widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) -} - -func (widget *extensionWidget) Render() template.HTML { - return widget.cachedHTML -} - -type extensionType int - -const ( - extensionContentHTML extensionType = iota - extensionContentUnknown -) - -var extensionStringToType = map[string]extensionType{ - "html": extensionContentHTML, -} - -const ( - extensionHeaderTitle = "Widget-Title" - extensionHeaderTitleURL = "Widget-Title-URL" - extensionHeaderContentType = "Widget-Content-Type" - extensionHeaderContentFrameless = "Widget-Content-Frameless" -) - -type extensionRequestOptions struct { - URL string `yaml:"url"` - FallbackContentType string `yaml:"fallback-content-type"` - Parameters queryParametersField `yaml:"parameters"` - Headers map[string]string `yaml:"headers"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` -} - -type extension struct { - Title string - TitleURL string - Content template.HTML - Frameless bool -} - -func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML { - switch contentType { - case extensionContentHTML: - if options.AllowHtml { - return template.HTML(content) - } - - fallthrough - default: - return template.HTML("
" + html.EscapeString(string(content)) + "
") - } -} - -func fetchExtension(options extensionRequestOptions) (extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - if len(options.Parameters) > 0 { - request.URL.RawQuery = options.Parameters.toQueryString() - } - - for key, value := range options.Headers { - request.Header.Add(key, value) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - slog.Error("Failed fetching extension", "url", options.URL, "error", err) - return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - slog.Error("Failed reading response body of extension", "url", options.URL, "error", err) - return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err) - } - - extension := extension{} - - if response.Header.Get(extensionHeaderTitle) == "" { - extension.Title = "Extension" - } else { - extension.Title = response.Header.Get(extensionHeaderTitle) - } - - if response.Header.Get(extensionHeaderTitleURL) != "" { - extension.TitleURL = response.Header.Get(extensionHeaderTitleURL) - } - - contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] - - if !ok { - contentType, ok = extensionStringToType[options.FallbackContentType] - - if !ok { - contentType = extensionContentUnknown - } - } - - if stringToBool(response.Header.Get(extensionHeaderContentFrameless)) { - extension.Frameless = true - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/glance/widget-html.go b/internal/glance/widget-html.go deleted file mode 100644 index 0e32a468..00000000 --- a/internal/glance/widget-html.go +++ /dev/null @@ -1,20 +0,0 @@ -package glance - -import ( - "html/template" -) - -type htmlWidget struct { - widgetBase `yaml:",inline"` - Source template.HTML `yaml:"source"` -} - -func (widget *htmlWidget) initialize() error { - widget.withTitle("").withError(nil) - - return nil -} - -func (widget *htmlWidget) Render() template.HTML { - return widget.Source -} diff --git a/internal/glance/widget-iframe.go b/internal/glance/widget-iframe.go deleted file mode 100644 index 830b3837..00000000 --- a/internal/glance/widget-iframe.go +++ /dev/null @@ -1,43 +0,0 @@ -package glance - -import ( - "errors" - "fmt" - "html/template" - "net/url" -) - -var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html") - -type iframeWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - Source string `yaml:"source"` - Height int `yaml:"height"` -} - -func (widget *iframeWidget) initialize() error { - widget.withTitle("IFrame").withError(nil) - - if widget.Source == "" { - return errors.New("source is required") - } - - if _, err := url.Parse(widget.Source); err != nil { - return fmt.Errorf("parsing URL: %v", err) - } - - if widget.Height == 50 { - widget.Height = 300 - } else if widget.Height < 50 { - widget.Height = 50 - } - - widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate) - - return nil -} - -func (widget *iframeWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go deleted file mode 100644 index b53b10a1..00000000 --- a/internal/glance/widget-markets.go +++ /dev/null @@ -1,228 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "log/slog" - "math" - "net/http" - "sort" - "strings" - "time" -) - -var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html") - -type marketsWidget struct { - widgetBase `yaml:",inline"` - StocksRequests []marketRequest `yaml:"stocks"` - MarketRequests []marketRequest `yaml:"markets"` - ChartLinkTemplate string `yaml:"chart-link-template"` - SymbolLinkTemplate string `yaml:"symbol-link-template"` - Sort string `yaml:"sort-by"` - Markets marketList `yaml:"-"` -} - -func (widget *marketsWidget) initialize() error { - widget.withTitle("Markets").withCacheDuration(time.Hour) - - // legacy support, remove in v0.10.0 - if len(widget.MarketRequests) == 0 { - widget.MarketRequests = widget.StocksRequests - } - - for i := range widget.MarketRequests { - m := &widget.MarketRequests[i] - - if widget.ChartLinkTemplate != "" && m.ChartLink == "" { - m.ChartLink = strings.ReplaceAll(widget.ChartLinkTemplate, "{SYMBOL}", m.Symbol) - } - - if widget.SymbolLinkTemplate != "" && m.SymbolLink == "" { - m.SymbolLink = strings.ReplaceAll(widget.SymbolLinkTemplate, "{SYMBOL}", m.Symbol) - } - } - - return nil -} - -func (widget *marketsWidget) update(ctx context.Context) { - markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Sort == "absolute-change" { - markets.sortByAbsChange() - } else if widget.Sort == "change" { - markets.sortByChange() - } - - widget.Markets = markets -} - -func (widget *marketsWidget) Render() template.HTML { - return widget.renderTemplate(widget, marketsWidgetTemplate) -} - -type marketRequest struct { - CustomName string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type market struct { - marketRequest - Name string - Currency string - Price float64 - PriceHint int - PercentChange float64 - SvgChartPoints string -} - -type marketList []market - -func (t marketList) sortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -func (t marketList) sortByChange() { - sort.Slice(t, func(i, j int) bool { - return t[i].PercentChange > t[j].PercentChange - }) -} - -type marketResponseJson struct { - Chart struct { - Result []struct { - Meta struct { - Currency string `json:"currency"` - Symbol string `json:"symbol"` - RegularMarketPrice float64 `json:"regularMarketPrice"` - ChartPreviousClose float64 `json:"chartPreviousClose"` - ShortName string `json:"shortName"` - PriceHint int `json:"priceHint"` - } `json:"meta"` - Indicators struct { - Quote []struct { - Close []float64 `json:"close,omitempty"` - } `json:"quote"` - } `json:"indicators"` - } `json:"result"` - } `json:"chart"` -} - -// TODO: allow changing chart time frame -const marketChartDays = 21 - -func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) { - requests := make([]*http.Request, 0, len(marketRequests)) - - for i := range marketRequests { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) - setBrowserUserAgentHeader(request) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests) - responses, errs, err := workerPoolDo(job) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - markets := make(marketList, 0, len(responses)) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) - continue - } - - response := responses[i] - - if len(response.Chart.Result) == 0 { - failed++ - slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) - continue - } - - result := &response.Chart.Result[0] - prices := result.Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := result.Meta.RegularMarketPrice - - if len(prices) >= 2 && prices[len(prices)-2] != 0 { - previous = prices[len(prices)-2] - } - - points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - - currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)] - if !exists { - currency = result.Meta.Currency - } - - markets = append(markets, market{ - marketRequest: marketRequests[i], - Price: result.Meta.RegularMarketPrice, - Currency: currency, - PriceHint: result.Meta.PriceHint, - Name: ternary(marketRequests[i].CustomName == "", - result.Meta.ShortName, - marketRequests[i].CustomName, - ), - PercentChange: percentChange( - result.Meta.RegularMarketPrice, - previous, - ), - SvgChartPoints: points, - }) - } - - if len(markets) == 0 { - return nil, errNoContent - } - - if failed > 0 { - return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed) - } - - return markets, nil -} - -var currencyToSymbol = map[string]string{ - "USD": "$", - "EUR": "€", - "JPY": "¥", - "CAD": "C$", - "AUD": "A$", - "GBP": "£", - "CHF": "Fr", - "NZD": "N$", - "INR": "₹", - "BRL": "R$", - "RUB": "₽", - "TRY": "₺", - "ZAR": "R", - "CNY": "¥", - "KRW": "₩", - "HKD": "HK$", - "SGD": "S$", - "SEK": "kr", - "NOK": "kr", - "DKK": "kr", - "PLN": "zł", - "PHP": "₱", -} diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go deleted file mode 100644 index cdce0d6b..00000000 --- a/internal/glance/widget-monitor.go +++ /dev/null @@ -1,193 +0,0 @@ -package glance - -import ( - "context" - "errors" - "html/template" - "net/http" - "slices" - "strconv" - "time" -) - -var ( - monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html") - monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html") -) - -type monitorWidget struct { - widgetBase `yaml:",inline"` - Sites []struct { - *SiteStatusRequest `yaml:",inline"` - Status *siteStatus `yaml:"-"` - URL string `yaml:"-"` - ErrorURL string `yaml:"error-url"` - Title string `yaml:"title"` - Icon customIconField `yaml:"icon"` - SameTab bool `yaml:"same-tab"` - StatusText string `yaml:"-"` - StatusStyle string `yaml:"-"` - AltStatusCodes []int `yaml:"alt-status-codes"` - } `yaml:"sites"` - Style string `yaml:"style"` - ShowFailingOnly bool `yaml:"show-failing-only"` - HasFailing bool `yaml:"-"` -} - -func (widget *monitorWidget) initialize() error { - widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) - - return nil -} - -func (widget *monitorWidget) update(ctx context.Context) { - requests := make([]*SiteStatusRequest, len(widget.Sites)) - - for i := range widget.Sites { - requests[i] = widget.Sites[i].SiteStatusRequest - } - - statuses, err := fetchStatusForSites(requests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.HasFailing = false - - for i := range widget.Sites { - site := &widget.Sites[i] - status := &statuses[i] - site.Status = status - - if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.Error != nil) { - widget.HasFailing = true - } - - if status.Error != nil && site.ErrorURL != "" { - site.URL = site.ErrorURL - } else { - site.URL = site.DefaultURL - } - - site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes) - site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes) - } -} - -func (widget *monitorWidget) Render() template.HTML { - if widget.Style == "compact" { - return widget.renderTemplate(widget, monitorWidgetCompactTemplate) - } - - return widget.renderTemplate(widget, monitorWidgetTemplate) -} - -func statusCodeToText(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "OK" - } - if status == 404 { - return "Not Found" - } - if status == 403 { - return "Forbidden" - } - if status == 401 { - return "Unauthorized" - } - if status >= 500 { - return "Server Error" - } - if status >= 400 { - return "Client Error" - } - - return strconv.Itoa(status) -} - -func statusCodeToStyle(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "ok" - } - - return "error" -} - -type SiteStatusRequest struct { - DefaultURL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` - Timeout durationField `yaml:"timeout"` - BasicAuth struct { - Username string `yaml:"username"` - Password string `yaml:"password"` - } `yaml:"basic-auth"` -} - -type siteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.DefaultURL - } - - timeout := ternary(statusRequest.Timeout > 0, time.Duration(statusRequest.Timeout), 3*time.Second) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return siteStatus{ - Error: err, - }, nil - } - - if statusRequest.BasicAuth.Username != "" || statusRequest.BasicAuth.Password != "" { - request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password) - } - - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultHTTPClient.Do(request) - } else { - response, err = defaultInsecureHTTPClient.Do(request) - } - - status := siteStatus{ResponseTime: time.Since(requestSentAt)} - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - status.TimedOut = true - } - - status.Error = err - return status, nil - } - - defer response.Body.Close() - - status.Code = response.StatusCode - - return status, nil -} - -func fetchStatusForSites(requests []*SiteStatusRequest) ([]siteStatus, error) { - job := newJob(fetchSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/glance/widget-old-calendar.go b/internal/glance/widget-old-calendar.go deleted file mode 100644 index e4fbe74c..00000000 --- a/internal/glance/widget-old-calendar.go +++ /dev/null @@ -1,86 +0,0 @@ -package glance - -import ( - "context" - "html/template" - "time" -) - -var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html") - -type oldCalendarWidget struct { - widgetBase `yaml:",inline"` - Calendar *calendar - StartSunday bool `yaml:"start-sunday"` -} - -func (widget *oldCalendarWidget) initialize() error { - widget.withTitle("Calendar").withCacheOnTheHour() - - return nil -} - -func (widget *oldCalendarWidget) update(ctx context.Context) { - widget.Calendar = newCalendar(time.Now(), widget.StartSunday) - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *oldCalendarWidget) Render() template.HTML { - return widget.renderTemplate(widget, oldCalendarWidgetTemplate) -} - -type calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -// TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing between showing the previous and next week and the entire month -func newCalendar(now time.Time, startSunday bool) *calendar { - year, week := now.ISOWeek() - weekday := now.Weekday() - if !startSunday { - weekday = (weekday + 6) % 7 // Shift Monday to 0 - } - - currentMonthDays := daysInMonth(now.Month(), year) - - var previousMonthDays int - - if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 { - previousMonthDays = daysInMonth(12, year-1) - } else { - previousMonthDays = daysInMonth(previousMonthNumber, year) - } - - startDaysFrom := now.Day() - int(weekday) - 7 - - days := make([]int, 21) - - for i := 0; i < 21; i++ { - day := startDaysFrom + i - - if day < 1 { - day = previousMonthDays + day - } else if day > currentMonthDays { - day = day - currentMonthDays - } - - days[i] = day - } - - return &calendar{ - CurrentDay: now.Day(), - CurrentWeekNumber: week, - CurrentMonthName: now.Month().String(), - CurrentYear: year, - Days: days, - } -} - -func daysInMonth(m time.Month, year int) int { - return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() -} diff --git a/internal/glance/widget-repository.go b/internal/glance/widget-repository.go deleted file mode 100644 index 1eeb8b4b..00000000 --- a/internal/glance/widget-repository.go +++ /dev/null @@ -1,238 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "net/http" - "strings" - "sync" - "time" -) - -var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html") - -type repositoryWidget struct { - widgetBase `yaml:",inline"` - RequestedRepository string `yaml:"repository"` - Token string `yaml:"token"` - PullRequestsLimit int `yaml:"pull-requests-limit"` - IssuesLimit int `yaml:"issues-limit"` - CommitsLimit int `yaml:"commits-limit"` - Repository repository `yaml:"-"` -} - -func (widget *repositoryWidget) initialize() error { - widget.withTitle("Repository").withCacheDuration(1 * time.Hour) - - if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 { - widget.PullRequestsLimit = 3 - } - - if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 { - widget.IssuesLimit = 3 - } - - if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { - widget.CommitsLimit = -1 - } - - return nil -} - -func (widget *repositoryWidget) update(ctx context.Context) { - details, err := fetchRepositoryDetailsFromGithub( - widget.RequestedRepository, - string(widget.Token), - widget.PullRequestsLimit, - widget.IssuesLimit, - widget.CommitsLimit, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Repository = details -} - -func (widget *repositoryWidget) Render() template.HTML { - return widget.renderTemplate(widget, repositoryWidgetTemplate) -} - -type repository struct { - Name string - Stars int - Forks int - OpenPullRequests int - PullRequests []githubTicket - OpenIssues int - Issues []githubTicket - LastCommits int - Commits []githubCommitDetails -} - -type githubTicket struct { - Number int - CreatedAt time.Time - Title string -} - -type githubCommitDetails struct { - Sha string - Author string - CreatedAt time.Time - Message string -} - -type githubRepositoryResponseJson struct { - Name string `json:"full_name"` - Stars int `json:"stargazers_count"` - Forks int `json:"forks_count"` -} - -type githubTicketResponseJson struct { - Count int `json:"total_count"` - Tickets []struct { - Number int `json:"number"` - CreatedAt string `json:"created_at"` - Title string `json:"title"` - } `json:"items"` -} - -type gitHubCommitResponseJson struct { - Sha string `json:"sha"` - Commit struct { - Author struct { - Name string `json:"name"` - Date string `json:"date"` - } `json:"author"` - Message string `json:"message"` - } `json:"commit"` -} - -func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) { - repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil) - if err != nil { - return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err) - } - - PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil) - issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil) - CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil) - - if token != "" { - token = fmt.Sprintf("Bearer %s", token) - repositoryRequest.Header.Add("Authorization", token) - PRsRequest.Header.Add("Authorization", token) - issuesRequest.Header.Add("Authorization", token) - CommitsRequest.Header.Add("Authorization", token) - } - - var repositoryResponse githubRepositoryResponseJson - var detailsErr error - var PRsResponse githubTicketResponseJson - var PRsErr error - var issuesResponse githubTicketResponseJson - var issuesErr error - var commitsResponse []gitHubCommitResponseJson - var CommitsErr error - var wg sync.WaitGroup - - wg.Add(1) - go (func() { - defer wg.Done() - repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest) - })() - - if maxPRs > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest) - })() - } - - if maxIssues > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest) - })() - } - - if maxCommits > 0 { - wg.Add(1) - go (func() { - defer wg.Done() - commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest) - })() - } - - wg.Wait() - - if detailsErr != nil { - return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr) - } - - details := repository{ - Name: repositoryResponse.Name, - Stars: repositoryResponse.Stars, - Forks: repositoryResponse.Forks, - PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)), - Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)), - Commits: make([]githubCommitDetails, 0, len(commitsResponse)), - } - - err = nil - - if maxPRs > 0 { - if PRsErr != nil { - err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr) - } else { - details.OpenPullRequests = PRsResponse.Count - - for i := range PRsResponse.Tickets { - details.PullRequests = append(details.PullRequests, githubTicket{ - Number: PRsResponse.Tickets[i].Number, - CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), - Title: PRsResponse.Tickets[i].Title, - }) - } - } - } - - if maxIssues > 0 { - if issuesErr != nil { - // TODO: fix, overwriting the previous error - err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr) - } else { - details.OpenIssues = issuesResponse.Count - - for i := range issuesResponse.Tickets { - details.Issues = append(details.Issues, githubTicket{ - Number: issuesResponse.Tickets[i].Number, - CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), - Title: issuesResponse.Tickets[i].Title, - }) - } - } - } - - if maxCommits > 0 { - if CommitsErr != nil { - err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr) - } else { - for i := range commitsResponse { - details.Commits = append(details.Commits, githubCommitDetails{ - Sha: commitsResponse[i].Sha, - Author: commitsResponse[i].Commit.Author.Name, - CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), - Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0], - }) - } - } - } - - return details, err -} diff --git a/internal/glance/widget-search.go b/internal/glance/widget-search.go deleted file mode 100644 index 300361d9..00000000 --- a/internal/glance/widget-search.go +++ /dev/null @@ -1,78 +0,0 @@ -package glance - -import ( - "fmt" - "html/template" - "strings" -) - -var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html") - -type SearchBang struct { - Title string - Shortcut string - URL string -} - -type searchWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - SearchEngine string `yaml:"search-engine"` - Bangs []SearchBang `yaml:"bangs"` - NewTab bool `yaml:"new-tab"` - Target string `yaml:"target"` - Autofocus bool `yaml:"autofocus"` - Placeholder string `yaml:"placeholder"` -} - -func convertSearchUrl(url string) string { - // Go's template is being stubborn and continues to escape the curlies in the - // URL regardless of what the type of the variable is so this is my way around it - return strings.ReplaceAll(url, "{QUERY}", "!QUERY!") -} - -var searchEngines = map[string]string{ - "duckduckgo": "https://duckduckgo.com/?q={QUERY}", - "google": "https://www.google.com/search?q={QUERY}", - "bing": "https://www.bing.com/search?q={QUERY}", - "perplexity": "https://www.perplexity.ai/search?q={QUERY}", - "kagi": "https://kagi.com/search?q={QUERY}", - "startpage": "https://www.startpage.com/search?q={QUERY}", -} - -func (widget *searchWidget) initialize() error { - widget.withTitle("Search").withError(nil) - - if widget.SearchEngine == "" { - widget.SearchEngine = "duckduckgo" - } - - if widget.Placeholder == "" { - widget.Placeholder = "Type here to search…" - } - - if url, ok := searchEngines[widget.SearchEngine]; ok { - widget.SearchEngine = url - } - - widget.SearchEngine = convertSearchUrl(widget.SearchEngine) - - for i := range widget.Bangs { - if widget.Bangs[i].Shortcut == "" { - return fmt.Errorf("search bang #%d has no shortcut", i+1) - } - - if widget.Bangs[i].URL == "" { - return fmt.Errorf("search bang #%d has no URL", i+1) - } - - widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL) - } - - widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate) - return nil -} - -func (widget *searchWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-server-stats.go b/internal/glance/widget-server-stats.go deleted file mode 100644 index 90bf8db2..00000000 --- a/internal/glance/widget-server-stats.go +++ /dev/null @@ -1,117 +0,0 @@ -package glance - -import ( - "context" - "html/template" - "log/slog" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/glanceapp/glance/pkg/sysinfo" -) - -var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html") - -type serverStatsWidget struct { - widgetBase `yaml:",inline"` - Servers []serverStatsRequest `yaml:"servers"` -} - -func (widget *serverStatsWidget) initialize() error { - widget.withTitle("Server Stats").withCacheDuration(15 * time.Second) - widget.widgetBase.WIP = true - - if len(widget.Servers) == 0 { - widget.Servers = []serverStatsRequest{{Type: "local"}} - } - - for i := range widget.Servers { - widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/") - - if widget.Servers[i].Timeout == 0 { - widget.Servers[i].Timeout = durationField(3 * time.Second) - } - } - - return nil -} - -func (widget *serverStatsWidget) update(context.Context) { - // Refactor later, most of it may change depending on feedback - var wg sync.WaitGroup - - for i := range widget.Servers { - serv := &widget.Servers[i] - - if serv.Type == "local" { - info, errs := sysinfo.Collect(serv.SystemInfoRequest) - - if len(errs) > 0 { - for i := range errs { - slog.Warn("Getting system info: " + errs[i].Error()) - } - } - - serv.IsReachable = true - serv.Info = info - } else { - wg.Add(1) - go func() { - defer wg.Done() - info, err := fetchRemoteServerInfo(serv) - if err != nil { - slog.Warn("Getting remote system info: " + err.Error()) - serv.IsReachable = false - serv.Info = &sysinfo.SystemInfo{ - Hostname: "Unnamed server #" + strconv.Itoa(i+1), - } - } else { - serv.IsReachable = true - serv.Info = info - } - }() - } - } - - wg.Wait() - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *serverStatsWidget) Render() template.HTML { - return widget.renderTemplate(widget, serverStatsWidgetTemplate) -} - -type serverStatsRequest struct { - *sysinfo.SystemInfoRequest `yaml:",inline"` - Info *sysinfo.SystemInfo `yaml:"-"` - IsReachable bool `yaml:"-"` - StatusText string `yaml:"-"` - Name string `yaml:"name"` - HideSwap bool `yaml:"hide-swap"` - Type string `yaml:"type"` - URL string `yaml:"url"` - Token string `yaml:"token"` - Timeout durationField `yaml:"timeout"` - // Support for other agents - // Provider string `yaml:"provider"` -} - -func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout)) - defer cancel() - - request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil) - if infoReq.Token != "" { - request.Header.Set("Authorization", "Bearer "+infoReq.Token) - } - - info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request) - if err != nil { - return nil, err - } - - return info, nil -} diff --git a/internal/glance/widget-todo.go b/internal/glance/widget-todo.go deleted file mode 100644 index a261e5d5..00000000 --- a/internal/glance/widget-todo.go +++ /dev/null @@ -1,24 +0,0 @@ -package glance - -import ( - "html/template" -) - -var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html") - -type todoWidget struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - TodoID string `yaml:"id"` -} - -func (widget *todoWidget) initialize() error { - widget.withTitle("To-do").withError(nil) - - widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate) - return nil -} - -func (widget *todoWidget) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/glance/widget-twitch-channels.go b/internal/glance/widget-twitch-channels.go deleted file mode 100644 index 1290a265..00000000 --- a/internal/glance/widget-twitch-channels.go +++ /dev/null @@ -1,238 +0,0 @@ -package glance - -import ( - "context" - "encoding/json" - "fmt" - "html/template" - "log/slog" - "net/http" - "sort" - "strings" - "time" -) - -var twitchChannelsWidgetTemplate = mustParseTemplate("twitch-channels.html", "widget-base.html") - -type twitchChannelsWidget struct { - widgetBase `yaml:",inline"` - ChannelsRequest []string `yaml:"channels"` - Channels []twitchChannel `yaml:"-"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` -} - -func (widget *twitchChannelsWidget) initialize() error { - widget. - withTitle("Twitch Channels"). - withTitleURL("https://www.twitch.tv/directory/following"). - withCacheDuration(time.Minute * 10) - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.SortBy != "viewers" && widget.SortBy != "live" { - widget.SortBy = "viewers" - } - - return nil -} - -func (widget *twitchChannelsWidget) update(ctx context.Context) { - channels, err := fetchChannelsFromTwitch(widget.ChannelsRequest) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.SortBy == "viewers" { - channels.sortByViewers() - } else if widget.SortBy == "live" { - channels.sortByLive() - } - - widget.Channels = channels -} - -func (widget *twitchChannelsWidget) Render() template.HTML { - return widget.renderTemplate(widget, twitchChannelsWidgetTemplate) -} - -type twitchChannel struct { - Login string - Exists bool - Name string - StreamTitle string - AvatarUrl string - IsLive bool - LiveSince time.Time - Category string - CategorySlug string - ViewersCount int -} - -type twitchChannelList []twitchChannel - -func (channels twitchChannelList) sortByViewers() { - sort.Slice(channels, func(i, j int) bool { - return channels[i].ViewersCount > channels[j].ViewersCount - }) -} - -func (channels twitchChannelList) sortByLive() { - sort.SliceStable(channels, func(i, j int) bool { - return channels[i].IsLive && !channels[j].IsLive - }) -} - -type twitchOperationResponse struct { - Data json.RawMessage - Extensions struct { - OperationName string `json:"operationName"` - } -} - -type twitchChannelShellOperationResponse struct { - UserOrError struct { - Type string `json:"__typename"` - DisplayName string `json:"displayName"` - ProfileImageUrl string `json:"profileImageURL"` - Stream *struct { - ViewersCount int `json:"viewersCount"` - } - } `json:"userOrError"` -} - -type twitchStreamMetadataOperationResponse struct { - UserOrNull *struct { - Stream *struct { - StartedAt string `json:"createdAt"` - Game *struct { - Slug string `json:"slug"` - Name string `json:"name"` - } `json:"game"` - } `json:"stream"` - LastBroadcast *struct { - Title string `json:"title"` - } - } `json:"user"` -} - -const twitchChannelStatusOperationRequestBody = `[ -{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}}, -{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}} -]` - -// TODO: rework -// The operations for multiple channels can all be sent in a single request -// rather than sending a separate request for each channel. Need to figure out -// what the limit is for max operations per request and batch operations in -// multiple requests if number of channels exceeds allowed limit. - -func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { - result := twitchChannel{ - Login: strings.ToLower(channel), - } - - reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel)) - request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) - request.Header.Add("Client-ID", twitchGqlClientId) - - response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request) - if err != nil { - return result, err - } - - if len(response) != 2 { - return result, fmt.Errorf("expected 2 operation responses, got %d", len(response)) - } - - var channelShell twitchChannelShellOperationResponse - var streamMetadata twitchStreamMetadataOperationResponse - - for i := range response { - switch response[i].Extensions.OperationName { - case "ChannelShell": - if err = json.Unmarshal(response[i].Data, &channelShell); err != nil { - return result, fmt.Errorf("unmarshalling channel shell: %w", err) - } - case "StreamMetadata": - if err = json.Unmarshal(response[i].Data, &streamMetadata); err != nil { - return result, fmt.Errorf("unmarshalling stream metadata: %w", err) - } - default: - return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName) - } - } - - if channelShell.UserOrError.Type != "User" { - result.Name = result.Login - return result, nil - } - - result.Exists = true - result.Name = channelShell.UserOrError.DisplayName - result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl - - if channelShell.UserOrError.Stream != nil { - result.IsLive = true - result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount - - if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil { - if streamMetadata.UserOrNull.LastBroadcast != nil { - result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title - } - - if streamMetadata.UserOrNull.Stream.Game != nil { - result.Category = streamMetadata.UserOrNull.Stream.Game.Name - result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug - } - startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt) - - if err == nil { - result.LiveSince = startedAt - } else { - slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) - } - } - } else { - // This prevents live channels with 0 viewers from being - // incorrectly sorted lower than offline channels - result.ViewersCount = -1 - } - - return result, nil -} - -func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) { - result := make(twitchChannelList, 0, len(channelLogins)) - - job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10) - channels, errs, err := workerPoolDo(job) - if err != nil { - return result, err - } - - var failed int - - for i := range channels { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i]) - continue - } - - result = append(result, channels[i]) - } - - if failed == len(channelLogins) { - return result, errNoContent - } - - if failed > 0 { - return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed) - } - - return result, nil -} diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go deleted file mode 100644 index 4235bc96..00000000 --- a/internal/glance/widget-twitch-top-games.go +++ /dev/null @@ -1,125 +0,0 @@ -package glance - -import ( - "context" - "errors" - "fmt" - "html/template" - "net/http" - "slices" - "strings" - "time" -) - -var twitchGamesWidgetTemplate = mustParseTemplate("twitch-games-list.html", "widget-base.html") - -type twitchGamesWidget struct { - widgetBase `yaml:",inline"` - Categories []twitchCategory `yaml:"-"` - Exclude []string `yaml:"exclude"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *twitchGamesWidget) initialize() error { - widget. - withTitle("Top games on Twitch"). - withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT"). - withCacheDuration(time.Minute * 10) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - return nil -} - -func (widget *twitchGamesWidget) update(ctx context.Context) { - categories, err := fetchTopGamesFromTwitch(widget.Exclude, widget.Limit) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Categories = categories -} - -func (widget *twitchGamesWidget) Render() template.HTML { - return widget.renderTemplate(widget, twitchGamesWidgetTemplate) -} - -type twitchCategory struct { - Slug string `json:"slug"` - Name string `json:"name"` - AvatarUrl string `json:"avatarURL"` - ViewersCount int `json:"viewersCount"` - Tags []struct { - Name string `json:"tagName"` - } `json:"tags"` - GameReleaseDate string `json:"originalReleaseDate"` - IsNew bool `json:"-"` -} - -type twitchDirectoriesOperationResponse struct { - Data struct { - DirectoriesWithTags struct { - Edges []struct { - Node twitchCategory `json:"node"` - } `json:"edges"` - } `json:"directoriesWithTags"` - } `json:"data"` -} - -const twitchDirectoriesOperationRequestBody = `[ -{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}} -]` - -func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, error) { - reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) - request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) - request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultHTTPClient, request) - if err != nil { - return nil, err - } - - if len(response) == 0 { - return nil, errors.New("no categories could be retrieved") - } - - edges := (response)[0].Data.DirectoriesWithTags.Edges - categories := make([]twitchCategory, 0, len(edges)) - - for i := range edges { - if slices.Contains(exclude, edges[i].Node.Slug) { - continue - } - - category := &edges[i].Node - category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1) - - if len(category.Tags) > 2 { - category.Tags = category.Tags[:2] - } - - gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate) - - if err == nil { - if time.Since(gameReleasedDate) < 14*24*time.Hour { - category.IsNew = true - } - } - - categories = append(categories, *category) - } - - if len(categories) > limit { - categories = categories[:limit] - } - - return categories, nil -} diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go deleted file mode 100644 index ff798640..00000000 --- a/internal/glance/widget-videos.go +++ /dev/null @@ -1,216 +0,0 @@ -package glance - -import ( - "context" - "fmt" - "html/template" - "log/slog" - "net/http" - "net/url" - "sort" - "strings" - "time" -) - -const videosWidgetPlaylistPrefix = "playlist:" - -var ( - videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html") - videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - videosWidgetVerticalListTemplate = mustParseTemplate("videos-vertical-list.html", "widget-base.html") -) - -type videosWidget struct { - widgetBase `yaml:",inline"` - Videos videoList `yaml:"-"` - VideoUrlTemplate string `yaml:"video-url-template"` - Style string `yaml:"style"` - CollapseAfter int `yaml:"collapse-after"` - CollapseAfterRows int `yaml:"collapse-after-rows"` - Channels []string `yaml:"channels"` - Playlists []string `yaml:"playlists"` - Limit int `yaml:"limit"` - IncludeShorts bool `yaml:"include-shorts"` -} - -func (widget *videosWidget) initialize() error { - widget.withTitle("Videos").withCacheDuration(time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 25 - } - - if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 { - widget.CollapseAfterRows = 4 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 7 - } - - // A bit cheeky, but from a user's perspective it makes more sense when channels and - // playlists are separate things rather than specifying a list of channels and some of - // them awkwardly have a "playlist:" prefix - if len(widget.Playlists) > 0 { - initialLen := len(widget.Channels) - widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...) - - for i := range widget.Playlists { - widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i] - } - } - - return nil -} - -func (widget *videosWidget) update(ctx context.Context) { - videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(videos) > widget.Limit { - videos = videos[:widget.Limit] - } - - widget.Videos = videos -} - -func (widget *videosWidget) Render() template.HTML { - var template *template.Template - - switch widget.Style { - case "grid-cards": - template = videosWidgetGridTemplate - case "vertical-list": - template = videosWidgetVerticalListTemplate - default: - template = videosWidgetTemplate - } - - return widget.renderTemplate(widget, template) -} - -type youtubeFeedResponseXml struct { - Channel string `xml:"author>name"` - ChannelLink string `xml:"author>uri"` - Videos []struct { - Title string `xml:"title"` - Published string `xml:"published"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - - Group struct { - Thumbnail struct { - Url string `xml:"url,attr"` - } `xml:"http://search.yahoo.com/mrss/ thumbnail"` - } `xml:"http://search.yahoo.com/mrss/ group"` - } `xml:"entry"` -} - -func parseYoutubeFeedTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - if err != nil { - return time.Now() - } - - return parsedTime -} - -type video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type videoList []video - -func (v videoList) sortByNewest() videoList { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} - -func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool) (videoList, error) { - requests := make([]*http.Request, 0, len(channelOrPlaylistIDs)) - - for i := range channelOrPlaylistIDs { - var feedUrl string - if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) { - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + - strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) - } else if !includeShorts && strings.HasPrefix(channelOrPlaylistIDs[i], "UC") { - playlistId := strings.Replace(channelOrPlaylistIDs[i], "UC", "UULF", 1) - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId - } else { - feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelOrPlaylistIDs[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultHTTPClient), requests).withWorkers(30) - responses, errs, err := workerPoolDo(job) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - videos := make(videoList, 0, len(channelOrPlaylistIDs)*15) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelOrPlaylistIDs[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - v := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = v.Link.Href - } else { - parsedUrl, err := url.Parse(v.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, video{ - ThumbnailUrl: v.Group.Thumbnail.Url, - Title: v.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(v.Published), - }) - } - } - - if len(videos) == 0 { - return nil, errNoContent - } - - videos.sortByNewest() - - if failed > 0 { - return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed) - } - - return videos, nil -} diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go deleted file mode 100644 index 79861d0b..00000000 --- a/internal/glance/widget-weather.go +++ /dev/null @@ -1,326 +0,0 @@ -package glance - -import ( - "context" - "errors" - "fmt" - "html/template" - "math" - "net/http" - "net/url" - "slices" - "strings" - "time" - - _ "time/tzdata" -) - -var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html") - -type weatherWidget struct { - widgetBase `yaml:",inline"` - Location string `yaml:"location"` - ShowAreaName bool `yaml:"show-area-name"` - HideLocation bool `yaml:"hide-location"` - HourFormat string `yaml:"hour-format"` - Units string `yaml:"units"` - Place *openMeteoPlaceResponseJson `yaml:"-"` - Weather *weather `yaml:"-"` - TimeLabels [12]string `yaml:"-"` -} - -var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"} -var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"} - -func (widget *weatherWidget) initialize() error { - widget.withTitle("Weather").withCacheOnTheHour() - - if widget.Location == "" { - return fmt.Errorf("location is required") - } - - if widget.HourFormat == "" || widget.HourFormat == "12h" { - widget.TimeLabels = timeLabels12h - } else if widget.HourFormat == "24h" { - widget.TimeLabels = timeLabels24h - } else { - return errors.New("hour-format must be either 12h or 24h") - } - - if widget.Units == "" { - widget.Units = "metric" - } else if widget.Units != "metric" && widget.Units != "imperial" { - return errors.New("units must be either metric or imperial") - } - - return nil -} - -func (widget *weatherWidget) update(ctx context.Context) { - if widget.Place == nil { - place, err := fetchOpenMeteoPlaceFromName(widget.Location) - if err != nil { - widget.withError(err).scheduleEarlyUpdate() - return - } - - widget.Place = place - } - - weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Weather = weather -} - -func (widget *weatherWidget) Render() template.HTML { - return widget.renderTemplate(widget, weatherWidgetTemplate) -} - -type weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -func (w *weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -type openMeteoPlacesResponseJson struct { - Results []openMeteoPlaceResponseJson -} - -type openMeteoPlaceResponseJson struct { - Name string - Area string `json:"admin1"` - Latitude float64 - Longitude float64 - Timezone string - Country string - location *time.Location -} - -type openMeteoWeatherResponseJson struct { - Daily struct { - Sunrise []int64 `json:"sunrise"` - Sunset []int64 `json:"sunset"` - } `json:"daily"` - - Hourly struct { - Temperature []float64 `json:"temperature_2m"` - PrecipitationProbability []int `json:"precipitation_probability"` - } `json:"hourly"` - - Current struct { - Temperature float64 `json:"temperature_2m"` - ApparentTemperature float64 `json:"apparent_temperature"` - WeatherCode int `json:"weather_code"` - } `json:"current"` -} - -type weatherColumn struct { - Temperature int - Scale float64 - HasPrecipitation bool -} - -var commonCountryAbbreviations = map[string]string{ - "US": "United States", - "USA": "United States", - "UK": "United Kingdom", -} - -func expandCountryAbbreviations(name string) string { - if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok { - return expanded - } - - return name -} - -// Separates the location that Open Meteo accepts from the administrative area -// which can then be used to filter to the correct place after the list of places -// has been retrieved. Also expands abbreviations since Open Meteo does not accept -// country names like "US", "USA" and "UK" -func parsePlaceName(name string) (string, string) { - parts := strings.Split(name, ",") - - if len(parts) == 1 { - return name, "" - } - - if len(parts) == 2 { - return parts[0] + ", " + expandCountryAbbreviations(parts[1]), "" - } - - return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1]) -} - -func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) { - location, area := parsePlaceName(location) - requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=20&language=en&format=json", url.QueryEscape(location)) - request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request) - if err != nil { - return nil, fmt.Errorf("fetching places data: %v", err) - } - - if len(responseJson.Results) == 0 { - return nil, fmt.Errorf("no places found for %s", location) - } - - var place *openMeteoPlaceResponseJson - - if area != "" { - area = strings.ToLower(area) - - for i := range responseJson.Results { - if strings.ToLower(responseJson.Results[i].Area) == area { - place = &responseJson.Results[i] - break - } - } - - if place == nil { - return nil, fmt.Errorf("no place found for %s in %s", location, area) - } - } else { - place = &responseJson.Results[0] - } - - loc, err := time.LoadLocation(place.Timezone) - if err != nil { - return nil, fmt.Errorf("loading location: %v", err) - } - - place.location = loc - - return place, nil -} - -func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) { - query := url.Values{} - var temperatureUnit string - - if units == "imperial" { - temperatureUnit = "fahrenheit" - } else { - temperatureUnit = "celsius" - } - - query.Add("latitude", fmt.Sprintf("%f", place.Latitude)) - query.Add("longitude", fmt.Sprintf("%f", place.Longitude)) - query.Add("timeformat", "unixtime") - query.Add("timezone", place.Timezone) - query.Add("forecast_days", "1") - query.Add("current", "temperature_2m,apparent_temperature,weather_code") - query.Add("hourly", "temperature_2m,precipitation_probability") - query.Add("daily", "sunrise,sunset") - query.Add("temperature_unit", temperatureUnit) - - requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() - request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request) - if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) - } - - now := time.Now().In(place.location) - bars := make([]weatherColumn, 0, 24) - currentBar := now.Hour() / 2 - sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2 - sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2 - - if sunsetBar < 0 { - sunsetBar = 0 - } - - if len(responseJson.Hourly.Temperature) == 24 { - temperatures := make([]int, 12) - precipitations := make([]bool, 12) - - t := responseJson.Hourly.Temperature - p := responseJson.Hourly.PrecipitationProbability - - for i := 0; i < 24; i += 2 { - if i/2 == currentBar { - temperatures[i/2] = int(responseJson.Current.Temperature) - } else { - temperatures[i/2] = int(math.Round((t[i] + t[i+1]) / 2)) - } - - precipitations[i/2] = (p[i]+p[i+1])/2 > 75 - } - - minT := slices.Min(temperatures) - maxT := slices.Max(temperatures) - - temperaturesRange := float64(maxT - minT) - - for i := 0; i < 12; i++ { - bars = append(bars, weatherColumn{ - Temperature: temperatures[i], - HasPrecipitation: precipitations[i], - }) - - if temperaturesRange > 0 { - bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange - } else { - bars[i].Scale = 1 - } - } - } - - return &weather{ - Temperature: int(responseJson.Current.Temperature), - ApparentTemperature: int(responseJson.Current.ApparentTemperature), - WeatherCode: responseJson.Current.WeatherCode, - CurrentColumn: currentBar, - SunriseColumn: sunriseBar, - SunsetColumn: sunsetBar, - Columns: bars, - }, nil -} - -var weatherCodeTable = map[int]string{ - 0: "Clear Sky", - 1: "Mainly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Rime Fog", - 51: "Drizzle", - 53: "Drizzle", - 55: "Drizzle", - 56: "Drizzle", - 57: "Drizzle", - 61: "Rain", - 63: "Moderate Rain", - 65: "Heavy Rain", - 66: "Freezing Rain", - 67: "Freezing Rain", - 71: "Snow", - 73: "Moderate Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Rain", - 81: "Moderate Rain", - 82: "Heavy Rain", - 85: "Snow", - 86: "Snow", - 95: "Thunderstorm", - 96: "Thunderstorm", - 99: "Thunderstorm", -} diff --git a/internal/glance/widget.go b/internal/glance/widget.go deleted file mode 100644 index 50dc3cb5..00000000 --- a/internal/glance/widget.go +++ /dev/null @@ -1,367 +0,0 @@ -package glance - -import ( - "bytes" - "context" - "errors" - "fmt" - "html/template" - "log/slog" - "math" - "net/http" - "sync/atomic" - "time" - - "gopkg.in/yaml.v3" -) - -var widgetIDCounter atomic.Uint64 - -func newWidget(widgetType string) (widget, error) { - if widgetType == "" { - return nil, errors.New("widget 'type' property is empty or not specified") - } - - var w widget - - switch widgetType { - case "calendar": - w = &calendarWidget{} - case "calendar-legacy": - w = &oldCalendarWidget{} - case "clock": - w = &clockWidget{} - case "weather": - w = &weatherWidget{} - case "bookmarks": - w = &bookmarksWidget{} - case "iframe": - w = &iframeWidget{} - case "html": - w = &htmlWidget{} - case "hacker-news": - w = &hackerNewsWidget{} - case "releases": - w = &releasesWidget{} - case "videos": - w = &videosWidget{} - case "markets", "stocks": - w = &marketsWidget{} - case "reddit": - w = &redditWidget{} - case "rss": - w = &rssWidget{} - case "monitor": - w = &monitorWidget{} - case "twitch-top-games": - w = &twitchGamesWidget{} - case "twitch-channels": - w = &twitchChannelsWidget{} - case "lobsters": - w = &lobstersWidget{} - case "change-detection": - w = &changeDetectionWidget{} - case "repository": - w = &repositoryWidget{} - case "search": - w = &searchWidget{} - case "extension": - w = &extensionWidget{} - case "group": - w = &groupWidget{} - case "dns-stats": - w = &dnsStatsWidget{} - case "split-column": - w = &splitColumnWidget{} - case "custom-api": - w = &customAPIWidget{} - case "docker-containers": - w = &dockerContainersWidget{} - case "server-stats": - w = &serverStatsWidget{} - case "to-do": - w = &todoWidget{} - default: - return nil, fmt.Errorf("unknown widget type: %s", widgetType) - } - - w.setID(widgetIDCounter.Add(1)) - - return w, nil -} - -type widgets []widget - -func (w *widgets) UnmarshalYAML(node *yaml.Node) error { - var nodes []yaml.Node - - if err := node.Decode(&nodes); err != nil { - return err - } - - for _, node := range nodes { - meta := struct { - Type string `yaml:"type"` - }{} - - if err := node.Decode(&meta); err != nil { - return err - } - - widget, err := newWidget(meta.Type) - if err != nil { - return fmt.Errorf("line %d: %w", node.Line, err) - } - - if err = node.Decode(widget); err != nil { - return err - } - - *w = append(*w, widget) - } - - return nil -} - -type widget interface { - // These need to be exported because they get called in templates - Render() template.HTML - GetType() string - GetID() uint64 - - initialize() error - requiresUpdate(*time.Time) bool - setProviders(*widgetProviders) - update(context.Context) - setID(uint64) - handleRequest(w http.ResponseWriter, r *http.Request) - setHideHeader(bool) -} - -type cacheType int - -const ( - cacheTypeInfinite cacheType = iota - cacheTypeDuration - cacheTypeOnTheHour -) - -type widgetBase struct { - ID uint64 `yaml:"-"` - Providers *widgetProviders `yaml:"-"` - Type string `yaml:"type"` - Title string `yaml:"title"` - TitleURL string `yaml:"title-url"` - HideHeader bool `yaml:"hide-header"` - CSSClass string `yaml:"css-class"` - CustomCacheDuration durationField `yaml:"cache"` - ContentAvailable bool `yaml:"-"` - WIP bool `yaml:"-"` - Error error `yaml:"-"` - Notice error `yaml:"-"` - templateBuffer bytes.Buffer `yaml:"-"` - cacheDuration time.Duration `yaml:"-"` - cacheType cacheType `yaml:"-"` - nextUpdate time.Time `yaml:"-"` - updateRetriedTimes int `yaml:"-"` -} - -type widgetProviders struct { - assetResolver func(string) string -} - -func (w *widgetBase) requiresUpdate(now *time.Time) bool { - if w.cacheType == cacheTypeInfinite { - return false - } - - if w.nextUpdate.IsZero() { - return true - } - - return now.After(w.nextUpdate) -} - -func (w *widgetBase) IsWIP() bool { - return w.WIP -} - -func (w *widgetBase) update(ctx context.Context) { - -} - -func (w *widgetBase) GetID() uint64 { - return w.ID -} - -func (w *widgetBase) setID(id uint64) { - w.ID = id -} - -func (w *widgetBase) setHideHeader(value bool) { - w.HideHeader = value -} - -func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) -} - -func (w *widgetBase) GetType() string { - return w.Type -} - -func (w *widgetBase) setProviders(providers *widgetProviders) { - w.Providers = providers -} - -func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { - w.templateBuffer.Reset() - err := t.Execute(&w.templateBuffer, data) - if err != nil { - w.ContentAvailable = false - w.Error = err - - slog.Error("Failed to render template", "error", err) - - // need to immediately re-render with the error, - // otherwise risk breaking the page since the widget - // will likely be partially rendered with tags not closed. - w.templateBuffer.Reset() - err2 := t.Execute(&w.templateBuffer, data) - - if err2 != nil { - slog.Error("Failed to render error within widget", "error", err2, "initial_error", err) - w.templateBuffer.Reset() - // TODO: add some kind of a generic widget error template when the widget - // failed to render, and we also failed to re-render the widget with the error - } - } - - return template.HTML(w.templateBuffer.String()) -} - -func (w *widgetBase) withTitle(title string) *widgetBase { - if w.Title == "" { - w.Title = title - } - - return w -} - -func (w *widgetBase) withTitleURL(titleURL string) *widgetBase { - if w.TitleURL == "" { - w.TitleURL = titleURL - } - - return w -} - -func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase { - w.cacheType = cacheTypeDuration - - if duration == -1 || w.CustomCacheDuration == 0 { - w.cacheDuration = duration - } else { - w.cacheDuration = time.Duration(w.CustomCacheDuration) - } - - return w -} - -func (w *widgetBase) withCacheOnTheHour() *widgetBase { - w.cacheType = cacheTypeOnTheHour - - return w -} - -func (w *widgetBase) withNotice(err error) *widgetBase { - w.Notice = err - - return w -} - -func (w *widgetBase) withError(err error) *widgetBase { - if err == nil && !w.ContentAvailable { - w.ContentAvailable = true - } - - w.Error = err - - return w -} - -func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool { - // TODO: needs covering more edge cases. - // if there's partial content and we update early there's a chance - // the early update returns even less content than the initial update. - // need some kind of mechanism that tells us whether we should update early - // or not depending on the number of things that failed during the initial - // and subsequent update and how they failed - ie whether it was server - // error (like gateway timeout, do retry early) or client error (like - // hitting a rate limit, don't retry early). will require reworking a - // good amount of code in the feed package and probably having a custom - // error type that holds more information because screw wrapping errors. - // alternatively have a resource cache and only refetch the failed resources, - // then rebuild the widget. - - if err != nil { - w.scheduleEarlyUpdate() - - if !errors.Is(err, errPartialContent) { - w.withError(err) - w.withNotice(nil) - return false - } - - w.withError(nil) - w.withNotice(err) - return true - } - - w.withNotice(nil) - w.withError(nil) - w.scheduleNextUpdate() - return true -} - -func (w *widgetBase) getNextUpdateTime() time.Time { - now := time.Now() - - if w.cacheType == cacheTypeDuration { - return now.Add(w.cacheDuration) - } - - if w.cacheType == cacheTypeOnTheHour { - return now.Add(time.Duration( - ((60-now.Minute())*60)-now.Second(), - ) * time.Second) - } - - return time.Time{} -} - -func (w *widgetBase) scheduleNextUpdate() *widgetBase { - w.nextUpdate = w.getNextUpdateTime() - w.updateRetriedTimes = 0 - - return w -} - -func (w *widgetBase) scheduleEarlyUpdate() *widgetBase { - w.updateRetriedTimes++ - - if w.updateRetriedTimes > 5 { - w.updateRetriedTimes = 5 - } - - nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute) - nextUsualUpdate := w.getNextUpdateTime() - - if nextEarlyUpdate.After(nextUsualUpdate) { - w.nextUpdate = nextUsualUpdate - } else { - w.nextUpdate = nextEarlyUpdate - } - - return w -} diff --git a/main.go b/main.go index fa1d7f2f..e676ad0f 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,9 @@ package main import ( "os" - "github.com/glanceapp/glance/internal/glance" + "github.com/glanceapp/glance/pkg/widgets" ) func main() { - os.Exit(glance.Main()) + os.Exit(widgets.Main()) } diff --git a/internal/glance/widget-shared.go b/pkg/sources/forum-post.go similarity index 66% rename from internal/glance/widget-shared.go rename to pkg/sources/forum-post.go index 45144ac8..4ad99658 100644 --- a/internal/glance/widget-shared.go +++ b/pkg/sources/forum-post.go @@ -1,4 +1,4 @@ -package glance +package sources import ( "math" @@ -6,13 +6,14 @@ import ( "time" ) -const twitchGqlEndpoint = "https://gql.twitch.tv/gql" -const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" - -var forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") - type forumPost struct { - Title string + ID string + title string + Description string + // MatchSummary is the LLM generated rationale for why this is a good match for the filter query + MatchSummary string + // MatchScore is the LLM generated score indicating how well this post matches the query + MatchScore int DiscussionUrl string TargetUrl string TargetUrlDomain string @@ -25,6 +26,30 @@ type forumPost struct { IsCrosspost bool } +func (f forumPost) UID() string { + return f.ID +} + +func (f forumPost) Title() string { + return f.title +} + +func (f forumPost) Body() string { + return f.Description +} + +func (f forumPost) URL() string { + return f.TargetUrl +} + +func (f forumPost) ImageURL() string { + return f.ThumbnailUrl +} + +func (f forumPost) CreatedAt() time.Time { + return f.TimePosted +} + type forumPostList []forumPost const depreciatePostsOlderThanHours = 7 diff --git a/pkg/sources/http-proxy.go b/pkg/sources/http-proxy.go new file mode 100644 index 00000000..6d678174 --- /dev/null +++ b/pkg/sources/http-proxy.go @@ -0,0 +1,95 @@ +package sources + +import ( + "crypto/tls" + "fmt" + "gopkg.in/yaml.v3" + "net/http" + "net/url" + "regexp" + "strconv" + "time" +) + +type proxyOptionsField struct { + URL string `yaml:"url"` + AllowInsecure bool `yaml:"allow-insecure"` + Timeout durationField `yaml:"timeout"` + client *http.Client `yaml:"-"` +} + +func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error { + type proxyOptionsFieldAlias proxyOptionsField + alias := (*proxyOptionsFieldAlias)(p) + var proxyURL string + + if err := node.Decode(&proxyURL); err != nil { + if err := node.Decode(alias); err != nil { + return err + } + } + + if proxyURL == "" && p.URL == "" { + return nil + } + + if p.URL != "" { + proxyURL = p.URL + } + + parsedUrl, err := url.Parse(proxyURL) + if err != nil { + return fmt.Errorf("parsing proxy URL: %v", err) + } + + var timeout = defaultClientTimeout + if p.Timeout > 0 { + timeout = time.Duration(p.Timeout) + } + + p.client = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedUrl), + TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure}, + }, + } + + return nil +} + +var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) + +type durationField time.Duration + +func (d *durationField) UnmarshalYAML(node *yaml.Node) error { + var value string + + if err := node.Decode(&value); err != nil { + return err + } + + matches := durationFieldPattern.FindStringSubmatch(value) + + if len(matches) != 3 { + return fmt.Errorf("invalid duration format: %s", value) + } + + duration, err := strconv.Atoi(matches[1]) + if err != nil { + return err + } + + switch matches[2] { + case "s": + *d = durationField(time.Duration(duration) * time.Second) + case "m": + *d = durationField(time.Duration(duration) * time.Minute) + case "h": + *d = durationField(time.Duration(duration) * time.Hour) + case "d": + *d = durationField(time.Duration(duration) * 24 * time.Hour) + } + + return nil +} diff --git a/pkg/sources/source.go b/pkg/sources/source.go new file mode 100644 index 00000000..522a64cf --- /dev/null +++ b/pkg/sources/source.go @@ -0,0 +1,222 @@ +package sources + +import ( + "errors" + "fmt" + "math" + "time" +) + +func NewSource(widgetType string) (Source, error) { + if widgetType == "" { + return nil, errors.New("widget 'type' property is empty or not specified") + } + + var s Source + + switch widgetType { + case "mastodon": + s = &mastodonSource{} + case "hacker-news": + s = &hackerNewsSource{} + case "reddit": + s = &redditSource{} + case "lobsters": + s = &lobstersSource{} + case "rss": + s = &rssSource{} + case "releases": + s = &githubReleasesSource{} + case "issues": + s = &githubIssuesSource{} + case "change-detection": + s = &changeDetectionWidget{} + default: + return nil, fmt.Errorf("unknown widget type: %s", widgetType) + } + + return s, nil +} + +// Source TODO(pulse): Feed() returns cached activities, but refactor it to fetch fresh activities given filters and cache them in a global activity registry. +type Source interface { + // Feed return cached feed entries in a standard Activity format. + Feed() []Activity + RequiresUpdate(now *time.Time) bool +} + +type Activity interface { + UID() string + Title() string + Body() string + URL() string + ImageURL() string + CreatedAt() time.Time + // TODO: Add Metadata() that returns custom fields? +} + +type cacheType int + +const ( + cacheTypeInfinite cacheType = iota + cacheTypeDuration + cacheTypeOnTheHour +) + +type sourceBase struct { + ID uint64 `yaml:"-"` + Title string `yaml:"title"` + TitleURL string `yaml:"title-url"` + ContentAvailable bool `yaml:"-"` + Error error `yaml:"-"` + Notice error `yaml:"-"` + Providers *sourceProviders `yaml:"-"` + CustomCacheDuration durationField `yaml:"cache"` + cacheDuration time.Duration `yaml:"-"` + cacheType cacheType `yaml:"-"` + nextUpdate time.Time `yaml:"-"` + updateRetriedTimes int `yaml:"-"` +} + +// TODO(pulse): Do we need this? +type sourceProviders struct { + assetResolver func(string) string +} + +func (w *sourceBase) withTitle(title string) *sourceBase { + if w.Title == "" { + w.Title = title + } + + return w +} + +func (w *sourceBase) withTitleURL(titleURL string) *sourceBase { + if w.TitleURL == "" { + w.TitleURL = titleURL + } + + return w +} + +func (w *sourceBase) RequiresUpdate(now *time.Time) bool { + if w.cacheType == cacheTypeInfinite { + return false + } + + if w.nextUpdate.IsZero() { + return true + } + + return now.After(w.nextUpdate) +} + +func (w *sourceBase) withCacheDuration(duration time.Duration) *sourceBase { + w.cacheType = cacheTypeDuration + + if duration == -1 || w.CustomCacheDuration == 0 { + w.cacheDuration = duration + } else { + w.cacheDuration = time.Duration(w.CustomCacheDuration) + } + + return w +} + +func (w *sourceBase) withCacheOnTheHour() *sourceBase { + w.cacheType = cacheTypeOnTheHour + + return w +} + +func (w *sourceBase) getNextUpdateTime() time.Time { + now := time.Now() + + if w.cacheType == cacheTypeDuration { + return now.Add(w.cacheDuration) + } + + if w.cacheType == cacheTypeOnTheHour { + return now.Add(time.Duration( + ((60-now.Minute())*60)-now.Second(), + ) * time.Second) + } + + return time.Time{} +} + +func (w *sourceBase) scheduleNextUpdate() *sourceBase { + w.nextUpdate = w.getNextUpdateTime() + w.updateRetriedTimes = 0 + + return w +} + +func (w *sourceBase) scheduleEarlyUpdate() *sourceBase { + w.updateRetriedTimes++ + + if w.updateRetriedTimes > 5 { + w.updateRetriedTimes = 5 + } + + nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute) + nextUsualUpdate := w.getNextUpdateTime() + + if nextEarlyUpdate.After(nextUsualUpdate) { + w.nextUpdate = nextUsualUpdate + } else { + w.nextUpdate = nextEarlyUpdate + } + + return w +} + +func (w *sourceBase) withNotice(err error) *sourceBase { + w.Notice = err + + return w +} + +func (w *sourceBase) withError(err error) *sourceBase { + if err == nil && !w.ContentAvailable { + w.ContentAvailable = true + } + + w.Error = err + + return w +} + +func (s *sourceBase) canContinueUpdateAfterHandlingErr(err error) bool { + // TODO: needs covering more edge cases. + // if there's partial content and we update early there's a chance + // the early update returns even less content than the initial update. + // need some kind of mechanism that tells us whether we should update early + // or not depending on the number of things that failed during the initial + // and subsequent update and how they failed - ie whether it was server + // error (like gateway timeout, do retry early) or client error (like + // hitting a rate limit, don't retry early). will require reworking a + // good amount of code in the feed package and probably having a custom + // error type that holds more information because screw wrapping errors. + // alternatively have a resource cache and only refetch the failed resources, + // then rebuild the widget. + + if err != nil { + s.scheduleEarlyUpdate() + + if !errors.Is(err, errPartialContent) { + s.withError(err) + s.withNotice(nil) + return false + } + + s.withError(nil) + s.withNotice(err) + return true + } + + s.withNotice(nil) + s.withError(nil) + s.scheduleNextUpdate() + return true +} diff --git a/internal/glance/widget-utils.go b/pkg/sources/utils.go similarity index 82% rename from internal/glance/widget-utils.go rename to pkg/sources/utils.go index fa2fad55..cd7c4dc4 100644 --- a/internal/glance/widget-utils.go +++ b/pkg/sources/utils.go @@ -1,4 +1,4 @@ -package glance +package sources import ( "context" @@ -10,7 +10,10 @@ import ( "io" "math/rand/v2" "net/http" + "net/url" + "regexp" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -41,7 +44,9 @@ type requestDoer interface { Do(*http.Request) (*http.Response, error) } -var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance" +var BuildVersion = "dev" + +var pulseUserAgentString = "Pulse/" + BuildVersion + " +https://github.com/bartolomej/pulse" var userAgentPersistentVersion atomic.Int32 func getBrowserUserAgentHeader() string { @@ -238,3 +243,51 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error) return results, errs, err } + +func limitStringLength(s string, max int) (string, bool) { + asRunes := []rune(s) + + if len(asRunes) > max { + return string(asRunes[:max]), true + } + + return s, false +} + +func parseRFC3339Time(t string) time.Time { + parsed, err := time.Parse(time.RFC3339, t) + if err != nil { + return time.Now() + } + + return parsed +} + +func normalizeVersionFormat(version string) string { + version = strings.ToLower(strings.TrimSpace(version)) + + if len(version) > 0 && version[0] != 'v' { + return "v" + version + } + + return version +} + +var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) + +func stripURLScheme(url string) string { + return urlSchemePattern.ReplaceAllString(url, "") +} + +func extractDomainFromUrl(u string) string { + if u == "" { + return "" + } + + parsed, err := url.Parse(u) + if err != nil { + return "" + } + + return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") +} diff --git a/internal/glance/widget-changedetection.go b/pkg/sources/widget-changedetection.go similarity index 68% rename from internal/glance/widget-changedetection.go rename to pkg/sources/widget-changedetection.go index 8ca8803b..1dfb7520 100644 --- a/internal/glance/widget-changedetection.go +++ b/pkg/sources/widget-changedetection.go @@ -1,9 +1,8 @@ -package glance +package sources import ( "context" "fmt" - "html/template" "log/slog" "net/http" "sort" @@ -11,10 +10,8 @@ import ( "time" ) -var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html") - type changeDetectionWidget struct { - widgetBase `yaml:",inline"` + sourceBase `yaml:",inline"` ChangeDetections changeDetectionWatchList `yaml:"-"` WatchUUIDs []string `yaml:"watches"` InstanceURL string `yaml:"instance-url"` @@ -23,60 +20,89 @@ type changeDetectionWidget struct { CollapseAfter int `yaml:"collapse-after"` } -func (widget *changeDetectionWidget) initialize() error { - widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) +func (s *changeDetectionWidget) Feed() []Activity { + activities := make([]Activity, len(s.ChangeDetections)) + for i, c := range s.ChangeDetections { + activities[i] = c + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 10 +func (s *changeDetectionWidget) initialize() error { + s.withTitle("Change Detection").withCacheDuration(1 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 10 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.InstanceURL == "" { - widget.InstanceURL = "https://www.changedetection.io" + if s.InstanceURL == "" { + s.InstanceURL = "https://www.changedetection.io" } return nil } -func (widget *changeDetectionWidget) update(ctx context.Context) { - if len(widget.WatchUUIDs) == 0 { - uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token)) +func (s *changeDetectionWidget) update(ctx context.Context) { + if len(s.WatchUUIDs) == 0 { + uuids, err := fetchWatchUUIDsFromChangeDetection(s.InstanceURL, string(s.Token)) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - widget.WatchUUIDs = uuids + s.WatchUUIDs = uuids } - watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) + watches, err := fetchWatchesFromChangeDetection(s.InstanceURL, s.WatchUUIDs, string(s.Token)) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(watches) > widget.Limit { - watches = watches[:widget.Limit] + if len(watches) > s.Limit { + watches = watches[:s.Limit] } - widget.ChangeDetections = watches -} - -func (widget *changeDetectionWidget) Render() template.HTML { - return widget.renderTemplate(widget, changeDetectionWidgetTemplate) + s.ChangeDetections = watches } type changeDetectionWatch struct { - Title string - URL string + title string + url string LastChanged time.Time DiffURL string PreviousHash string } +func (c changeDetectionWatch) UID() string { + return fmt.Sprintf("%s-%d", c.url, c.LastChanged.Unix()) +} + +func (c changeDetectionWatch) Title() string { + return c.title +} + +func (c changeDetectionWatch) Body() string { + return "" +} + +func (c changeDetectionWatch) URL() string { + return c.url +} + +func (c changeDetectionWatch) ImageURL() string { + // TODO(pulse): Use website favicon + return "" +} + +func (c changeDetectionWatch) CreatedAt() time.Time { + return c.LastChanged +} + type changeDetectionWatchList []changeDetectionWatch func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList { @@ -154,7 +180,7 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str watchJson := responses[i] watch := changeDetectionWatch{ - URL: watchJson.URL, + url: watchJson.URL, DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), } @@ -165,9 +191,9 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str } if watchJson.Title != "" { - watch.Title = watchJson.Title + watch.title = watchJson.Title } else { - watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.") + watch.title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.") } if watchJson.PreviousHash != "" { diff --git a/pkg/sources/widget-github-issues.go b/pkg/sources/widget-github-issues.go new file mode 100644 index 00000000..6b1ae257 --- /dev/null +++ b/pkg/sources/widget-github-issues.go @@ -0,0 +1,323 @@ +package sources + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type githubIssuesSource struct { + sourceBase `yaml:",inline"` + Issues issueActivityList `yaml:"-"` + Repositories []*issueRequest `yaml:"repositories"` + Token string `yaml:"token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ActivityTypes []string `yaml:"activity-types"` +} + +func (s *githubIssuesSource) Feed() []Activity { + activities := make([]Activity, len(s.Issues)) + for i, issue := range s.Issues { + activities[i] = issue + } + return activities +} + +type issueActivity struct { + ID string + Summary string + Description string + Source string + SourceIconURL string + Repository string + IssueNumber int + title string + State string + ActivityType string + IssueType string + HTMLURL string + TimeUpdated time.Time + MatchScore int +} + +func (i issueActivity) UID() string { + return i.ID +} + +func (i issueActivity) Title() string { + return i.title +} + +func (i issueActivity) Body() string { + return i.Description +} + +func (i issueActivity) URL() string { + return i.HTMLURL +} + +func (i issueActivity) ImageURL() string { + return i.SourceIconURL +} + +func (i issueActivity) CreatedAt() time.Time { + return i.TimeUpdated +} + +type issueActivityList []issueActivity + +func (i issueActivityList) sortByNewest() issueActivityList { + sort.Slice(i, func(a, b int) bool { + return i[a].TimeUpdated.After(i[b].TimeUpdated) + }) + return i +} + +type issueRequest struct { + Repository string `yaml:"repository"` + token *string +} + +func (i *issueRequest) UnmarshalYAML(node *yaml.Node) error { + var repository string + + if err := node.Decode(&repository); err != nil { + type issueRequestAlias issueRequest + alias := (*issueRequestAlias)(i) + if err := node.Decode(alias); err != nil { + return fmt.Errorf("could not unmarshal repository into string or struct: %v", err) + } + } + + if i.Repository == "" { + if repository == "" { + return errors.New("repository is required") + } + i.Repository = repository + } + + return nil +} + +func (s *githubIssuesSource) initialize() error { + s.withTitle("Issue Activity").withCacheDuration(30 * time.Minute) + + if s.Limit <= 0 { + s.Limit = 10 + } + + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 + } + + if len(s.ActivityTypes) == 0 { + s.ActivityTypes = []string{"opened", "closed", "commented"} + } + + for i := range s.Repositories { + r := s.Repositories[i] + if s.Token != "" { + r.token = &s.Token + } + } + + return nil +} + +func (s *githubIssuesSource) update(ctx context.Context) { + activities, err := fetchIssueActivities(s.Repositories, s.ActivityTypes) + + if !s.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(activities) > s.Limit { + activities = activities[:s.Limit] + } + + for i := range activities { + activities[i].SourceIconURL = s.Providers.assetResolver("icons/github.svg") + } + + s.Issues = activities +} + +type githubIssueResponse struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + UpdatedAt string `json:"updated_at"` + Body string `json:"body"` + PullRequest *struct{} `json:"pull_request,omitempty"` +} + +type githubIssueCommentResponse struct { + ID int `json:"ID"` + Body string `json:"body"` + IssueURL string `json:"issue_url"` + HTMLURL string `json:"html_url"` + UpdatedAt string `json:"updated_at"` +} + +func fetchIssueActivities(requests []*issueRequest, activityTypes []string) (issueActivityList, error) { + job := newJob(fetchIssueActivityTask, requests).withWorkers(20) + results, errs, err := workerPoolDo(job) + if err != nil { + return nil, err + } + + var failed int + activities := make(issueActivityList, 0, len(requests)*len(activityTypes)) + + for i := range results { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch issue activity", "repository", requests[i].Repository, "error", errs[i]) + continue + } + + activities = append(activities, results[i]...) + } + + if failed == len(requests) { + return nil, errNoContent + } + + activities.sortByNewest() + + if failed > 0 { + return activities, fmt.Errorf("%w: could not get issue activities for %d repositories", errPartialContent, failed) + } + + return activities, nil +} + +func fetchIssueActivityTask(request *issueRequest) ([]issueActivity, error) { + activities := make([]issueActivity, 0) + + issues, err := fetchLatestIssues(request) + if err != nil { + return nil, err + } + + comments, err := fetchLatestComments(request) + if err != nil { + return nil, err + } + + for _, issue := range issues { + issueType := "issue" + if issue.PullRequest != nil { + issueType = "pull request" + } + activities = append(activities, issueActivity{ + ID: fmt.Sprintf("issue-%d", issue.Number), + Description: issue.Body, + Source: "github", + Repository: request.Repository, + IssueNumber: issue.Number, + title: issue.Title, + State: issue.State, + ActivityType: issue.State, + IssueType: issueType, + HTMLURL: issue.HTMLURL, + TimeUpdated: parseRFC3339Time(issue.UpdatedAt), + }) + } + + for _, comment := range comments { + issueNumber := 0 + if comment.IssueURL != "" { + parts := strings.Split(comment.IssueURL, "/") + if len(parts) > 0 { + if n, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + issueNumber = n + } + } + } + title := comment.Body + titleLimit := 40 + if len(title) > titleLimit { + title = title[:titleLimit] + "..." + } + activities = append(activities, issueActivity{ + ID: fmt.Sprintf("comment-%d", comment.ID), + Description: comment.Body, + IssueNumber: issueNumber, + Source: "github", + Repository: request.Repository, + ActivityType: "commented", + title: title, + IssueType: "issue", + HTMLURL: comment.HTMLURL, + TimeUpdated: parseRFC3339Time(comment.UpdatedAt), + }) + } + + return activities, nil +} + +func fetchLatestIssues(request *issueRequest) ([]githubIssueResponse, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/issues?state=all&sort=updated&direction=desc&per_page=10", request.Repository), + nil, + ) + if err != nil { + return nil, err + } + + // TODO(pulse): Change secrets config approach + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } + + response, err := decodeJsonFromRequest[[]githubIssueResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return response, nil +} + +func fetchLatestComments(request *issueRequest) ([]githubIssueCommentResponse, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/issues/comments?sort=updated&direction=desc&per_page=10", request.Repository), + nil, + ) + if err != nil { + return nil, err + } + + // TODO(pulse): Change secrets config approach + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } + + response, err := decodeJsonFromRequest[[]githubIssueCommentResponse](defaultHTTPClient, httpRequest) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/internal/glance/widget-releases.go b/pkg/sources/widget-github-releases.go similarity index 83% rename from internal/glance/widget-releases.go rename to pkg/sources/widget-github-releases.go index de56bc51..665ddcb9 100644 --- a/internal/glance/widget-releases.go +++ b/pkg/sources/widget-github-releases.go @@ -1,24 +1,23 @@ -package glance +package sources import ( "context" "errors" "fmt" - "html/template" "log/slog" "net/http" "net/url" + "os" "sort" + "strconv" "strings" "time" "gopkg.in/yaml.v3" ) -var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html") - -type releasesWidget struct { - widgetBase `yaml:",inline"` +type githubReleasesSource struct { + sourceBase `yaml:",inline"` Releases appReleaseList `yaml:"-"` Repositories []*releaseRequest `yaml:"repositories"` Token string `yaml:"token"` @@ -28,50 +27,54 @@ type releasesWidget struct { ShowSourceIcon bool `yaml:"show-source-icon"` } -func (widget *releasesWidget) initialize() error { - widget.withTitle("Releases").withCacheDuration(2 * time.Hour) +func (s *githubReleasesSource) Feed() []Activity { + activities := make([]Activity, len(s.Releases)) + for i, r := range s.Releases { + activities[i] = r + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 10 +func (s *githubReleasesSource) initialize() error { + s.withTitle("Releases").withCacheDuration(2 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 10 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - for i := range widget.Repositories { - r := widget.Repositories[i] + for i := range s.Repositories { + r := s.Repositories[i] - if r.source == releaseSourceGithub && widget.Token != "" { - r.token = &widget.Token - } else if r.source == releaseSourceGitlab && widget.GitLabToken != "" { - r.token = &widget.GitLabToken + if r.source == releaseSourceGithub && s.Token != "" { + r.token = &s.Token + } else if r.source == releaseSourceGitlab && s.GitLabToken != "" { + r.token = &s.GitLabToken } } return nil } -func (widget *releasesWidget) update(ctx context.Context) { - releases, err := fetchLatestReleases(widget.Repositories) +func (s *githubReleasesSource) update(ctx context.Context) { + releases, err := fetchLatestReleases(s.Repositories) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(releases) > widget.Limit { - releases = releases[:widget.Limit] + if len(releases) > s.Limit { + releases = releases[:s.Limit] } for i := range releases { - releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") + releases[i].SourceIconURL = s.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") } - widget.Releases = releases -} - -func (widget *releasesWidget) Render() template.HTML { - return widget.renderTemplate(widget, releasesWidgetTemplate) + s.Releases = releases } type releaseSource string @@ -84,6 +87,8 @@ const ( ) type appRelease struct { + ID string + Description string Source releaseSource SourceIconURL string Name string @@ -91,6 +96,32 @@ type appRelease struct { NotesUrl string TimeReleased time.Time Downvotes int + MatchSummary string + MatchScore int +} + +func (a appRelease) UID() string { + return a.ID +} + +func (a appRelease) Title() string { + return a.Name +} + +func (a appRelease) Body() string { + return a.Description +} + +func (a appRelease) URL() string { + return a.NotesUrl +} + +func (a appRelease) ImageURL() string { + return a.SourceIconURL +} + +func (a appRelease) CreatedAt() time.Time { + return a.TimeReleased } type appReleaseList []appRelease @@ -203,9 +234,11 @@ func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) { } type githubReleaseResponseJson struct { + ID int `json:"ID"` TagName string `json:"tag_name"` PublishedAt string `json:"published_at"` HtmlUrl string `json:"html_url"` + Body string `json:"body"` Reactions struct { Downvotes int `json:"-1"` } `json:"reactions"` @@ -224,9 +257,14 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { return nil, err } + // TODO(pulse): Change secrets config approach if request.token != nil { httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) } + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + httpRequest.Header.Add("Authorization", "Bearer "+envToken) + } var response githubReleaseResponseJson @@ -249,6 +287,8 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { } return &appRelease{ + ID: strconv.Itoa(response.ID), + Description: response.Body, Source: releaseSourceGithub, Name: request.Repository, Version: normalizeVersionFormat(response.TagName), diff --git a/internal/glance/widget-hacker-news.go b/pkg/sources/widget-hacker-news.go similarity index 64% rename from internal/glance/widget-hacker-news.go rename to pkg/sources/widget-hacker-news.go index ad00df01..8f2c42ba 100644 --- a/internal/glance/widget-hacker-news.go +++ b/pkg/sources/widget-hacker-news.go @@ -1,18 +1,19 @@ -package glance +package sources import ( "context" "fmt" - "html/template" "log/slog" "net/http" "strconv" "strings" "time" + + "github.com/go-shiori/go-readability" ) -type hackerNewsWidget struct { - widgetBase `yaml:",inline"` +type hackerNewsSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` Limit int `yaml:"limit"` SortBy string `yaml:"sort-by"` @@ -22,52 +23,56 @@ type hackerNewsWidget struct { ShowThumbnails bool `yaml:"-"` } -func (widget *hackerNewsWidget) initialize() error { - widget. +func (s *hackerNewsSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *hackerNewsSource) initialize() error { + s. withTitle("Hacker News"). withTitleURL("https://news.ycombinator.com/"). withCacheDuration(30 * time.Minute) - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { - widget.SortBy = "top" + if s.SortBy != "top" && s.SortBy != "new" && s.SortBy != "best" { + s.SortBy = "top" } return nil } -func (widget *hackerNewsWidget) update(ctx context.Context) { - posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) +func (s *hackerNewsSource) update(ctx context.Context) { + posts, err := fetchHackerNewsPosts(s.SortBy, 40, s.CommentsUrlTemplate) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if widget.ExtraSortBy == "engagement" { + if s.ExtraSortBy == "engagement" { posts.calculateEngagement() posts.sortByEngagement() } - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] + if s.Limit < len(posts) { + posts = posts[:s.Limit] } - widget.Posts = posts -} - -func (widget *hackerNewsWidget) Render() template.HTML { - return widget.renderTemplate(widget, forumPostsTemplate) + s.Posts = posts } type hackerNewsPostResponseJson struct { - Id int `json:"id"` + Id int `json:"ID"` Score int `json:"score"` Title string `json:"title"` TargetUrl string `json:"url,omitempty"` @@ -102,7 +107,7 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for posts := make(forumPostList, 0, len(postIds)) - for i := range results { + for i, res := range results { if errs[i] != nil { slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) continue @@ -111,20 +116,31 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for var commentsUrl string if commentsUrlTemplate == "" { - commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) + commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(res.Id) } else { - commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) + commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(res.Id)) } - posts = append(posts, forumPost{ - Title: results[i].Title, + forumPost := forumPost{ + ID: strconv.Itoa(res.Id), + title: res.Title, + Description: res.Title, DiscussionUrl: commentsUrl, - TargetUrl: results[i].TargetUrl, - TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl), - CommentCount: results[i].CommentCount, - Score: results[i].Score, - TimePosted: time.Unix(results[i].TimePosted, 0), - }) + TargetUrl: res.TargetUrl, + TargetUrlDomain: extractDomainFromUrl(res.TargetUrl), + CommentCount: res.CommentCount, + Score: res.Score, + TimePosted: time.Unix(res.TimePosted, 0), + } + + article, err := readability.FromURL(forumPost.TargetUrl, 5*time.Second) + if err == nil { + forumPost.Description = article.TextContent + } else { + slog.Error("Failed to fetch hacker news article", "error", err, "url", forumPost.TargetUrl) + } + + posts = append(posts, forumPost) } if len(posts) == 0 { diff --git a/internal/glance/widget-lobsters.go b/pkg/sources/widget-lobsters.go similarity index 54% rename from internal/glance/widget-lobsters.go rename to pkg/sources/widget-lobsters.go index 786d1dfb..28e4b2c2 100644 --- a/internal/glance/widget-lobsters.go +++ b/pkg/sources/widget-lobsters.go @@ -1,15 +1,17 @@ -package glance +package sources import ( "context" - "html/template" + "log/slog" "net/http" "strings" "time" + + "github.com/go-shiori/go-readability" ) -type lobstersWidget struct { - widgetBase `yaml:",inline"` +type lobstersSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` InstanceURL string `yaml:"instance-url"` CustomURL string `yaml:"custom-url"` @@ -20,49 +22,54 @@ type lobstersWidget struct { ShowThumbnails bool `yaml:"-"` } -func (widget *lobstersWidget) initialize() error { - widget.withTitle("Lobsters").withCacheDuration(time.Hour) +func (s *lobstersSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *lobstersSource) initialize() error { + s.withTitle("Lobsters").withCacheDuration(time.Hour) - if widget.InstanceURL == "" { - widget.withTitleURL("https://lobste.rs") + if s.InstanceURL == "" { + s.withTitleURL("https://lobste.rs") } else { - widget.withTitleURL(widget.InstanceURL) + s.withTitleURL(s.InstanceURL) } - if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { - widget.SortBy = "hot" + if s.SortBy == "" || (s.SortBy != "hot" && s.SortBy != "new") { + s.SortBy = "hot" } - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } return nil } -func (widget *lobstersWidget) update(ctx context.Context) { - posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) +func (s *lobstersSource) update(ctx context.Context) { + posts, err := fetchLobstersPosts(s.CustomURL, s.InstanceURL, s.SortBy, s.Tags) - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] + if s.Limit < len(posts) { + posts = posts[:s.Limit] } - widget.Posts = posts -} - -func (widget *lobstersWidget) Render() template.HTML { - return widget.renderTemplate(widget, forumPostsTemplate) + s.Posts = posts } type lobstersPostResponseJson struct { + ID string `json:"short_id"` CreatedAt string `json:"created_at"` Title string `json:"title"` URL string `json:"url"` @@ -87,19 +94,30 @@ func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { posts := make(forumPostList, 0, len(feed)) - for i := range feed { - createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) - - posts = append(posts, forumPost{ - Title: feed[i].Title, - DiscussionUrl: feed[i].CommentsURL, - TargetUrl: feed[i].URL, - TargetUrlDomain: extractDomainFromUrl(feed[i].URL), - CommentCount: feed[i].CommentCount, - Score: feed[i].Score, + for _, post := range feed { + createdAt, _ := time.Parse(time.RFC3339, post.CreatedAt) + + forumPost := forumPost{ + ID: post.ID, + title: post.Title, + Description: post.Title, + DiscussionUrl: post.CommentsURL, + TargetUrl: post.URL, + TargetUrlDomain: extractDomainFromUrl(post.URL), + CommentCount: post.CommentCount, + Score: post.Score, TimePosted: createdAt, - Tags: feed[i].Tags, - }) + Tags: post.Tags, + } + + article, err := readability.FromURL(post.URL, 5*time.Second) + if err == nil { + forumPost.Description = article.TextContent + } else { + slog.Error("Failed to fetch lobster article", "error", err, "url", forumPost.TargetUrl) + } + + posts = append(posts, forumPost) } if len(posts) == 0 { diff --git a/pkg/sources/widget-mastodon.go b/pkg/sources/widget-mastodon.go new file mode 100644 index 00000000..b67bdf2c --- /dev/null +++ b/pkg/sources/widget-mastodon.go @@ -0,0 +1,198 @@ +package sources + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/net/html" +) + +type mastodonSource struct { + sourceBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + InstanceURL string `yaml:"instance-url"` + Accounts []string `yaml:"accounts"` + Hashtags []string `yaml:"hashtags"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ShowThumbnails bool `yaml:"-"` +} + +func (s *mastodonSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *mastodonSource) initialize() error { + if s.InstanceURL == "" { + return fmt.Errorf("instance-url is required") + } + + s. + withTitle("Mastodon"). + withTitleURL(s.InstanceURL). + withCacheDuration(30 * time.Minute) + + if s.Limit <= 0 { + s.Limit = 15 + } + + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 + } + + return nil +} + +func (s *mastodonSource) update(ctx context.Context) { + posts, err := fetchMastodonPosts(s.InstanceURL, s.Accounts, s.Hashtags) + + if !s.canContinueUpdateAfterHandlingErr(err) { + return + } + + if s.Limit < len(posts) { + posts = posts[:s.Limit] + } + + s.Posts = posts +} + +type mastodonPostResponseJson struct { + ID string `json:"ID"` + Content string `json:"content"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + Reblogs int `json:"reblogs_count"` + Favorites int `json:"favourites_count"` + Replies int `json:"replies_count"` + Account struct { + Username string `json:"username"` + URL string `json:"url"` + } `json:"account"` + MediaAttachments []struct { + URL string `json:"url"` + } `json:"media_attachments"` + Tags []struct { + Name string `json:"name"` + } `json:"tags"` +} + +func fetchMastodonPosts(instanceURL string, accounts []string, hashtags []string) (forumPostList, error) { + instanceURL = strings.TrimRight(instanceURL, "/") + var posts forumPostList + + // Fetch posts from specified accounts + for _, account := range accounts { + url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses", instanceURL, account) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + accountPosts, err := decodeJsonFromRequest[[]mastodonPostResponseJson](defaultHTTPClient, request) + if err != nil { + slog.Error("Failed to fetch Mastodon account posts", "error", err, "account", account) + continue + } + + for _, post := range accountPosts { + forumPost := convertMastodonPostToForumPost(post) + posts = append(posts, forumPost) + } + } + + // Fetch posts from specified hashtags + for _, hashtag := range hashtags { + url := fmt.Sprintf("%s/api/v1/timelines/tag/%s", instanceURL, hashtag) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + hashtagPosts, err := decodeJsonFromRequest[[]mastodonPostResponseJson](defaultHTTPClient, request) + if err != nil { + slog.Error("Failed to fetch Mastodon hashtag posts", "error", err, "hashtag", hashtag) + continue + } + + for _, post := range hashtagPosts { + forumPost := convertMastodonPostToForumPost(post) + posts = append(posts, forumPost) + } + } + + if len(posts) == 0 { + return nil, errNoContent + } + + return posts, nil +} + +func convertMastodonPostToForumPost(post mastodonPostResponseJson) forumPost { + tags := make([]string, len(post.Tags)) + for i, tag := range post.Tags { + tags[i] = "#" + tag.Name + } + + plainText := extractTextFromHTML(post.Content) + title := oneLineTitle(plainText, 50) + + forumPost := forumPost{ + ID: post.ID, + title: title, + Description: plainText, + DiscussionUrl: post.URL, + CommentCount: post.Replies, + Score: post.Reblogs + post.Favorites, + TimePosted: post.CreatedAt, + // TODO(pulse): Hide tags for now, as they introduce too much noise + // Tags: tags, + } + + if len(post.MediaAttachments) > 0 { + forumPost.ThumbnailUrl = post.MediaAttachments[0].URL + } + + return forumPost +} + +func extractTextFromHTML(htmlStr string) string { + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + return htmlStr + } + var b strings.Builder + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.TextNode { + b.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(doc) + return strings.TrimSpace(b.String()) +} + +func oneLineTitle(text string, maxLen int) string { + // Replace newlines and tabs with spaces, collapse multiple spaces + re := regexp.MustCompile(`\s+`) + t := re.ReplaceAllString(text, " ") + t = strings.TrimSpace(t) + if utf8.RuneCountInString(t) > maxLen { + runes := []rune(t) + return string(runes[:maxLen-1]) + "…" + } + return t +} diff --git a/internal/glance/widget-reddit.go b/pkg/sources/widget-reddit.go similarity index 65% rename from internal/glance/widget-reddit.go rename to pkg/sources/widget-reddit.go index a2cb5d9a..3f97e8d1 100644 --- a/internal/glance/widget-reddit.go +++ b/pkg/sources/widget-reddit.go @@ -1,25 +1,22 @@ -package glance +package sources import ( "context" "errors" "fmt" "html" - "html/template" + "log/slog" "net/http" "net/url" "strconv" "strings" "time" -) -var ( - redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html") - redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html") + "github.com/go-shiori/go-readability" ) -type redditWidget struct { - widgetBase `yaml:",inline"` +type redditSource struct { + sourceBase `yaml:",inline"` Posts forumPostList `yaml:"-"` Subreddit string `yaml:"subreddit"` Proxy proxyOptionsField `yaml:"proxy"` @@ -37,7 +34,7 @@ type redditWidget struct { AppAuth struct { Name string `yaml:"name"` - ID string `yaml:"id"` + ID string `yaml:"ID"` Secret string `yaml:"secret"` enabled bool @@ -46,36 +43,44 @@ type redditWidget struct { } `yaml:"app-auth"` } -func (widget *redditWidget) initialize() error { - if widget.Subreddit == "" { +func (s *redditSource) Feed() []Activity { + activities := make([]Activity, len(s.Posts)) + for i, post := range s.Posts { + activities[i] = post + } + return activities +} + +func (s *redditSource) initialize() error { + if s.Subreddit == "" { return errors.New("subreddit is required") } - if widget.Limit <= 0 { - widget.Limit = 15 + if s.Limit <= 0 { + s.Limit = 15 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - s := widget.SortBy - if s != "hot" && s != "new" && s != "top" && s != "rising" { - widget.SortBy = "hot" + sort := s.SortBy + if sort != "hot" && sort != "new" && sort != "top" && sort != "rising" { + s.SortBy = "hot" } - p := widget.TopPeriod + p := s.TopPeriod if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" { - widget.TopPeriod = "day" + s.TopPeriod = "day" } - if widget.RequestURLTemplate != "" { - if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") { + if s.RequestURLTemplate != "" { + if !strings.Contains(s.RequestURLTemplate, "{REQUEST-URL}") { return errors.New("no `{REQUEST-URL}` placeholder specified") } } - a := &widget.AppAuth + a := &s.AppAuth if a.Name != "" || a.ID != "" || a.Secret != "" { if a.Name == "" || a.ID == "" || a.Secret == "" { return errors.New("application name, client ID and client secret are required") @@ -83,51 +88,39 @@ func (widget *redditWidget) initialize() error { a.enabled = true } - widget. - withTitle("r/" + widget.Subreddit). - withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). + s. + withTitle("r/" + s.Subreddit). + withTitleURL("https://www.reddit.com/r/" + s.Subreddit + "/"). withCacheDuration(30 * time.Minute) return nil } -func (widget *redditWidget) update(ctx context.Context) { - posts, err := widget.fetchSubredditPosts() - if !widget.canContinueUpdateAfterHandlingErr(err) { +func (s *redditSource) update(ctx context.Context) { + posts, err := s.fetchSubredditPosts() + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if len(posts) > widget.Limit { - posts = posts[:widget.Limit] + if len(posts) > s.Limit { + posts = posts[:s.Limit] } - if widget.ExtraSortBy == "engagement" { + if s.ExtraSortBy == "engagement" { posts.calculateEngagement() posts.sortByEngagement() } - widget.Posts = posts -} - -func (widget *redditWidget) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate) - } - - if widget.Style == "vertical-cards" { - return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate) - } - - return widget.renderTemplate(widget, forumPostsTemplate) - + s.Posts = posts } type subredditResponseJson struct { Data struct { Children []struct { Data struct { - Id string `json:"id"` + Id string `json:"ID"` Title string `json:"title"` + SelfText string `json:"selftext"` Upvotes int `json:"ups"` Url string `json:"url"` Time float64 `json:"created"` @@ -140,7 +133,7 @@ type subredditResponseJson struct { Thumbnail string `json:"thumbnail"` Flair string `json:"link_flair_text"` ParentList []struct { - Id string `json:"id"` + Id string `json:"ID"` Subreddit string `json:"subreddit"` Permalink string `json:"permalink"` } `json:"crosspost_parent_list"` @@ -149,21 +142,21 @@ type subredditResponseJson struct { } `json:"data"` } -func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string { - template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit) +func (s *redditSource) parseCustomCommentsURL(subreddit, postId, postPath string) string { + template := strings.ReplaceAll(s.CommentsURLTemplate, "{SUBREDDIT}", subreddit) template = strings.ReplaceAll(template, "{POST-ID}", postId) template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/")) return template } -func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { +func (s *redditSource) fetchSubredditPosts() (forumPostList, error) { var client requestDoer = defaultHTTPClient var baseURL string var requestURL string var headers http.Header query := url.Values{} - app := &widget.AppAuth + app := &s.AppAuth if !app.enabled { baseURL = "https://www.reddit.com" @@ -174,7 +167,7 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { baseURL = "https://oauth.reddit.com" if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) { - if err := widget.fetchNewAppAccessToken(); err != nil { + if err := s.fetchNewAppAccessToken(); err != nil { return nil, fmt.Errorf("fetching new app access token: %v", err) } } @@ -185,25 +178,25 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { } } - if widget.Limit > 25 { - query.Set("limit", strconv.Itoa(widget.Limit)) + if s.Limit > 25 { + query.Set("limit", strconv.Itoa(s.Limit)) } - if widget.Search != "" { - query.Set("q", widget.Search+" subreddit:"+widget.Subreddit) - query.Set("sort", widget.SortBy) + if s.Search != "" { + query.Set("q", s.Search+" subreddit:"+s.Subreddit) + query.Set("sort", s.SortBy) requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode()) } else { - if widget.SortBy == "top" { - query.Set("t", widget.TopPeriod) + if s.SortBy == "top" { + query.Set("t", s.TopPeriod) } - requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode()) + requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, s.Subreddit, s.SortBy, query.Encode()) } - if widget.RequestURLTemplate != "" { - requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL) - } else if widget.Proxy.client != nil { - client = widget.Proxy.client + if s.RequestURLTemplate != "" { + requestURL = strings.ReplaceAll(s.RequestURLTemplate, "{REQUEST-URL}", requestURL) + } else if s.Proxy.client != nil { + client = s.Proxy.client } request, err := http.NewRequest("GET", requestURL, nil) @@ -232,14 +225,16 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { var commentsUrl string - if widget.CommentsURLTemplate == "" { + if s.CommentsURLTemplate == "" { commentsUrl = "https://www.reddit.com" + post.Permalink } else { - commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink) + commentsUrl = s.parseCustomCommentsURL(s.Subreddit, post.Id, post.Permalink) } forumPost := forumPost{ - Title: html.UnescapeString(post.Title), + ID: post.Id, + title: html.UnescapeString(post.Title), + Description: post.SelfText, DiscussionUrl: commentsUrl, TargetUrlDomain: post.Domain, CommentCount: post.CommentsCount, @@ -255,7 +250,7 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { forumPost.TargetUrl = post.Url } - if widget.ShowFlairs && post.Flair != "" { + if s.ShowFlairs && post.Flair != "" { forumPost.Tags = append(forumPost.Tags, post.Flair) } @@ -263,10 +258,10 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { forumPost.IsCrosspost = true forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit - if widget.CommentsURLTemplate == "" { + if s.CommentsURLTemplate == "" { forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink } else { - forumPost.TargetUrl = widget.parseCustomCommentsURL( + forumPost.TargetUrl = s.parseCustomCommentsURL( post.ParentList[0].Subreddit, post.ParentList[0].Id, post.ParentList[0].Permalink, @@ -274,20 +269,29 @@ func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { } } + if forumPost.TargetUrl != "" { + article, err := readability.FromURL(forumPost.TargetUrl, 5*time.Second) + if err == nil { + forumPost.Description += fmt.Sprintf("\n\nReferenced article: \n%s", article.TextContent) + } else { + slog.Error("Failed to fetch reddit article", "error", err, "url", forumPost.TargetUrl) + } + } + posts = append(posts, forumPost) } return posts, nil } -func (widget *redditWidget) fetchNewAppAccessToken() error { +func (s *redditSource) fetchNewAppAccessToken() error { body := strings.NewReader("grant_type=client_credentials") req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body) if err != nil { return fmt.Errorf("creating request for app access token: %v", err) } - app := &widget.AppAuth + app := &s.AppAuth req.SetBasicAuth(app.ID, app.Secret) req.Header.Add("User-Agent", app.Name+"/1.0") req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -297,7 +301,10 @@ func (widget *redditWidget) fetchNewAppAccessToken() error { ExpiresIn int `json:"expires_in"` } - client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient) + client := defaultHTTPClient + if s.Proxy.client != nil { + client = s.Proxy.client + } response, err := decodeJsonFromRequest[tokenResponse](client, req) if err != nil { return err diff --git a/internal/glance/widget-rss.go b/pkg/sources/widget-rss.go similarity index 72% rename from internal/glance/widget-rss.go rename to pkg/sources/widget-rss.go index fe17b2fb..db6f0db1 100644 --- a/internal/glance/widget-rss.go +++ b/pkg/sources/widget-rss.go @@ -1,10 +1,9 @@ -package glance +package sources import ( "context" "fmt" "html" - "html/template" "io" "log/slog" "net/http" @@ -19,17 +18,10 @@ import ( gofeedext "github.com/mmcdole/gofeed/extensions" ) -var ( - rssWidgetTemplate = mustParseTemplate("rss-list.html", "widget-base.html") - rssWidgetDetailedListTemplate = mustParseTemplate("rss-detailed-list.html", "widget-base.html") - rssWidgetHorizontalCardsTemplate = mustParseTemplate("rss-horizontal-cards.html", "widget-base.html") - rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html") -) - var feedParser = gofeed.NewParser() -type rssWidget struct { - widgetBase `yaml:",inline"` +type rssSource struct { + sourceBase `yaml:",inline"` FeedRequests []rssFeedRequest `yaml:"feeds"` Style string `yaml:"style"` ThumbnailHeight float64 `yaml:"thumbnail-height"` @@ -46,69 +38,61 @@ type rssWidget struct { cachedFeeds map[string]*cachedRSSFeed `yaml:"-"` } -func (widget *rssWidget) initialize() error { - widget.withTitle("RSS Feed").withCacheDuration(2 * time.Hour) +func (s *rssSource) Feed() []Activity { + activities := make([]Activity, len(s.Items)) + for i, item := range s.Items { + activities[i] = item + } + return activities +} - if widget.Limit <= 0 { - widget.Limit = 25 +func (s *rssSource) initialize() error { + s.withTitle("RSS Feed").withCacheDuration(2 * time.Hour) + + if s.Limit <= 0 { + s.Limit = 25 } - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 + if s.CollapseAfter == 0 || s.CollapseAfter < -1 { + s.CollapseAfter = 5 } - if widget.ThumbnailHeight < 0 { - widget.ThumbnailHeight = 0 + if s.ThumbnailHeight < 0 { + s.ThumbnailHeight = 0 } - if widget.CardHeight < 0 { - widget.CardHeight = 0 + if s.CardHeight < 0 { + s.CardHeight = 0 } - if widget.Style == "detailed-list" { - for i := range widget.FeedRequests { - widget.FeedRequests[i].IsDetailed = true + if s.Style == "detailed-list" { + for i := range s.FeedRequests { + s.FeedRequests[i].IsDetailed = true } } - widget.NoItemsMessage = "No items were returned from the feeds." - widget.cachedFeeds = make(map[string]*cachedRSSFeed) + s.NoItemsMessage = "No items were returned from the feeds." + s.cachedFeeds = make(map[string]*cachedRSSFeed) return nil } -func (widget *rssWidget) update(ctx context.Context) { - items, err := widget.fetchItemsFromFeeds() +func (s *rssSource) update(ctx context.Context) { + items, err := s.fetchItemsFromFeeds() - if !widget.canContinueUpdateAfterHandlingErr(err) { + if !s.canContinueUpdateAfterHandlingErr(err) { return } - if !widget.PreserveOrder { + if !s.PreserveOrder { items.sortByNewest() } - if len(items) > widget.Limit { - items = items[:widget.Limit] - } - - widget.Items = items -} - -func (widget *rssWidget) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.renderTemplate(widget, rssWidgetHorizontalCardsTemplate) - } - - if widget.Style == "horizontal-cards-2" { - return widget.renderTemplate(widget, rssWidgetHorizontalCards2Template) - } - - if widget.Style == "detailed-list" { - return widget.renderTemplate(widget, rssWidgetDetailedListTemplate) + if len(items) > s.Limit { + items = items[:s.Limit] } - return widget.renderTemplate(widget, rssWidgetTemplate) + s.Items = items } type cachedRSSFeed struct { @@ -118,16 +102,41 @@ type cachedRSSFeed struct { } type rssFeedItem struct { + ID string ChannelName string ChannelURL string - Title string + title string Link string - ImageURL string + imageURL string Categories []string Description string PublishedAt time.Time } +func (r rssFeedItem) UID() string { + return r.ID +} + +func (r rssFeedItem) Title() string { + return r.title +} + +func (r rssFeedItem) Body() string { + return r.Description +} + +func (r rssFeedItem) URL() string { + return r.Link +} + +func (r rssFeedItem) ImageURL() string { + return r.imageURL +} + +func (r rssFeedItem) CreatedAt() time.Time { + return r.PublishedAt +} + type rssFeedRequest struct { URL string `yaml:"url"` Title string `yaml:"title"` @@ -149,10 +158,10 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList { return f } -func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) { - requests := widget.FeedRequests +func (s *rssSource) fetchItemsFromFeeds() (rssFeedItemList, error) { + requests := s.FeedRequests - job := newJob(widget.fetchItemsFromFeedTask, requests).withWorkers(30) + job := newJob(s.fetchItemsFromFeedTask, requests).withWorkers(30) feeds, errs, err := workerPoolDo(job) if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) @@ -189,16 +198,16 @@ func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) { return entries, nil } -func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { +func (s *rssSource) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { req, err := http.NewRequest("GET", request.URL, nil) if err != nil { return nil, err } - req.Header.Add("User-Agent", glanceUserAgentString) + req.Header.Add("User-Agent", pulseUserAgentString) - widget.cachedFeedsMutex.Lock() - cache, isCached := widget.cachedFeeds[request.URL] + s.cachedFeedsMutex.Lock() + cache, isCached := s.cachedFeeds[request.URL] if isCached { if cache.etag != "" { req.Header.Add("If-None-Match", cache.etag) @@ -207,7 +216,7 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe req.Header.Add("If-Modified-Since", cache.lastModified) } } - widget.cachedFeedsMutex.Unlock() + s.cachedFeedsMutex.Unlock() for key, value := range request.Headers { req.Header.Set(key, value) @@ -247,6 +256,7 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe item := feed.Items[i] rssItem := rssFeedItem{ + ID: item.GUID, ChannelURL: feed.Link, } @@ -274,9 +284,9 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if item.Title != "" { - rssItem.Title = html.UnescapeString(item.Title) + rssItem.title = html.UnescapeString(item.Title) } else { - rssItem.Title = shortenFeedDescriptionLen(item.Description, 100) + rssItem.title = shortenFeedDescriptionLen(item.Description, 100) } if request.IsDetailed { @@ -310,14 +320,14 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if item.Image != nil { - rssItem.ImageURL = item.Image.URL + rssItem.imageURL = item.Image.URL } else if url := findThumbnailInItemExtensions(item); url != "" { - rssItem.ImageURL = url + rssItem.imageURL = url } else if feed.Image != nil { if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' { - rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL + rssItem.imageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL } else { - rssItem.ImageURL = feed.Image.URL + rssItem.imageURL = feed.Image.URL } } @@ -331,13 +341,13 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe } if resp.Header.Get("ETag") != "" || resp.Header.Get("Last-Modified") != "" { - widget.cachedFeedsMutex.Lock() - widget.cachedFeeds[request.URL] = &cachedRSSFeed{ + s.cachedFeedsMutex.Lock() + s.cachedFeeds[request.URL] = &cachedRSSFeed{ etag: resp.Header.Get("ETag"), lastModified: resp.Header.Get("Last-Modified"), items: items, } - widget.cachedFeedsMutex.Unlock() + s.cachedFeedsMutex.Unlock() } return items, nil @@ -374,6 +384,7 @@ func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extens } var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`) +var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func sanitizeFeedDescription(description string) string { if description == "" { diff --git a/internal/glance/auth.go b/pkg/widgets/auth.go similarity index 99% rename from internal/glance/auth.go rename to pkg/widgets/auth.go index e6497a19..45b09e69 100644 --- a/internal/glance/auth.go +++ b/pkg/widgets/auth.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/auth_test.go b/pkg/widgets/auth_test.go similarity index 99% rename from internal/glance/auth_test.go rename to pkg/widgets/auth_test.go index 97e6bc92..649e2c62 100644 --- a/internal/glance/auth_test.go +++ b/pkg/widgets/auth_test.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/cli.go b/pkg/widgets/cli.go similarity index 97% rename from internal/glance/cli.go rename to pkg/widgets/cli.go index 5544b8bc..75327be8 100644 --- a/internal/glance/cli.go +++ b/pkg/widgets/cli.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "flag" @@ -17,7 +17,6 @@ const ( cliIntentServe cliIntentConfigValidate cliIntentConfigPrint - cliIntentDiagnose cliIntentSensorsPrint cliIntentMountpointInfo cliIntentSecretMake @@ -76,8 +75,6 @@ func parseCliOptions() (*cliOptions, error) { intent = cliIntentConfigPrint } else if args[0] == "sensors:print" { intent = cliIntentSensorsPrint - } else if args[0] == "diagnose" { - intent = cliIntentDiagnose } else if args[0] == "secret:make" { intent = cliIntentSecretMake } else { diff --git a/internal/glance/config-fields.go b/pkg/widgets/config-fields.go similarity index 72% rename from internal/glance/config-fields.go rename to pkg/widgets/config-fields.go index d3681404..6c8bc981 100644 --- a/internal/glance/config-fields.go +++ b/pkg/widgets/config-fields.go @@ -1,17 +1,13 @@ -package glance +package widgets import ( - "crypto/tls" "fmt" + "gopkg.in/yaml.v3" "html/template" - "net/http" "net/url" "regexp" "strconv" "strings" - "time" - - "gopkg.in/yaml.v3" ) var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`) @@ -93,42 +89,6 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return nil } -var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) - -type durationField time.Duration - -func (d *durationField) UnmarshalYAML(node *yaml.Node) error { - var value string - - if err := node.Decode(&value); err != nil { - return err - } - - matches := durationFieldPattern.FindStringSubmatch(value) - - if len(matches) != 3 { - return fmt.Errorf("invalid duration format: %s", value) - } - - duration, err := strconv.Atoi(matches[1]) - if err != nil { - return err - } - - switch matches[2] { - case "s": - *d = durationField(time.Duration(duration) * time.Second) - case "m": - *d = durationField(time.Duration(duration) * time.Minute) - case "h": - *d = durationField(time.Duration(duration) * time.Hour) - case "d": - *d = durationField(time.Duration(duration) * 24 * time.Hour) - } - - return nil -} - type customIconField struct { URL template.URL IsFlatIcon bool @@ -194,53 +154,6 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { return nil } -type proxyOptionsField struct { - URL string `yaml:"url"` - AllowInsecure bool `yaml:"allow-insecure"` - Timeout durationField `yaml:"timeout"` - client *http.Client `yaml:"-"` -} - -func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error { - type proxyOptionsFieldAlias proxyOptionsField - alias := (*proxyOptionsFieldAlias)(p) - var proxyURL string - - if err := node.Decode(&proxyURL); err != nil { - if err := node.Decode(alias); err != nil { - return err - } - } - - if proxyURL == "" && p.URL == "" { - return nil - } - - if p.URL != "" { - proxyURL = p.URL - } - - parsedUrl, err := url.Parse(proxyURL) - if err != nil { - return fmt.Errorf("parsing proxy URL: %v", err) - } - - var timeout = defaultClientTimeout - if p.Timeout > 0 { - timeout = time.Duration(p.Timeout) - } - - p.client = &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - Proxy: http.ProxyURL(parsedUrl), - TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure}, - }, - } - - return nil -} - type queryParametersField map[string][]string func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error { diff --git a/internal/glance/config.go b/pkg/widgets/config.go similarity index 99% rename from internal/glance/config.go rename to pkg/widgets/config.go index 84714d0d..4cbc84ae 100644 --- a/internal/glance/config.go +++ b/pkg/widgets/config.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" diff --git a/internal/glance/glance.go b/pkg/widgets/glance.go similarity index 90% rename from internal/glance/glance.go rename to pkg/widgets/glance.go index 2980f456..8d0679b8 100644 --- a/internal/glance/glance.go +++ b/pkg/widgets/glance.go @@ -1,10 +1,12 @@ -package glance +package widgets import ( "bytes" "context" "encoding/base64" "fmt" + "github.com/glanceapp/glance/pkg/sources" + "github.com/glanceapp/glance/web" "log" "net/http" "path/filepath" @@ -15,6 +17,7 @@ import ( "time" "golang.org/x/crypto/bcrypt" + "golang.org/x/sync/errgroup" ) var ( @@ -46,7 +49,7 @@ type application struct { func newApplication(c *config) (*application, error) { app := &application{ - Version: buildVersion, + Version: sources.BuildVersion, CreatedAt: time.Now(), Config: *c, slugToPage: make(map[string]*page), @@ -228,43 +231,40 @@ func newApplication(c *config) (*application, error) { return app, nil } -func (p *page) updateOutdatedWidgets() { +func (p *page) updateOutdatedWidgets() error { now := time.Now() - var wg sync.WaitGroup - context := context.Background() - + var allWidgets []widget for w := range p.HeadWidgets { - widget := p.HeadWidgets[w] - - if !widget.requiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.update(context) - }() + allWidgets = append(allWidgets, p.HeadWidgets[w]) } - for c := range p.Columns { for w := range p.Columns[c].Widgets { - widget := p.Columns[c].Widgets[w] + allWidgets = append(allWidgets, p.Columns[c].Widgets[w]) + } + } - if !widget.requiresUpdate(&now) { - continue - } + var eg errgroup.Group + ctx := context.Background() - wg.Add(1) - go func() { - defer wg.Done() - widget.update(context) - }() + for _, widget := range allWidgets { + if !widget.source().RequiresUpdate(&now) { + continue } + + eg.Go(func() error { + widget.update(ctx) + // TODO: Handle errors + return nil + }) + } + + err := eg.Wait() + if err != nil { + return fmt.Errorf("widget update: %w", err) } - wg.Wait() + return nil } func (a *application) resolveUserDefinedAssetPath(path string) string { @@ -276,7 +276,8 @@ func (a *application) resolveUserDefinedAssetPath(path string) string { } type templateRequestData struct { - Theme *themeProperties + Theme *themeProperties + Filter string } type templateData struct { @@ -297,6 +298,7 @@ func (a *application) populateTemplateRequestData(data *templateRequestData, r * } data.Theme = theme + data.Filter = r.URL.Query().Get("filter") } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { @@ -342,6 +344,8 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re Page: page, } + a.populateTemplateRequestData(&pageData.Request, r) + var err error var responseBytes bytes.Buffer @@ -349,7 +353,10 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re page.mu.Lock() defer page.mu.Unlock() - page.updateOutdatedWidgets() + err = page.updateOutdatedWidgets() + if err != nil { + return + } err = pageContentTemplate.Execute(&responseBytes, pageData) }() @@ -421,7 +428,7 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request } func (a *application) StaticAssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset + return a.Config.Server.BaseURL + "/static/" + web.StaticFSHash + "/" + asset } func (a *application) VersionedAssetPath(asset string) string { @@ -449,10 +456,10 @@ func (a *application) server() (func() error, func() error) { } mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + fmt.Sprintf("GET /static/%s/{path...}", web.StaticFSHash), http.StripPrefix( - "/static/"+staticFSHash, - fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION), + "/static/"+web.StaticFSHash, + fileServerWithCache(http.FS(web.StaticFS), STATIC_ASSETS_CACHE_DURATION), ), ) @@ -461,10 +468,10 @@ func (a *application) server() (func() error, func() error) { int(STATIC_ASSETS_CACHE_DURATION.Seconds()), ) - mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", web.StaticFSHash), func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Cache-Control", assetCacheControlValue) w.Header().Add("Content-Type", "text/css; charset=utf-8") - w.Write(bundledCSSContents) + w.Write(web.BundledCSSContents) }) mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/widgets/llm.go b/pkg/widgets/llm.go new file mode 100644 index 00000000..a7a72d26 --- /dev/null +++ b/pkg/widgets/llm.go @@ -0,0 +1,91 @@ +package widgets + +import ( + "context" + "fmt" + "strings" + + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/llms/openai" + "github.com/tmc/langchaingo/outputparser" +) + +type LLM struct { + model llms.Model +} + +func NewLLM() (*LLM, error) { + model, err := openai.New( + openai.WithModel("gpt-4o-mini"), + ) + if err != nil { + return nil, err + } + return &LLM{model: model}, nil +} + +type feedMatch struct { + ID string `json:"id"` + Score int `json:"score" description:"How closely this item matches the query, from 0 to 10."` + Highlight string `json:"highlight" description:"A short and concise summary for why this item is a good match for the query. No any unecessary filler text (e.g. 'This includes...'). Must be two or three short sentences max."` +} + +type completionResponse struct { + Matches []feedMatch `json:"matches"` +} + +// filterFeed returns the IDs of feed entries that match the query +func (llm *LLM) filterFeed(ctx context.Context, feed []feedEntry, query string) ([]feedMatch, error) { + prompt := strings.Builder{} + + prompt.WriteString(` +## Role +You are an activity feed personalization assistant, +that helps the user find and focus on the most relevant content. + +You are given a list of feed entries with id, title, and description fields - given the natural language query, +you should rank these entries based on how well they match the query on a scale of 0 to 10. + +## Relevance scoring +For each entry, use the associated highlight text as a reflective summary to help assess how well the entry matches the user’s query. Follow these rules: + • If the highlight does not clearly explain how the entry is relevant to the user’s query, assign a low relevance score (≤ 3/10), even if the entry is interesting on its own. + • If the highlight is vague or generic (e.g., asks a broad question or restates the title), treat it as insufficient evidence of relevance unless the original content clearly supports the query. + • Strong highlights should: + • Explicitly mention key topics, entities, or themes from the user query. + • Clearly describe how the entry contributes useful, novel, or actionable insight toward the user’s intent. + • Use the highlight as a justification tool: If it doesn’t support the match, downgrade the score. If it adds clarity and alignment, consider upgrading the score. + +Always base the relevance score on how well the highlight connects the entry to the user’s information needs, not just on the entry’s popularity or standalone quality. +`) + prompt.WriteString(fmt.Sprintf("filter query: %s\n", query)) + + for _, entry := range feed { + prompt.WriteString(fmt.Sprintf("id: %s\n", entry.ID)) + prompt.WriteString(fmt.Sprintf("title: %s\n", entry.Title)) + prompt.WriteString(fmt.Sprintf("description: %s\n", entry.Description)) + prompt.WriteString("\n") + } + + parser, err := outputparser.NewDefined(completionResponse{}) + if err != nil { + return nil, fmt.Errorf("creating parser: %w", err) + } + + prompt.WriteString(fmt.Sprintf("\n\n%s", parser.GetFormatInstructions())) + + out, err := llms.GenerateFromSinglePrompt( + ctx, + llm.model, + prompt.String(), + ) + if err != nil { + return nil, fmt.Errorf("generating completion: %w", err) + } + + response, err := parser.Parse(out) + if err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return response.Matches, nil +} diff --git a/internal/glance/main.go b/pkg/widgets/main.go similarity index 73% rename from internal/glance/main.go rename to pkg/widgets/main.go index 6d73a831..dcd2b0a2 100644 --- a/internal/glance/main.go +++ b/pkg/widgets/main.go @@ -1,17 +1,12 @@ -package glance +package widgets import ( "fmt" - "io" - "log" - "net/http" - "os" - + "github.com/glanceapp/glance/pkg/sources" "golang.org/x/crypto/bcrypt" + "log" ) -var buildVersion = "dev" - func Main() int { options, err := parseCliOptions() if err != nil { @@ -21,13 +16,8 @@ func Main() int { switch options.intent { case cliIntentVersionPrint: - fmt.Println(buildVersion) + fmt.Println(sources.BuildVersion) case cliIntentServe: - // remove in v0.10.0 - if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { - return 1 - } - if err := serveApp(options.configPath); err != nil { fmt.Println(err) return 1 @@ -55,8 +45,6 @@ func Main() int { return cliSensorsPrint() case cliIntentMountpointInfo: return cliMountpointInfo(options.args[1]) - case cliIntentDiagnose: - runDiagnostic() case cliIntentSecretMake: key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) if err != nil { @@ -179,41 +167,3 @@ func serveApp(configPath string) error { <-exitChannel return nil } - -func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { - if !isRunningInsideDockerContainer() { - return false - } - - if _, err := os.Stat(configPath); err == nil { - return false - } - - // glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory - if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() { - return false - } - - templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") - bodyContents, _ := io.ReadAll(templateFile) - - fmt.Println("!!! WARNING !!!") - fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.") - fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.") - - mux := http.NewServeMux() - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(bodyContents)) - }) - - server := http.Server{ - Addr: ":8080", - Handler: mux, - } - server.ListenAndServe() - - return true -} diff --git a/internal/glance/templates.go b/pkg/widgets/templates.go similarity index 79% rename from internal/glance/templates.go rename to pkg/widgets/templates.go index 97c32294..5b95ebae 100644 --- a/internal/glance/templates.go +++ b/pkg/widgets/templates.go @@ -1,7 +1,8 @@ -package glance +package widgets import ( "fmt" + "github.com/glanceapp/glance/web" "html/template" "math" "strconv" @@ -56,12 +57,26 @@ var globalTemplateFunctions = template.FuncMap{ return template.HTML(value + ` ` + label + ``) }, + "matchScoreBadgeClass": func(score int) string { + switch { + case score <= 2: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 4: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 6: + return "ai-match-badge score-" + strconv.Itoa(score) + case score <= 8: + return "ai-match-badge score-" + strconv.Itoa(score) + default: + return "ai-match-badge score-" + strconv.Itoa(score) + } + }, } func mustParseTemplate(primary string, dependencies ...string) *template.Template { t, err := template.New(primary). Funcs(globalTemplateFunctions). - ParseFS(templateFS, append([]string{primary}, dependencies...)...) + ParseFS(web.TemplateFS, append([]string{primary}, dependencies...)...) if err != nil { panic(err) diff --git a/internal/glance/theme.go b/pkg/widgets/theme.go similarity index 99% rename from internal/glance/theme.go rename to pkg/widgets/theme.go index 07f3921c..2c3fd947 100644 --- a/internal/glance/theme.go +++ b/pkg/widgets/theme.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "fmt" diff --git a/internal/glance/utils.go b/pkg/widgets/utils.go similarity index 61% rename from internal/glance/utils.go rename to pkg/widgets/utils.go index 21cd69b6..414311e6 100644 --- a/internal/glance/utils.go +++ b/pkg/widgets/utils.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "bytes" @@ -6,10 +6,8 @@ import ( "html/template" "math" "net/http" - "net/url" "os" "regexp" - "slices" "strings" "time" ) @@ -28,72 +26,6 @@ func percentChange(current, previous float64) float64 { return (current/previous - 1) * 100 } -func extractDomainFromUrl(u string) string { - if u == "" { - return "" - } - - parsed, err := url.Parse(u) - if err != nil { - return "" - } - - return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") -} - -func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { - if len(values) < 2 { - return "" - } - - verticalPadding := height * 0.02 - height -= verticalPadding * 2 - coordinates := make([]string, len(values)) - distanceBetweenPoints := width / float64(len(values)-1) - min := slices.Min(values) - max := slices.Max(values) - - for i := range values { - coordinates[i] = fmt.Sprintf( - "%.2f,%.2f", - float64(i)*distanceBetweenPoints, - ((max-values[i])/(max-min))*height+verticalPadding, - ) - } - - return strings.Join(coordinates, " ") -} - -func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T { - if len(values) == 0 { - return values - } - - for i := range values { - if values[i] != 0 { - continue - } - - c := make([]T, 0, len(values)-1) - - for i := range values { - if values[i] != 0 { - c = append(c, values[i]) - } - } - - return c - } - - return values -} - -var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) - -func stripURLScheme(url string) string { - return urlSchemePattern.ReplaceAllString(url, "") -} - func isRunningInsideDockerContainer() bool { _, err := os.Stat("/.dockerenv") return err == nil @@ -109,35 +41,6 @@ func prefixStringLines(prefix string, s string) string { return strings.Join(lines, "\n") } -func limitStringLength(s string, max int) (string, bool) { - asRunes := []rune(s) - - if len(asRunes) > max { - return string(asRunes[:max]), true - } - - return s, false -} - -func parseRFC3339Time(t string) time.Time { - parsed, err := time.Parse(time.RFC3339, t) - if err != nil { - return time.Now() - } - - return parsed -} - -func normalizeVersionFormat(version string) string { - version = strings.ToLower(strings.TrimSpace(version)) - - if len(version) > 0 && version[0] != 'v' { - return "v" + version - } - - return version -} - func titleToSlug(s string) string { s = strings.ToLower(s) s = sequentialWhitespacePattern.ReplaceAllString(s, "-") @@ -167,10 +70,6 @@ func executeTemplateToString(t *template.Template, data any) (string, error) { return b.String(), nil } -func stringToBool(s string) bool { - return s == "true" || s == "yes" -} - func itemAtIndexOrDefault[T any](items []T, index int, def T) T { if index >= len(items) { return def diff --git a/internal/glance/widget-container.go b/pkg/widgets/widget-container.go similarity index 89% rename from internal/glance/widget-container.go rename to pkg/widgets/widget-container.go index 4c9f33a7..02220425 100644 --- a/internal/glance/widget-container.go +++ b/pkg/widgets/widget-container.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -27,7 +27,7 @@ func (widget *containerWidgetBase) _update(ctx context.Context) { for w := range widget.Widgets { widget := widget.Widgets[w] - if !widget.requiresUpdate(&now) { + if !widget.source().RequiresUpdate(&now) { continue } @@ -49,7 +49,7 @@ func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) { func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool { for i := range widget.Widgets { - if widget.Widgets[i].requiresUpdate(now) { + if widget.Widgets[i].source().RequiresUpdate(now) { return true } } diff --git a/internal/glance/widget-group.go b/pkg/widgets/widget-group.go similarity index 93% rename from internal/glance/widget-group.go rename to pkg/widgets/widget-group.go index 2ea38133..04fc9d52 100644 --- a/internal/glance/widget-group.go +++ b/pkg/widgets/widget-group.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -15,7 +15,8 @@ type groupWidget struct { } func (widget *groupWidget) initialize() error { - widget.withError(nil) + // TODO(pulse): Refactor error handling + //widget.withError(nil) widget.HideHeader = true for i := range widget.Widgets { diff --git a/internal/glance/widget-split-column.go b/pkg/widgets/widget-split-column.go similarity index 88% rename from internal/glance/widget-split-column.go rename to pkg/widgets/widget-split-column.go index 71747c92..e07e2b4c 100644 --- a/internal/glance/widget-split-column.go +++ b/pkg/widgets/widget-split-column.go @@ -1,4 +1,4 @@ -package glance +package widgets import ( "context" @@ -15,7 +15,8 @@ type splitColumnWidget struct { } func (widget *splitColumnWidget) initialize() error { - widget.withError(nil).withTitle("Split Column").setHideHeader(true) + // TODO(pulse): Refactor error handling + //widget.withError(nil).withTitle("Split Column").setHideHeader(true) if err := widget.containerWidgetBase._initializeWidgets(); err != nil { return err diff --git a/pkg/widgets/widget.go b/pkg/widgets/widget.go new file mode 100644 index 00000000..0a86ed56 --- /dev/null +++ b/pkg/widgets/widget.go @@ -0,0 +1,209 @@ +package widgets + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/glanceapp/glance/pkg/sources" + "html/template" + "log/slog" + "net/http" + "sync/atomic" + "time" + + "gopkg.in/yaml.v3" +) + +var widgetIDCounter atomic.Uint64 + +func newWidget(widgetType string) (widget, error) { + if widgetType == "" { + return nil, errors.New("widget 'type' property is empty or not specified") + } + + var w widget + + switch widgetType { + case "group": + w = &groupWidget{} + case "split-column": + w = &splitColumnWidget{} + default: + // widget type is treated as a data source type in this case, + // which depends on the base widget that renders the generic widget display card + w = &widgetBase{} + } + + w.setID(widgetIDCounter.Add(1)) + + return w, nil +} + +type widgets []widget + +func (w *widgets) UnmarshalYAML(node *yaml.Node) error { + var nodes []yaml.Node + + if err := node.Decode(&nodes); err != nil { + return err + } + + for _, node := range nodes { + meta := struct { + Type string `yaml:"type"` + }{} + + if err := node.Decode(&meta); err != nil { + return err + } + + widget, err := newWidget(meta.Type) + if err != nil { + return fmt.Errorf("line %d: %w", node.Line, err) + } + + source, err := sources.NewSource(meta.Type) + if err != nil { + return fmt.Errorf("line %d: %w", node.Line, err) + } + + widget.setSource(source) + + if err = node.Decode(widget); err != nil { + return err + } + + *w = append(*w, widget) + } + + return nil +} + +type widget interface { + // These need to be exported because they get called in templates + Render() template.HTML + GetType() string + GetID() uint64 + + initialize() error + setProviders(*widgetProviders) + update(context.Context) + setID(uint64) + handleRequest(w http.ResponseWriter, r *http.Request) + setHideHeader(bool) + source() sources.Source + setSource(sources.Source) +} + +type feedEntry struct { + ID string + Title string + Description string + URL string + ImageURL string + PublishedAt time.Time +} + +type cacheType int + +const ( + cacheTypeInfinite cacheType = iota + cacheTypeDuration + cacheTypeOnTheHour +) + +type widgetBase struct { + ID uint64 `yaml:"-"` + Providers *widgetProviders `yaml:"-"` + Type string `yaml:"type"` + HideHeader bool `yaml:"hide-header"` + CSSClass string `yaml:"css-class"` + ContentAvailable bool `yaml:"-"` + WIP bool `yaml:"-"` + Error error `yaml:"-"` + Notice error `yaml:"-"` + // Source TODO(pulse): Temporary store source on a widget. Later it should be stored in a source registry and only passed to the widget for rendering. + Source sources.Source `yaml:"-"` + templateBuffer bytes.Buffer `yaml:"-"` +} + +type widgetProviders struct { + assetResolver func(string) string +} + +func (w *widgetBase) IsWIP() bool { + return w.WIP +} + +func (w *widgetBase) update(ctx context.Context) { + +} + +func (w *widgetBase) GetID() uint64 { + return w.ID +} + +func (w *widgetBase) setID(id uint64) { + w.ID = id +} + +func (w *widgetBase) setHideHeader(value bool) { + w.HideHeader = value +} + +func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) +} + +func (w *widgetBase) GetType() string { + return w.Type +} + +func (w *widgetBase) setProviders(providers *widgetProviders) { + w.Providers = providers +} + +func (w *widgetBase) source() sources.Source { + return w.Source +} + +func (w *widgetBase) setSource(s sources.Source) { + w.Source = s +} + +func (w *widgetBase) Render() template.HTML { + //TODO(pulse) render the generic widget card + panic("implement me") +} + +func (w *widgetBase) initialize() error { + //TODO(pulse) implement me + panic("implement me") +} + +func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { + w.templateBuffer.Reset() + err := t.Execute(&w.templateBuffer, data) + if err != nil { + w.ContentAvailable = false + w.Error = err + + slog.Error("Failed to render template", "error", err) + + // need to immediately re-render with the error, + // otherwise risk breaking the page since the widget + // will likely be partially rendered with tags not closed. + w.templateBuffer.Reset() + err2 := t.Execute(&w.templateBuffer, data) + + if err2 != nil { + slog.Error("Failed to render error within widget", "error", err2, "initial_error", err) + w.templateBuffer.Reset() + // TODO: add some kind of a generic widget error template when the widget + // failed to render, and we also failed to re-render the widget with the error + } + } + + return template.HTML(w.templateBuffer.String()) +} diff --git a/internal/glance/embed.go b/web/embed.go similarity index 89% rename from internal/glance/embed.go rename to web/embed.go index e09caa84..bab614ef 100644 --- a/internal/glance/embed.go +++ b/web/embed.go @@ -1,4 +1,4 @@ -package glance +package web import ( "bytes" @@ -23,15 +23,17 @@ var _staticFS embed.FS //go:embed templates var _templateFS embed.FS -var staticFS, _ = fs.Sub(_staticFS, "static") -var templateFS, _ = fs.Sub(_templateFS, "templates") +var StaticFS, _ = fs.Sub(_staticFS, "static") +var TemplateFS, _ = fs.Sub(_templateFS, "templates") + +var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`) func readAllFromStaticFS(path string) ([]byte, error) { // For some reason fs.FS only works with forward slashes, so in case we're // running on Windows or pass paths with backslashes we need to replace them. path = strings.ReplaceAll(path, "\\", "/") - file, err := staticFS.Open(path) + file, err := StaticFS.Open(path) if err != nil { return nil, err } @@ -39,8 +41,8 @@ func readAllFromStaticFS(path string) ([]byte, error) { return io.ReadAll(file) } -var staticFSHash = func() string { - hash, err := computeFSHash(staticFS) +var StaticFSHash = func() string { + hash, err := computeFSHash(StaticFS) if err != nil { log.Printf("Could not compute static assets cache key: %v", err) return strconv.FormatInt(time.Now().Unix(), 10) @@ -83,8 +85,8 @@ func computeFSHash(files fs.FS) (string, error) { var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`) var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`) -// Yes, we bundle at runtime, give comptime pls -var bundledCSSContents = func() []byte { +// BundledCSSContents Yes, we bundle at runtime, give comptime pls +var BundledCSSContents = func() []byte { const mainFilePath = "css/main.css" var recursiveParseImports func(path string, depth int) ([]byte, error) diff --git a/internal/glance/static/app-icon.png b/web/static/app-icon.png similarity index 100% rename from internal/glance/static/app-icon.png rename to web/static/app-icon.png diff --git a/internal/glance/static/css/forum-posts.css b/web/static/css/forum-posts.css similarity index 74% rename from internal/glance/static/css/forum-posts.css rename to web/static/css/forum-posts.css index e58ac6ea..f0d8f91b 100644 --- a/internal/glance/static/css/forum-posts.css +++ b/web/static/css/forum-posts.css @@ -12,6 +12,13 @@ transform: translateY(-0.15rem); } +.forum-post-match-summary { + font-size: 0.9em; + color: var(--color-text-subdue); + margin: 0.3rem 0; + line-height: 1.4; +} + @container widget (max-width: 550px) { .forum-post-autohide { display: none; diff --git a/internal/glance/static/css/login.css b/web/static/css/login.css similarity index 100% rename from internal/glance/static/css/login.css rename to web/static/css/login.css diff --git a/internal/glance/static/css/main.css b/web/static/css/main.css similarity index 100% rename from internal/glance/static/css/main.css rename to web/static/css/main.css diff --git a/internal/glance/static/css/mobile.css b/web/static/css/mobile.css similarity index 100% rename from internal/glance/static/css/mobile.css rename to web/static/css/mobile.css diff --git a/internal/glance/static/css/popover.css b/web/static/css/popover.css similarity index 100% rename from internal/glance/static/css/popover.css rename to web/static/css/popover.css diff --git a/internal/glance/static/css/site.css b/web/static/css/site.css similarity index 85% rename from internal/glance/static/css/site.css rename to web/static/css/site.css index fbf3c8ad..3c69429a 100644 --- a/internal/glance/static/css/site.css +++ b/web/static/css/site.css @@ -396,3 +396,57 @@ kbd:active { .theme-picker.popover-active .current-theme-preview, .theme-picker:hover { opacity: 1; } + +.page-filter-bar { + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: var(--border-radius); + padding: 1.2rem 1.5rem 1.2rem 1.5rem; + margin-bottom: var(--widget-gap); + margin-top: 0.5rem; + display: flex; + align-items: center; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.02); +} + +.filter-form { + gap: 0.5rem; + width: 100%; +} + +.filter-form button { + background: var(--color-primary); + color: #fff; + border-radius: var(--border-radius); + padding: 0.7em 1.5em; + font-size: 1em; + font-weight: 600; + border: none; + transition: background .2s; +} + +.filter-form button:hover, .filter-form button:focus { + background: var(--color-primary-hover, #1d4ed8); +} + +.filter-form textarea { + background: var(--color-widget-background-highlight); + border: 1px solid var(--color-widget-content-border); + border-radius: var(--border-radius); + padding: 0.7em 1.1em; + font-size: 1em; + color: var(--color-text-base); + transition: border-color .2s; + width: 100%; + max-width: 500px; + min-height: 2.5em; + max-height: 8em; + resize: vertical; + font-family: inherit; + box-sizing: border-box; +} + +.filter-form textarea:focus { + border-color: var(--color-primary); + outline: none; +} diff --git a/internal/glance/static/css/utils.css b/web/static/css/utils.css similarity index 100% rename from internal/glance/static/css/utils.css rename to web/static/css/utils.css diff --git a/internal/glance/static/css/widget-bookmarks.css b/web/static/css/widget-bookmarks.css similarity index 100% rename from internal/glance/static/css/widget-bookmarks.css rename to web/static/css/widget-bookmarks.css diff --git a/internal/glance/static/css/widget-calendar.css b/web/static/css/widget-calendar.css similarity index 100% rename from internal/glance/static/css/widget-calendar.css rename to web/static/css/widget-calendar.css diff --git a/internal/glance/static/css/widget-clock.css b/web/static/css/widget-clock.css similarity index 100% rename from internal/glance/static/css/widget-clock.css rename to web/static/css/widget-clock.css diff --git a/internal/glance/static/css/widget-dns-stats.css b/web/static/css/widget-dns-stats.css similarity index 100% rename from internal/glance/static/css/widget-dns-stats.css rename to web/static/css/widget-dns-stats.css diff --git a/internal/glance/static/css/widget-docker-containers.css b/web/static/css/widget-docker-containers.css similarity index 100% rename from internal/glance/static/css/widget-docker-containers.css rename to web/static/css/widget-docker-containers.css diff --git a/internal/glance/static/css/widget-group.css b/web/static/css/widget-group.css similarity index 100% rename from internal/glance/static/css/widget-group.css rename to web/static/css/widget-group.css diff --git a/internal/glance/static/css/widget-markets.css b/web/static/css/widget-markets.css similarity index 100% rename from internal/glance/static/css/widget-markets.css rename to web/static/css/widget-markets.css diff --git a/internal/glance/static/css/widget-monitor.css b/web/static/css/widget-monitor.css similarity index 100% rename from internal/glance/static/css/widget-monitor.css rename to web/static/css/widget-monitor.css diff --git a/internal/glance/static/css/widget-reddit.css b/web/static/css/widget-reddit.css similarity index 100% rename from internal/glance/static/css/widget-reddit.css rename to web/static/css/widget-reddit.css diff --git a/internal/glance/static/css/widget-releases.css b/web/static/css/widget-releases.css similarity index 100% rename from internal/glance/static/css/widget-releases.css rename to web/static/css/widget-releases.css diff --git a/internal/glance/static/css/widget-rss.css b/web/static/css/widget-rss.css similarity index 100% rename from internal/glance/static/css/widget-rss.css rename to web/static/css/widget-rss.css diff --git a/internal/glance/static/css/widget-search.css b/web/static/css/widget-search.css similarity index 100% rename from internal/glance/static/css/widget-search.css rename to web/static/css/widget-search.css diff --git a/internal/glance/static/css/widget-server-stats.css b/web/static/css/widget-server-stats.css similarity index 100% rename from internal/glance/static/css/widget-server-stats.css rename to web/static/css/widget-server-stats.css diff --git a/internal/glance/static/css/widget-todo.css b/web/static/css/widget-todo.css similarity index 100% rename from internal/glance/static/css/widget-todo.css rename to web/static/css/widget-todo.css diff --git a/internal/glance/static/css/widget-twitch.css b/web/static/css/widget-twitch.css similarity index 100% rename from internal/glance/static/css/widget-twitch.css rename to web/static/css/widget-twitch.css diff --git a/internal/glance/static/css/widget-videos.css b/web/static/css/widget-videos.css similarity index 100% rename from internal/glance/static/css/widget-videos.css rename to web/static/css/widget-videos.css diff --git a/internal/glance/static/css/widget-weather.css b/web/static/css/widget-weather.css similarity index 100% rename from internal/glance/static/css/widget-weather.css rename to web/static/css/widget-weather.css diff --git a/internal/glance/static/css/widgets.css b/web/static/css/widgets.css similarity index 77% rename from internal/glance/static/css/widgets.css rename to web/static/css/widgets.css index 07b41c8e..41e90244 100644 --- a/internal/glance/static/css/widgets.css +++ b/web/static/css/widgets.css @@ -91,3 +91,30 @@ .widget + .widget { margin-top: var(--widget-gap); } + +.ai-match-badge { + display: inline-block; + color: #fff; + font-size: 0.85em; + font-weight: 600; + border-radius: 999px; + padding: 0.15em 0.7em; + margin-left: 0.5em; + vertical-align: middle; + letter-spacing: 0.02em; + cursor: help; +} + +.ai-match-badge.score-0, .ai-match-badge.score-1, .ai-match-badge.score-2, .ai-match-badge.score-3 { + background: #fca5a5; + color: #991b1b; +} +.ai-match-badge.score-4, .ai-match-badge.score-5, .ai-match-badge.score-6 { + background: #fde68a; + color: #a16207; +} +.ai-match-badge.score-7, .ai-match-badge.score-8, .ai-match-badge.score-9, .ai-match-badge.score-10 { + background: #bbf7d0; + color: #166534; +} + \ No newline at end of file diff --git a/internal/glance/static/favicon.png b/web/static/favicon.png similarity index 100% rename from internal/glance/static/favicon.png rename to web/static/favicon.png diff --git a/internal/glance/static/favicon.svg b/web/static/favicon.svg similarity index 100% rename from internal/glance/static/favicon.svg rename to web/static/favicon.svg diff --git a/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 b/web/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/glance/static/fonts/JetBrainsMono-Regular.woff2 rename to web/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/glance/static/icons/codeberg.svg b/web/static/icons/codeberg.svg similarity index 100% rename from internal/glance/static/icons/codeberg.svg rename to web/static/icons/codeberg.svg diff --git a/internal/glance/static/icons/dockerhub.svg b/web/static/icons/dockerhub.svg similarity index 100% rename from internal/glance/static/icons/dockerhub.svg rename to web/static/icons/dockerhub.svg diff --git a/internal/glance/static/icons/github.svg b/web/static/icons/github.svg similarity index 100% rename from internal/glance/static/icons/github.svg rename to web/static/icons/github.svg diff --git a/internal/glance/static/icons/gitlab.svg b/web/static/icons/gitlab.svg similarity index 100% rename from internal/glance/static/icons/gitlab.svg rename to web/static/icons/gitlab.svg diff --git a/internal/glance/static/js/animations.js b/web/static/js/animations.js similarity index 100% rename from internal/glance/static/js/animations.js rename to web/static/js/animations.js diff --git a/internal/glance/static/js/calendar.js b/web/static/js/calendar.js similarity index 100% rename from internal/glance/static/js/calendar.js rename to web/static/js/calendar.js diff --git a/internal/glance/static/js/login.js b/web/static/js/login.js similarity index 100% rename from internal/glance/static/js/login.js rename to web/static/js/login.js diff --git a/internal/glance/static/js/masonry.js b/web/static/js/masonry.js similarity index 100% rename from internal/glance/static/js/masonry.js rename to web/static/js/masonry.js diff --git a/internal/glance/static/js/page.js b/web/static/js/page.js similarity index 96% rename from internal/glance/static/js/page.js rename to web/static/js/page.js index e3a3a84f..ccd80166 100644 --- a/internal/glance/static/js/page.js +++ b/web/static/js/page.js @@ -6,7 +6,14 @@ import { elem, find, findAll } from './templating.js'; async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs // TODO: add retries - const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`); + const urlParams = new URLSearchParams(window.location.search); + const reqParams = new URLSearchParams(); + + if (urlParams.has("filter")) { + reqParams.set("filter", urlParams.get("filter")); + } + + const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/?${reqParams.toString()}`); const content = await response.text(); return content; @@ -775,6 +782,20 @@ async function setupPage() { document.body.classList.add("page-columns-transitioned"); }, 300); } + + if (document.getElementById('filter-form')) { + document.getElementById('filter-form').addEventListener('submit', function(e) { + e.preventDefault(); + const filter = document.getElementById('filter-input').value.trim(); + const url = new URL(window.location.href); + if (filter) { + url.searchParams.set('filter', filter); + } else { + url.searchParams.delete('filter'); + } + window.location.href = url.toString(); + }); + } } setupPage(); diff --git a/internal/glance/static/js/popover.js b/web/static/js/popover.js similarity index 100% rename from internal/glance/static/js/popover.js rename to web/static/js/popover.js diff --git a/internal/glance/static/js/templating.js b/web/static/js/templating.js similarity index 100% rename from internal/glance/static/js/templating.js rename to web/static/js/templating.js diff --git a/internal/glance/static/js/todo.js b/web/static/js/todo.js similarity index 100% rename from internal/glance/static/js/todo.js rename to web/static/js/todo.js diff --git a/internal/glance/static/js/utils.js b/web/static/js/utils.js similarity index 100% rename from internal/glance/static/js/utils.js rename to web/static/js/utils.js diff --git a/internal/glance/templates/document.html b/web/templates/document.html similarity index 100% rename from internal/glance/templates/document.html rename to web/templates/document.html diff --git a/internal/glance/templates/extension.html b/web/templates/extension.html similarity index 100% rename from internal/glance/templates/extension.html rename to web/templates/extension.html diff --git a/internal/glance/templates/footer.html b/web/templates/footer.html similarity index 100% rename from internal/glance/templates/footer.html rename to web/templates/footer.html diff --git a/internal/glance/templates/group.html b/web/templates/group.html similarity index 100% rename from internal/glance/templates/group.html rename to web/templates/group.html diff --git a/internal/glance/templates/login.html b/web/templates/login.html similarity index 100% rename from internal/glance/templates/login.html rename to web/templates/login.html diff --git a/internal/glance/templates/manifest.json b/web/templates/manifest.json similarity index 100% rename from internal/glance/templates/manifest.json rename to web/templates/manifest.json diff --git a/internal/glance/templates/page-content.html b/web/templates/page-content.html similarity index 64% rename from internal/glance/templates/page-content.html rename to web/templates/page-content.html index 4cf67a72..52622d8a 100644 --- a/internal/glance/templates/page-content.html +++ b/web/templates/page-content.html @@ -10,6 +10,14 @@ {{ end }} +
+
+ +
+ +
+
+
{{- range .Page.Columns }}
diff --git a/internal/glance/templates/page.html b/web/templates/page.html similarity index 100% rename from internal/glance/templates/page.html rename to web/templates/page.html diff --git a/internal/glance/templates/split-column.html b/web/templates/split-column.html similarity index 100% rename from internal/glance/templates/split-column.html rename to web/templates/split-column.html diff --git a/internal/glance/templates/theme-preset-preview.html b/web/templates/theme-preset-preview.html similarity index 100% rename from internal/glance/templates/theme-preset-preview.html rename to web/templates/theme-preset-preview.html diff --git a/internal/glance/templates/theme-style.gotmpl b/web/templates/theme-style.gotmpl similarity index 100% rename from internal/glance/templates/theme-style.gotmpl rename to web/templates/theme-style.gotmpl diff --git a/internal/glance/templates/forum-posts.html b/web/templates/widget-base-content.html similarity index 89% rename from internal/glance/templates/forum-posts.html rename to web/templates/widget-base-content.html index 5b65b69a..ff64619d 100644 --- a/internal/glance/templates/forum-posts.html +++ b/web/templates/widget-base-content.html @@ -2,7 +2,7 @@ {{- define "widget-content" }}
    - {{- range .Posts }} + {{- range .Feed }}
  • {{- if $.ShowThumbnails }} @@ -24,6 +24,9 @@ {{- end }}
    {{ .Title }} + {{- if .MatchScore }} + {{ .MatchScore }}0% + {{- end }} {{- if .Tags }} {{- end }} + {{- if .MatchSummary }} +
    {{ .MatchSummary }}
    + {{- end }}
    • {{ .Score | formatApproxNumber }} points
    • diff --git a/internal/glance/templates/widget-base.html b/web/templates/widget-base.html similarity index 100% rename from internal/glance/templates/widget-base.html rename to web/templates/widget-base.html