fedora-csb-system-manager
  1;;; journelly-location-weather.el --- Location and weather helpers for Journelly -*- lexical-binding: t; -*-
  2
  3;; Copyright (C) 2025 Vincent Demeester
  4
  5;; Author: Vincent Demeester <vincent@demeester.fr>
  6;; Keywords: org-mode, journelly, location, weather
  7;; Version: 1.0.0
  8
  9;;; Commentary:
 10
 11;; Emacs Lisp functions to get location and weather data for Journelly journal entries.
 12;;
 13;; Location:
 14;; - Uses IP-based geolocation (ipinfo.io)
 15;; - Returns city name and GPS coordinates
 16;; - Caches results for 1 hour
 17;;
 18;; Weather:
 19;; - Uses wttr.in weather service
 20;; - Returns temperature, condition, and iOS SF Symbol
 21;; - Caches results for 30 minutes
 22;; - Intelligent day/night symbol mapping
 23;;
 24;; Functions:
 25;; - journelly-get-location: Get current location via IP geolocation
 26;; - journelly-get-weather: Get current weather
 27;; - journelly-batch-get-location: Batch mode wrapper for location
 28;; - journelly-batch-get-weather: Batch mode wrapper for weather
 29;;
 30;; Usage (batch mode):
 31;;   emacs --batch \
 32;;     --load journelly-location-weather.el \
 33;;     --eval "(journelly-batch-get-location)"
 34;;
 35;;   emacs --batch \
 36;;     --load journelly-location-weather.el \
 37;;     --eval "(journelly-batch-get-weather)"
 38
 39;;; Code:
 40
 41(require 'url)
 42(require 'json)
 43
 44;;; Configuration
 45
 46(defvar journelly-cache-dir
 47  (expand-file-name "journal" (or (getenv "XDG_CACHE_HOME")
 48                                   (expand-file-name ".cache" "~")))
 49  "Directory for caching location and weather data.")
 50
 51(defvar journelly-location-cache-timeout 3600
 52  "Location cache timeout in seconds (default: 1 hour).")
 53
 54(defvar journelly-weather-cache-timeout 1800
 55  "Weather cache timeout in seconds (default: 30 minutes).")
 56
 57;;; Utility functions
 58
 59(defun journelly--ensure-cache-dir ()
 60  "Ensure cache directory exists."
 61  (unless (file-exists-p journelly-cache-dir)
 62    (make-directory journelly-cache-dir t)))
 63
 64(defun journelly--cache-file (key)
 65  "Get cache file path for KEY."
 66  (expand-file-name (format "%s.json" key) journelly-cache-dir))
 67
 68(defun journelly--cache-valid-p (cache-file timeout)
 69  "Check if CACHE-FILE is valid within TIMEOUT seconds."
 70  (when (file-exists-p cache-file)
 71    (let* ((file-time (nth 5 (file-attributes cache-file)))
 72           (current-time (current-time))
 73           (age (float-time (time-subtract current-time file-time))))
 74      (< age timeout))))
 75
 76(defun journelly--read-cache (cache-file)
 77  "Read JSON data from CACHE-FILE."
 78  (when (file-exists-p cache-file)
 79    (with-temp-buffer
 80      (insert-file-contents cache-file)
 81      (goto-char (point-min))
 82      (json-read))))
 83
 84(defun journelly--write-cache (cache-file data)
 85  "Write DATA as JSON to CACHE-FILE."
 86  (journelly--ensure-cache-dir)
 87  (with-temp-file cache-file
 88    (insert (json-encode data))))
 89
 90(defun journelly--fetch-url (url)
 91  "Fetch URL and return parsed JSON response."
 92  (let ((url-request-method "GET")
 93        (url-request-extra-headers '(("User-Agent" . "Emacs/journelly"))))
 94    (with-current-buffer (url-retrieve-synchronously url t nil 10)
 95      (goto-char (point-min))
 96      ;; Skip HTTP headers
 97      (re-search-forward "^$")
 98      (forward-line)
 99      (let ((json-data (json-read)))
100        (kill-buffer)
101        json-data))))
102
103(defun journelly--is-night-p ()
104  "Return t if current time is night (20:00-06:00)."
105  (let ((hour (string-to-number (format-time-string "%H"))))
106    (or (>= hour 20) (< hour 6))))
107
108;;; Location functions
109
110(defun journelly--map-weather-symbol (description &optional is-night)
111  "Map weather DESCRIPTION to iOS SF Symbol name.
112If IS-NIGHT is non-nil, return night-appropriate symbols."
113  (let ((desc (downcase description)))
114    (if is-night
115        ;; Night conditions
116        (cond
117         ((string-match-p "\\(clear\\|sunny\\)" desc) "moon.stars")
118         ((string-match-p "partly.*cloud" desc) "cloud.moon")
119         ((string-match-p "\\(rain\\|drizzle\\|shower\\)" desc) "cloud.moon.rain")
120         (t "cloud.moon"))
121      ;; Day conditions
122      (cond
123       ((string-match-p "\\(clear\\|sunny\\)" desc) "sun.max")
124       ((string-match-p "partly.*cloud" desc) "cloud.sun")
125       ((string-match-p "\\(cloudy\\|overcast\\)" desc) "cloud")
126       ((string-match-p "heavy.*rain" desc) "cloud.heavyrain")
127       ((string-match-p "\\(rain\\|shower\\)" desc) "cloud.rain")
128       ((string-match-p "\\(drizzle\\|light.*rain\\)" desc) "cloud.drizzle")
129       ((string-match-p "snow" desc) "cloud.snow")
130       ((string-match-p "sleet" desc) "cloud.sleet")
131       ((string-match-p "\\(fog\\|mist\\)" desc) "cloud.fog")
132       ((string-match-p "\\(haze\\|smoke\\)" desc) "smoke")
133       ((string-match-p "wind" desc) "wind")
134       ((string-match-p "\\(thunder\\|storm\\)" desc) "cloud.bolt")
135       (t "cloud")))))
136
137(defun journelly-get-location (&optional no-cache)
138  "Get current location via IP geolocation.
139Returns alist with city, latitude, and longitude.
140If NO-CACHE is non-nil, fetch fresh data ignoring cache."
141  (let ((cache-file (journelly--cache-file "location")))
142    (if (and (not no-cache)
143             (journelly--cache-valid-p cache-file journelly-location-cache-timeout))
144        ;; Return cached data
145        (journelly--read-cache cache-file)
146      ;; Fetch fresh data
147      (let* ((response (journelly--fetch-url "https://ipinfo.io/json"))
148             (city (cdr (assoc 'city response)))
149             (loc (cdr (assoc 'loc response)))
150             (coords (when loc (split-string loc ",")))
151             (lat (when coords (car coords)))
152             (lon (when coords (cadr coords)))
153             (data `((city . ,(or city "Unknown"))
154                    (lat . ,(or lat "0"))
155                    (lon . ,(or lon "0")))))
156        ;; Cache the result
157        (journelly--write-cache cache-file data)
158        data))))
159
160(defun journelly-get-weather (&optional location no-cache)
161  "Get current weather for LOCATION (city name or coordinates).
162If LOCATION is nil, uses current location via IP.
163Returns alist with temperature, condition, and symbol.
164If NO-CACHE is non-nil, fetch fresh data ignoring cache."
165  (let* ((loc (or location ""))
166         (cache-key (if (string-empty-p loc) "weather-auto" (format "weather-%s" loc)))
167         (cache-file (journelly--cache-file cache-key)))
168    (if (and (not no-cache)
169             (journelly--cache-valid-p cache-file journelly-weather-cache-timeout))
170        ;; Return cached data
171        (journelly--read-cache cache-file)
172      ;; Fetch fresh data
173      (let* ((url (if (string-empty-p loc)
174                     "https://wttr.in/?format=j1"
175                   (format "https://wttr.in/%s?format=j1" (url-hexify-string loc))))
176             (response (journelly--fetch-url url))
177             (current (aref (cdr (assoc 'current_condition response)) 0))
178             (temp-c (cdr (assoc 'temp_C current)))
179             (weather-desc-array (cdr (assoc 'weatherDesc current)))
180             (weather-desc (cdr (assoc 'value (aref weather-desc-array 0))))
181             (temperature (format "%s°C" temp-c))
182             (is-night (journelly--is-night-p))
183             (symbol (journelly--map-weather-symbol weather-desc is-night))
184             (data `((temperature . ,temperature)
185                    (condition . ,weather-desc)
186                    (symbol . ,symbol))))
187        ;; Cache the result
188        (journelly--write-cache cache-file data)
189        data))))
190
191;;; Batch mode functions
192
193(defun journelly-batch-get-location (&optional format no-cache)
194  "Batch mode: Get location and print to stdout.
195FORMAT can be: json (default), city, coords, lat, lon, or all.
196If NO-CACHE is non-nil, ignore cache."
197  (let* ((format-type (or format "json"))
198         (data (journelly-get-location no-cache))
199         (city (cdr (assoc 'city data)))
200         (lat (cdr (assoc 'lat data)))
201         (lon (cdr (assoc 'lon data))))
202    (cond
203     ((string= format-type "json")
204      (princ (json-encode data))
205      (terpri))
206     ((string= format-type "city")
207      (princ city)
208      (terpri))
209     ((string= format-type "coords")
210      (princ (format "%s,%s" lat lon))
211      (terpri))
212     ((string= format-type "lat")
213      (princ lat)
214      (terpri))
215     ((string= format-type "lon")
216      (princ lon)
217      (terpri))
218     ((string= format-type "all")
219      (princ (format "%s (%s,%s)" city lat lon))
220      (terpri))
221     (t
222      (error "Unknown format: %s" format-type)))))
223
224(defun journelly-batch-get-weather (&optional location format no-cache)
225  "Batch mode: Get weather and print to stdout.
226LOCATION is optional city name or coordinates.
227FORMAT can be: json (default), temperature, condition, symbol, or all.
228If NO-CACHE is non-nil, ignore cache."
229  (let* ((format-type (or format "json"))
230         (data (journelly-get-weather location no-cache))
231         (temperature (cdr (assoc 'temperature data)))
232         (condition (cdr (assoc 'condition data)))
233         (symbol (cdr (assoc 'symbol data))))
234    (cond
235     ((string= format-type "json")
236      (princ (json-encode data))
237      (terpri))
238     ((string= format-type "temperature")
239      (princ temperature)
240      (terpri))
241     ((string= format-type "condition")
242      (princ condition)
243      (terpri))
244     ((string= format-type "symbol")
245      (princ symbol)
246      (terpri))
247     ((string= format-type "all")
248      (princ (format "%s %s (%s)" temperature condition symbol))
249      (terpri))
250     (t
251      (error "Unknown format: %s" format-type)))))
252
253;;; Provide
254
255(provide 'journelly-location-weather)
256
257;;; journelly-location-weather.el ends here