auto-update-daily-20260202
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