aboutsummaryrefslogtreecommitdiff
path: root/wttrin-geolocation.el
diff options
context:
space:
mode:
Diffstat (limited to 'wttrin-geolocation.el')
-rw-r--r--wttrin-geolocation.el190
1 files changed, 190 insertions, 0 deletions
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