mirror of
https://github.com/Xevion/glance.git
synced 2025-12-10 04:07:26 -06:00
Initial commit
This commit is contained in:
15
internal/assets/files.go
Normal file
15
internal/assets/files.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _publicFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
BIN
internal/assets/static/favicon.png
Normal file
BIN
internal/assets/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/assets/static/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
internal/assets/static/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
1032
internal/assets/static/main.css
Normal file
1032
internal/assets/static/main.css
Normal file
File diff suppressed because it is too large
Load Diff
163
internal/assets/static/main.js
Normal file
163
internal/assets/static/main.js
Normal file
@@ -0,0 +1,163 @@
|
||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
async function fetchPageContents (pageSlug) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
// TODO: add retries
|
||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
||||
const content = await response.text();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function setupCarousels() {
|
||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||
|
||||
for (let i = 0; i < carouselElements.length; i++) {
|
||||
const carousel = carouselElements[i];
|
||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||
|
||||
const determineSideCutoffs = () => {
|
||||
if (itemsContainer.scrollLeft != 0) {
|
||||
carousel.classList.add("show-left-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-left-cutoff");
|
||||
}
|
||||
|
||||
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
||||
carousel.classList.add("show-right-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-right-cutoff");
|
||||
}
|
||||
}
|
||||
|
||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||
|
||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||
|
||||
determineSideCutoffs();
|
||||
}
|
||||
}
|
||||
|
||||
const minuteInSeconds = 60;
|
||||
const hourInSeconds = minuteInSeconds * 60;
|
||||
const dayInSeconds = hourInSeconds * 24;
|
||||
const monthInSeconds = dayInSeconds * 30;
|
||||
const yearInSeconds = monthInSeconds * 12;
|
||||
|
||||
function relativeTimeSince(timestamp) {
|
||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||
|
||||
if (delta < minuteInSeconds) {
|
||||
return "1m";
|
||||
}
|
||||
if (delta < hourInSeconds) {
|
||||
return Math.floor(delta / minuteInSeconds) + "m";
|
||||
}
|
||||
if (delta < dayInSeconds) {
|
||||
return Math.floor(delta / hourInSeconds) + "h";
|
||||
}
|
||||
if (delta < monthInSeconds) {
|
||||
return Math.floor(delta / dayInSeconds) + "d";
|
||||
}
|
||||
if (delta < yearInSeconds) {
|
||||
return Math.floor(delta / monthInSeconds) + "mo";
|
||||
}
|
||||
|
||||
return Math.floor(delta / yearInSeconds) + "y";
|
||||
}
|
||||
|
||||
function updateRelativeTimeForElements(elements)
|
||||
{
|
||||
for (let i = 0; i < elements.length; i++)
|
||||
{
|
||||
const element = elements[i];
|
||||
const timestamp = element.dataset.dynamicRelativeTime;
|
||||
|
||||
if (timestamp === undefined)
|
||||
continue
|
||||
|
||||
element.innerText = relativeTimeSince(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function setupDynamicRelativeTime() {
|
||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||
const updateInterval = 60 * 1000;
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
const updateElementsAndTimestamp = () => {
|
||||
updateRelativeTimeForElements(elements);
|
||||
lastUpdateTime = Date.now();
|
||||
};
|
||||
|
||||
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
||||
|
||||
if (document.hidden === undefined) {
|
||||
scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = scheduleRepeatingUpdate();
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Date.now() - lastUpdateTime;
|
||||
|
||||
if (delta >= updateInterval) {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
}, updateInterval - delta);
|
||||
});
|
||||
}
|
||||
|
||||
async function setupPage() {
|
||||
const pageElement = document.getElementById("page");
|
||||
const pageContents = await fetchPageContents(pageData.slug);
|
||||
|
||||
pageElement.innerHTML = pageContents;
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.classList.add("animate-element-transition");
|
||||
}, 150);
|
||||
|
||||
setupCarousels();
|
||||
setupDynamicRelativeTime();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupPage);
|
||||
} else {
|
||||
setupPage();
|
||||
}
|
||||
112
internal/assets/templates.go
Normal file
112
internal/assets/templates.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||
PageContentTemplate = compileTemplate("content.html")
|
||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
|
||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSCardsTemplate = compileTemplate("rss-cards.html", "widget-base.html")
|
||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"formatTime": func(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"shouldCollapse": func(i int, collapseAfter int) bool {
|
||||
if collapseAfter < -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return i >= collapseAfter
|
||||
},
|
||||
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
||||
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||
},
|
||||
}
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
}
|
||||
|
||||
func relativeTimeSince(t time.Time) string {
|
||||
delta := time.Since(t)
|
||||
|
||||
if delta < time.Minute {
|
||||
return "1m"
|
||||
}
|
||||
if delta < time.Hour {
|
||||
return fmt.Sprintf("%dm", delta/time.Minute)
|
||||
}
|
||||
if delta < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", delta/time.Hour)
|
||||
}
|
||||
if delta < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dd", delta/(24*time.Hour))
|
||||
}
|
||||
if delta < 12*30*24*time.Hour {
|
||||
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
|
||||
}
|
||||
16
internal/assets/templates/bookmarks.html
Normal file
16
internal/assets/templates/bookmarks.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
27
internal/assets/templates/calendar.html
Normal file
27
internal/assets/templates/calendar.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
<div class="calendar-day">Su</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
9
internal/assets/templates/content.html
Normal file
9
internal/assets/templates/content.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="page-columns">
|
||||
{{ range .Page.Columns }}
|
||||
<div class="page-column page-column-{{ .Size }}">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
17
internal/assets/templates/document.html
Normal file
17
internal/assets/templates/document.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
||||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "document-body" . }}
|
||||
</body>
|
||||
</html>
|
||||
22
internal/assets/templates/forum-posts.html
Normal file
22
internal/assets/templates/forum-posts.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $post := .Posts }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
||||
<li>{{ $post.Score | formatNumber }} points</li>
|
||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
||||
{{ if $post.HasTargetUrl }}
|
||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
7
internal/assets/templates/iframe.html
Normal file
7
internal/assets/templates/iframe.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<iframe src="{{ .Source }}" width="100%" height="{{ .Height }}px" frameborder="0"></iframe>
|
||||
{{ end }}
|
||||
39
internal/assets/templates/monitor.html
Normal file
39
internal/assets/templates/monitor.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Sites }}
|
||||
<li class="monitor-site flex items-center gap-15">
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div>
|
||||
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
{{ if not .Status.Error }}
|
||||
<li>{{ .StatusText }}</li>
|
||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||
{{ else if .Status.TimedOut }}
|
||||
<li class="color-negative">Timed Out</li>
|
||||
{{ else }}
|
||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ if eq .StatusStyle "good" }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
<style>
|
||||
:root {
|
||||
{{ if .App.Config.Theme.BackgroundColor }}
|
||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
||||
{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
||||
}
|
||||
</style>
|
||||
64
internal/assets/templates/page.html
Normal file
64
internal/assets/templates/page.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{{ template "document.html" . }}
|
||||
|
||||
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
||||
|
||||
{{ define "document-head-before" }}
|
||||
<script>
|
||||
const pageData = {
|
||||
slug: "{{ .Page.Slug }}",
|
||||
};
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||
{{ define "document-head-after" }}{{ template "page-style-overrides.gotmpl" . }}{{ end }}
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
{{ range .App.Config.Pages }}
|
||||
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-body" }}
|
||||
<div class="header-container content-bounds">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||
<div class="logo">G</div>
|
||||
<div class="nav flex flex-grow">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-navigation">
|
||||
<div class="mobile-navigation-icons">
|
||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||
{{ range $i, $column := .Page.Columns }}
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
{{ end }}
|
||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
<div class="mobile-navigation-page-links">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds">
|
||||
<div class="page" id="page">
|
||||
<div class="page-loading-container">
|
||||
<!-- TODO: add a bigger/better loading indicator -->
|
||||
<div class="loading-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer flex items-center flex-column">
|
||||
<div>
|
||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
||||
</div>
|
||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
31
internal/assets/templates/reddit-horizontal-cards.html
Normal file
31
internal/assets/templates/reddit-horizontal-cards.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="cards-horizontal carousel-items-container">
|
||||
{{ range .Posts }}
|
||||
<div class="card widget-content-frame relative">
|
||||
{{ if ne "" .ThumbnailUrl }}
|
||||
<div class="reddit-card-thumbnail-container">
|
||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="padding-widget flex flex-column flex-grow relative">
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||
{{ else }}
|
||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||
{{ end }}
|
||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
29
internal/assets/templates/reddit-vertical-cards.html
Normal file
29
internal/assets/templates/reddit-vertical-cards.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="cards-vertical">
|
||||
{{ range .Posts }}
|
||||
<div class="widget-content-frame relative">
|
||||
{{ if ne "" .ThumbnailUrl }}
|
||||
<div class="reddit-card-thumbnail-container">
|
||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="padding-widget relative">
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<a class="color-highlight size-h5 text-truncate visited-indicator block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||
{{ else }}
|
||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||
{{ end }}
|
||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text margin-top-7">
|
||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
21
internal/assets/templates/releases.html
Normal file
21
internal/assets/templates/releases.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $release := .Releases }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
||||
<li>{{ $release.Version }}</li>
|
||||
{{ if gt $release.Downvotes 3 }}
|
||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
28
internal/assets/templates/rss-cards.html
Normal file
28
internal/assets/templates/rss-cards.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="cards-horizontal carousel-items-container">
|
||||
{{ range .Items }}
|
||||
<div class="card widget-content-frame thumbnail-container">
|
||||
{{ if ne "" .ImageURL }}
|
||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||
{{ else }}
|
||||
<svg class="rss-card-image" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column flex-grow">
|
||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
20
internal/assets/templates/rss-list.html
Normal file
20
internal/assets/templates/rss-list.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $item := .Items }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||
{{ if gt (len $.FeedRequests) 1 }}
|
||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Items) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
23
internal/assets/templates/stocks.html
Normal file
23
internal/assets/templates/stocks.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Stocks }}
|
||||
<li class="flex items-center gap-15">
|
||||
<div class="shrink min-width-0">
|
||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
</svg>
|
||||
|
||||
<div class="stock-values shrink-0">
|
||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||
<div class="text-right">${{ .Price | formatPrice }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
40
internal/assets/templates/twitch-channels.html
Normal file
40
internal/assets/templates/twitch-channels.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $channel := .Channels }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
{{ if $channel.Exists }}
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
||||
{{ else }}
|
||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="shrink min-width-0">
|
||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
||||
{{ if $channel.Exists }}
|
||||
{{ if $channel.IsLive }}
|
||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div>Offline</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="color-negative">Not found</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
35
internal/assets/templates/twitch-games-list.html
Normal file
35
internal/assets/templates/twitch-games-list.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $category := .Categories }}
|
||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="flex gap-10 items-center">
|
||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
||||
<div class="shrink min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
||||
{{ if $category.IsNew }}
|
||||
<li class="color-primary">NEW</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="list-horizontal-text flex-nowrap">
|
||||
{{ range $i, $tag := $category.Tags }}
|
||||
{{ if eq $i 0 }}
|
||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||
{{ else }}
|
||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
24
internal/assets/templates/videos.html
Normal file
24
internal/assets/templates/videos.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="videos cards-horizontal carousel-items-container">
|
||||
{{ range .Videos }}
|
||||
<div class="card widget-content-frame thumbnail-container">
|
||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget">
|
||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li class="shrink min-width-0">
|
||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
29
internal/assets/templates/weather.html
Normal file
29
internal/assets/templates/weather.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°C</div>
|
||||
|
||||
<div class="weather-columns flex margin-top-15 justify-center">
|
||||
{{ range $i, $column := .Weather.Columns }}
|
||||
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
|
||||
{{ if $column.HasPrecipitation }}
|
||||
<div class="weather-column-rain"></div>
|
||||
{{ end }}
|
||||
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
|
||||
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
|
||||
{{ end }}
|
||||
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
|
||||
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
|
||||
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if not .HideLocation }}
|
||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||
<div class="location-icon"></div>
|
||||
<div class="text-truncate">{{ .Place.Name }}, {{ .Place.Country }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
21
internal/assets/templates/widget-base.html
Normal file
21
internal/assets/templates/widget-base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="widget widget-type-{{ .GetType }}">
|
||||
<div class="widget-header">
|
||||
<div class="uppercase">{{ .Title }}</div>
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<div class="widget-error-icon"></div>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
</div>
|
||||
</div>
|
||||
53
internal/feed/calendar.go
Normal file
53
internal/feed/calendar.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package feed
|
||||
|
||||
import "time"
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing first day of week
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func NewCalendar(now time.Time) *Calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday+6)
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &Calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
117
internal/feed/github.go
Normal file
117
internal/feed/github.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type githubReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
PreRelease bool `json:"prerelease"`
|
||||
Reactions struct {
|
||||
Downvotes int `json:"-1"`
|
||||
} `json:"reactions"`
|
||||
}
|
||||
|
||||
func parseGithubTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
|
||||
appReleases := make(AppReleases, 0, len(repositories))
|
||||
|
||||
if len(repositories) == 0 {
|
||||
return appReleases, nil
|
||||
}
|
||||
|
||||
requests := make([]*http.Request, len(repositories))
|
||||
|
||||
for i, repository := range repositories {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
||||
|
||||
if token != "" {
|
||||
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(15)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
releases := responses[i]
|
||||
|
||||
if len(releases) < 1 {
|
||||
failed++
|
||||
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var liveRelease *githubReleaseResponseJson
|
||||
|
||||
for i := range releases {
|
||||
release := &releases[i]
|
||||
|
||||
if !release.Draft && !release.PreRelease {
|
||||
liveRelease = release
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if liveRelease == nil {
|
||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
version := liveRelease.TagName
|
||||
|
||||
if version[0] != 'v' {
|
||||
version = "v" + version
|
||||
}
|
||||
|
||||
appReleases = append(appReleases, AppRelease{
|
||||
Name: repositories[i],
|
||||
Version: version,
|
||||
NotesUrl: liveRelease.HtmlUrl,
|
||||
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
|
||||
Downvotes: liveRelease.Reactions.Downvotes,
|
||||
})
|
||||
}
|
||||
|
||||
if len(appReleases) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
appReleases.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return appReleases, nil
|
||||
}
|
||||
89
internal/feed/hacker-news.go
Normal file
89
internal/feed/hacker-news.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hackerNewsPostResponseJson struct {
|
||||
Id int `json:"id"`
|
||||
Score int `json:"score"`
|
||||
Title string `json:"title"`
|
||||
TargetUrl string `json:"url,omitempty"`
|
||||
CommentCount int `json:"descendants"`
|
||||
TimePosted int64 `json:"time"`
|
||||
}
|
||||
|
||||
func getHackerNewsTopPostIds() ([]int, error) {
|
||||
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
|
||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
||||
requests := make([]*http.Request, len(postIds))
|
||||
|
||||
for i, id := range postIds {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(30)
|
||||
results, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(postIds))
|
||||
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: results[i].Title,
|
||||
DiscussionUrl: "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id),
|
||||
TargetUrl: results[i].TargetUrl,
|
||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||
CommentCount: results[i].CommentCount,
|
||||
Score: results[i].Score,
|
||||
TimePosted: time.Unix(results[i].TimePosted, 0),
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if len(posts) != len(postIds) {
|
||||
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
||||
postIds, err := getHackerNewsTopPostIds()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(postIds) > limit {
|
||||
postIds = postIds[:limit]
|
||||
}
|
||||
|
||||
return getHackerNewsPostsFromIds(postIds)
|
||||
}
|
||||
51
internal/feed/monitor.go
Normal file
51
internal/feed/monitor.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SiteStatus struct {
|
||||
Code int
|
||||
TimedOut bool
|
||||
ResponseTime time.Duration
|
||||
Error error
|
||||
}
|
||||
|
||||
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
start := time.Now()
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
took := time.Since(start)
|
||||
status := SiteStatus{ResponseTime: took}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status.TimedOut = true
|
||||
}
|
||||
|
||||
status.Error = err
|
||||
return status, err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
status.Code = response.StatusCode
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
|
||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||
results, _, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
151
internal/feed/openmeteo.go
Normal file
151
internal/feed/openmeteo.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
type PlacesResponseJson struct {
|
||||
Results []PlaceJson
|
||||
}
|
||||
|
||||
type PlaceJson struct {
|
||||
Name string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Timezone string
|
||||
Country string
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
type WeatherResponseJson 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
|
||||
}
|
||||
|
||||
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(location))
|
||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not fetch places data: %v", err)
|
||||
}
|
||||
|
||||
if len(responseJson.Results) == 0 {
|
||||
return nil, fmt.Errorf("no places found for %s", location)
|
||||
}
|
||||
|
||||
place := &responseJson.Results[0]
|
||||
|
||||
loc, err := time.LoadLocation(place.Timezone)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load location: %v", err)
|
||||
}
|
||||
|
||||
place.location = loc
|
||||
|
||||
return place, nil
|
||||
}
|
||||
|
||||
func barIndexFromHour(h int) int {
|
||||
return h / 2
|
||||
}
|
||||
|
||||
// TODO: bunch of spaget, refactor
|
||||
// TODO: allow changing between C and F
|
||||
func FetchWeatherForPlace(place *PlaceJson) (*Weather, error) {
|
||||
query := url.Values{}
|
||||
|
||||
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,wind_speed_10m")
|
||||
query.Add("hourly", "temperature_2m,precipitation_probability")
|
||||
query.Add("daily", "sunrise,sunset")
|
||||
|
||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||
responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
now := time.Now().In(place.location)
|
||||
bars := make([]weatherColumn, 0, 24)
|
||||
currentBar := barIndexFromHour(now.Hour())
|
||||
sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour())
|
||||
sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1
|
||||
|
||||
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)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
bars = append(bars, weatherColumn{
|
||||
Temperature: temperatures[i],
|
||||
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||
HasPrecipitation: precipitations[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
183
internal/feed/primitives.go
Normal file
183
internal/feed/primitives.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ForumPost struct {
|
||||
Title string
|
||||
DiscussionUrl string
|
||||
TargetUrl string
|
||||
TargetUrlDomain string
|
||||
ThumbnailUrl string
|
||||
CommentCount int
|
||||
Score int
|
||||
Engagement float64
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type ForumPosts []ForumPost
|
||||
|
||||
type Calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
type Weather struct {
|
||||
Temperature int
|
||||
ApparentTemperature int
|
||||
WeatherCode int
|
||||
CurrentColumn int
|
||||
SunriseColumn int
|
||||
SunsetColumn int
|
||||
Columns []weatherColumn
|
||||
}
|
||||
|
||||
type AppRelease struct {
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
}
|
||||
|
||||
type AppReleases []AppRelease
|
||||
|
||||
type Video struct {
|
||||
ThumbnailUrl string
|
||||
Title string
|
||||
Url string
|
||||
Author string
|
||||
AuthorUrl string
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type Videos []Video
|
||||
|
||||
type Stock struct {
|
||||
Name string
|
||||
Symbol string
|
||||
Price float64
|
||||
PercentChange float64
|
||||
SvgChartPoints string
|
||||
}
|
||||
|
||||
type Stocks []Stock
|
||||
|
||||
func (t Stocks) SortByAbsChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Rime Fog",
|
||||
51: "Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Drizzle",
|
||||
56: "Drizzle",
|
||||
57: "Drizzle",
|
||||
61: "Rain",
|
||||
63: "Moderate Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Snow",
|
||||
73: "Moderate Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Rain",
|
||||
81: "Moderate Rain",
|
||||
82: "Heavy Rain",
|
||||
85: "Snow",
|
||||
86: "Snow",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm",
|
||||
99: "Thunderstorm",
|
||||
}
|
||||
|
||||
func (w *Weather) WeatherCodeAsString() string {
|
||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
||||
return weatherCode
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const depreciatePostsOlderThanHours = 7
|
||||
const maxDepreciation = 0.9
|
||||
const maxDepreciationAfterHours = 24
|
||||
|
||||
func (p ForumPosts) CalculateEngagement() {
|
||||
var totalComments int
|
||||
var totalScore int
|
||||
|
||||
for i := range p {
|
||||
totalComments += p[i].CommentCount
|
||||
totalScore += p[i].Score
|
||||
}
|
||||
|
||||
numberOfPosts := float64(len(p))
|
||||
averageComments := float64(totalComments) / numberOfPosts
|
||||
averageScore := float64(totalScore) / numberOfPosts
|
||||
|
||||
for i := range p {
|
||||
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
|
||||
|
||||
elapsed := time.Since(p[i].TimePosted)
|
||||
|
||||
if elapsed < time.Hour*depreciatePostsOlderThanHours {
|
||||
continue
|
||||
}
|
||||
|
||||
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
|
||||
}
|
||||
}
|
||||
|
||||
func (p ForumPosts) SortByEngagement() {
|
||||
sort.Slice(p, func(i, j int) bool {
|
||||
return p[i].Engagement > p[j].Engagement
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ForumPost) HasTargetUrl() bool {
|
||||
return s.TargetUrl != ""
|
||||
}
|
||||
|
||||
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
|
||||
recent := make([]ForumPost, 0, len(p))
|
||||
|
||||
for i := range p {
|
||||
if time.Since(p[i].TimePosted) < postedBefore {
|
||||
recent = append(recent, p[i])
|
||||
}
|
||||
}
|
||||
|
||||
return recent
|
||||
}
|
||||
|
||||
func (r AppReleases) SortByNewest() AppReleases {
|
||||
sort.Slice(r, func(i, j int) bool {
|
||||
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (v Videos) SortByNewest() Videos {
|
||||
sort.Slice(v, func(i, j int) bool {
|
||||
return v[i].TimePosted.After(v[j].TimePosted)
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
83
internal/feed/reddit.go
Normal file
83
internal/feed/reddit.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type subredditResponseJson struct {
|
||||
Data struct {
|
||||
Children []struct {
|
||||
Data struct {
|
||||
Title string `json:"title"`
|
||||
Upvotes int `json:"ups"`
|
||||
Url string `json:"url"`
|
||||
Time float64 `json:"created"`
|
||||
CommentsCount int `json:"num_comments"`
|
||||
Domain string `json:"domain"`
|
||||
Permalink string `json:"permalink"`
|
||||
Stickied bool `json:"stickied"`
|
||||
Pinned bool `json:"pinned"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
} `json:"data"`
|
||||
} `json:"children"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
||||
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit))
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||
addBrowserUserAgentHeader(request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseJson.Data.Children) == 0 {
|
||||
return nil, fmt.Errorf("no posts found")
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
||||
|
||||
for i := range responseJson.Data.Children {
|
||||
post := &responseJson.Data.Children[i].Data
|
||||
|
||||
if post.Stickied || post.Pinned {
|
||||
continue
|
||||
}
|
||||
|
||||
forumPost := ForumPost{
|
||||
Title: html.UnescapeString(post.Title),
|
||||
DiscussionUrl: "https://www.reddit.com" + post.Permalink,
|
||||
TargetUrlDomain: post.Domain,
|
||||
CommentCount: post.CommentsCount,
|
||||
Score: post.Upvotes,
|
||||
TimePosted: time.Unix(int64(post.Time), 0),
|
||||
}
|
||||
|
||||
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
|
||||
forumPost.ThumbnailUrl = post.Thumbnail
|
||||
}
|
||||
|
||||
if !post.IsSelf {
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
posts = append(posts, forumPost)
|
||||
}
|
||||
|
||||
posts.CalculateEngagement()
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
195
internal/feed/requests.go
Normal file
195
internal/feed/requests.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
type RequestDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func addBrowserUserAgentHeader(request *http.Request) {
|
||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
||||
}
|
||||
|
||||
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||
response, err := client.Do(request)
|
||||
var result T
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &result)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
|
||||
return func(request *http.Request) (T, error) {
|
||||
return decodeJsonFromRequest[T](client, request)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: tidy up, these are a copy of the above but with a line changed
|
||||
func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||
response, err := client.Do(request)
|
||||
var result T
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
||||
}
|
||||
|
||||
err = xml.Unmarshal(body, &result)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeXmlFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
|
||||
return func(request *http.Request) (T, error) {
|
||||
return decodeXmlFromRequest[T](client, request)
|
||||
}
|
||||
}
|
||||
|
||||
type workerPoolTask[I any, O any] struct {
|
||||
index int
|
||||
input I
|
||||
output O
|
||||
err error
|
||||
}
|
||||
|
||||
type workerPoolJob[I any, O any] struct {
|
||||
data []I
|
||||
workers int
|
||||
task func(I) (O, error)
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
const defaultNumWorkers = 10
|
||||
|
||||
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
|
||||
if workers == 0 {
|
||||
job.workers = defaultNumWorkers
|
||||
} else if workers > len(job.data) {
|
||||
job.workers = len(job.data)
|
||||
} else {
|
||||
job.workers = workers
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
// func (job *workerPoolJob[I, O]) withContext(ctx context.Context) *workerPoolJob[I, O] {
|
||||
// if ctx != nil {
|
||||
// job.ctx = ctx
|
||||
// }
|
||||
|
||||
// return job
|
||||
// }
|
||||
|
||||
func newJob[I any, O any](task func(I) (O, error), data []I) *workerPoolJob[I, O] {
|
||||
return &workerPoolJob[I, O]{
|
||||
workers: defaultNumWorkers,
|
||||
task: task,
|
||||
data: data,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error) {
|
||||
results := make([]O, len(job.data))
|
||||
errs := make([]error, len(job.data))
|
||||
|
||||
if len(job.data) == 0 {
|
||||
return results, errs, nil
|
||||
}
|
||||
|
||||
tasksQueue := make(chan *workerPoolTask[I, O])
|
||||
resultsQueue := make(chan *workerPoolTask[I, O])
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range job.workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for t := range tasksQueue {
|
||||
t.output, t.err = job.task(t.input)
|
||||
resultsQueue <- t
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
go func() {
|
||||
loop:
|
||||
for i := range job.data {
|
||||
select {
|
||||
default:
|
||||
tasksQueue <- &workerPoolTask[I, O]{
|
||||
index: i,
|
||||
input: job.data[i],
|
||||
}
|
||||
case <-job.ctx.Done():
|
||||
err = job.ctx.Err()
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
close(tasksQueue)
|
||||
wg.Wait()
|
||||
close(resultsQueue)
|
||||
}()
|
||||
|
||||
for task := range resultsQueue {
|
||||
errs[task.index] = task.err
|
||||
results[task.index] = task.output
|
||||
}
|
||||
|
||||
return results, errs, err
|
||||
}
|
||||
117
internal/feed/rss.go
Normal file
117
internal/feed/rss.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
type RSSFeedItem struct {
|
||||
ChannelName string
|
||||
ChannelURL string
|
||||
Title string
|
||||
Link string
|
||||
ImageURL string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
type RSSFeedRequest struct {
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
}
|
||||
|
||||
type RSSFeedItems []RSSFeedItem
|
||||
|
||||
func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
||||
sort.Slice(f, func(i, j int) bool {
|
||||
return f[i].PublishedAt.After(f[j].PublishedAt)
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
var feedParser = gofeed.NewParser()
|
||||
|
||||
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make(RSSFeedItems, 0, len(feed.Items))
|
||||
|
||||
for i := range feed.Items {
|
||||
item := feed.Items[i]
|
||||
|
||||
rssItem := RSSFeedItem{
|
||||
ChannelURL: feed.Link,
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
}
|
||||
|
||||
if request.Title != "" {
|
||||
rssItem.ChannelName = request.Title
|
||||
} else {
|
||||
rssItem.ChannelName = feed.Title
|
||||
}
|
||||
|
||||
if item.Image != nil {
|
||||
rssItem.ImageURL = item.Image.URL
|
||||
} else if feed.Image != nil {
|
||||
rssItem.ImageURL = feed.Image.URL
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
rssItem.PublishedAt = *item.PublishedParsed
|
||||
} else {
|
||||
rssItem.PublishedAt = time.Now()
|
||||
}
|
||||
|
||||
items = append(items, rssItem)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
entries := make(RSSFeedItems, 0, len(feeds)*10)
|
||||
|
||||
for i := range feeds {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, feeds[i]...)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
entries.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
248
internal/feed/twitch.go
Normal file
248
internal/feed/twitch.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchCategory struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl string `json:"avatarURL"`
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
Tags []struct {
|
||||
Name string `json:"tagName"`
|
||||
} `json:"tags"`
|
||||
GameReleaseDate string `json:"originalReleaseDate"`
|
||||
IsNew bool `json:"-"`
|
||||
}
|
||||
|
||||
type TwitchChannel struct {
|
||||
Login string
|
||||
Exists bool
|
||||
Name string
|
||||
AvatarUrl string
|
||||
IsLive bool
|
||||
LiveSince time.Time
|
||||
Category string
|
||||
CategorySlug string
|
||||
ViewersCount int
|
||||
}
|
||||
|
||||
type TwitchChannels []TwitchChannel
|
||||
|
||||
func (channels TwitchChannels) SortByViewers() {
|
||||
sort.Slice(channels, func(i, j int) bool {
|
||||
return channels[i].ViewersCount > channels[j].ViewersCount
|
||||
})
|
||||
}
|
||||
|
||||
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"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type twitchDirectoriesOperationResponse struct {
|
||||
Data struct {
|
||||
DirectoriesWithTags struct {
|
||||
Edges []struct {
|
||||
Node TwitchCategory `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"directoriesWithTags"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
|
||||
const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
|
||||
const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
|
||||
|
||||
func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
|
||||
reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
|
||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||
response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response) == 0 {
|
||||
return nil, errors.New("no categories could be retrieved")
|
||||
}
|
||||
|
||||
edges := (response)[0].Data.DirectoriesWithTags.Edges
|
||||
categories := make([]TwitchCategory, 0, len(edges))
|
||||
|
||||
for i := range edges {
|
||||
if slices.Contains(exclude, edges[i].Node.Slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
category := &edges[i].Node
|
||||
category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
|
||||
|
||||
if len(category.Tags) > 2 {
|
||||
category.Tags = category.Tags[:2]
|
||||
}
|
||||
|
||||
gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
|
||||
|
||||
if err == nil {
|
||||
if time.Since(gameReleasedDate) < 14*24*time.Hour {
|
||||
category.IsNew = true
|
||||
}
|
||||
}
|
||||
|
||||
categories = append(categories, *category)
|
||||
}
|
||||
|
||||
if len(categories) > limit {
|
||||
categories = categories[:limit]
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||
|
||||
// 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](defaultClient, 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":
|
||||
err = json.Unmarshal(response[i].Data, &channelShell)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
|
||||
}
|
||||
case "StreamMetadata":
|
||||
err = json.Unmarshal(response[i].Data, &streamMetadata)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
|
||||
}
|
||||
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 && 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
|
||||
result := make(TwitchChannels, 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.Warn("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
|
||||
}
|
||||
79
internal/feed/utils.go
Normal file
79
internal/feed/utils.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoContent = errors.New("failed to retrieve any content")
|
||||
ErrPartialContent = errors.New("failed to retrieve some of the content")
|
||||
)
|
||||
|
||||
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(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
|
||||
}
|
||||
102
internal/feed/yahoo.go
Normal file
102
internal/feed/yahoo.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type stockResponseJson struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Symbol string `json:"symbol"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Close []float64 `json:"close,omitempty"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
type StockRequest struct {
|
||||
Symbol string
|
||||
Name string
|
||||
}
|
||||
|
||||
// TODO: allow changing chart time frame
|
||||
const stockChartDays = 21
|
||||
|
||||
func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
||||
requests := make([]*http.Request, 0, len(stockRequests))
|
||||
|
||||
for i := range stockRequests {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
stocks := make(Stocks, 0, len(responses))
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
if len(response.Chart.Result) == 0 {
|
||||
failed++
|
||||
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
|
||||
continue
|
||||
}
|
||||
|
||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||
|
||||
if len(prices) > stockChartDays {
|
||||
prices = prices[len(prices)-stockChartDays:]
|
||||
}
|
||||
|
||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||
|
||||
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
||||
previous = prices[len(prices)-2]
|
||||
}
|
||||
|
||||
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
stocks = append(stocks, Stock{
|
||||
Name: stockRequests[i].Name,
|
||||
Symbol: response.Chart.Result[0].Meta.Symbol,
|
||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
PercentChange: percentChange(
|
||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
previous,
|
||||
),
|
||||
SvgChartPoints: points,
|
||||
})
|
||||
}
|
||||
|
||||
if len(stocks) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return stocks, nil
|
||||
}
|
||||
100
internal/feed/youtube.go
Normal file
100
internal/feed/youtube.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type youtubeFeedResponseXml struct {
|
||||
Channel string `xml:"title"`
|
||||
ChannelLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
Videos []struct {
|
||||
Title string `xml:"title"`
|
||||
Published string `xml:"published"`
|
||||
Link struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
|
||||
Group struct {
|
||||
Thumbnail struct {
|
||||
Url string `xml:"url,attr"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
} `xml:"entry"`
|
||||
}
|
||||
|
||||
func parseYoutubeFeedTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
|
||||
requests := make([]*http.Request, 0, len(channelIds))
|
||||
|
||||
for i := range channelIds {
|
||||
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
|
||||
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
videos := make(Videos, 0, len(channelIds)*15)
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
for j := range response.Videos {
|
||||
video := &response.Videos[j]
|
||||
|
||||
// TODO: figure out a better way of skipping shorts
|
||||
if strings.Contains(video.Title, "#shorts") {
|
||||
continue
|
||||
}
|
||||
|
||||
videos = append(videos, Video{
|
||||
ThumbnailUrl: video.Group.Thumbnail.Url,
|
||||
Title: video.Title,
|
||||
Url: video.Link.Href,
|
||||
Author: response.Channel,
|
||||
AuthorUrl: response.ChannelLink.Href + "/videos",
|
||||
TimePosted: parseYoutubeFeedTime(video.Published),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(videos) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
videos.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
||||
42
internal/glance/cli.go
Normal file
42
internal/glance/cli.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
)
|
||||
|
||||
type CliIntent uint8
|
||||
|
||||
const (
|
||||
CliIntentServe CliIntent = iota
|
||||
CliIntentCheckConfig = iota
|
||||
)
|
||||
|
||||
type CliOptions struct {
|
||||
Intent CliIntent
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
func ParseCliOptions() (*CliOptions, error) {
|
||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||
|
||||
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
|
||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||
|
||||
err := flags.Parse(os.Args[1:])
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := CliIntentServe
|
||||
|
||||
if *checkConfig {
|
||||
intent = CliIntentCheckConfig
|
||||
}
|
||||
|
||||
return &CliOptions{
|
||||
Intent: intent,
|
||||
ConfigPath: *configPath,
|
||||
}, nil
|
||||
}
|
||||
79
internal/glance/config.go
Normal file
79
internal/glance/config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Theme Theme `yaml:"theme"`
|
||||
Pages []Page `yaml:"pages"`
|
||||
}
|
||||
|
||||
func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
||||
config := NewConfig()
|
||||
|
||||
contentBytes, err := io.ReadAll(contents)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(contentBytes, config)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = configIsValid(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
config := &Config{}
|
||||
|
||||
config.Server.Host = ""
|
||||
config.Server.Port = 8080
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func configIsValid(config *Config) error {
|
||||
for i := range config.Pages {
|
||||
if config.Pages[i].Title == "" {
|
||||
return fmt.Errorf("Page %d has no title", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) == 0 {
|
||||
return fmt.Errorf("Page %d has no columns", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) > 3 {
|
||||
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
|
||||
}
|
||||
|
||||
columnSizesCount := make(map[string]int)
|
||||
|
||||
for j := range config.Pages[i].Columns {
|
||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
||||
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
}
|
||||
|
||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
||||
}
|
||||
|
||||
full := columnSizesCount["full"]
|
||||
|
||||
if full > 2 || full == 0 {
|
||||
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
221
internal/glance/glance.go
Normal file
221
internal/glance/glance.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/widget"
|
||||
)
|
||||
|
||||
var buildVersion = "dev"
|
||||
|
||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
|
||||
type Application struct {
|
||||
Version string
|
||||
Config Config
|
||||
slugToPage map[string]*Page
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
BackgroundColor *widget.HSLColorField `yaml:"background-color"`
|
||||
PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
|
||||
PositiveColor *widget.HSLColorField `yaml:"positive-color"`
|
||||
NegativeColor *widget.HSLColorField `yaml:"negative-color"`
|
||||
Light bool `yaml:"light"`
|
||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
StartedAt time.Time `yaml:"-"`
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
Size string `yaml:"size"`
|
||||
Widgets widget.Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
App *Application
|
||||
Page *Page
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Title string `yaml:"name"`
|
||||
Slug string `yaml:"slug"`
|
||||
Columns []Column `yaml:"columns"`
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *Page) UpdateOutdatedWidgets() {
|
||||
now := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
context := context.Background()
|
||||
|
||||
for c := range p.Columns {
|
||||
for w := range p.Columns[c].Widgets {
|
||||
widget := p.Columns[c].Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(context)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TODO: fix, currently very simple, lots of uncovered edge cases
|
||||
func titleToSlug(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func NewApplication(config *Config) (*Application, error) {
|
||||
if len(config.Pages) == 0 {
|
||||
return nil, fmt.Errorf("no pages configured")
|
||||
}
|
||||
|
||||
app := &Application{
|
||||
Version: buildVersion,
|
||||
Config: *config,
|
||||
slugToPage: make(map[string]*Page),
|
||||
}
|
||||
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
for i := range config.Pages {
|
||||
if config.Pages[i].Slug == "" {
|
||||
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
|
||||
}
|
||||
|
||||
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
page, exists := a.slugToPage[r.PathValue("page")]
|
||||
|
||||
if !exists {
|
||||
a.HandleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pageData := templateData{
|
||||
Page: page,
|
||||
App: a,
|
||||
}
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := assets.PageTemplate.Execute(&responseBytes, pageData)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
|
||||
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
page, exists := a.slugToPage[r.PathValue("page")]
|
||||
|
||||
if !exists {
|
||||
a.HandleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pageData := templateData{
|
||||
Page: page,
|
||||
}
|
||||
|
||||
page.mu.Lock()
|
||||
defer page.mu.Unlock()
|
||||
page.UpdateOutdatedWidgets()
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
|
||||
func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: add proper not found page
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("Page not found"))
|
||||
}
|
||||
|
||||
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
|
||||
server := http.FileServer(fs)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: fix always setting cache control even if the file doesn't exist
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
|
||||
server.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Application) Serve() error {
|
||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
||||
// TODO: add HTTPS support
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
||||
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
|
||||
|
||||
if a.Config.Server.AssetsPath != "" {
|
||||
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
|
||||
}
|
||||
|
||||
slog.Info("Serving assets", "path", absAssetsPath)
|
||||
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
||||
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
||||
}
|
||||
|
||||
server := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
a.Config.Server.StartedAt = time.Now()
|
||||
|
||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
46
internal/glance/main.go
Normal file
46
internal/glance/main.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Main() int {
|
||||
options, err := ParseCliOptions()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
configFile, err := os.Open(options.ConfigPath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed opening config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
config, err := NewConfigFromYml(configFile)
|
||||
configFile.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed parsing config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if options.Intent == CliIntentServe {
|
||||
app, err := NewApplication(config)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed creating application: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if app.Serve() != nil {
|
||||
fmt.Printf("http server error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
31
internal/widget/bookmarks.go
Normal file
31
internal/widget/bookmarks.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Bookmarks struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
Groups []struct {
|
||||
Title string `yaml:"title"`
|
||||
Color *HSLColorField `yaml:"color"`
|
||||
Links []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
} `yaml:"links"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
|
||||
func (widget *Bookmarks) Initialize() error {
|
||||
widget.withTitle("Bookmarks").withError(nil)
|
||||
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Bookmarks) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
30
internal/widget/calendar.go
Normal file
30
internal/widget/calendar.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Calendar struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Calendar *feed.Calendar
|
||||
}
|
||||
|
||||
func (widget *Calendar) Initialize() error {
|
||||
widget.withTitle("Calendar").withCacheOnTheHour()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Calendar) Update(ctx context.Context) {
|
||||
widget.Calendar = feed.NewCalendar(time.Now())
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *Calendar) Render() template.HTML {
|
||||
return widget.render(widget, assets.CalendarTemplate)
|
||||
}
|
||||
152
internal/widget/fields.go
Normal file
152
internal/widget/fields.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
|
||||
|
||||
const (
|
||||
HSLHueMax = 360
|
||||
HSLSaturationMax = 100
|
||||
HSLLightnessMax = 100
|
||||
)
|
||||
|
||||
type HSLColorField struct {
|
||||
Hue uint16
|
||||
Saturation uint8
|
||||
Lightness uint8
|
||||
}
|
||||
|
||||
func (c *HSLColorField) String() string {
|
||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
||||
}
|
||||
|
||||
func (c *HSLColorField) AsCSSValue() template.CSS {
|
||||
return template.CSS(c.String())
|
||||
}
|
||||
|
||||
func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := HSLColorPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 4 {
|
||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||
}
|
||||
|
||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hue > HSLHueMax {
|
||||
return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax)
|
||||
}
|
||||
|
||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if saturation > HSLSaturationMax {
|
||||
return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax)
|
||||
}
|
||||
|
||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lightness > HSLLightnessMax {
|
||||
return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax)
|
||||
}
|
||||
|
||||
c.Hue = uint16(hue)
|
||||
c.Saturation = uint8(saturation)
|
||||
c.Lightness = uint8(lightness)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
type DurationField time.Duration
|
||||
|
||||
func (d *DurationField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := DurationPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 3 {
|
||||
return fmt.Errorf("invalid duration format: %s", value)
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(matches[1])
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch matches[2] {
|
||||
case "s":
|
||||
*d = DurationField(time.Duration(duration) * time.Second)
|
||||
case "m":
|
||||
*d = DurationField(time.Duration(duration) * time.Minute)
|
||||
case "h":
|
||||
*d = DurationField(time.Duration(duration) * time.Hour)
|
||||
case "d":
|
||||
*d = DurationField(time.Duration(duration) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type OptionalEnvString string
|
||||
|
||||
func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
err := node.Decode(&value)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := EnvFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 2 {
|
||||
*f = OptionalEnvString(value)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(matches[1])
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("environment variable %s not found", matches[1])
|
||||
}
|
||||
|
||||
*f = OptionalEnvString(value)
|
||||
|
||||
return nil
|
||||
}
|
||||
52
internal/widget/hacker-news.go
Normal file
52
internal/widget/hacker-news.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type HackerNews struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Initialize() error {
|
||||
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchHackerNewsTopPosts(40)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
posts.CalculateEngagement()
|
||||
posts.SortByEngagement()
|
||||
|
||||
if widget.Limit < len(posts) {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Render() template.HTML {
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
}
|
||||
45
internal/widget/iframe.go
Normal file
45
internal/widget/iframe.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type IFrame struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
Source string `yaml:"source"`
|
||||
Height int `yaml:"height"`
|
||||
}
|
||||
|
||||
func (widget *IFrame) Initialize() error {
|
||||
widget.withTitle("IFrame").withError(nil)
|
||||
|
||||
if widget.Source == "" {
|
||||
return errors.New("missing source for iframe")
|
||||
}
|
||||
|
||||
_, err := url.Parse(widget.Source)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source for iframe: %v", err)
|
||||
}
|
||||
|
||||
if widget.Height == 50 {
|
||||
widget.Height = 300
|
||||
} else if widget.Height < 50 {
|
||||
widget.Height = 50
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *IFrame) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
100
internal/widget/monitor.go
Normal file
100
internal/widget/monitor.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
func statusCodeToText(status int) string {
|
||||
if status == 200 {
|
||||
return "OK"
|
||||
}
|
||||
if status == 404 {
|
||||
return "Not Found"
|
||||
}
|
||||
if status == 403 {
|
||||
return "Forbidden"
|
||||
}
|
||||
if status == 401 {
|
||||
return "Unauthorized"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
if status >= 500 {
|
||||
return "Server Error"
|
||||
}
|
||||
|
||||
return strconv.Itoa(status)
|
||||
}
|
||||
|
||||
func statusCodeToStyle(status int) string {
|
||||
if status == 200 {
|
||||
return "good"
|
||||
}
|
||||
|
||||
return "bad"
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Sites []struct {
|
||||
Title string `yaml:"title"`
|
||||
Url string `yaml:"url"`
|
||||
IconUrl string `yaml:"icon"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
} `yaml:"sites"`
|
||||
}
|
||||
|
||||
func (widget *Monitor) Initialize() error {
|
||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Monitor) Update(ctx context.Context) {
|
||||
requests := make([]*http.Request, len(widget.Sites))
|
||||
|
||||
for i := range widget.Sites {
|
||||
request, err := http.NewRequest("HEAD", widget.Sites[i].Url, nil)
|
||||
|
||||
if err != nil {
|
||||
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
||||
widget.withNotice(message)
|
||||
continue
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
statuses, err := feed.FetchStatusesForRequests(requests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range widget.Sites {
|
||||
site := &widget.Sites[i]
|
||||
status := &statuses[i]
|
||||
|
||||
site.Status = status
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *Monitor) Render() template.HTML {
|
||||
return widget.render(widget, assets.MonitorTemplate)
|
||||
}
|
||||
66
internal/widget/reddit.go
Normal file
66
internal/widget/reddit.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Reddit struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
Subreddit string `yaml:"subreddit"`
|
||||
Style string `yaml:"style"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *Reddit) Initialize() error {
|
||||
if widget.Subreddit == "" {
|
||||
return errors.New("no subreddit specified")
|
||||
}
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Reddit) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchSubredditPosts(widget.Subreddit)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(posts) > widget.Limit {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
posts.SortByEngagement()
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *Reddit) Render() template.HTML {
|
||||
if widget.Style == "horizontal-cards" {
|
||||
return widget.render(widget, assets.RedditCardsHorizontalTemplate)
|
||||
}
|
||||
|
||||
if widget.Style == "vertical-cards" {
|
||||
return widget.render(widget, assets.RedditCardsVerticalTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
|
||||
}
|
||||
51
internal/widget/releases.go
Normal file
51
internal/widget/releases.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Releases struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Releases feed.AppReleases `yaml:"-"`
|
||||
Repositories []string `yaml:"repositories"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *Releases) Initialize() error {
|
||||
widget.withTitle("Releases").withCacheDuration(2 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Releases) Update(ctx context.Context) {
|
||||
releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(releases) > widget.Limit {
|
||||
releases = releases[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Releases = releases
|
||||
}
|
||||
|
||||
func (widget *Releases) Render() template.HTML {
|
||||
return widget.render(widget, assets.ReleasesTemplate)
|
||||
}
|
||||
55
internal/widget/rss.go
Normal file
55
internal/widget/rss.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type RSS struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
||||
Style string `yaml:"style"`
|
||||
Items feed.RSSFeedItems `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *RSS) Initialize() error {
|
||||
widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *RSS) Update(ctx context.Context) {
|
||||
items, err := feed.GetItemsFromRSSFeeds(widget.FeedRequests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) > widget.Limit {
|
||||
items = items[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Items = items
|
||||
}
|
||||
|
||||
func (widget *RSS) Render() template.HTML {
|
||||
if widget.Style == "horizontal-cards" {
|
||||
return widget.render(widget, assets.RSSCardsTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.RSSListTemplate)
|
||||
}
|
||||
37
internal/widget/stocks.go
Normal file
37
internal/widget/stocks.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Stocks struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Stocks feed.Stocks `yaml:"-"`
|
||||
Tickers []feed.StockRequest `yaml:"stocks"`
|
||||
}
|
||||
|
||||
func (widget *Stocks) Initialize() error {
|
||||
widget.withTitle("Stocks").withCacheDuration(time.Hour)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Stocks) Update(ctx context.Context) {
|
||||
stocks, err := feed.FetchStocksDataFromYahoo(widget.Tickers)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
stocks.SortByAbsChange()
|
||||
widget.Stocks = stocks
|
||||
}
|
||||
|
||||
func (widget *Stocks) Render() template.HTML {
|
||||
return widget.render(widget, assets.StocksTemplate)
|
||||
}
|
||||
42
internal/widget/twitch-channels.go
Normal file
42
internal/widget/twitch-channels.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type TwitchChannels struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
ChannelsRequest []string `yaml:"channels"`
|
||||
Channels []feed.TwitchChannel `yaml:"-"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Initialize() error {
|
||||
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Update(ctx context.Context) {
|
||||
channels, err := feed.FetchChannelsFromTwitch(widget.ChannelsRequest)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
channels.SortByViewers()
|
||||
widget.Channels = channels
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Render() template.HTML {
|
||||
return widget.render(widget, assets.TwitchChannelsTemplate)
|
||||
}
|
||||
46
internal/widget/twitch-top-games.go
Normal file
46
internal/widget/twitch-top-games.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type TwitchGames struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Categories []feed.TwitchCategory `yaml:"-"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Initialize() error {
|
||||
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Update(ctx context.Context) {
|
||||
categories, err := feed.FetchTopGamesFromTwitch(widget.Exclude, widget.Limit)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Categories = categories
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Render() template.HTML {
|
||||
return widget.render(widget, assets.TwitchGamesListTemplate)
|
||||
}
|
||||
45
internal/widget/videos.go
Normal file
45
internal/widget/videos.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Videos struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Videos feed.Videos `yaml:"-"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Limit int `yaml:"limit"`
|
||||
}
|
||||
|
||||
func (widget *Videos) Initialize() error {
|
||||
widget.withTitle("Videos").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Videos) Update(ctx context.Context) {
|
||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(videos) > widget.Limit {
|
||||
videos = videos[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Videos = videos
|
||||
}
|
||||
|
||||
func (widget *Videos) Render() template.HTML {
|
||||
return widget.render(widget, assets.VideosTemplate)
|
||||
}
|
||||
50
internal/widget/weather.go
Normal file
50
internal/widget/weather.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Weather struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Location string `yaml:"location"`
|
||||
HideLocation bool `yaml:"hide-location"`
|
||||
Place *feed.PlaceJson `yaml:"-"`
|
||||
Weather *feed.Weather `yaml:"-"`
|
||||
TimeLabels [12]string `yaml:"-"`
|
||||
}
|
||||
|
||||
var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||
|
||||
func (widget *Weather) Initialize() error {
|
||||
widget.withTitle("Weather").withCacheOnTheHour()
|
||||
widget.TimeLabels = timeLabels
|
||||
|
||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
|
||||
}
|
||||
|
||||
widget.Place = place
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Weather) Update(ctx context.Context) {
|
||||
weather, err := feed.FetchWeatherForPlace(widget.Place)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Weather = weather
|
||||
}
|
||||
|
||||
func (widget *Weather) Render() template.HTML {
|
||||
return widget.render(widget, assets.WeatherTemplate)
|
||||
}
|
||||
282
internal/widget/widget.go
Normal file
282
internal/widget/widget.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func New(widgetType string) (Widget, error) {
|
||||
switch widgetType {
|
||||
case "calendar":
|
||||
return &Calendar{}, nil
|
||||
case "weather":
|
||||
return &Weather{}, nil
|
||||
case "bookmarks":
|
||||
return &Bookmarks{}, nil
|
||||
case "iframe":
|
||||
return &IFrame{}, nil
|
||||
case "hacker-news":
|
||||
return &HackerNews{}, nil
|
||||
case "releases":
|
||||
return &Releases{}, nil
|
||||
case "videos":
|
||||
return &Videos{}, nil
|
||||
case "stocks":
|
||||
return &Stocks{}, nil
|
||||
case "reddit":
|
||||
return &Reddit{}, nil
|
||||
case "rss":
|
||||
return &RSS{}, nil
|
||||
case "monitor":
|
||||
return &Monitor{}, nil
|
||||
case "twitch-top-games":
|
||||
return &TwitchGames{}, nil
|
||||
case "twitch-channels":
|
||||
return &TwitchChannels{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
}
|
||||
|
||||
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 := New(meta.Type)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = node.Decode(widget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = widget.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*w = append(*w, widget)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Widget interface {
|
||||
Initialize() error
|
||||
RequiresUpdate(*time.Time) bool
|
||||
Update(context.Context)
|
||||
Render() template.HTML
|
||||
GetType() string
|
||||
}
|
||||
|
||||
type cacheType int
|
||||
|
||||
const (
|
||||
cacheTypeInfinite cacheType = iota
|
||||
cacheTypeDuration
|
||||
cacheTypeOnTheHour
|
||||
)
|
||||
|
||||
type widgetBase struct {
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
CustomCacheDuration DurationField `yaml:"cache"`
|
||||
ContentAvailable bool `yaml:"-"`
|
||||
Error error `yaml:"-"`
|
||||
Notice error `yaml:"-"`
|
||||
templateBuffer bytes.Buffer `yaml:"-"`
|
||||
cacheDuration time.Duration `yaml:"-"`
|
||||
cacheType cacheType `yaml:"-"`
|
||||
nextUpdate time.Time `yaml:"-"`
|
||||
updateRetriedTimes int `yaml:"-"`
|
||||
}
|
||||
|
||||
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) Update(ctx context.Context) {
|
||||
|
||||
}
|
||||
|
||||
func (w *widgetBase) GetType() string {
|
||||
return w.Type
|
||||
}
|
||||
|
||||
func (w *widgetBase) render(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) 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, feed.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
|
||||
}
|
||||
Reference in New Issue
Block a user