From c634c8664546c073b8bb3ada9e7e3f0730a06dbd Mon Sep 17 00:00:00 2001 From: Quentin Aristote Date: Fri, 19 Nov 2021 12:45:23 +0100 Subject: initial commit --- local/bibli-paris/bibli-paris.el | 396 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 local/bibli-paris/bibli-paris.el (limited to 'local') diff --git a/local/bibli-paris/bibli-paris.el b/local/bibli-paris/bibli-paris.el new file mode 100644 index 0000000..fe1ab1e --- /dev/null +++ b/local/bibli-paris/bibli-paris.el @@ -0,0 +1,396 @@ +(require 'request) +(require 'request-deferred) +(require 'deferred) +(require 'cl-lib) +(require 'subr-x) +(require 'org) +(require 'parse-csv) + + +;; constants + +(defvar bibli-paris/default-library "75013 - Jean-Pierre Melville" + "The default library from which to fetch updates.") + +(defvar bibli-paris/central-library "75000 - Réserve centrale" + "The name of the central library.") + +(defvar bibli-paris/base-url + "https://bibliotheques.paris.fr/" + "The URL of the Parisian libraries' website.") + +(defvar bibli-paris/holdings-api-url + (concat bibli-paris/base-url "Default/Portal/Services/ILSClient.svc/GetHoldings") + "The URL to which holdings request should be made.") + +(defvar bibli-paris/base-entry-url + (concat bibli-paris/base-url "Default/doc/SYRACUSE/") + "The base URL of an entry's webpage.") + +(defvar bibli-paris/max-asynchronous-processes + 500 + "The maximum number of asynchronous processes that can be launched by Emacs. + Determined experimentally.") + + +;; utils + +(defun bibli-paris/get-entry-author (&optional pom) + "Return the record number of the entry located at POM (marker)." + (org-entry-get pom "AUTEUR")) + +(defun bibli-paris/get-entry-quotes(&optional pom) + "Return the quotes of the entry located at POM (marker)." + (org-entry-get pom "COTES")) + +(defun bibli-paris/get-entry-recnum (&optional pom) + "Return the record number of the entry located at POM (marker)." + (org-entry-get pom "N°_DE_NOTICE")) + +(defun bibli-paris/get-entry-title (&optional pom) + "Return the title of the entry located at POM (marker)." + (org-entry-get pom "TITRE")) + + +(defun bibli-paris/get-holding-quote (holding) + "Return the quote associated with HOLDING (hash-tbl)." + (gethash "Cote" holding)) + +(defun bibli-paris/get-holding-return-date (holding) + "Return the return date associated with HOLDING (hash-tbl)." + (gethash "WhenBack" holding)) + +(defun bibli-paris/get-holding-site (holding) + "Return the site associated with HOLDING (hash-tbl)." + (gethash "Site" holding)) + +(defun bibli-paris/get-holding-status (holding) + "Return the status associated with HOLDING (hash-tbl)." + (gethash "Statut" holding)) + +(defun bibli-paris/get-csv-recnum (csv-entry) + "Return the record number contained in the CSV row CSV-ENTRY (string list)." + (car (cdr csv-entry))) + +(defun bibli-paris/get-csv-title (csv-entry) + "Return the title contained in the CSV row CSV-ENTRY (string list)." + (car csv-entry)) + +;;;###autoload +(defun bibli-paris/number-of-entries () + "Return the number library entries in the current buffer." + (interactive) + (length (org-map-entries (lambda () t)))) + +;; sort entries + +;;;###autoload +(defun bibli-paris/sort () + "Sort entries by their quotes, grouping them (hopefully) by series and author." + (interactive) + (save-excursion ; not working ? + ;; select the whole buffer + (set-mark (point-min)) + (goto-char (point-max)) + ;; run org-sort + (org-sort-entries nil + ?f + ;; order entries by their quotes (the quote in the main + ;; library having higher priority) + #'(lambda () (save-excursion + (org-end-of-meta-data) + (forward-line) + (let ((main-quote (thing-at-point 'line t)) + (other-quotes (bibli-paris/get-entry-quotes))) + ;; (message "%s" result) + (concat main-quote " " other-quotes))))))) + + +;; update entries + +(defun bibli-paris/fetch-entry-holdings-by-id (recnum) + "Return a deferred object that downloads JSON metadata about which library +have the entry specified by its record number RECNUM (string) available." + (deferred:$ + (request-deferred bibli-paris/holdings-api-url + :type "POST" + :data (json-encode + `(,`("Record" . + ,`(("Docbase" . "Syracuse") + ,`("RscId" . ,recnum))))) + :headers '(("Content-Type" . "application/json")) + :encoding 'utf-8 + :parser (lambda () + (let ((json-object-type 'hash-table) + (json-array-type 'list) + (json-key-type 'string)) + (json-read)))) + (deferred:nextc it + (lambda (response) + (let ((error-thrown (request-response-error-thrown response))) + (if error-thrown + (let ((error-symbol (car error-thrown)) + (error-data (cdr error-thrown))) + (signal error-symbol error-data)) + (let* ((data (request-response-data response)) + (d (gethash "d" data))) + (if d (gethash "Holdings" d) nil)))))))) + +(defun bibli-paris/find-library-holding (holdings &optional library) + "Find the holding data corresponding to the library LIBRARY (string) in +a list of holdings HOLDINGS (list of hash-tbls). If LIBRARY is nil, +BIBLI-PARIS/DEFAULT-LIBRARY is used." + (seq-find (lambda (holding) + (string-suffix-p (if library library bibli-paris/default-library) + (bibli-paris/get-holding-site holding))) + holdings)) + +(defun bibli-paris/update-entry-schedule-from (holding) + "Update entry schedule according to HOLDING (hash-tbl) the holding data of +the entry from a library. +The schedule is removed if the entry is marked as available, set to the return +date if borrowed and set to the maximum unix date if unavailable." + (let* ((new-status (if holding (bibli-paris/get-holding-status holding) nil)) + (new-date (pcase new-status + ('"En rayon" nil) + ('"Emprunté" + (let* ((unformatted-date (bibli-paris/get-holding-return-date + holding)) + (day-month-year (split-string unformatted-date + "/"))) + (apply 'format + "%3$s-%2$s-%1$s" + day-month-year))) + (_ "9999-12-31")))) + (if new-date + (org-schedule nil new-date) + (org-schedule '(4))))) + + +(defun bibli-paris/clean-quote (quote) + "Remove extra whitespace from and uniformizes the quote QUOTE." + (let ((blank "[[:blank:]\r\n]+")) + (string-trim (replace-regexp-in-string + "BD EN RESERVE" "EN RESERVE BD" + (replace-regexp-in-string blank " " quote t t) + t t) + blank blank))) + +(defun bibli-paris/update-entry-quote-from (holding) + "Update entry quote according to HOLDING (hash-tbl) the holding data of the +entry from a library." + (let ((new-quote (if holding + (bibli-paris/clean-quote (bibli-paris/get-holding-quote holding)) + "unavailable")) + (begin) (end)) + (save-excursion + (org-back-to-heading) + (org-end-of-meta-data) + (if new-quote (progn + (newline) + (insert new-quote) + (newline))) + (setq begin (point)) + (org-next-visible-heading 1) + (setq end (point)) + (delete-region begin end) + (newline) + (message "Set quote to \"%s\"" new-quote)))) + +(defun bibli-paris/update-availability-at-central-library (holdings) + "Update entry tags according to whether it is held in HOLDINGS (hash-tbl list) +at the central library." + (org-toggle-tag "RéserveCentrale" + (if (seq-some + (lambda (holding) + (string-suffix-p bibli-paris/central-library + (bibli-paris/get-holding-site holding))) + holdings) + 'on 'off))) + +(defun bibli-paris/update-entry-from (holdings) + "Update the entry at point using its holdings HOLDINGS (hash-tbl list)." + (let ((holding (if holdings + (bibli-paris/find-library-holding holdings) + nil))) + (message "Updating %s (%s) ..." (bibli-paris/get-entry-title) (bibli-paris/get-entry-author)) + (bibli-paris/update-entry-schedule-from holding) + (bibli-paris/update-entry-quote-from holding) + (bibli-paris/update-availability-at-central-library holdings))) + +;;;###autoload +(defun bibli-paris/update-entry () + "Update the schedule and quote of the entry at point." + (interactive + (deferred:$ + (bibli-paris/fetch-entry-holdings-by-id (bibli-paris/get-entry-recnum)) + (deferred:nextc it 'bibli-paris/update-entry-from)))) + +(defun bibli-paris/async-update-entries-at-points (pom-recnum-seq) + "Update all entries specified by their positions and record numbers in +POM-RECNUM-SEQ, fetching the corresponding data asynchronously." + (let ((pom-holdingsd-seq + (seq-map + (lambda (pom-recnum) + (let ((pom (car pom-recnum)) + (recnum (cdr pom-recnum))) + `(,pom . ,(deferred:$ + (deferred:call 'message + "Fetching holdings for %s (%s) ..." + (bibli-paris/get-entry-title pom) + (bibli-paris/get-entry-author pom)) + (bibli-paris/fetch-entry-holdings-by-id recnum))))) + pom-recnum-seq))) + (seq-reduce + (lambda (prevd pom-holdingsd) + (let ((pom (car pom-holdingsd)) + (holdingsd (cdr pom-holdingsd))) + (deferred:$ + (deferred:parallel + (deferred:$ prevd (deferred:nextc it `(lambda () ,pom))) + holdingsd) + (deferred:nextc it + (lambda (pom-holdings) + (let ((pom (car pom-holdings)) + (holdings (car (cdr pom-holdings)))) + (save-excursion + (goto-char pom) + (bibli-paris/update-entry-from holdings)))))))) + pom-holdingsd-seq + (deferred:call 'message "Updating batch ...")))) + +(defun bibli-paris/update-entries-sequential () + "Update all entries' schedules and quotes, fetching the corresponding data +sequentially. Terribly inefficient but works." + (let ((poms (org-map-entries 'point))) + (deferred:loop + (seq-reverse poms) + (lambda (pom) (deferred:$ + (deferred:call 'goto-char pom) + (deferred:nextc it 'bibli-paris/update-entry)))))) + +(defun bibli-paris/update-entries-batch () + "Update all entries' schedules and quotes, by batches so as to prevent +emacs from opening too many files." + (let ((poms (org-map-entries (lambda () + `(,(point) . ,(bibli-paris/get-entry-recnum)))))) + (deferred:$ + (deferred:next (lambda () (message "Update started."))) + (deferred:loop + (seq-partition (seq-reverse poms) bibli-paris/max-asynchronous-processes) + 'bibli-paris/async-update-entries-at-points) + (deferred:nextc it 'bibli-paris/sort) + (deferred:nextc it (lambda () (message "Update done.")))))) + +;;;###autoload +(defun bibli-paris/update-entries () + "Update all entries' schedules and quotes." + (interactive) + (bibli-paris/update-entries-batch)) + + +;; import entries + +(defun bibli-paris/insert-csv-entry (keys row &optional tags state old) + "Insert at point a new entry described by a list of keys KEYS and associated +values in ROW, and set the tags TAGS (string seq) and the state STATE (string that +defaults to TODO.). If OLD is not nil, only update the properties and tags without +inserting the heading." + (let ((title (bibli-paris/get-csv-title row)) + (recnum (bibli-paris/get-csv-recnum row))) + (let ((heading (format "* %s [[%s][%s]]" + (if state state "TODO") + (concat bibli-paris/base-entry-url recnum) + title))) + (if (not old) + (progn (newline) (insert heading))) + (cl-mapc (lambda (key value) + (let ((formatted-key (upcase + (replace-regexp-in-string " " "_" + key))) + (formatted-value (string-trim value))) + (if (not (string-equal formatted-value "")) + (org-set-property formatted-key formatted-value)))) + keys row) + (org-toggle-tag tags 'on)))) + +(defun bibli-paris/insert-or-update-csv-entries (keys rows recnum-lines + &optional tags state) + "Insert entries described by a list of keys KEYS and associated to values in +ROW at point. Also set the tags TAGS (string seq) and the state STATE (string +that defaults to TODO.). If an entry has a record number found in RECNUM-POMS +(string to marker hash table), only update the entry at corresponding point +(without inserting the heading)." + ;; TODO : remove race conditions + (seq-do (lambda (row) + (let ((recnum (bibli-paris/get-csv-recnum row))) + (if recnum + (let ((line (gethash recnum recnum-lines))) + (save-excursion + (if line + (progn + (goto-char (point-min)) + (org-next-visible-heading line) + (message "Updating %s (%s) ..." + (bibli-paris/get-entry-title) + (bibli-paris/get-entry-author))) + (progn + (goto-char (point-max)) + (message "Inserting %s (%s) ..." + (bibli-paris/get-entry-title) + (bibli-paris/get-entry-author)))) + (bibli-paris/insert-csv-entry keys row tags state line)))))) + rows)) + +(defun bibli-paris/parse-csv (csv-file) + "Load a CSV file named CSV-FILE (string) into a list of rows, each row being +being encoded as a list of strings." + (let ((csv-buffer (generate-new-buffer "bibli-paris/csv-to-import-from"))) + (let ((result + (save-current-buffer + (set-buffer csv-buffer) + (insert-file-contents csv-file) + (parse-csv-string-rows (buffer-string) ?\; ?\" "\n")))) + (kill-buffer csv-buffer) + result))) + +;;;###autoload +(defun bibli-paris/import-from-csv (csv-file &optional tags state) + "" + (interactive "fImport from : \nsTags : ") + (let ((recnum-lines (make-hash-table :test 'equal + :size (bibli-paris/number-of-entries) + :weakness 'key-and-value)) + (entry-number 0) + (csv-rows (bibli-paris/parse-csv csv-file))) + (message "Importing library entries from %s ..." csv-file) + (org-map-entries (lambda () + (progn + (puthash (bibli-paris/get-entry-recnum) entry-number recnum-lines) + (setq entry-number (1+ entry-number))))) + (bibli-paris/insert-or-update-csv-entries (car csv-rows) + (cdr csv-rows) + recnum-lines + tags + state) + (message "Import done."))) + + +;; archive entries + +;;;###autoload +(defun bibli-paris/archive-all-read () + "Archive all entries in the DONE state." + (interactive) + (org-map-entries (lambda () + (if (equal (org-get-todo-state) "DONE") + (org-archive-subtree))))) + +;; minor mode + +;;;###autoload +(define-minor-mode bibli-paris/mode + "Manage reading lists of documents available in Paris' libraries." + :lighter "bibli-paris") + +(provide 'bibli-paris) -- cgit v1.2.3