;;; bom.el -- Australian weather forecast from the Bureau -*- lexical-binding: t -*- ;; Copyright (C) 2023 Free Software Foundation, Inc. ;; Author: Yuchen Pei ;; Package-Requires: ((emacs "28.2") (web-server "0.0.2")) ;; This file is part of bom.el. ;; bom.el is free software: you can redistribute it and/or modify it ;; under the terms of the GNU Affero General Public License as ;; published by the Free Software Foundation, either version 3 of the ;; License, or (at your option) any later version. ;; bom.el 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 Affero General ;; Public License for more details. ;; You should have received a copy of the GNU Affero General Public ;; License along with bom.el. If not, see . ;;; Commentary: ;; Australian weather forecast from the Bureau. A client that ;; downloads weather forecast and serves them using emacs-web-server. ;;; Code: (require 'hierarchy) (require 'web-server) (defvar bom-state-files '((act . "IDN11060") (nsw . "IDN11060") (nt . "IDD10207") (qld . "IDQ11295") (sa . "IDS10044") (tas . "IDT16710") (vic . "IDV10753") (wa . "IDW14199")) "Alist of states and territories and their corresponding filenames on the BOM FTP server, see .") (defun bom-api (state) "Get weather forecast data of STATE from the BOM FTP." (if-let ((filename (alist-get (intern (downcase state)) bom-state-files))) (with-current-buffer (find-file-noselect (format "/ftp:anonymous@ftp.bom.gov.au:/anon/gen/fwo/%s.xml" filename)) (libxml-parse-xml-region (point-min) (point-max))) (user-error "State %s not found" state))) (defun bom-get-areas (resp) "Given an API response RESP, get all areas." (dom-children (dom-by-tag resp 'forecast))) (defvar bom-areas nil "List of areas with forecasts.") (defvar bom-server nil "The server object.") (defvar bom-port 9000 "The port number of the server.") (defun bom-area-parent (area) "Search `bom-areas' for the parent of AREA. Used as parentfn for hierarchy." (let ((parent-aac (dom-attr area 'parent-aac))) (cl-find-if (lambda (area) (equal (dom-attr area 'aac) parent-aac)) bom-areas))) (defun bom-hyphenate-downcase (name) "Downcase NAME and replace all space with hyphens." (replace-regexp-in-string " " "-" (downcase name))) (defun bom-format-area (area level) "Format an AREA of LEVEL." (format "%s %s :PROPERTIES: :CUSTOM_ID: %s :END: %s " (make-string level ?*) (dom-attr area 'description) (bom-hyphenate-downcase (dom-attr area 'description)) (if (dom-children area) (concat "\n" (bom-format-forecasts (dom-children area))) ""))) (defun bom-area-lessp (a1 a2) "Compare two areas A1 and A2 based on their AAC identifier." (string-lessp (dom-attr a1 'aac) (dom-attr a2 'aac))) (defun bom-format-forecasts (forecasts) "Format a list of FORECASTS." (mapconcat 'bom-format-forecast forecasts "\n")) (defun bom-format-forecast (forecast) "Format a FORECAST period." (let ((date (substring (dom-attr forecast 'start-time-local) 5 10)) (data (dom-children forecast)) (min-temp) (max-temp) (precis) (prec-prob)) (dolist (item data) (pcase (dom-attr item 'type) ("air_temperature_minimum" (setq min-temp (dom-text item))) ("air_temperature_maximum" (setq max-temp (dom-text item))) ("probability_of_precipitation" (setq prec-prob (dom-text item))) ("precis" (setq precis (dom-text item))))) (format "- %s :: %s %s %s" date prec-prob precis (bom-format-temp-range min-temp max-temp)))) (defun bom-format-temp-range (min-temp max-temp) "Format a temperature range from MIN-TEMP to MAX-TEMP." (if (or min-temp max-temp) (format "%s - %s" (if min-temp (concat min-temp "C") "") (if max-temp (concat max-temp "C") "")) "")) (defun bom-format-areas-org (areas) "Format hierarchy of AREAS with forecasts. We use the description of the first root as the state name." (let ((state-name (dom-attr (car (hierarchy-roots areas)) 'description))) (format "#+title: %s weather forecast\n\n%s" state-name (string-join (hierarchy-map 'bom-format-area areas 1) "\n")))) (defun bom-org-to-html (s) "Export org string S to html and return the html string." (with-temp-buffer (insert s) (let ((org-html-postamble)) (org-export-as 'html)))) (defun bom-format-areas-html (areas) "Format hierarchy of AREAS with forecasts to html." (bom-org-to-html (bom-format-areas-org areas))) (defun bom-get-forecasts (state) "Call BOM API and return hierarchy of STATE areas with forecasts." (let ((areas (hierarchy-new))) (hierarchy-add-trees areas (setq bom-areas (bom-get-areas (bom-api state))) 'bom-area-parent) (hierarchy-sort areas 'bom-area-lessp) areas)) (defun bom-format-state-lists-org () (format "#+title: Australia weather forecast\n%s" (mapconcat (lambda (pair) (format "[[file:%s][%s]]" (car pair) (upcase (format "%s" (car pair))))) bom-state-files " "))) (defun bom-format-state-lists-html () (bom-org-to-html (bom-format-state-lists-org))) (defun bom-format-state-lists-html ()) (defun bom-serve (path) "Serve based on PATH." (setq path (substring path 1)) (if (string-empty-p path) (bom-format-state-lists-html) (bom-format-areas-html (bom-get-forecasts path)))) ;;;###autoload (defun bom-start () "Start serving weather forecasts." (when bom-server (bom-stop)) (setq bom-server (ws-start (lambda (request) (with-slots (process headers) request (ws-response-header process 200 '("Content-type" . "text/html")) (process-send-string process (bom-serve (alist-get :GET headers)) ))) bom-port))) (defun bom-stop () "Stop serving weather forecasts." (ws-stop bom-server)) (provide 'bom) ;;; bom.el ends here