Merge pull request #53 from stevedylandev/feat/add-weather-to-posts
0da518b6
add weather to posts
12 file(s) · +317 −14
add weather to posts
| 1 | - | - [ ] Fix input for sipp so it reflects jotts |
| 38 | 38 | MetaImage *string |
|
| 39 | 39 | Lang string |
|
| 40 | 40 | Tags *string |
|
| 41 | + | Weather *string |
|
| 41 | 42 | Content string |
|
| 42 | 43 | Status string |
|
| 43 | 44 | CreatedAt string |
|
| 251 | 252 | SiteURL string |
|
| 252 | 253 | HeaderHTML template.HTML |
|
| 253 | 254 | FooterHTML template.HTML |
|
| 255 | + | Weather Weather |
|
| 254 | 256 | } |
|
| 255 | 257 | ||
| 256 | 258 | type pagePageData struct { |
|
| 305 | 307 | OGImageURL string |
|
| 306 | 308 | CustomHeader string |
|
| 307 | 309 | CustomFooter string |
|
| 310 | + | DefaultLocation string |
|
| 308 | 311 | Success bool |
|
| 309 | 312 | } |
|
| 310 | 313 | ||
| 5 | 5 | "errors" |
|
| 6 | 6 | "strings" |
|
| 7 | 7 | "time" |
|
| 8 | + | "fmt" |
|
| 8 | 9 | ||
| 9 | 10 | "github.com/stevedylandev/andromeda/pkg/auth" |
|
| 10 | 11 | ) |
|
| 22 | 23 | meta_image TEXT, |
|
| 23 | 24 | lang TEXT NOT NULL DEFAULT 'en', |
|
| 24 | 25 | tags TEXT, |
|
| 26 | + | weather TEXT, |
|
| 25 | 27 | content TEXT NOT NULL, |
|
| 26 | 28 | status TEXT NOT NULL DEFAULT 'draft', |
|
| 27 | 29 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), |
|
| 69 | 71 | {"custom_footer", `<div> |
|
| 70 | 72 | <a href="/feed.xml" class="rss-link" title="RSS Feed"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path fill="currentColor" d="M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8"/></svg></a> |
|
| 71 | 73 | </div>`}, |
|
| 74 | + | {"default_location", ""}, |
|
| 72 | 75 | } |
|
| 73 | 76 | ||
| 74 | 77 | func seedDefaultSettings(db *sql.DB) { |
|
| 77 | 80 | } |
|
| 78 | 81 | } |
|
| 79 | 82 | ||
| 80 | - | const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at` |
|
| 83 | + | const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, weather, content, status, created_at, updated_at` |
|
| 81 | 84 | ||
| 82 | 85 | func scanPost(s interface{ Scan(...any) error }) (*Post, error) { |
|
| 83 | 86 | var p Post |
|
| 84 | - | var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags sql.NullString |
|
| 87 | + | var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags, weather sql.NullString |
|
| 85 | 88 | err := s.Scan(&p.ID, &p.ShortID, &title, &p.Slug, &alias, &canonicalURL, |
|
| 86 | - | &publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &p.Content, |
|
| 89 | + | &publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &weather, &p.Content, |
|
| 87 | 90 | &p.Status, &p.CreatedAt, &p.UpdatedAt) |
|
| 88 | 91 | if errors.Is(err, sql.ErrNoRows) { |
|
| 89 | 92 | return nil, nil |
|
| 119 | 122 | v := tags.String |
|
| 120 | 123 | p.Tags = &v |
|
| 121 | 124 | } |
|
| 125 | + | if weather.Valid { |
|
| 126 | + | v := weather.String |
|
| 127 | + | p.Weather = &v |
|
| 128 | + | } |
|
| 122 | 129 | return &p, nil |
|
| 123 | 130 | } |
|
| 124 | 131 | ||
| 134 | 141 | MetaImage *string |
|
| 135 | 142 | Lang string |
|
| 136 | 143 | Tags *string |
|
| 144 | + | Weather *string |
|
| 137 | 145 | } |
|
| 138 | 146 | ||
| 139 | 147 | func nullable(p *string) any { |
|
| 150 | 158 | } |
|
| 151 | 159 | in.PublishedDate = normalizePubDatePtr(in.PublishedDate) |
|
| 152 | 160 | res, err := db.Exec( |
|
| 153 | - | `INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags) |
|
| 154 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
|
| 161 | + | `INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, weather) |
|
| 162 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
|
| 155 | 163 | shortID, nullable(in.Title), in.Slug, in.Content, in.Status, |
|
| 156 | 164 | nullable(in.Alias), nullable(in.CanonicalURL), nullable(in.PublishedDate), |
|
| 157 | - | nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), |
|
| 165 | + | nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), nullable(in.Weather), |
|
| 158 | 166 | ) |
|
| 159 | 167 | if err != nil { |
|
| 160 | 168 | return nil, err |
|
| 214 | 222 | res, err := db.Exec( |
|
| 215 | 223 | `UPDATE posts SET title = ?, slug = ?, content = ?, status = ?, alias = ?, canonical_url = ?, |
|
| 216 | 224 | published_date = CASE WHEN ? = 'published' THEN COALESCE(?, published_date, strftime('%Y-%m-%dT%H:%M:%SZ','now')) ELSE ? END, |
|
| 217 | - | meta_description = ?, meta_image = ?, lang = ?, tags = ?, |
|
| 225 | + | meta_description = ?, meta_image = ?, lang = ?, tags = ?, weather = ?, |
|
| 218 | 226 | updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE short_id = ?`, |
|
| 219 | 227 | nullable(in.Title), in.Slug, in.Content, in.Status, nullable(in.Alias), nullable(in.CanonicalURL), |
|
| 220 | 228 | in.Status, nullable(in.PublishedDate), nullable(in.PublishedDate), |
|
| 221 | - | nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), shortID, |
|
| 229 | + | nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), nullable(in.Weather), shortID, |
|
| 222 | 230 | ) |
|
| 223 | 231 | if err != nil { |
|
| 224 | 232 | return nil, err |
|
| 471 | 479 | return nil |
|
| 472 | 480 | } |
|
| 473 | 481 | ||
| 482 | + | // migrateWeather updates tables to have new optional weather column |
|
| 483 | + | func migrateWeather(db *sql.DB) error { |
|
| 484 | + | var count int |
|
| 485 | + | if err := db.QueryRow( |
|
| 486 | + | `SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name = 'weather'`, |
|
| 487 | + | ).Scan(&count); err != nil { |
|
| 488 | + | return err |
|
| 489 | + | } |
|
| 490 | + | if count == 0 { |
|
| 491 | + | _, err := db.Exec(`ALTER TABLE posts ADD COLUMN weather TEXT`) |
|
| 492 | + | return err |
|
| 493 | + | } |
|
| 494 | + | return nil |
|
| 495 | + | } |
|
| 496 | + | ||
| 474 | 497 | func normalizePubDatePtr(p *string) *string { |
|
| 475 | 498 | if p == nil { |
|
| 476 | 499 | return nil |
|
| 479 | 502 | return &v |
|
| 480 | 503 | } |
|
| 481 | 504 | return p |
|
| 505 | + | } |
|
| 506 | + | ||
| 507 | + | func runMigrations(db *sql.DB) error { |
|
| 508 | + | if err := migrateTimestamps(db); err != nil { |
|
| 509 | + | return fmt.Errorf("migrate timestamps: %w", err) |
|
| 510 | + | } |
|
| 511 | + | if err := migrateWeather(db); err != nil { |
|
| 512 | + | return fmt.Errorf("add weather column: %w", err) |
|
| 513 | + | } |
|
| 514 | + | return nil |
|
| 482 | 515 | } |
|
| 483 | 516 | ||
| 484 | 517 | func _useStrings() { |
|
| 77 | 77 | if l := strings.TrimSpace(attrs.Lang); l != "" { |
|
| 78 | 78 | lang = l |
|
| 79 | 79 | } |
|
| 80 | + | defaultLocation, err := getSetting(a.DB, "default_location") |
|
| 81 | + | if err != nil { |
|
| 82 | + | defaultLocation = "" |
|
| 83 | + | } |
|
| 84 | + | weather := getWeather(defaultLocation) |
|
| 85 | + | if newWeather := strings.TrimSpace(attrs.Weather); newWeather != "" { |
|
| 86 | + | weather = newWeather |
|
| 87 | + | } |
|
| 80 | 88 | pub := strings.TrimSpace(attrs.PublishedDate) |
|
| 81 | 89 | if pub == "" { |
|
| 82 | 90 | pub = nowDatetime() |
|
| 88 | 96 | MetaDescription: optStr(attrs.MetaDescription), |
|
| 89 | 97 | MetaImage: optStr(attrs.MetaImage), |
|
| 90 | 98 | Lang: lang, Tags: optStr(attrs.Tags), |
|
| 99 | + | Weather: optStr(weather), |
|
| 91 | 100 | } |
|
| 92 | 101 | if _, err := createPost(a.DB, in); err != nil { |
|
| 93 | 102 | a.Log.Error("create post", "err", err) |
|
| 143 | 152 | if t := strings.TrimSpace(attrs.PublishedDate); t != "" { |
|
| 144 | 153 | pubDate = &t |
|
| 145 | 154 | } |
|
| 155 | + | defaultLocation, err := getSetting(a.DB, "default_location") |
|
| 156 | + | if err != nil { |
|
| 157 | + | defaultLocation = "" |
|
| 158 | + | } |
|
| 159 | + | weather := getWeather(defaultLocation) |
|
| 160 | + | if newWeather := strings.TrimSpace(attrs.Weather); newWeather != "" { |
|
| 161 | + | weather = newWeather |
|
| 162 | + | } |
|
| 146 | 163 | in := PostInput{ |
|
| 147 | 164 | Title: optStr(title), Slug: slug, Content: r.FormValue("content"), |
|
| 148 | 165 | Status: status, Alias: optStr(attrs.Alias), |
|
| 150 | 167 | MetaDescription: optStr(attrs.MetaDescription), |
|
| 151 | 168 | MetaImage: optStr(attrs.MetaImage), |
|
| 152 | 169 | Lang: lang, Tags: optStr(attrs.Tags), |
|
| 170 | + | Weather: optStr(weather), |
|
| 153 | 171 | } |
|
| 154 | 172 | if _, err := updatePost(a.DB, shortID, in); err != nil { |
|
| 155 | 173 | a.Log.Error("update post", "err", err) |
|
| 263 | 281 | OGImageURL: get("og_image_url"), |
|
| 264 | 282 | CustomHeader: get("custom_header"), |
|
| 265 | 283 | CustomFooter: get("custom_footer"), |
|
| 284 | + | DefaultLocation: get("default_location"), |
|
| 266 | 285 | Success: r.URL.Query().Get("success") == "true", |
|
| 267 | 286 | }) |
|
| 268 | 287 | } |
|
| 281 | 300 | _ = setSetting(a.DB, "og_image_url", strings.TrimSpace(r.FormValue("og_image_url"))) |
|
| 282 | 301 | _ = setSetting(a.DB, "custom_header", r.FormValue("custom_header")) |
|
| 283 | 302 | _ = setSetting(a.DB, "custom_footer", r.FormValue("custom_footer")) |
|
| 303 | + | _ = setSetting(a.DB, "default_location", r.FormValue("default_location")) |
|
| 284 | 304 | http.Redirect(w, r, "/admin/settings?success=true", http.StatusSeeOther) |
|
| 285 | 305 | } |
|
| 286 | 306 | ||
| 4 | 4 | "html/template" |
|
| 5 | 5 | "net/http" |
|
| 6 | 6 | "strings" |
|
| 7 | + | "fmt" |
|
| 7 | 8 | ) |
|
| 8 | 9 | ||
| 9 | 10 | func (a *App) site() siteContext { |
|
| 95 | 96 | http.Error(w, "Not found", http.StatusNotFound) |
|
| 96 | 97 | return |
|
| 97 | 98 | } |
|
| 99 | + | var weather Weather |
|
| 100 | + | if post.Weather != nil { |
|
| 101 | + | w, err := formatWeather(*post.Weather) |
|
| 102 | + | if err != nil { |
|
| 103 | + | fmt.Printf("Problem formatting weather: %s", err.Error()) |
|
| 104 | + | } else { |
|
| 105 | + | weather = w |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 98 | 108 | ctx := a.site() |
|
| 99 | 109 | rendered := renderMarkdown(post.Content) |
|
| 100 | 110 | a.renderPage(w, "post.html", postPageData{ |
|
| 101 | 111 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post, |
|
| 102 | 112 | RenderedContent: template.HTML(rendered), |
|
| 103 | 113 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
|
| 104 | - | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
|
| 114 | + | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, Weather: weather, |
|
| 105 | 115 | }) |
|
| 106 | 116 | } |
|
| 107 | 117 | ||
| 23 | 23 | log.Fatal(err) |
|
| 24 | 24 | } |
|
| 25 | 25 | defer db.Close() |
|
| 26 | - | if err := migrateTimestamps(db); err != nil { |
|
| 27 | - | log.Fatalf("migrate timestamps: %v", err) |
|
| 26 | + | if err := runMigrations(db); err != nil { |
|
| 27 | + | log.Fatalf("migrations: %v", err) |
|
| 28 | 28 | } |
|
| 29 | 29 | seedDefaultSettings(db) |
|
| 30 | 30 |
| 79 | 79 | ||
| 80 | 80 | .post-date { |
|
| 81 | 81 | font-size: 12px; |
|
| 82 | - | opacity: 0.5; |
|
| 83 | 82 | } |
|
| 84 | 83 | ||
| 85 | 84 | .post-excerpt { |
|
| 92 | 91 | display: flex; |
|
| 93 | 92 | gap: 0.4rem; |
|
| 94 | 93 | flex-wrap: wrap; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | .post-weather { |
|
| 97 | + | display: flex; |
|
| 98 | + | align-items: center; |
|
| 99 | + | flex-wrap: wrap; |
|
| 100 | + | gap: 0.75rem; |
|
| 101 | + | font-size: 12px; |
|
| 102 | + | opacity: 0.5; |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | .post-weather svg { |
|
| 106 | + | height: 16px; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | .weather-location { |
|
| 110 | + | display: flex; |
|
| 111 | + | align-items: center; |
|
| 112 | + | gap: 0.75rem; |
|
| 95 | 113 | } |
|
| 96 | 114 | ||
| 97 | 115 | /* Post header (public single) */ |
|
| 13 | 13 | {{end}}{{if $p.Tags}}tags: {{$p.TagsStr}} |
|
| 14 | 14 | {{end}}{{if $p.Alias}}alias: {{$p.AliasStr}} |
|
| 15 | 15 | {{end}}{{if $p.MetaImage}}meta_image: {{$p.MetaImageStr}} |
|
| 16 | - | {{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}{{end}}</textarea> |
|
| 16 | + | {{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}} |
|
| 17 | + | {{end}}{{if $p.Weather}}weather: {{$p.Weather}}{{end}}</textarea> |
|
| 17 | 18 | <details class="available-fields"> |
|
| 18 | 19 | <summary>available fields</summary> |
|
| 19 | 20 | <div class="fields-list"> |
|
| 25 | 26 | <span>alias: /old/path</span> |
|
| 26 | 27 | <span>meta_image: https://example.com/image.jpg</span> |
|
| 27 | 28 | <span>description: A short summary of the post</span> |
|
| 29 | + | <span>weather: Conditions,Degrees,City,State<span> |
|
| 28 | 30 | </div> |
|
| 29 | 31 | </details> |
|
| 30 | 32 | <label for="content">content</label> |
|
| 53 | 55 | <span>alias: /old/path</span> |
|
| 54 | 56 | <span>meta_image: https://example.com/image.jpg</span> |
|
| 55 | 57 | <span>description: A short summary of the post</span> |
|
| 58 | + | <span>weather: Conditions,Degrees,City,State<span> |
|
| 56 | 59 | </div> |
|
| 57 | 60 | </details> |
|
| 58 | 61 | <label for="content">content</label> |
|
| 14 | 14 | <input type="text" id="favicon_url" name="favicon_url" value="{{.FaviconURL}}" placeholder="https://example.com/favicon.png"> |
|
| 15 | 15 | <label for="og_image_url">default OG image URL (used when posts don't have their own)</label> |
|
| 16 | 16 | <input type="text" id="og_image_url" name="og_image_url" value="{{.OGImageURL}}" placeholder="https://example.com/og.png"> |
|
| 17 | + | <label for="default_location">default location for weather metadata</label> |
|
| 18 | + | <input type="text" id="default_location" name="default_location" value="{{.DefaultLocation}}" placeholder="lat,long"> |
|
| 17 | 19 | <label for="intro_content">intro content (markdown, shown on homepage — use {{latest_posts}} to embed recent posts)</label> |
|
| 18 | 20 | <textarea id="intro_content" name="intro_content" class="post-content">{{.IntroContent}}</textarea> |
|
| 19 | 21 | <div class="switch-row"> |
| 28 | 28 | {{if .Post.MetaDescription}} |
|
| 29 | 29 | <p class="post-description">{{.Post.MetaDescriptionStr}}</p> |
|
| 30 | 30 | {{end}} |
|
| 31 | + | <div class="post-weather"> |
|
| 31 | 32 | {{if .Post.PublishedDate}} |
|
| 33 | + | <span> |
|
| 32 | 34 | <time class="post-date">{{.Post.PublishedDateStr}}</time> |
|
| 35 | + | </span> |
|
| 36 | + | <span>•</span> |
|
| 33 | 37 | {{end}} |
|
| 38 | + | {{if .Post.Weather}} |
|
| 39 | + | {{.Weather.Icon}} |
|
| 40 | + | <p class="weather-conditions">{{.Weather.Conditions}}</p> |
|
| 41 | + | <p class="weather-temp">{{.Weather.Temperature}}</p> |
|
| 42 | + | • |
|
| 43 | + | <div class="weather-location"> |
|
| 44 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M128 68a36 36 0 1 0 36 36a36 36 0 0 0-36-36m0 64a28 28 0 1 1 28-28a28 28 0 0 1-28 28m0-112a84.09 84.09 0 0 0-84 84c0 30.42 14.17 62.79 41 93.62a250 250 0 0 0 40.73 37.66a4 4 0 0 0 4.58 0A250 250 0 0 0 171 197.62c26.81-30.83 41-63.2 41-93.62a84.09 84.09 0 0 0-84-84m37.1 172.23A254.6 254.6 0 0 1 128 227a254.6 254.6 0 0 1-37.1-34.81C73.15 171.8 52 139.9 52 104a76 76 0 0 1 152 0c0 35.9-21.15 67.8-38.9 88.23"/></svg> |
|
| 45 | + | <p>{{.Weather.Location}}</p> |
|
| 46 | + | </div> |
|
| 47 | + | {{end}} |
|
| 48 | + | </div> |
|
| 34 | 49 | {{if .Post.Tags}} |
|
| 35 | 50 | <div class="post-tags"> |
|
| 36 | 51 | {{range .Post.TagList}} |
| 1 | 1 | package main |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | + | "encoding/json" |
|
| 5 | + | "fmt" |
|
| 6 | + | "html/template" |
|
| 7 | + | "net/http" |
|
| 8 | + | "regexp" |
|
| 9 | + | "strconv" |
|
| 4 | 10 | "strings" |
|
| 5 | 11 | "time" |
|
| 6 | 12 | ) |
|
| 15 | 21 | Lang string |
|
| 16 | 22 | Tags string |
|
| 17 | 23 | Status string |
|
| 24 | + | Weather string |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | type WeatherPointResponse struct { |
|
| 28 | + | Properties struct { |
|
| 29 | + | ForecastHourly string `json:"forecastHourly"` |
|
| 30 | + | RelativeLocation struct { |
|
| 31 | + | Properties struct { |
|
| 32 | + | City string `json:"city"` |
|
| 33 | + | State string `json:"state"` |
|
| 34 | + | } `json:"properties"` |
|
| 35 | + | } `json:"relativeLocation"` |
|
| 36 | + | } `json:"properties"` |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | type Period struct { |
|
| 40 | + | Temperature int `json:"temperature"` |
|
| 41 | + | ShortForecast string `json:"shortForecast"` |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | type WeatherForecastResponse struct { |
|
| 45 | + | Properties struct { |
|
| 46 | + | Periods []Period `json:"periods"` |
|
| 47 | + | } `json:"properties"` |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | type Weather struct { |
|
| 51 | + | Icon template.HTML |
|
| 52 | + | Conditions string |
|
| 53 | + | Temperature string |
|
| 54 | + | Location string |
|
| 18 | 55 | } |
|
| 19 | 56 | ||
| 20 | 57 | func parseAttributes(text string) parsedAttributes { |
|
| 45 | 82 | a.Tags = value |
|
| 46 | 83 | case "status": |
|
| 47 | 84 | a.Status = value |
|
| 85 | + | case "weather": |
|
| 86 | + | a.Weather = value |
|
| 48 | 87 | } |
|
| 49 | 88 | } |
|
| 50 | 89 | return a |
|
| 317 | 356 | } |
|
| 318 | 357 | return strings.ToUpper(cleaned[:1]) + cleaned[1:] |
|
| 319 | 358 | } |
|
| 359 | + | ||
| 360 | + | var weatherClient = &http.Client{Timeout: 10 * time.Second} |
|
| 361 | + | ||
| 362 | + | var qualifierRE = regexp.MustCompile(`(?i)^(slight chance|chance|isolated|scattered|patchy|areas)\s+(of\s+)?`) |
|
| 363 | + | ||
| 364 | + | func getWeather(location string) string { |
|
| 365 | + | if location == "" { |
|
| 366 | + | return "" |
|
| 367 | + | } |
|
| 368 | + | // Fetch Points data using lat,long |
|
| 369 | + | pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location) |
|
| 370 | + | resp, err := weatherClient.Get(pointURL) |
|
| 371 | + | if err != nil { |
|
| 372 | + | fmt.Printf("Error fetching pointUrl: %s", err.Error()) |
|
| 373 | + | return "" |
|
| 374 | + | } |
|
| 375 | + | defer resp.Body.Close() |
|
| 376 | + | var weatherPoint WeatherPointResponse |
|
| 377 | + | if err := json.NewDecoder(resp.Body).Decode(&weatherPoint); err != nil { |
|
| 378 | + | fmt.Printf("Error decoding pointUrl: %s", err.Error()) |
|
| 379 | + | return "" |
|
| 380 | + | } |
|
| 381 | + | forecastURL := weatherPoint.Properties.ForecastHourly |
|
| 382 | + | city := weatherPoint.Properties.RelativeLocation.Properties.City |
|
| 383 | + | state := weatherPoint.Properties.RelativeLocation.Properties.State |
|
| 384 | + | ||
| 385 | + | // Forcast using points data |
|
| 386 | + | forecastResp, err := weatherClient.Get(forecastURL) |
|
| 387 | + | if err != nil { |
|
| 388 | + | fmt.Printf("Error fetching forecast: %s", err.Error()) |
|
| 389 | + | return "" |
|
| 390 | + | } |
|
| 391 | + | defer forecastResp.Body.Close() |
|
| 392 | + | var weatherForecast WeatherForecastResponse |
|
| 393 | + | if err := json.NewDecoder(forecastResp.Body).Decode(&weatherForecast); err != nil { |
|
| 394 | + | fmt.Printf("Error decoding forecast: %s", err.Error()) |
|
| 395 | + | return "" |
|
| 396 | + | } |
|
| 397 | + | if len(weatherForecast.Properties.Periods) == 0 { |
|
| 398 | + | fmt.Printf("Error: no forecast periods returned for %q", location) |
|
| 399 | + | return "" |
|
| 400 | + | } |
|
| 401 | + | period := weatherForecast.Properties.Periods[0] |
|
| 402 | + | temp := strconv.Itoa(period.Temperature) |
|
| 403 | + | formattedConditions := strings.TrimSpace(qualifierRE.ReplaceAllString(period.ShortForecast, "")) |
|
| 404 | + | weather := fmt.Sprintf("%s,%s,%s,%s", formattedConditions, temp, city, state) |
|
| 405 | + | return weather |
|
| 406 | + | } |
|
| 407 | + | ||
| 408 | + | func formatWeather(weather string) (Weather, error) { |
|
| 409 | + | parts := strings.Split(weather, ",") |
|
| 410 | + | if len(parts) < 4 { |
|
| 411 | + | return Weather{}, fmt.Errorf("unexpected weather format: %q", weather) |
|
| 412 | + | } |
|
| 413 | + | for i := range parts { |
|
| 414 | + | parts[i] = strings.TrimSpace(parts[i]) // handles ", " vs "," |
|
| 415 | + | } |
|
| 416 | + | ||
| 417 | + | conditions := parts[0] |
|
| 418 | + | return Weather{ |
|
| 419 | + | Conditions: conditions, |
|
| 420 | + | Temperature: fmt.Sprintf("%s°F", parts[1]), |
|
| 421 | + | Location: fmt.Sprintf("%s, %s", parts[2], parts[3]), |
|
| 422 | + | Icon: weatherIcons[categorize(conditions)], |
|
| 423 | + | }, nil |
|
| 424 | + | } |
|
| 425 | + | ||
| 426 | + | type WeatherCategory int |
|
| 427 | + | ||
| 428 | + | const ( |
|
| 429 | + | CatStorm WeatherCategory = iota |
|
| 430 | + | CatSleet |
|
| 431 | + | CatSnow |
|
| 432 | + | CatRain |
|
| 433 | + | CatFog |
|
| 434 | + | CatPartlyCloudy |
|
| 435 | + | CatCloudy |
|
| 436 | + | CatClear |
|
| 437 | + | CatUnknown |
|
| 438 | + | ) |
|
| 439 | + | ||
| 440 | + | var categoryRules = []struct { |
|
| 441 | + | category WeatherCategory |
|
| 442 | + | keywords []string |
|
| 443 | + | }{ |
|
| 444 | + | {CatStorm, []string{"thunder", "tstorm", "storm"}}, |
|
| 445 | + | {CatSleet, []string{"sleet", "freez", "frzg", "mix"}}, |
|
| 446 | + | {CatSnow, []string{"snow", "flurr"}}, |
|
| 447 | + | {CatRain, []string{"rain", "shower", "drizzle"}}, |
|
| 448 | + | {CatFog, []string{"fog", "mist", "haze"}}, |
|
| 449 | + | {CatPartlyCloudy, []string{"partly", "variable"}}, |
|
| 450 | + | {CatCloudy, []string{"overcast", "cloud"}}, |
|
| 451 | + | {CatClear, []string{"clear", "sunny", "sun", "fair"}}, |
|
| 452 | + | } |
|
| 453 | + | ||
| 454 | + | func categorize(conditions string) WeatherCategory { |
|
| 455 | + | c := strings.ToLower(conditions) |
|
| 456 | + | for _, rule := range categoryRules { |
|
| 457 | + | for _, kw := range rule.keywords { |
|
| 458 | + | if strings.Contains(c, kw) { |
|
| 459 | + | return rule.category |
|
| 460 | + | } |
|
| 461 | + | } |
|
| 462 | + | } |
|
| 463 | + | return CatUnknown |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | var weatherIcons = map[WeatherCategory]template.HTML{ |
|
| 467 | + | CatPartlyCloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M164 76a71.9 71.9 0 0 0-22.14 3.48A51.8 51.8 0 0 0 129 63.83l11.56-16.51a4 4 0 0 0-6.56-4.59l-11.55 16.51A52 52 0 0 0 96 52c-1.71 0-3.4.09-5.06.25l-3.5-19.85a4 4 0 0 0-7.88 1.39l3.5 19.84A52.2 52.2 0 0 0 55.85 71L39.32 59.42A4 4 0 0 0 34.73 66l16.53 11.54A51.63 51.63 0 0 0 44 104c0 1.69.09 3.37.25 5l-19.85 3.5a4 4 0 0 0 .69 7.94a4 4 0 0 0 .7-.06l19.85-3.5A52.1 52.1 0 0 0 54 134.6A48 48 0 0 0 84 220h80a72 72 0 0 0 0-144M52 104a44 44 0 0 1 82.33-21.61a72.23 72.23 0 0 0-38.82 43A48.3 48.3 0 0 0 84 124a47.76 47.76 0 0 0-23.4 6.11A44 44 0 0 1 52 104m112 108H84a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 92 143.77a4 4 0 0 0 8 .46a64.3 64.3 0 0 1 2-12.67c0-.12.07-.24.09-.36A64.06 64.06 0 1 1 164 212"/></svg>`, |
|
| 468 | + | CatClear: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M124 40v-8a4 4 0 0 1 8 0v8a4 4 0 0 1-8 0m64 88a60 60 0 1 1-60-60a60.07 60.07 0 0 1 60 60m-8 0a52 52 0 1 0-52 52a52.06 52.06 0 0 0 52-52M61.17 66.83a4 4 0 0 0 5.66-5.66l-8-8a4 4 0 0 0-5.66 5.66Zm0 122.34l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 0 0-5.66-5.66m136-136l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 1 0-5.66-5.66m-2.34 136a4 4 0 0 0-5.66 5.66l8 8a4 4 0 0 0 5.66-5.66ZM40 124h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8m88 88a4 4 0 0 0-4 4v8a4 4 0 0 0 8 0v-8a4 4 0 0 0-4-4m96-88h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8"/></svg>`, |
|
| 469 | + | CatCloudy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M160 44a84.11 84.11 0 0 0-76.41 49.12A60.7 60.7 0 0 0 72 92a60 60 0 0 0 0 120h88a84 84 0 0 0 0-168m0 160H72a52 52 0 1 1 8.55-103.3A83.7 83.7 0 0 0 76 128a4 4 0 0 0 8 0a76 76 0 1 1 76 76"/></svg>`, |
|
| 470 | + | CatStorm: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M156 20a72.19 72.19 0 0 0-68.49 49.39A48 48 0 1 0 76 164h44.94l-20.37 33.94A4 4 0 0 0 104 204h32.94l-20.37 33.94a4 4 0 0 0 6.86 4.12l24-40A4 4 0 0 0 144 196h-32.94l19.2-32H156a72 72 0 0 0 0-144m0 136H76a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 84 87.77a4 4 0 0 0 8 .46A64.06 64.06 0 1 1 156 156"/></svg>`, |
|
| 471 | + | CatRain: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="m155.33 194.22l-32 48a4 4 0 1 1-6.66-4.44l32-48a4 4 0 0 1 6.66 4.44M228 92a72.08 72.08 0 0 1-72 72h-25.86l-30.81 46.22a4 4 0 1 1-6.66-4.44L120.53 164H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 472 | + | CatSnow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 473 | + | CatSleet: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 474 | + | CatFog: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M120 204H72a4 4 0 0 1 0-8h48a4 4 0 0 1 0 8m64-8h-24a4 4 0 0 0 0 8h24a4 4 0 0 0 0-8m-24 32h-56a4 4 0 0 0 0 8h56a4 4 0 0 0 0-8m68-128a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 100m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 164h80a64.07 64.07 0 0 0 64-64"/></svg>`, |
|
| 475 | + | } |
|
| 1 | + | Severe storm |
|
| 2 | + | Showers storms |
|
| 3 | + | Showers storms |
|
| 4 | + | Thunder storm |
|
| 5 | + | Rain Sleet |
|
| 6 | + | FrzgRn Snow |
|
| 7 | + | FrzgRn Snow |
|
| 8 | + | Chance Snow/Rain |
|
| 9 | + | Chance Snow/Rain |
|
| 10 | + | Rain and Snow |
|
| 11 | + | Rain or Snow |
|
| 12 | + | Freezing Rain |
|
| 13 | + | Rain likely |
|
| 14 | + | Snow showers |
|
| 15 | + | Showers likely |
|
| 16 | + | Chance showers |
|
| 17 | + | Isolated showers |
|
| 18 | + | Scattered showers |
|
| 19 | + | Chance rain |
|
| 20 | + | Rain |
|
| 21 | + | Mix |
|
| 22 | + | Sleet |
|
| 23 | + | Snow |
|
| 24 | + | Fog a.m. |
|
| 25 | + | Fog late |
|
| 26 | + | Fog |
|
| 27 | + | Very Cold |
|
| 28 | + | Very Hot |
|
| 29 | + | Hot |
|
| 30 | + | Overcast |
|
| 31 | + | Mostly Cloudy |
|
| 32 | + | Partly Cloudy |
|
| 33 | + | Cloudy |
|
| 34 | + | Partly Sunny |
|
| 35 | + | Mostly Sunny |
|
| 36 | + | Partly Sunny |
|
| 37 | + | Mostly Sunny |
|
| 38 | + | Partly Sunny |
|
| 39 | + | Mostly Sunny |
|
| 40 | + | Mostly Clear |
|
| 41 | + | Sunny |
|
| 42 | + | Clear |
|
| 43 | + | Fair |
|
| 44 | + | Variable Clouds |