chore: mvp of weather metadata feature
f12453a3
10 file(s) · +183 −12
| 1 | - | - [ ] Fix input for sipp so it reflects jotts |
|
| 1 | + | - [ ] Add current weather to /now posts |
|
| 2 | + | - General flow |
|
| 3 | + | - Creating a new post with attribute `weather` and value `{conditions},{temperature},{city},{state}` |
|
| 4 | + | - If no value is given, create value using default location with fresh API call |
|
| 5 | + | - Store as new column in db called `weather` |
|
| 6 | + | - `"{conditions},{temperature},{city},{state}"` |
|
| 7 | + | - Need DB migration |
|
| 8 | + | - Show in available attributes |
|
| 9 | + | - Handle null state `Error fetching forecast: Get "": unsupported protocol scheme ""` |
|
| 10 | + | - Handle Edits |
|
| 11 | + | - Create display icon mapping |
|
| 12 | + |
| 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 |
|
| 305 | 306 | OGImageURL string |
|
| 306 | 307 | CustomHeader string |
|
| 307 | 308 | CustomFooter string |
|
| 309 | + | DefaultLocation string |
|
| 308 | 310 | Success bool |
|
| 309 | 311 | } |
|
| 310 | 312 | ||
| 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) |
|
| 263 | 272 | OGImageURL: get("og_image_url"), |
|
| 264 | 273 | CustomHeader: get("custom_header"), |
|
| 265 | 274 | CustomFooter: get("custom_footer"), |
|
| 275 | + | DefaultLocation: get("default_location"), |
|
| 266 | 276 | Success: r.URL.Query().Get("success") == "true", |
|
| 267 | 277 | }) |
|
| 268 | 278 | } |
|
| 281 | 291 | _ = setSetting(a.DB, "og_image_url", strings.TrimSpace(r.FormValue("og_image_url"))) |
|
| 282 | 292 | _ = setSetting(a.DB, "custom_header", r.FormValue("custom_header")) |
|
| 283 | 293 | _ = setSetting(a.DB, "custom_footer", r.FormValue("custom_footer")) |
|
| 294 | + | _ = setSetting(a.DB, "default_location", r.FormValue("default_location")) |
|
| 284 | 295 | http.Redirect(w, r, "/admin/settings?success=true", http.StatusSeeOther) |
|
| 285 | 296 | } |
|
| 286 | 297 | ||
| 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 |
| 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"> |
| 31 | 31 | {{if .Post.PublishedDate}} |
|
| 32 | 32 | <time class="post-date">{{.Post.PublishedDateStr}}</time> |
|
| 33 | 33 | {{end}} |
|
| 34 | + | {{if .Post.Weather}} |
|
| 35 | + | <p class="post-description">{{.Post.Weather}}</p> |
|
| 36 | + | {{end}} |
|
| 34 | 37 | {{if .Post.Tags}} |
|
| 35 | 38 | <div class="post-tags"> |
|
| 36 | 39 | {{range .Post.TagList}} |
| 1 | 1 | package main |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "net/http" |
|
| 4 | 6 | "strings" |
|
| 5 | 7 | "time" |
|
| 8 | + | "encoding/json" |
|
| 9 | + | "strconv" |
|
| 6 | 10 | ) |
|
| 7 | 11 | ||
| 8 | 12 | type parsedAttributes struct { |
|
| 15 | 19 | Lang string |
|
| 16 | 20 | Tags string |
|
| 17 | 21 | Status string |
|
| 22 | + | Weather string |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | type WeatherPointResponse struct { |
|
| 26 | + | Properties struct { |
|
| 27 | + | ForecastHourly string `json:"forecastHourly"` |
|
| 28 | + | RelativeLocation struct { |
|
| 29 | + | Properties struct { |
|
| 30 | + | City string `json:"city"` |
|
| 31 | + | State string `json:"state"` |
|
| 32 | + | } `json:"properties"` |
|
| 33 | + | } `json:"relativeLocation"` |
|
| 34 | + | } `json:"properties"` |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | type Period struct { |
|
| 38 | + | Temperature int `json:"temperature"` |
|
| 39 | + | ShortForecast string `json:"shortForecast"` |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | type WeatherForecastResponse struct { |
|
| 43 | + | Properties struct { |
|
| 44 | + | Periods []Period `json:"periods"` |
|
| 45 | + | } `json:"properties"` |
|
| 18 | 46 | } |
|
| 19 | 47 | ||
| 20 | 48 | func parseAttributes(text string) parsedAttributes { |
|
| 45 | 73 | a.Tags = value |
|
| 46 | 74 | case "status": |
|
| 47 | 75 | a.Status = value |
|
| 76 | + | case "weather": |
|
| 77 | + | a.Weather = value |
|
| 48 | 78 | } |
|
| 49 | 79 | } |
|
| 50 | 80 | return a |
|
| 317 | 347 | } |
|
| 318 | 348 | return strings.ToUpper(cleaned[:1]) + cleaned[1:] |
|
| 319 | 349 | } |
|
| 350 | + | ||
| 351 | + | func getWeather(location string) string { |
|
| 352 | + | // Fetch Points data using lat,long |
|
| 353 | + | pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location) |
|
| 354 | + | resp, err := http.Get(pointURL) |
|
| 355 | + | if err != nil { |
|
| 356 | + | fmt.Printf("Error fetching pointUrl: %s", err.Error()) |
|
| 357 | + | return "" |
|
| 358 | + | } |
|
| 359 | + | defer resp.Body.Close() |
|
| 360 | + | var weatherPoint WeatherPointResponse |
|
| 361 | + | json.NewDecoder(resp.Body).Decode(&weatherPoint) |
|
| 362 | + | forecastURL := weatherPoint.Properties.ForecastHourly |
|
| 363 | + | city := weatherPoint.Properties.RelativeLocation.Properties.City |
|
| 364 | + | state := weatherPoint.Properties.RelativeLocation.Properties.State |
|
| 365 | + | ||
| 366 | + | // Forcast using points data |
|
| 367 | + | forecastResp, err := http.Get(forecastURL) |
|
| 368 | + | if err != nil { |
|
| 369 | + | fmt.Printf("Error fetching forecast: %s", err.Error()) |
|
| 370 | + | return "" |
|
| 371 | + | } |
|
| 372 | + | defer resp.Body.Close() |
|
| 373 | + | var weatherForecast WeatherForecastResponse |
|
| 374 | + | json.NewDecoder(forecastResp.Body).Decode(&weatherForecast) |
|
| 375 | + | temp := strconv.Itoa(weatherForecast.Properties.Periods[0].Temperature) |
|
| 376 | + | conditions := weatherForecast.Properties.Periods[0].ShortForecast |
|
| 377 | + | ||
| 378 | + | weather := fmt.Sprintf("%s,%s,%s,%s", conditions, temp, city,state) |
|
| 379 | + | return weather |
|
| 380 | + | } |
|
| 381 | + | ||
| 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 |