diff options
| -rw-r--r-- | README.org | 19 | ||||
| -rw-r--r-- | tests/test-wttrin-geolocation--parse-ipapi.el | 83 | ||||
| -rw-r--r-- | tests/test-wttrin-geolocation--parse-ipinfo.el | 61 | ||||
| -rw-r--r-- | tests/test-wttrin-geolocation--parse-ipwhois.el | 63 | ||||
| -rw-r--r-- | tests/test-wttrin-geolocation-detect.el | 179 | ||||
| -rw-r--r-- | tests/test-wttrin-set-location-from-geolocation.el | 122 | ||||
| -rw-r--r-- | wttrin-geolocation.el | 190 | ||||
| -rw-r--r-- | wttrin.el | 34 |
8 files changed, 751 insertions, 0 deletions
@@ -233,6 +233,25 @@ If a refresh fails, the emoji dims to gray and the tooltip tells you what went w *Note:* If the weather emoji appears as a monochrome symbol instead of a color icon, try setting `wttrin-mode-line-emoji-font` to match a color emoji font installed on your system. Use `M-x fc-list` or check your system fonts to see what's available. +*** Setting the Favorite Location from IP Geolocation +If you don't want to type your city by hand, wttrin can detect it for you: + +#+begin_src emacs-lisp + M-x wttrin-set-location-from-geolocation +#+end_src + +This looks up your city via IP geolocation, shows the detected location, and sets =wttrin-favorite-location= after you confirm. To make the setting persist across Emacs sessions, run =M-x customize-save-variable RET wttrin-favorite-location RET=, or add =(setq wttrin-favorite-location "Your City, State")= to your init file. + +The default lookup provider is =ipapi.co=. Two alternatives ship with the package, both free and key-less: + +#+begin_src emacs-lisp + (setq wttrin-geolocation-provider 'ipapi) ;; ipapi.co (default, 30k/month) + (setq wttrin-geolocation-provider 'ipinfo) ;; ipinfo.io (50k/month) + (setq wttrin-geolocation-provider 'ipwhois) ;; ipwho.is (10k/month) +#+end_src + +*Note:* IP-based geolocation can be wrong when you are behind a VPN or using a mobile hotspot. The confirmation prompt lets you reject an inaccurate result. If you prefer, set =wttrin-favorite-location= directly to any city string that wttr.in understands. + ** Debugging and Troubleshooting If something isn't working, debug mode logs every fetch, every display update, and every error. diff --git a/tests/test-wttrin-geolocation--parse-ipapi.el b/tests/test-wttrin-geolocation--parse-ipapi.el new file mode 100644 index 0000000..26d67bc --- /dev/null +++ b/tests/test-wttrin-geolocation--parse-ipapi.el @@ -0,0 +1,83 @@ +;;; test-wttrin-geolocation--parse-ipapi.el --- Tests for ipapi.co response parser -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: +;; Unit tests for `wttrin-geolocation--parse-ipapi'. Pure function — no +;; network, no async. Covers normal, boundary, and error cases per the +;; Normal/Boundary/Error discipline. + +;;; Code: + +(require 'ert) +(require 'wttrin-geolocation) + +;;; Setup and Teardown + +(defun test-wttrin-geolocation--parse-ipapi-setup () + "Setup for ipapi parser tests." + nil) + +(defun test-wttrin-geolocation--parse-ipapi-teardown () + "Teardown for ipapi parser tests." + nil) + +;;; Normal Cases + +(ert-deftest test-wttrin-geolocation--parse-ipapi-normal-full-response-returns-formatted-string () + "A full ipapi.co response yields \"City, Region\"." + (let ((json "{\"ip\":\"1.2.3.4\",\"city\":\"Berkeley\",\"region\":\"California\",\"country_name\":\"United States\",\"latitude\":37.87,\"longitude\":-122.27}")) + (should (string= "Berkeley, California" + (wttrin-geolocation--parse-ipapi json))))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-normal-unicode-preserved () + "Unicode city and region names round-trip through the parser." + (let ((json "{\"city\":\"São Paulo\",\"region\":\"São Paulo\"}")) + (should (string= "São Paulo, São Paulo" + (wttrin-geolocation--parse-ipapi json))))) + +;;; Boundary Cases + +(ert-deftest test-wttrin-geolocation--parse-ipapi-boundary-extra-fields-ignored () + "Unknown fields in the response do not affect the parsed result." + (let ((json "{\"city\":\"Paris\",\"region\":\"Ile-de-France\",\"currency\":\"EUR\",\"asn\":\"AS1234\"}")) + (should (string= "Paris, Ile-de-France" + (wttrin-geolocation--parse-ipapi json))))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-boundary-multi-word-city-preserved () + "Multi-word city names are preserved verbatim." + (let ((json "{\"city\":\"New Orleans\",\"region\":\"Louisiana\"}")) + (should (string= "New Orleans, Louisiana" + (wttrin-geolocation--parse-ipapi json))))) + +;;; Error Cases + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-nil-input-returns-nil () + "A nil input string returns nil." + (should-not (wttrin-geolocation--parse-ipapi nil))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-empty-string-returns-nil () + "An empty input string returns nil." + (should-not (wttrin-geolocation--parse-ipapi ""))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-malformed-json-returns-nil () + "Malformed JSON returns nil rather than signalling." + (should-not (wttrin-geolocation--parse-ipapi "{not valid json"))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-missing-city-returns-nil () + "A response without a city field returns nil." + (let ((json "{\"region\":\"California\"}")) + (should-not (wttrin-geolocation--parse-ipapi json)))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-missing-region-returns-nil () + "A response without a region field returns nil." + (let ((json "{\"city\":\"Berkeley\"}")) + (should-not (wttrin-geolocation--parse-ipapi json)))) + +(ert-deftest test-wttrin-geolocation--parse-ipapi-error-api-error-flag-returns-nil () + "An ipapi rate-limit / error response returns nil even if city/region are absent." + (let ((json "{\"error\":true,\"reason\":\"RateLimited\",\"message\":\"wait\"}")) + (should-not (wttrin-geolocation--parse-ipapi json)))) + +(provide 'test-wttrin-geolocation--parse-ipapi) +;;; test-wttrin-geolocation--parse-ipapi.el ends here diff --git a/tests/test-wttrin-geolocation--parse-ipinfo.el b/tests/test-wttrin-geolocation--parse-ipinfo.el new file mode 100644 index 0000000..4543e32 --- /dev/null +++ b/tests/test-wttrin-geolocation--parse-ipinfo.el @@ -0,0 +1,61 @@ +;;; test-wttrin-geolocation--parse-ipinfo.el --- Tests for ipinfo.io response parser -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: +;; Unit tests for `wttrin-geolocation--parse-ipinfo'. Pure function — no +;; network, no async. + +;;; Code: + +(require 'ert) +(require 'wttrin-geolocation) + +;;; Setup and Teardown + +(defun test-wttrin-geolocation--parse-ipinfo-setup () + "Setup for ipinfo parser tests." + nil) + +(defun test-wttrin-geolocation--parse-ipinfo-teardown () + "Teardown for ipinfo parser tests." + nil) + +;;; Normal Cases + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-normal-full-response-returns-formatted-string () + "A full ipinfo.io response yields \"City, Region\"." + (let ((json "{\"ip\":\"8.8.8.8\",\"city\":\"Mountain View\",\"region\":\"California\",\"country\":\"US\",\"loc\":\"37.3860,-122.0838\",\"org\":\"AS15169 Google LLC\",\"postal\":\"94035\",\"timezone\":\"America/Los_Angeles\"}")) + (should (string= "Mountain View, California" + (wttrin-geolocation--parse-ipinfo json))))) + +;;; Boundary Cases + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-boundary-minimal-fields-works () + "A response with only city and region parses correctly." + (let ((json "{\"city\":\"Berlin\",\"region\":\"Berlin\"}")) + (should (string= "Berlin, Berlin" + (wttrin-geolocation--parse-ipinfo json))))) + +;;; Error Cases + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-nil-input-returns-nil () + "A nil input string returns nil." + (should-not (wttrin-geolocation--parse-ipinfo nil))) + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-malformed-json-returns-nil () + "Malformed JSON returns nil rather than signalling." + (should-not (wttrin-geolocation--parse-ipinfo "not json at all"))) + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-missing-city-returns-nil () + "A response without a city field returns nil." + (let ((json "{\"region\":\"California\",\"country\":\"US\"}")) + (should-not (wttrin-geolocation--parse-ipinfo json)))) + +(ert-deftest test-wttrin-geolocation--parse-ipinfo-error-missing-region-returns-nil () + "A response without a region field returns nil." + (let ((json "{\"city\":\"Mountain View\",\"country\":\"US\"}")) + (should-not (wttrin-geolocation--parse-ipinfo json)))) + +(provide 'test-wttrin-geolocation--parse-ipinfo) +;;; test-wttrin-geolocation--parse-ipinfo.el ends here diff --git a/tests/test-wttrin-geolocation--parse-ipwhois.el b/tests/test-wttrin-geolocation--parse-ipwhois.el new file mode 100644 index 0000000..14c8119 --- /dev/null +++ b/tests/test-wttrin-geolocation--parse-ipwhois.el @@ -0,0 +1,63 @@ +;;; test-wttrin-geolocation--parse-ipwhois.el --- Tests for ipwho.is response parser -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: +;; Unit tests for `wttrin-geolocation--parse-ipwhois'. Pure function — no +;; network, no async. The ipwho.is response carries a `success' flag which +;; is false on rate-limit / lookup failure even when the response is +;; well-formed JSON. + +;;; Code: + +(require 'ert) +(require 'wttrin-geolocation) + +;;; Setup and Teardown + +(defun test-wttrin-geolocation--parse-ipwhois-setup () + "Setup for ipwho.is parser tests." + nil) + +(defun test-wttrin-geolocation--parse-ipwhois-teardown () + "Teardown for ipwho.is parser tests." + nil) + +;;; Normal Cases + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-normal-success-response-returns-formatted-string () + "A successful ipwho.is response yields \"City, Region\"." + (let ((json "{\"ip\":\"8.8.8.8\",\"success\":true,\"type\":\"IPv4\",\"country\":\"United States\",\"country_code\":\"US\",\"region\":\"California\",\"region_code\":\"CA\",\"city\":\"Mountain View\",\"latitude\":37.386,\"longitude\":-122.0838}")) + (should (string= "Mountain View, California" + (wttrin-geolocation--parse-ipwhois json))))) + +;;; Boundary Cases + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-boundary-unicode-preserved () + "Unicode city and region names round-trip through the parser." + (let ((json "{\"success\":true,\"city\":\"München\",\"region\":\"Bayern\"}")) + (should (string= "München, Bayern" + (wttrin-geolocation--parse-ipwhois json))))) + +;;; Error Cases + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-nil-input-returns-nil () + "A nil input string returns nil." + (should-not (wttrin-geolocation--parse-ipwhois nil))) + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-success-false-returns-nil () + "A response with `success: false' returns nil even if city/region are present." + (let ((json "{\"success\":false,\"message\":\"rate limit reached\",\"city\":\"Anywhere\",\"region\":\"Anywhere\"}")) + (should-not (wttrin-geolocation--parse-ipwhois json)))) + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-missing-city-returns-nil () + "A success response without a city field returns nil." + (let ((json "{\"success\":true,\"region\":\"California\"}")) + (should-not (wttrin-geolocation--parse-ipwhois json)))) + +(ert-deftest test-wttrin-geolocation--parse-ipwhois-error-malformed-json-returns-nil () + "Malformed JSON returns nil rather than signalling." + (should-not (wttrin-geolocation--parse-ipwhois "{\"success\":true,\"city\":"))) + +(provide 'test-wttrin-geolocation--parse-ipwhois) +;;; test-wttrin-geolocation--parse-ipwhois.el ends here diff --git a/tests/test-wttrin-geolocation-detect.el b/tests/test-wttrin-geolocation-detect.el new file mode 100644 index 0000000..ccd113f --- /dev/null +++ b/tests/test-wttrin-geolocation-detect.el @@ -0,0 +1,179 @@ +;;; test-wttrin-geolocation-detect.el --- Tests for wttrin-geolocation-detect -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: +;; Unit tests for `wttrin-geolocation-detect'. Mocks `url-retrieve' at +;; the boundary — exercises the real extract/parse logic via the selected +;; provider. No network, no async timing. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'wttrin-geolocation) + +;;; Setup and Teardown + +(defun test-wttrin-geolocation-detect-setup () + "Setup for detect tests — pin provider to ipapi for determinism." + (setq wttrin-geolocation-provider 'ipapi)) + +(defun test-wttrin-geolocation-detect-teardown () + "Teardown for detect tests — restore default provider." + (setq wttrin-geolocation-provider 'ipapi)) + +;;; Helpers + +(defmacro test-wttrin-geolocation-detect--with-http (status body-string &rest body) + "Run BODY with `url-retrieve' mocked to return HTTP STATUS and BODY-STRING. +The mock writes a full HTTP response into a temp buffer and invokes the +retrieval callback with a nil status plist (success)." + (declare (indent 2)) + `(cl-letf (((symbol-function 'url-retrieve) + (lambda (_url callback) + (with-temp-buffer + (insert (format "HTTP/1.1 %d OK\r\n" ,status)) + (insert "Content-Type: application/json\r\n\r\n") + (insert ,body-string) + (funcall callback nil))))) + ,@body)) + +(defmacro test-wttrin-geolocation-detect--with-network-error (&rest body) + "Run BODY with `url-retrieve' mocked to report a network-level error." + (declare (indent 0)) + `(cl-letf (((symbol-function 'url-retrieve) + (lambda (_url callback) + (with-temp-buffer + (funcall callback '(:error (error "Network unreachable"))))))) + ,@body)) + +;;; Normal Cases + +(ert-deftest test-wttrin-geolocation-detect-normal-ipapi-success-callback-receives-location () + "A successful ipapi response leads to the callback receiving \"City, Region\"." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 200 + "{\"city\":\"Berkeley\",\"region\":\"California\"}" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should (string= "Berkeley, California" result))) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-normal-selected-provider-used () + "Switching `wttrin-geolocation-provider' routes through that provider's URL and parser." + (let ((wttrin-geolocation-provider 'ipinfo) + (requested-url nil) + (result nil)) + (cl-letf (((symbol-function 'url-retrieve) + (lambda (url callback) + (setq requested-url url) + (with-temp-buffer + (insert "HTTP/1.1 200 OK\r\n\r\n") + (insert "{\"city\":\"Mountain View\",\"region\":\"California\",\"loc\":\"37.4,-122.0\"}") + (funcall callback nil))))) + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should (string= "https://ipinfo.io/json" requested-url)) + (should (string= "Mountain View, California" result)))) + +;;; Boundary Cases + +(ert-deftest test-wttrin-geolocation-detect-boundary-parser-returns-nil-callback-gets-nil () + "If the parser rejects the response (missing fields), the callback receives nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 200 + "{\"city\":\"Berkeley\"}" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-boundary-ipapi-rate-limit-error-flag-returns-nil () + "An ipapi rate-limit response (HTTP 200 with error flag) yields nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 200 + "{\"error\":true,\"reason\":\"RateLimited\"}" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +;;; Error Cases + +(ert-deftest test-wttrin-geolocation-detect-error-network-failure-calls-callback-with-nil () + "A network-level error surfaces as nil to the callback." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((called nil) + (result 'unset)) + (test-wttrin-geolocation-detect--with-network-error + (wttrin-geolocation-detect (lambda (loc) + (setq called t) + (setq result loc)))) + (should called) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-http-429-returns-nil () + "An HTTP 429 rate-limit response yields nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 429 + "{\"error\":\"rate limit\"}" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-http-500-returns-nil () + "An HTTP 500 server error yields nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 500 + "Internal Server Error" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-empty-body-returns-nil () + "An empty response body yields nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 200 "" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-malformed-json-returns-nil () + "Malformed JSON in the response body yields nil." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((result 'unset)) + (test-wttrin-geolocation-detect--with-http 200 "{not valid json" + (wttrin-geolocation-detect (lambda (loc) (setq result loc)))) + (should-not result)) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-buffer-cleanup-after-success () + "The response buffer is killed after a successful fetch (no leaks)." + (test-wttrin-geolocation-detect-setup) + (unwind-protect + (let ((buffers-before (length (buffer-list)))) + (test-wttrin-geolocation-detect--with-http 200 + "{\"city\":\"Paris\",\"region\":\"IDF\"}" + (wttrin-geolocation-detect #'ignore)) + (should (= buffers-before (length (buffer-list))))) + (test-wttrin-geolocation-detect-teardown))) + +(ert-deftest test-wttrin-geolocation-detect-error-unknown-provider-signals-error () + "Selecting an unknown provider signals a clear error." + (let ((wttrin-geolocation-provider 'nonexistent-provider)) + (should-error (wttrin-geolocation-detect #'ignore) :type 'error))) + +(provide 'test-wttrin-geolocation-detect) +;;; test-wttrin-geolocation-detect.el ends here diff --git a/tests/test-wttrin-set-location-from-geolocation.el b/tests/test-wttrin-set-location-from-geolocation.el new file mode 100644 index 0000000..170d0fb --- /dev/null +++ b/tests/test-wttrin-set-location-from-geolocation.el @@ -0,0 +1,122 @@ +;;; test-wttrin-set-location-from-geolocation.el --- Tests for wttrin-set-location-from-geolocation -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: +;; Unit tests for the interactive `wttrin-set-location-from-geolocation' +;; command. Mocks `wttrin-geolocation-detect' (to invoke the callback +;; synchronously with a chosen value) and `yes-or-no-p' (to simulate +;; user consent). Does not hit the network and does not prompt. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'wttrin) +(require 'wttrin-geolocation) + +;;; Setup and Teardown + +(defvar test-wttrin-set-location-from-geolocation--saved-favorite nil + "Snapshot of `wttrin-favorite-location' restored in teardown.") + +(defun test-wttrin-set-location-from-geolocation-setup () + "Snapshot `wttrin-favorite-location' and clear it for the test." + (setq test-wttrin-set-location-from-geolocation--saved-favorite + wttrin-favorite-location) + (setq wttrin-favorite-location nil)) + +(defun test-wttrin-set-location-from-geolocation-teardown () + "Restore `wttrin-favorite-location' to its pre-test value." + (setq wttrin-favorite-location + test-wttrin-set-location-from-geolocation--saved-favorite)) + +;;; Helpers + +(defmacro test-wttrin-set-location--with-detected (location confirm &rest body) + "Run BODY with `wttrin-geolocation-detect' returning LOCATION and `yes-or-no-p' returning CONFIRM." + (declare (indent 2)) + `(cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (callback) (funcall callback ,location))) + ((symbol-function 'yes-or-no-p) + (lambda (&rest _) ,confirm))) + ,@body)) + +;;; Normal Cases + +(ert-deftest test-wttrin-set-location-from-geolocation-normal-confirm-sets-variable () + "Successful detection followed by user confirmation sets the favorite." + (test-wttrin-set-location-from-geolocation-setup) + (unwind-protect + (progn + (test-wttrin-set-location--with-detected "Berkeley, California" t + (wttrin-set-location-from-geolocation)) + (should (string= "Berkeley, California" wttrin-favorite-location))) + (test-wttrin-set-location-from-geolocation-teardown))) + +(ert-deftest test-wttrin-set-location-from-geolocation-normal-decline-leaves-variable-unchanged () + "Successful detection followed by user declining leaves the favorite untouched." + (test-wttrin-set-location-from-geolocation-setup) + (setq wttrin-favorite-location "Pre-existing, Place") + (unwind-protect + (progn + (test-wttrin-set-location--with-detected "Berkeley, California" nil + (wttrin-set-location-from-geolocation)) + (should (string= "Pre-existing, Place" wttrin-favorite-location))) + (test-wttrin-set-location-from-geolocation-teardown))) + +;;; Boundary Cases + +(ert-deftest test-wttrin-set-location-from-geolocation-boundary-unicode-location () + "A Unicode location string round-trips into the favorite variable." + (test-wttrin-set-location-from-geolocation-setup) + (unwind-protect + (progn + (test-wttrin-set-location--with-detected "München, Bayern" t + (wttrin-set-location-from-geolocation)) + (should (string= "München, Bayern" wttrin-favorite-location))) + (test-wttrin-set-location-from-geolocation-teardown))) + +;;; Error Cases + +(ert-deftest test-wttrin-set-location-from-geolocation-error-nil-detection-leaves-variable-unchanged () + "When detection returns nil, the favorite variable is not modified." + (test-wttrin-set-location-from-geolocation-setup) + (setq wttrin-favorite-location "Pre-existing, Place") + (unwind-protect + (progn + (test-wttrin-set-location--with-detected nil t + (wttrin-set-location-from-geolocation)) + (should (string= "Pre-existing, Place" wttrin-favorite-location))) + (test-wttrin-set-location-from-geolocation-teardown))) + +(ert-deftest test-wttrin-set-location-from-geolocation-error-nil-detection-does-not-prompt () + "When detection returns nil, the user is not prompted for confirmation." + (test-wttrin-set-location-from-geolocation-setup) + (unwind-protect + (let ((prompt-called nil)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (callback) (funcall callback nil))) + ((symbol-function 'yes-or-no-p) + (lambda (&rest _) (setq prompt-called t) t))) + (wttrin-set-location-from-geolocation)) + (should-not prompt-called)) + (test-wttrin-set-location-from-geolocation-teardown))) + +(ert-deftest test-wttrin-set-location-from-geolocation-error-detection-failure-shows-message () + "When detection returns nil, the user sees a diagnostic message." + (test-wttrin-set-location-from-geolocation-setup) + (unwind-protect + (let ((messages nil)) + (cl-letf (((symbol-function 'wttrin-geolocation-detect) + (lambda (callback) (funcall callback nil))) + ((symbol-function 'message) + (lambda (fmt &rest args) + (push (apply #'format fmt args) messages)))) + (wttrin-set-location-from-geolocation)) + (should (cl-some (lambda (m) (string-match-p "[Cc]ould not detect" m)) + messages))) + (test-wttrin-set-location-from-geolocation-teardown))) + +(provide 'test-wttrin-set-location-from-geolocation) +;;; test-wttrin-set-location-from-geolocation.el ends here diff --git a/wttrin-geolocation.el b/wttrin-geolocation.el new file mode 100644 index 0000000..03b573a --- /dev/null +++ b/wttrin-geolocation.el @@ -0,0 +1,190 @@ +;;; wttrin-geolocation.el --- IP geolocation for wttrin -*- lexical-binding: t; coding: utf-8; -*- +;; +;; Copyright (C) 2026 Craig Jennings +;; Maintainer: Craig Jennings <c@cjennings.net> +;; Version: 0.3.1 +;; Package-Requires: ((emacs "24.4")) +;; Keywords: weather, wttrin + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <https://www.gnu.org/licenses/>. + +;; This file is NOT part of GNU Emacs. + +;;; Commentary: + +;; IP-based geolocation support for wttrin. Three built-in providers ship +;; with the package: ipapi.co (default), ipinfo.io, and ipwho.is. All three +;; are HTTPS, keyless, and have generous free tiers. +;; +;; Users select a provider via `wttrin-geolocation-provider' or register +;; custom providers by pushing onto `wttrin-geolocation--providers'. +;; +;; Each provider is a plist with keys: +;; :name - Human-readable name (string) +;; :url - JSON endpoint (string) +;; :parser - Function symbol taking a JSON string and returning +;; "City, Region" or nil on any failure. +;; +;; The public entry point is `wttrin-geolocation-detect', which fetches +;; asynchronously and invokes a callback with the parsed location string +;; or nil on failure. + +;;; Code: + +(require 'json) +(require 'url) + +(defgroup wttrin-geolocation nil + "IP geolocation settings for wttrin." + :prefix "wttrin-geolocation-" + :group 'wttrin) + +(defcustom wttrin-geolocation-provider 'ipapi + "Provider used by `wttrin-geolocation-detect'. +The value is a key into `wttrin-geolocation--providers'. Three +providers ship with the package: `ipapi' (ipapi.co, the default), +`ipinfo' (ipinfo.io), and `ipwhois' (ipwho.is). Users who register +additional providers by pushing onto `wttrin-geolocation--providers' +can select them here." + :group 'wttrin-geolocation + :type '(choice (const :tag "ipapi.co" ipapi) + (const :tag "ipinfo.io" ipinfo) + (const :tag "ipwho.is" ipwhois) + (symbol :tag "Other (registered in wttrin-geolocation--providers)"))) + +;;; Response Parsers +;; +;; Each parser takes a raw JSON string and returns "City, Region" or nil. +;; They differ only in which quirks of the upstream response they have to +;; handle (error flags, success flags) before extracting city and region. + +(defun wttrin-geolocation--decode-json (json-string) + "Parse JSON-STRING into an alist. +Return nil for a nil input, an empty string, or malformed JSON." + (when (and (stringp json-string) (> (length json-string) 0)) + (condition-case nil + (let ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol)) + (json-read-from-string json-string)) + (error nil)))) + +(defun wttrin-geolocation--format-city-region (data) + "Return \"City, Region\" built from DATA alist. +Return nil if either the city or the region field is missing or empty." + (let ((city (cdr (assq 'city data))) + (region (cdr (assq 'region data)))) + (when (and (stringp city) (> (length city) 0) + (stringp region) (> (length region) 0)) + (format "%s, %s" city region)))) + +(defun wttrin-geolocation--parse-ipapi (json-string) + "Parse an ipapi.co JSON response into a \"City, Region\" string. +Return nil on malformed JSON, missing city or region, or an ipapi +error response (which carries an \"error\": true field on rate-limit +or other failures)." + (let ((data (wttrin-geolocation--decode-json json-string))) + (when (and data (not (eq t (cdr (assq 'error data))))) + (wttrin-geolocation--format-city-region data)))) + +(defun wttrin-geolocation--parse-ipinfo (json-string) + "Parse an ipinfo.io JSON response into a \"City, Region\" string. +Return nil on malformed JSON or missing city or region." + (let ((data (wttrin-geolocation--decode-json json-string))) + (when data + (wttrin-geolocation--format-city-region data)))) + +(defun wttrin-geolocation--parse-ipwhois (json-string) + "Parse an ipwho.is JSON response into a \"City, Region\" string. +Return nil on malformed JSON, missing city or region, or a +`success: false' response (which ipwho.is uses to signal rate-limit +or lookup failure even when the HTTP response itself is 200)." + (let ((data (wttrin-geolocation--decode-json json-string))) + (when (and data (eq t (cdr (assq 'success data)))) + (wttrin-geolocation--format-city-region data)))) + +;;; Provider Registry + +(defvar wttrin-geolocation--providers + '((ipapi + :name "ipapi.co" + :url "https://ipapi.co/json/" + :parser wttrin-geolocation--parse-ipapi) + (ipinfo + :name "ipinfo.io" + :url "https://ipinfo.io/json" + :parser wttrin-geolocation--parse-ipinfo) + (ipwhois + :name "ipwho.is" + :url "https://ipwho.is/" + :parser wttrin-geolocation--parse-ipwhois)) + "Alist of geolocation providers keyed by symbol. +Each entry is a plist with keys :name, :url, and :parser. The +parser is a function symbol taking a JSON string and returning +\"City, Region\" or nil. Users may register additional providers +by pushing onto this list; the keys become valid values for +`wttrin-geolocation-provider'.") + +(defun wttrin-geolocation--lookup-provider (symbol) + "Return the provider plist for SYMBOL. +Signal an error if SYMBOL is not registered." + (or (cdr (assq symbol wttrin-geolocation--providers)) + (error "Unknown wttrin-geolocation provider: %S" symbol))) + +;;; Fetch and Detect + +(defun wttrin-geolocation--extract-body () + "Return the UTF-8 decoded HTTP body from the current buffer. +Return nil for non-2xx status codes or when no body separator is +found. Intended for use inside a `url-retrieve' callback." + (goto-char (point-min)) + (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) + (let ((status (string-to-number (match-string 1)))) + (when (and (>= status 200) (< status 300)) + (goto-char (point-min)) + (when (re-search-forward "\r?\n\r?\n" nil t) + (decode-coding-string + (buffer-substring-no-properties (point) (point-max)) + 'utf-8)))))) + +(defun wttrin-geolocation-detect (callback) + "Detect current location via the configured geolocation provider. +CALLBACK is invoked asynchronously with a single argument: a +\"City, Region\" string on success, or nil on any failure (network +error, HTTP 4xx or 5xx, malformed response, missing fields, or +provider-specific rate-limit signals). + +The provider is selected by `wttrin-geolocation-provider'. Signals +an error synchronously if that value is not registered in +`wttrin-geolocation--providers'." + (let* ((provider (wttrin-geolocation--lookup-provider + wttrin-geolocation-provider)) + (url (plist-get provider :url)) + (parser (plist-get provider :parser))) + (url-retrieve + url + (lambda (status) + (let ((result nil)) + (unless (plist-get status :error) + (condition-case nil + (let ((body (wttrin-geolocation--extract-body))) + (setq result (and body (funcall parser body)))) + (error nil))) + (ignore-errors (kill-buffer (current-buffer))) + (funcall callback result)))))) + +(provide 'wttrin-geolocation) +;;; wttrin-geolocation.el ends here @@ -39,6 +39,10 @@ ;; Declare xterm-color functions (loaded on-demand) (declare-function xterm-color-filter "xterm-color" (string)) +;; Declare geolocation entry point (loaded on-demand by +;; `wttrin-set-location-from-geolocation') +(declare-function wttrin-geolocation-detect "wttrin-geolocation" (callback)) + ;; No-op stubs for debug functions (overridden when wttrin-debug.el is loaded) (defun wttrin--debug-mode-line-info () "No-op stub. Replaced by `wttrin-debug' when debug mode is active." @@ -539,6 +543,36 @@ This creates headroom to avoid frequent cleanups." (clrhash wttrin--cache) (message "Weather cache cleared")) +;;;###autoload +(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' for this session. + +To persist the setting across Emacs sessions, either run +\\[customize-save-variable] on `wttrin-favorite-location', or add +\(setq wttrin-favorite-location ...\) to your init file. + +IP-based geolocation can be wrong behind a VPN or a mobile hotspot. +The confirmation prompt shows the detected location so you can +reject inaccurate results." + (interactive) + (require 'wttrin-geolocation) + (message "Detecting location...") + (wttrin-geolocation-detect + (lambda (location) + (cond + ((null location) + (message "Could not detect location (network or provider error)")) + ((yes-or-no-p (format "Detected location: %s. Set as favorite? " + location)) + (setq wttrin-favorite-location location) + (message "Set wttrin-favorite-location to: %s. Run M-x customize-save-variable to persist." + location)) + (t + (message "Location detection cancelled")))))) + (defvar-local wttrin--current-location nil "Current location displayed in this weather buffer.") |
