;;; wttrin.el --- Emacs Frontend for Service wttr.in -*- lexical-binding: t; coding: utf-8; -*- ;; ;; Copyright (C) 2024 Craig Jennings ;; Maintainer: Craig Jennings ;; ;; Original Authors: Carl X. Su ;; ono hiroko (kuanyui) ;; Version: 0.2.3 ;; Package-Requires: ((emacs "24.4") (xterm-color "1.0")) ;; Keywords: weather, wttrin, games ;; URL: https://github.com/cjennings/emacs-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 . ;; This file is NOT part of GNU Emacs. ;;; Commentary: ;; Displays the weather information from the wttr.in service for your submitted ;; location. ;;; Code: (require 'face-remap) (require 'url) (require 'xterm-color) ;; https://github.com/atomontage/xterm-color (defgroup wttrin nil "Emacs frontend for the weather web service wttr.in." :prefix "wttrin-" :group 'comm) (defcustom wttrin-font-name "Liberation Mono" "Preferred monospaced font name for weather display." :group 'wttrin :type 'string) (defcustom wttrin-font-height 130 "Preferred font height for weather display." :group 'wttrin :type 'integer) (defcustom wttrin-default-locations '("Honolulu, HI" "Berkeley, CA" "New Orleans, LA" "New York, NY" "London, GB" "Paris, FR" "Berlin, DE" "Naples, IT" "Athens, GR" "Kyiv, UA" "Tokyo, JP" "Taipei, TW") "Specify default locations list for quick completion." :group 'wttrin :type '(repeat string)) (defcustom wttrin-default-languages '("Accept-Language" . "en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4") "Specify default HTTP request Header for Accept-Language." :group 'wttrin :type '(cons (string :tag "Header") (string :tag "Language codes"))) (defcustom wttrin-unit-system nil "Specify units of measurement. Use \='m\=' for \='metric\=', \='u\=' for \='USCS\=', or nil for location based units (default)." :group 'wttrin :type 'string) (defcustom wttrin-cache-ttl 900 ; 15 minutes "Time to live for cached weather data in seconds." :group 'wttrin :type 'integer) (defcustom wttrin-cache-max-entries 50 "Maximum number of entries to keep in cache." :group 'wttrin :type 'integer) (defcustom wttrin-debug nil "If non-nil, save raw weather data to timestamped files for debugging. Raw data files are saved to `temporary-file-directory' with names like wttrin-debug-YYYYMMDD-HHMMSS.txt for bug reports." :group 'wttrin :type 'boolean) (defvar wttrin--cache (make-hash-table :test 'equal) "Cache for weather data: cache-key -> (timestamp . data).") (defvar wttrin--force-refresh nil "When non-nil, bypass cache on next fetch.") (defun wttrin-additional-url-params () "Concatenates extra information into the URL." (if wttrin-unit-system (concat "?" wttrin-unit-system) "?")) (defun wttrin--build-url (query) "Build wttr.in URL for QUERY with configured parameters. This is a pure function with no side effects, suitable for testing." (when (null query) (error "Query cannot be nil")) (concat "https://wttr.in/" (url-hexify-string query) (wttrin-additional-url-params) "A")) (defun wttrin-fetch-raw-string (query callback) "Asynchronously fetch weather information for QUERY. CALLBACK is called with the weather data string when ready, or nil on error." (let* ((url (wttrin--build-url query)) (url-request-extra-headers (list wttrin-default-languages)) (url-user-agent "curl")) (url-retrieve url (lambda (status) (let ((data nil)) (condition-case err (if (plist-get status :error) (progn (message "wttrin: Network error - %s" (cdr (plist-get status :error))) (setq data nil)) (unwind-protect (progn ;; Skip HTTP headers (goto-char (point-min)) (re-search-forward "\r?\n\r?\n" nil t) (setq data (decode-coding-string (buffer-substring-no-properties (point) (point-max)) 'utf-8))) (kill-buffer (current-buffer)))) (error (message "wttrin: Error processing response - %s" (error-message-string err)) (setq data nil))) (funcall callback data)))))) (defun wttrin-exit () "Exit the wttrin buffer." (interactive) (quit-window t)) (defun wttrin-requery () "Kill buffer and requery wttrin." (interactive) (let ((new-location (completing-read "Location Name: " wttrin-default-locations nil nil (when (= (length wttrin-default-locations) 1) (car wttrin-default-locations))))) (when (get-buffer "*wttr.in*") (kill-buffer "*wttr.in*")) (wttrin-query new-location))) (defvar wttrin-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "g") 'wttrin-requery) (define-key map (kbd "r") 'wttrin-requery-force) ;; Note: 'q' is bound to quit-window by special-mode map) "Keymap for wttrin-mode.") (define-derived-mode wttrin-mode special-mode "⛅" "Major mode for displaying wttr.in weather information. Weather data is displayed in a read-only buffer with the following keybindings: \\{wttrin-mode-map}" (buffer-disable-undo) (setq buffer-face-mode-face `(:family ,wttrin-font-name :height ,wttrin-font-height)) (buffer-face-mode t)) (defun wttrin--save-debug-data (location-name raw-string) "Save RAW-STRING to a timestamped debug file for LOCATION-NAME. Returns the path to the saved file." (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S")) (filename (format "wttrin-debug-%s.txt" timestamp)) (filepath (expand-file-name filename temporary-file-directory))) (with-temp-file filepath (insert (format "Location: %s\n" location-name)) (insert (format "Timestamp: %s\n" (format-time-string "%Y-%m-%d %H:%M:%S"))) (insert (format "wttrin-unit-system: %s\n" wttrin-unit-system)) (insert "\n--- Raw Response ---\n\n") (insert raw-string)) (message "Debug data saved to: %s" filepath) filepath)) (defun wttrin--display-weather (location-name raw-string) "Display weather data RAW-STRING for LOCATION-NAME in weather buffer." ;; Save debug data if enabled (when wttrin-debug (wttrin--save-debug-data location-name raw-string)) (if (or (null raw-string) (string-match "ERROR" raw-string)) (message "Cannot retrieve weather data. Perhaps the location was misspelled?") (let ((buffer (get-buffer-create (format "*wttr.in*")))) (switch-to-buffer buffer) ;; Temporarily allow editing (in case mode is already active) (let ((inhibit-read-only t)) (erase-buffer) (insert (xterm-color-filter raw-string)) ;; Remove verbose Location: coordinate line (goto-char (point-min)) (while (re-search-forward "^\\s-*Location:.*\\[.*\\].*$" nil t) (delete-region (line-beginning-position) (1+ (line-end-position)))) ;; Add user instructions at the bottom (goto-char (point-max)) (insert "\n\nPress: [g] to query another location [r] to refresh [q] to quit") ;; align buffer to top (goto-char (point-min))) ;; Enable wttrin-mode (sets up keybindings, read-only, font, etc.) ;; Must be called before setting buffer-local variables (wttrin-mode) ;; Set location after mode initialization (mode calls kill-all-local-variables) (setq-local wttrin--current-location location-name)))) (defun wttrin-query (location-name) "Asynchronously query weather of LOCATION-NAME, display result when ready." (let ((buffer (get-buffer-create (format "*wttr.in*")))) (switch-to-buffer buffer) (setq buffer-read-only nil) (erase-buffer) (insert "Loading weather for " location-name "...") (setq buffer-read-only t) (wttrin--get-cached-or-fetch location-name (lambda (raw-string) (when (buffer-live-p buffer) (with-current-buffer buffer (wttrin--display-weather location-name raw-string))))))) (defun wttrin--make-cache-key (location) "Create cache key from LOCATION and current settings." (concat location "|" (or wttrin-unit-system "default"))) (defun wttrin--get-cached-or-fetch (location callback) "Asynchronously get cached weather for LOCATION or fetch if expired. CALLBACK is called with the weather data string when ready, or nil on error." (let* ((cache-key (wttrin--make-cache-key location)) (cached (gethash cache-key wttrin--cache)) (timestamp (car cached)) (data (cdr cached)) (now (float-time))) (if (and cached (< (- now timestamp) wttrin-cache-ttl) (not wttrin--force-refresh)) ;; Return cached data immediately (funcall callback data) ;; Fetch fresh data asynchronously (wttrin-fetch-raw-string location (lambda (fresh-data) (if fresh-data (progn (wttrin--cleanup-cache-if-needed) (puthash cache-key (cons now fresh-data) wttrin--cache) (funcall callback fresh-data)) ;; On error, return stale cache if available (if cached (progn (message "Failed to fetch new data, using cached version") (funcall callback data)) (funcall callback nil)))))))) (defun wttrin--cleanup-cache-if-needed () "Remove old entries if cache exceeds max size." (when (> (hash-table-count wttrin--cache) wttrin-cache-max-entries) (let ((entries nil)) (maphash (lambda (k v) (push (cons k (car v)) entries)) wttrin--cache) (setq entries (sort entries (lambda (a b) (< (cdr a) (cdr b))))) ;; Remove oldest 20% of entries (dotimes (_ (/ (length entries) 5)) (when entries (remhash (caar entries) wttrin--cache) (setq entries (cdr entries))))))) (defun wttrin-clear-cache () "Clear the weather cache." (interactive) (clrhash wttrin--cache) (message "Weather cache cleared")) (defvar-local wttrin--current-location nil "Current location displayed in this weather buffer.") (defun wttrin-requery-force () "Force refresh weather data for current location, bypassing cache." (interactive) (if wttrin--current-location (let ((wttrin--force-refresh t)) (message "Refreshing weather data...") (wttrin-query wttrin--current-location)) (message "No location to refresh"))) ;;;###autoload (defun wttrin (location) "Display weather information for LOCATION. Weather data is fetched asynchronously to avoid blocking Emacs." (interactive (list (completing-read "Location Name: " wttrin-default-locations nil nil (when (= (length wttrin-default-locations) 1) (car wttrin-default-locations))))) (wttrin-query location)) (provide 'wttrin) ;;; wttrin.el ends here