diff options
Diffstat (limited to 'wttrin.el')
| -rw-r--r-- | wttrin.el | 502 |
1 files changed, 415 insertions, 87 deletions
@@ -35,6 +35,7 @@ (require 'face-remap) (require 'subr-x) ; string-trim +(require 'json) ; forecast j1 parsing (require 'url) ;; Declare xterm-color functions (loaded on-demand) @@ -237,13 +238,28 @@ coordinates. For example: (\"Home\" . \"41.37,-71.83\")) A bare string S used anywhere a location is expected is shorthand for -\(S . S) — name and query the same. Persisted across sessions via -`savehist-mode'; add entries interactively with \\[wttrin-save-location] or the -`d' key in a weather buffer, or set this in your init." +\(S . S) — name and query the same. Entries added interactively with +\\[wttrin-save-location] or the `d' key in a weather buffer are persisted in +`wttrin-state-file'; entries set here in your init are re-read each start." :group 'wttrin :type '(alist :key-type (string :tag "Name") :value-type (string :tag "Query"))) +(defvar wttrin--favorite-override nil + "Runtime favorite set by `d'/`wttrin-make-default' and geolocation commands. +Persisted across sessions in `wttrin-state-file'. When non-nil it shadows +the configured `wttrin-favorite-location', so a favorite chosen at runtime +survives a restart even when the user also sets `wttrin-favorite-location' +in their init. Carries the same tri-state shape as that option: nil (no +override), a string, or t (auto-detect).") + +(defvar wttrin--saved-locations-runtime nil + "Saved-location entries added at runtime via `d' or `wttrin-save-location'. +Persisted across sessions in `wttrin-state-file'. Overlaid on the configured +`wttrin-saved-locations' by `wttrin--saved-locations', runtime winning on a name +collision, so directory edits survive a restart without being clobbered by an +init that also sets `wttrin-saved-locations'. An alist of (NAME . QUERY).") + (defvar wttrin--resolved-favorite-location nil "Cached geolocation result for `wttrin-favorite-location' = t. Holds the resolved \"City, Region\" string so subsequent reads @@ -254,9 +270,19 @@ do not re-fetch. Reset implicitly when the Emacs session ends.") Prevents duplicate concurrent lookups when several consumers ask during the resolution window.") +(defun wttrin--favorite-location () + "Return the effective favorite: the runtime override, else the configured one. +`wttrin--favorite-override' (set at runtime by `d' and the geolocation +commands, persisted in `wttrin-state-file') wins over the +`wttrin-favorite-location' defcustom, so a runtime choice is not clobbered +by an init that also sets the option. Carries the option's tri-state +shape: nil, a string, or t." + (or wttrin--favorite-override wttrin-favorite-location)) + (defun wttrin--resolve-favorite-location () "Return the favorite location's query string, or nil if unavailable. -Resolves `wttrin-favorite-location' across the three modes: +Resolves the effective favorite (`wttrin--favorite-location') across the three +modes: - nil -> nil (disabled) - a string -> its saved-locations query when the string is a saved name, otherwise the string as-is (the query for a plain location) @@ -264,15 +290,16 @@ Resolves `wttrin-favorite-location' across the three modes: and no lookup is in flight, kicks off an async detect and returns nil for this call. The next call after the lookup completes returns the resolved string." - (cond - ((null wttrin-favorite-location) nil) - ((stringp wttrin-favorite-location) - (wttrin--resolve-location-query wttrin-favorite-location)) - ((eq wttrin-favorite-location t) - (or wttrin--resolved-favorite-location - (progn - (wttrin--start-favorite-location-detect) - nil))))) + (let ((fav (wttrin--favorite-location))) + (cond + ((null fav) nil) + ((stringp fav) + (wttrin--resolve-location-query fav)) + ((eq fav t) + (or wttrin--resolved-favorite-location + (progn + (wttrin--start-favorite-location-detect) + nil)))))) (defun wttrin--start-favorite-location-detect () "Kick off an async geolocation lookup if one is not already pending. @@ -296,12 +323,14 @@ call retries." "Return a human-readable name for the favorite location. For a string favorite this is the string itself (a saved-location name shows as its name, not its resolved query). For t it is the resolved geolocation place, -or \"current location\" while a lookup is pending. Nil when disabled." - (cond - ((stringp wttrin-favorite-location) wttrin-favorite-location) - ((eq wttrin-favorite-location t) - (or wttrin--resolved-favorite-location "current location")) - (t nil))) +or \"current location\" while a lookup is pending. Nil when disabled. +Reads the effective favorite (`wttrin--favorite-location')." + (let ((fav (wttrin--favorite-location))) + (cond + ((stringp fav) fav) + ((eq fav t) + (or wttrin--resolved-favorite-location "current location")) + (t nil)))) (defcustom wttrin-mode-line-refresh-interval 3600 "Interval in seconds to refresh mode-line weather data. @@ -311,6 +340,17 @@ wastes their bandwidth. Be kind to the free service." :group 'wttrin :type 'integer) +(defcustom wttrin-mode-line-tooltip-forecast-days 0 + "Days of forecast to append to the mode-line weather tooltip. +0 (the default) keeps the tooltip current-conditions-only. A positive +value appends one line per day: the remainder of today, tomorrow, and +the day after, labeled Today / Tomorrow / weekday. wttr.in's JSON feed +carries at most three days, so larger values are capped at what the +feed returns. When non-zero, the mode-line refresh performs a second +fetch (?format=j1) for the forecast data." + :group 'wttrin + :type 'integer) + (defcustom wttrin-mode-line-startup-delay 3 "Seconds to delay initial mode-line weather fetch after Emacs starts. This allows network stack and daemon initialization to complete before @@ -405,6 +445,14 @@ Set this to t BEFORE loading wttrin, typically in your init file: When non-nil, car is the `float-time' when data was fetched, and cdr is the weather string from the API.") +(defvar wttrin--mode-line-forecast-cache nil + "Cached forecast data as a (timestamp . DAY-LIST) cons cell, or nil. +When non-nil, car is the `float-time' of the fetch and cdr is the parsed +day list from `wttrin--forecast-parse'. The raw day list is cached (not +rendered text) so `wttrin--mode-line-tooltip' formats it at hover time +against the current `wttrin-mode-line-tooltip-forecast-days' and +`wttrin-unit-system' values.") + (defvar wttrin--mode-line-rendered-stale nil "Whether the mode-line emoji is currently rendered as stale (dimmed).") @@ -599,21 +647,147 @@ Persisted across sessions via `savehist-mode'.") (defvar savehist-additional-variables) (defun wttrin--savehist-register () - "Ensure wttrin's persisted variables are saved by savehist. -Registers `wttrin--location-history', `wttrin-favorite-location', and -`wttrin-saved-locations' so they survive across restarts without the Emacs -custom-variable mechanism. + "Register the search history with savehist. +Only `wttrin--location-history' rides savehist: it is scrub-tolerant +throwaway data, which is what savehist is for. The runtime favorite and +runtime directory are deliberately NOT registered — savehist rewrites its +whole file on every save keeping only currently-registered variables, so any +Emacs process that saves savehist without wttrin loaded (a deferred-loading +session where wttrin was never opened, a batch tool) would silently delete +them. Those live in `wttrin-state-file' instead, which only wttrin writes. Run both at load and on `savehist-save-hook', so the registration survives a user `setq' of `savehist-additional-variables' (a common config pattern) that -would otherwise drop the entries before they could be saved." - (add-to-list 'savehist-additional-variables 'wttrin--location-history) - (add-to-list 'savehist-additional-variables 'wttrin-favorite-location) - (add-to-list 'savehist-additional-variables 'wttrin-saved-locations)) +would otherwise drop the entry before it could be saved." + (add-to-list 'savehist-additional-variables 'wttrin--location-history)) + +;;;###autoload (with-eval-after-load 'savehist (add-to-list 'savehist-additional-variables 'wttrin--location-history)) (with-eval-after-load 'savehist (wttrin--savehist-register) (add-hook 'savehist-save-hook #'wttrin--savehist-register)) +(defun wttrin--state-file-set (symbol value) + "Set SYMBOL (`wttrin-state-file') to VALUE and restore state from it. +The `:set' handler for the option. The load-time restore below runs against +whatever path the option holds when wttrin loads, so a path customized +afterward — the use-package `:config' shape, `(setopt wttrin-state-file ...)' +after the require — would be written by the location commands but never +read back on the next start. Re-running `wttrin--state-load' here closes +that gap for `setopt', `customize-set-variable', and Customize. When the +restore changes the effective favorite and the mode-line mode is on, the +mode-line is refreshed so it shows the restored city rather than the +configured one. At definition time the loader is not yet defined, so the +`fboundp' guard makes that first call a plain `set-default'; the load-time +restore covers the initial read." + (let ((before (and (fboundp 'wttrin--favorite-location) + (wttrin--favorite-location)))) + (set-default symbol value) + (when (fboundp 'wttrin--state-load) + (wttrin--state-load) + (when (and (not (equal before (wttrin--favorite-location))) + (bound-and-true-p wttrin-mode-line-mode)) + (wttrin--mode-line-refresh-now))))) + +(defcustom wttrin-state-file (locate-user-emacs-file "wttrin-state.el") + "File persisting wttrin's runtime state across sessions. +Holds the runtime favorite (`wttrin--favorite-override') and the runtime +saved-locations directory (`wttrin--saved-locations-runtime') — the values +written by `d'/`wttrin-make-default', `wttrin-save-location', and the +geolocation commands. A dedicated file rather than savehist because savehist +rewrites its whole file keeping only the variables registered in the current +process: any Emacs session that saved savehist without wttrin loaded would +silently delete the persisted favorite. Only wttrin writes this file. +The state restores at load time from this path. Set the option with +`setopt', `customize-set-variable', or Customize (use-package `:custom' or +`:config' both work): changing it after wttrin has loaded re-reads the state +from the new path. A plain `setq' after load skips that restore and takes +effect only from the next one (`savehist-mode-hook' or restart)." + :group 'wttrin + :type 'file + :set #'wttrin--state-file-set) + +(defun wttrin--state-save () + "Write the runtime favorite and directory to `wttrin-state-file'. +Writes atomically (temp file + rename) so a crash mid-write cannot leave a +truncated file, always as UTF-8 so the file round-trips regardless of the +session's default coding system (the same reason savehist pins +`savehist-coding-system'). Never signals: persistence failure must not +break the location command that triggered it — it messages and moves on." + (condition-case err + (let* ((file (expand-file-name wttrin-state-file)) + (dir (file-name-directory file)) + (tmp (make-temp-name (concat file ".tmp"))) + (coding-system-for-write 'utf-8-emacs) + (print-length nil) + (print-level nil)) + (unless (file-directory-p dir) (make-directory dir t)) + (unwind-protect + (progn + (with-temp-buffer + (insert ";; wttrin runtime state -- managed by wttrin;" + " do not edit by hand.\n") + (prin1 (list :version 1 + :favorite-override wttrin--favorite-override + :saved-locations wttrin--saved-locations-runtime) + (current-buffer)) + (insert "\n") + (write-region (point-min) (point-max) tmp nil 'silent)) + (rename-file tmp file t) + (setq tmp nil)) + ;; Any failure above leaves the temp file behind; remove it. On + ;; success TMP is nil and there is nothing to clean. + (when (and tmp (file-exists-p tmp)) + (delete-file tmp)))) + (error (message "wttrin: could not save state to %s: %s" + wttrin-state-file (error-message-string err))))) + +(defun wttrin--state-read () + "Read `wttrin-state-file' and return its state plist, or nil. +Returns nil when the file is absent, unreadable, or does not contain a +versioned plist. Reads with `read', never `eval', so the state file cannot +execute code, and as UTF-8, matching how `wttrin--state-save' writes it. +Never signals — a broken state file must not break load." + (when (file-readable-p wttrin-state-file) + (condition-case err + (with-temp-buffer + (let ((coding-system-for-read 'utf-8-emacs)) + (insert-file-contents wttrin-state-file)) + (let ((data (read (current-buffer)))) + (if (and (listp data) (integerp (plist-get data :version))) + data + (message "wttrin: ignoring malformed state file %s" + wttrin-state-file) + nil))) + (error (message "wttrin: could not read state file %s: %s" + wttrin-state-file (error-message-string err)) + nil)))) + +(defun wttrin--state-load () + "Restore the runtime favorite and directory from `wttrin-state-file'. +An existing state file is authoritative: its values replace whatever is in +memory, which is what defeats a later savehist restore of stale legacy +entries (this function also runs on `savehist-mode-hook'). When the file +does not exist but either variable is non-nil — savehist restored legacy +values from a pre-state-file wttrin earlier in init — those values are +adopted and written, migrating existing users with zero steps. A corrupt +file is left in place and ignored: vars keep their current values and no +adoption write happens over it." + (let ((data (wttrin--state-read))) + (cond + (data + (setq wttrin--favorite-override (plist-get data :favorite-override)) + (setq wttrin--saved-locations-runtime (plist-get data :saved-locations))) + ((and (not (file-exists-p wttrin-state-file)) + (or wttrin--favorite-override wttrin--saved-locations-runtime)) + (wttrin--state-save))))) + +;; Restore persisted state at load — but not in batch (CI, test runners, +;; scripts), which stays hermetic by design. The savehist-mode-hook entry +;; re-asserts the state file after savehist restores, in case a savehist file +;; still carrying legacy pre-state-file entries loads later in init. +(unless noninteractive (wttrin--state-load)) +(add-hook 'savehist-mode-hook #'wttrin--state-load) + (defconst wttrin--geolocation-sentinel "Current location (detect)" "Picker candidate that triggers geolocation detection. Selecting it routes through `wttrin--query-selection' to a @@ -650,8 +824,8 @@ other, never both." (setq wttrin--location-history (delete location wttrin--location-history))))) -(defun wttrin--saved-locations () - "Return `wttrin-saved-locations' as a clean list of (NAME . QUERY) pairs. +(defun wttrin--normalize-location-entries (entries) + "Return ENTRIES as a clean list of (NAME . QUERY) pairs. Skips malformed entries — non-cons, a non-string name or query, or an empty name or query — and trims surrounding whitespace, so stale or hand-edited config never errors. A bare string S is read as (S . S)." @@ -667,7 +841,24 @@ config never errors. A bare string S is read as (S . S)." (let ((s (string-trim entry))) (and (> (length s) 0) (cons s s)))) (t nil))) - wttrin-saved-locations))) + entries))) + +(defun wttrin--saved-locations () + "Return the effective saved-locations directory as clean (NAME . QUERY) pairs. +The runtime layer `wttrin--saved-locations-runtime' (set by `d' and +`wttrin-save-location', persisted by savehist) is overlaid on the configured +`wttrin-saved-locations', runtime winning on a name collision. Both layers are +normalized (malformed entries skipped, whitespace trimmed, a bare string S read +as (S . S)); runtime entries are listed first, then config entries whose names +the runtime does not already define." + (let* ((runtime (wttrin--normalize-location-entries wttrin--saved-locations-runtime)) + (runtime-names (mapcar #'car runtime)) + (config (wttrin--normalize-location-entries wttrin-saved-locations))) + (append runtime + (delq nil + (mapcar (lambda (entry) + (unless (member (car entry) runtime-names) entry)) + config))))) (defun wttrin--resolve-location-query (selection) "Return the query string for a picker SELECTION. @@ -683,31 +874,41 @@ Used to keep a raw geolocation fix out of history and to decide when the (and (stringp string) (string-match-p "\\`[ ]*-?[0-9.]+[ ]*,[ ]*-?[0-9.]+[ ]*\\'" string))) -(defun wttrin--saved-locations-without (name) - "Return `wttrin-saved-locations' with any entry named NAME removed." +(defun wttrin--saved-locations-runtime-without (name) + "Return `wttrin--saved-locations-runtime' with any entry named NAME removed." (delq nil (mapcar (lambda (entry) (unless (and (consp entry) (equal (car entry) name)) entry)) - wttrin-saved-locations))) + wttrin--saved-locations-runtime))) (defun wttrin--put-saved-location (name query) - "Add or update NAME -> QUERY in `wttrin-saved-locations'; return the saved name. -Trims NAME and QUERY. Signals a `user-error' for an empty name or query, or a -name equal to the geolocation sentinel. An existing name has its query updated." + "Add or update NAME -> QUERY in the runtime directory; return the saved name. +Writes `wttrin--saved-locations-runtime' (persisted in `wttrin-state-file'), +never the `wttrin-saved-locations' defcustom, so a runtime save is not +clobbered by an init that also sets the option. Trims NAME and QUERY. +Signals a `user-error' +for an empty name or query, or a name equal to the geolocation sentinel. An +existing runtime name has its query updated." (let ((name (string-trim (or name ""))) (query (string-trim (or query "")))) (when (string= name "") (user-error "Location name cannot be empty")) (when (string= query "") (user-error "Location query cannot be empty")) (when (string= name wttrin--geolocation-sentinel) (user-error "That name is reserved for the geolocation entry")) - (setq wttrin-saved-locations - (append (wttrin--saved-locations-without name) + (setq wttrin--saved-locations-runtime + (append (wttrin--saved-locations-runtime-without name) (list (cons name query)))) + (wttrin--state-save) name)) (defun wttrin--remove-saved-location (name) - "Remove the saved location named NAME from `wttrin-saved-locations'." - (setq wttrin-saved-locations (wttrin--saved-locations-without name))) + "Remove the saved location named NAME from the runtime directory. +Only the runtime layer `wttrin--saved-locations-runtime' is mutated. An entry +from the `wttrin-saved-locations' defcustom cannot be deleted here (it is +re-read from the user's init) and remains in the effective directory." + (setq wttrin--saved-locations-runtime + (wttrin--saved-locations-runtime-without name)) + (wttrin--state-save)) (defvar-local wttrin--current-location nil "Query for the weather shown in this buffer (the fetch/cache identity).") @@ -745,8 +946,8 @@ history (the explicit alias wins over a same-named default or history string), so each place appears exactly once. The geolocation sentinel is prepended when geolocation is enabled." (let* ((saved (mapcar #'car (wttrin--saved-locations))) - (favorite (and (stringp wttrin-favorite-location) - (list wttrin-favorite-location))) + (favorite (let ((fav (wttrin--favorite-location))) + (and (stringp fav) (list fav)))) (deduped (delete-dups (append saved favorite (copy-sequence wttrin-default-locations) @@ -865,7 +1066,7 @@ the favorite is updated to NEW." (let ((query (cdr entry))) (wttrin--remove-saved-location old) (wttrin--put-saved-location new query) - (when (equal wttrin-favorite-location old) + (when (equal (wttrin--favorite-location) old) (wttrin--set-favorite-location new)) (message "Renamed %s to %s" old new)))))) @@ -880,16 +1081,22 @@ When NAME is the favorite, it is left as a literal query with a warning." ((not (assoc name (wttrin--saved-locations))) (user-error "No saved location named %s" name)) ((yes-or-no-p (format "Remove saved location \"%s\"? " name)) - (let ((query (cdr (assoc name (wttrin--saved-locations))))) + (let ((query (cdr (assoc name (wttrin--saved-locations)))) + (was-favorite (equal (wttrin--favorite-location) name))) (wttrin--remove-saved-location name) - (wttrin--drop-from-location-history name query)) - (if (equal wttrin-favorite-location name) - (progn - (when (bound-and-true-p wttrin-mode-line-mode) - (wttrin--mode-line-refresh-now)) - (message "Removed %s; it was your favorite and is now a literal query until you set a new one" - name)) - (message "Removed %s" name))) + (wttrin--drop-from-location-history name query) + (when (and was-favorite (bound-and-true-p wttrin-mode-line-mode)) + (wttrin--mode-line-refresh-now)) + (cond + ;; Still resolvable means the name comes from the user's init, which the + ;; runtime removal can't touch, so the entry stays in the directory. + ((assoc name (wttrin--saved-locations)) + (message "Removed the runtime entry %s; it is still defined in your init (wttrin-saved-locations) and remains in the directory" + name)) + (was-favorite + (message "Removed %s; it was your favorite and is now a literal query until you set a new one" + name)) + (t (message "Removed %s" name))))) (t (message "Cancelled")))) (defun wttrin--requery-location (new-location) @@ -1249,6 +1456,25 @@ coordinates but can name the place)." (defvar-local wttrin--current-request-id nil "Request id of the most recent query for this weather buffer.") +(defun wttrin--render-loading-placeholder (query display) + "Show the loading placeholder for QUERY in the current buffer. +DISPLAY is the name to show (a saved-location name); when nil it falls back to +QUERY. Erases the buffer, inserts the one-line placeholder, resets the font to +the base height (dropping any auto-fit remap from the previous weather), and +centers it via `wttrin--update-layout'. The explicit centering matters on an +`a' switch: the *wttr.in* buffer is already displayed, so +`window-configuration-change-hook' does not fire and the placeholder would +otherwise keep the previous weather block's window margin." + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "Loading weather for " (or display query) "...")) + (setq buffer-read-only t) + ;; The placeholder is one line; keep auto-fit off it (weather not yet rendered) + ;; and show it at the base font rather than the previous weather's size. + (setq-local wttrin--weather-rendered nil) + (wttrin--reset-font-height) + (wttrin--update-layout)) + (defun wttrin-query (query &optional display address) "Asynchronously query weather for QUERY, display the result when ready. QUERY is what weather is fetched by (and the cache key). Optional DISPLAY is @@ -1258,15 +1484,8 @@ coordinates from a geolocation command." (let ((buffer (get-buffer-create (format "*wttr.in*"))) (request-id (setq wttrin--request-counter (1+ wttrin--request-counter)))) (switch-to-buffer buffer) - (setq buffer-read-only nil) - (erase-buffer) - (insert "Loading weather for " (or display query) "...") - (setq buffer-read-only t) (setq-local wttrin--current-request-id request-id) - ;; The placeholder is one line; keep auto-fit off it and show it at the base - ;; font rather than the previous weather's auto-fitted (possibly capped) size. - (setq-local wttrin--weather-rendered nil) - (wttrin--reset-font-height) + (wttrin--render-loading-placeholder query display) (wttrin--get-cached-or-fetch query (lambda (raw-string &optional error-msg) @@ -1346,12 +1565,14 @@ This creates headroom to avoid frequent cleanups." (defun wttrin-set-location-from-geolocation () "Detect your location via IP geolocation and set it as the favorite. Uses the provider named by `wttrin-geolocation-provider' to fetch -\"City, Region\", asks for confirmation, and on yes assigns the -result to `wttrin-favorite-location'. +\"City, Region\", asks for confirmation, and on yes makes the result +the runtime favorite (`wttrin--favorite-override', which shadows the +`wttrin-favorite-location' option). With `savehist-mode' on, the favorite persists across sessions -automatically (wttrin registers it with savehist); no -`customize-save-variable' step is needed. +automatically (wttrin registers the override with savehist); no +`customize-save-variable' step is needed, and it is not overwritten by +an init that also sets `wttrin-favorite-location'. IP-based geolocation can be wrong behind a VPN or a mobile hotspot. The confirmation prompt shows the detected location so you can @@ -1373,7 +1594,7 @@ the detected city as your default." ((yes-or-no-p (format "Detected location: %s. Set as favorite? " location)) (wttrin--set-favorite-location location) - (message "Set wttrin-favorite-location to: %s%s" + (message "Set favorite location to: %s%s" location (if (bound-and-true-p savehist-mode) " (persisted via savehist)." @@ -1389,13 +1610,14 @@ the detected city as your default." ;;;###autoload (defun wttrin-use-current-location () "Make your current location the persistent favorite (always auto-detect). -Sets `wttrin-favorite-location' to t after confirmation, so the mode-line -and buffer track wherever you are via geolocation rather than a fixed city. -This is the labeled way to choose auto-detect without typing the bare symbol -t into your init. - -With `savehist-mode' on, the choice persists across sessions automatically. -Does nothing when `wttrin-geolocation-enabled' is nil." +After confirmation, sets the runtime favorite (`wttrin--favorite-override') to +t, so the mode-line and buffer track wherever you are via geolocation rather +than a fixed city. This is the labeled way to choose auto-detect without typing +the bare symbol t into your init. + +With `savehist-mode' on, the choice persists across sessions automatically, and +is not overwritten by an init that also sets `wttrin-favorite-location'. Does +nothing when `wttrin-geolocation-enabled' is nil." (interactive) (cond ((not wttrin-geolocation-enabled) @@ -1421,16 +1643,18 @@ Does nothing when `wttrin-geolocation-enabled' is nil." (message "No location to refresh"))) (defun wttrin--set-favorite-location (location) - "Set `wttrin-favorite-location' to LOCATION and drop it from search history. -LOCATION becomes a permanent default, so it no longer needs a history entry, -mirroring how `wttrin-default-locations' entries are kept out of history. -Persistence is handled by `wttrin--savehist-register', which registers the -variable when savehist loads and again on `savehist-save-hook', so the value -survives restarts without the Emacs custom-variable mechanism, and setting it -here works whether or not savehist is loaded." - (let ((changed (not (equal location wttrin-favorite-location)))) - (setq wttrin-favorite-location location) + "Set the runtime favorite to LOCATION and drop it from search history. +Writes `wttrin--favorite-override', not the `wttrin-favorite-location' +defcustom, so the choice survives a restart even when the user also sets the +option in their init (the override shadows the config; see +`wttrin--favorite-location'). LOCATION becomes a permanent default, so it no +longer needs a history entry, mirroring how `wttrin-default-locations' entries +are kept out of history. Persisted immediately to `wttrin-state-file', so the +choice survives restarts regardless of savehist." + (let ((changed (not (equal location (wttrin--favorite-location))))) + (setq wttrin--favorite-override location) (setq wttrin--location-history (delete location wttrin--location-history)) + (wttrin--state-save) (when (and changed (bound-and-true-p wttrin-mode-line-mode)) (wttrin--mode-line-refresh-now)))) @@ -1528,10 +1752,11 @@ On failure with no cache, shows error placeholder. When `wttrin-favorite-location' is t and geolocation has not yet resolved, this call is a no-op; the next tick after resolution proceeds normally." - (wttrin--debug-log "mode-line-fetch: Starting fetch for %s" wttrin-favorite-location) + (wttrin--debug-log "mode-line-fetch: Starting fetch for %s" (wttrin--favorite-location)) (let ((location (wttrin--resolve-favorite-location))) (if (not location) (wttrin--debug-log "mode-line-fetch: No favorite location available, skipping") + (wttrin--mode-line-fetch-forecast location) (let* (;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions (format-params (if wttrin-unit-system (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C") @@ -1581,25 +1806,128 @@ without a separate guard." (let ((age (- (float-time) (car cache-entry)))) (> age (* 2 wttrin-mode-line-refresh-interval))))) +(defun wttrin--forecast-parse (json-string) + "Parse a wttr.in ?format=j1 JSON-STRING into a list of day alists. +Returns the top-level weather array as a list (one alist per day), or +nil when JSON-STRING is nil, empty, malformed, or missing the weather +key. Never signals -- a broken forecast response must not take the +mode-line fetch down with it." + (when (and (stringp json-string) (> (length json-string) 0)) + (condition-case err + (let ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol)) + (alist-get 'weather (json-read-from-string json-string))) + (error + (wttrin--debug-log "forecast-parse: %s" (error-message-string err)) + nil)))) + +(defun wttrin--forecast-day-label (day index) + "Return the tooltip label for DAY (a day alist) at INDEX in the forecast. +Index 0 is \"Today\" and 1 is \"Tomorrow\"; later days use the abbreviated +weekday name from DAY's date field (\"Fri\"). Falls back to the raw date +string when it doesn't parse, and to the index when there's no date." + (let ((date (alist-get 'date day))) + (cond + ((= index 0) "Today") + ((= index 1) "Tomorrow") + ((stringp date) + (condition-case nil + (let ((parsed (parse-time-string date))) + (format-time-string + "%a" (encode-time 0 0 12 (nth 3 parsed) (nth 4 parsed) + (nth 5 parsed)))) + (error date))) + (t (format "Day %d" (1+ index)))))) + +(defun wttrin--forecast-midday-desc (day) + "Return DAY's midday weather description, or nil when unavailable. +Reads the hourly entry whose time is \"1200\" (falling back to the middle +entry) and returns the first weatherDesc value." + (let* ((hourly (alist-get 'hourly day)) + (midday (or (seq-find (lambda (h) (equal (alist-get 'time h) "1200")) + hourly) + (nth (/ (length hourly) 2) hourly)))) + (when midday + (let ((desc (alist-get 'value (car (alist-get 'weatherDesc midday))))) + ;; wttr.in pads some description values with trailing whitespace. + (when (stringp desc) (string-trim desc)))))) + +(defun wttrin--forecast-format (days count) + "Format up to COUNT entries of DAYS (parsed day alists) for the tooltip. +One line per day: \"<label> <min>-<max>°F <midday description>\" -- °F when +`wttrin-unit-system' is \"u\", otherwise °C (both are present in the j1 +data; wttr.in's own location-based default can't be known here). The +description is omitted when the day has no hourly data. Returns nil when +DAYS is nil or COUNT is not positive." + (when (and days (> count 0)) + (let* ((fahrenheit (equal wttrin-unit-system "u")) + (min-key (if fahrenheit 'mintempF 'mintempC)) + (max-key (if fahrenheit 'maxtempF 'maxtempC)) + (unit (if fahrenheit "°F" "°C")) + (index -1)) + (mapconcat + (lambda (day) + (setq index (1+ index)) + (let ((desc (wttrin--forecast-midday-desc day))) + (concat (wttrin--forecast-day-label day index) + (format " %s-%s%s" + (alist-get min-key day) + (alist-get max-key day) + unit) + (when desc (concat " " desc))))) + (seq-take days count) + "\n")))) + +(defun wttrin--mode-line-fetch-forecast (location) + "Fetch the j1 forecast for LOCATION into `wttrin--mode-line-forecast-cache'. +Fires only when `wttrin-mode-line-tooltip-forecast-days' is positive. +On a failed fetch or unparseable response the previous cache is kept -- +the tooltip just shows whatever forecast it last had." + (when (> wttrin-mode-line-tooltip-forecast-days 0) + (let ((url (concat "https://wttr.in/" + (url-hexify-string location) + "?format=j1"))) + (wttrin--debug-log "forecast-fetch: URL = %s" url) + (wttrin--fetch-url + url + (lambda (data &optional _error-msg) + (let ((days (wttrin--forecast-parse data))) + (if days + (setq wttrin--mode-line-forecast-cache + (cons (float-time) days)) + (wttrin--debug-log + "forecast-fetch: no usable forecast, keeping previous")))))))) + (defun wttrin--mode-line-tooltip (&optional _window _object _pos) "Compute tooltip text from `wttrin--mode-line-cache'. Calculates age at call time so the tooltip is always current. If staleness has changed since the last render, triggers a re-render so the emoji dimming matches. +When `wttrin-mode-line-tooltip-forecast-days' is positive and a forecast +has been fetched, the forecast lines sit between the conditions and the +age line. Optional arguments are ignored (required by `help-echo' function protocol)." (when wttrin--mode-line-cache (let* ((timestamp (car wttrin--mode-line-cache)) (weather-string (cdr wttrin--mode-line-cache)) (age (- (float-time) timestamp)) (stale-p (wttrin--mode-line-stale-p wttrin--mode-line-cache)) - (age-str (wttrin--format-age age))) + (age-str (wttrin--format-age age)) + (forecast (when (> wttrin-mode-line-tooltip-forecast-days 0) + (wttrin--forecast-format + (cdr wttrin--mode-line-forecast-cache) + wttrin-mode-line-tooltip-forecast-days))) + (head (if forecast + (concat weather-string "\n" forecast) + weather-string))) ;; Re-render emoji if staleness state has changed (unless (eq stale-p wttrin--mode-line-rendered-stale) (wttrin--mode-line-update-display)) (if stale-p (format "%s\nStale: updated %s — fetch failed, will retry" - weather-string age-str) - (format "%s\nUpdated %s" weather-string age-str))))) + head age-str) + (format "%s\nUpdated %s" head age-str))))) (defun wttrin--mode-line-update-display () "Update mode-line display from `wttrin--mode-line-cache'. @@ -1691,9 +2019,9 @@ mode-line state." (defun wttrin--mode-line-start () "Start mode-line weather display and refresh timer." (wttrin--debug-log "wttrin mode-line: Starting mode-line display (location=%s, interval=%s)" - wttrin-favorite-location + (wttrin--favorite-location) wttrin-mode-line-refresh-interval) - (when wttrin-favorite-location + (when (wttrin--favorite-location) ;; Trigger geolocation resolution in the background if needed; the ;; placeholder + scheduled fetch will pick up the resolved string ;; on the next tick. |
