anki-editor/anki-editor.el

373 lines
16 KiB
EmacsLisp
Raw Normal View History

2017-12-27 17:20:01 +01:00
;;; anki-editor.el --- Create Anki cards in Org-mode -*- lexical-binding: t; -*-
;; Copyright (C) 2018 Louie Tan
;; Author: Louie Tan <louietanlei@gmail.com>
;; This file is not part of GNU Emacs.
;; 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 distaributed 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 <http://www.gnu.org/licenses/>.
(require 'json)
(require 'org-element)
(defconst anki-editor-note-tag "note")
(defconst anki-editor-deck-tag "deck")
(defconst anki-editor-note-type-prop :ANKI_NOTE_TYPE)
(defconst anki-editor-note-tags-prop :ANKI_TAGS)
2018-01-13 16:57:48 +01:00
(defconst anki-editor-note-id-prop :ANKI_NOTE_ID)
(defconst anki-editor-html-output-buffer-name "*anki-editor html output*")
2017-12-27 17:20:01 +01:00
(defconst anki-editor-anki-connect-listening-address "127.0.0.1")
(defconst anki-editor-anki-connect-listening-port "8765")
;; Commands
2018-01-07 10:49:22 +01:00
;;;###autoload
2017-12-27 17:20:01 +01:00
(defun anki-editor-submit ()
2018-01-07 03:48:23 +01:00
"Send notes in current buffer to Anki."
2017-12-27 17:20:01 +01:00
(interactive)
(let* ((tree (org-element-parse-buffer))
(note-headings (anki-editor--get-note-headings tree))
(total (length note-headings)))
(if (null note-headings)
(message "No notes found in current buffer")
(message "Submitting %d notes to Anki..." total)
(anki-editor--anki-connect-invoke
"addNotes" 5
`(("notes" . ,(mapcar #'anki-editor--anki-connect-heading-to-note
note-headings)))
(lambda (result)
(let ((failed (seq-count #'null result)))
(message (format "Submitted %d notes, %d successful, %d failed." total (- total failed) failed))))))))
2018-01-07 10:49:22 +01:00
;;;###autoload
(defun anki-editor-insert-deck (&optional prefix)
2018-01-07 03:48:23 +01:00
"Insert a deck heading with the same level as current heading.
With prefix, only insert the deck name."
(interactive "P")
2018-01-05 17:09:31 +01:00
(message "Fetching decks...")
2018-01-05 14:53:37 +01:00
(anki-editor--anki-connect-invoke
"deckNames" 5 nil
(lambda (result)
2018-01-07 08:10:55 +01:00
(let (deckname)
(setq result (append (sort result #'string-lessp) nil)
deckname (completing-read "Choose a deck: " result))
(unless prefix (org-insert-heading-respect-content))
(insert deckname)
(unless prefix (anki-editor--set-tags-fix anki-editor-deck-tag))))))
2018-01-05 14:53:37 +01:00
2018-01-07 10:49:22 +01:00
;;;###autoload
2018-01-05 17:09:31 +01:00
(defun anki-editor-insert-note ()
2018-01-07 04:07:41 +01:00
"Insert a note heading that's one level lower to current heading.
2018-01-07 03:48:23 +01:00
The inserted heading will be structured with the property drawer
and subheadings that correspond to the fields of the selected
note type."
2018-01-05 17:09:31 +01:00
(interactive)
(message "Fetching note types...")
(anki-editor--anki-connect-invoke
"modelNames" 5 nil
(lambda (note-types)
(let (note-type note-heading)
(setq note-types (append (sort note-types #'string-lessp) nil)
note-type (completing-read "Choose a note type: " note-types))
(message "Fetching note fields...")
(anki-editor--anki-connect-invoke
"modelFieldNames" 5 `((modelName . ,note-type))
(lambda (fields)
2018-01-06 13:31:36 +01:00
(setq note-heading (read-from-minibuffer "Enter the heading: " "Item"))
(org-insert-heading-respect-content)
(org-do-demote)
2018-01-05 17:09:31 +01:00
(insert note-heading)
(anki-editor--set-tags-fix anki-editor-note-tag)
(org-set-property (substring (symbol-name anki-editor-note-type-prop) 1) note-type)
2018-01-06 13:31:36 +01:00
(seq-each (lambda (field)
(save-excursion
(org-insert-heading-respect-content)
(org-do-demote)
(insert field)))
fields)
2018-01-05 17:09:31 +01:00
(org-next-visible-heading 1)
(end-of-line)
(newline-and-indent)))))))
2018-01-05 14:53:37 +01:00
2018-01-07 10:49:22 +01:00
;;;###autoload
(defun anki-editor-export-heading-contents-to-html ()
2018-01-07 03:48:23 +01:00
"Export the contents of the heading at point to HTML."
(interactive)
(let ((tree (org-element-at-point))
contents)
(if (or (null tree)
(not (eq (org-element-type tree) 'headline)))
(error "No element at point or it's not a heading")
(setq contents (buffer-substring-no-properties (org-element-property :contents-begin tree)
(org-element-property :contents-end tree)))
(when (buffer-live-p (get-buffer anki-editor-html-output-buffer-name))
(kill-buffer anki-editor-html-output-buffer-name))
(switch-to-buffer-other-window (get-buffer-create anki-editor-html-output-buffer-name))
(insert (anki-editor--generate-html contents)))))
;;;###autoload
(defun anki-editor-convert-region-to-html ()
"Convert and replace region to HTML."
(interactive)
(unless (region-active-p) (error "No active region"))
(insert (anki-editor--generate-html
(delete-and-extract-region (region-beginning) (region-end)))))
2018-01-05 14:53:37 +01:00
(setq anki-editor--key-map `((,(kbd "C-c a s") . ,#'anki-editor-submit)
2018-01-05 17:19:40 +01:00
(,(kbd "C-c a i d") . ,#'anki-editor-insert-deck)
(,(kbd "C-c a i n") . ,#'anki-editor-insert-note)
(,(kbd "C-c a e") . ,#'anki-editor-export-heading-contents-to-html)))
2017-12-27 17:20:01 +01:00
2018-01-07 10:49:22 +01:00
;;;###autoload
2017-12-27 17:20:01 +01:00
(defun anki-editor-setup-default-keybindings ()
2018-01-07 03:48:23 +01:00
"Set up the default keybindings."
2017-12-27 17:20:01 +01:00
(interactive)
(dolist (map anki-editor--key-map)
(local-set-key (car map) (cdr map)))
(message "anki-editor default keybindings have been set"))
;; Core Functions
(defun anki-editor--get-note-headings (data &optional test)
(unless test (setq test 'identity))
(org-element-map data 'headline
(lambda (element)
(let ((tags (org-element-property :tags element)))
(when (and (member anki-editor-note-tag tags) (funcall test element))
element)))))
(defun anki-editor--heading-to-note (heading)
2018-01-13 16:57:48 +01:00
(let (note-id note-type tags fields)
(setq note-id (org-element-property anki-editor-note-id-prop heading)
2017-12-27 17:20:01 +01:00
note-type (org-element-property anki-editor-note-type-prop heading)
tags (org-element-property anki-editor-note-tags-prop heading)
fields (mapcar #'anki-editor--heading-to-note-field (anki-editor--get-subheadings heading)))
(unless note-type (error "Please specify a note type !"))
(unless fields (error "Please specify fields !"))
2018-01-13 16:57:48 +01:00
`((note-id . ,(string-to-number (or note-id "-1")))
2017-12-27 17:20:01 +01:00
(note-type . ,note-type)
(tags . ,(and tags (split-string tags " ")))
(fields . ,fields))))
(defun anki-editor--get-subheadings (heading)
(org-element-map (org-element-contents heading)
'headline 'identity nil nil 'headline))
(defun anki-editor--heading-to-note-field (heading)
(let ((field-name (substring-no-properties
(org-element-property
:raw-value
heading)))
(contents (org-element-contents heading)))
`(,field-name . ,(anki-editor--generate-html
(org-element-interpret-data contents)))))
(defun anki-editor--generate-html (org-content)
(with-temp-buffer
(insert org-content)
(setq anki-editor--replacement-records nil)
(anki-editor--replace-latex)
(anki-editor--buffer-to-html)
(anki-editor--translate-latex)
(buffer-substring-no-properties (point-min) (point-max))))
;; Transformers
(defun anki-editor--buffer-to-html ()
(when (> (buffer-size) 0)
(save-mark-and-excursion
(mark-whole-buffer)
(org-html-convert-region-to-html))))
(defun anki-editor--replace-latex ()
2018-01-08 11:20:11 +01:00
(let (object type memo)
2017-12-27 17:20:01 +01:00
(while (setq object (org-element-map
(org-element-parse-buffer)
2018-01-08 11:20:11 +01:00
'(latex-fragment latex-environment) 'identity nil t))
(setq type (org-element-type object)
memo (anki-editor--replace-node object
(lambda (original)
(anki-editor--hash type
original))))
(add-to-list 'anki-editor--replacement-records
`(,(cdr memo) . ((type . ,type)
(original . ,(car memo))))))))
2017-12-27 17:20:01 +01:00
(setq anki-editor--anki-latex-syntax-map
`((,(format "^%s" (regexp-quote "$$")) . "[$$]")
(,(format "%s$" (regexp-quote "$$")) . "[/$$]")
(,(format "^%s" (regexp-quote "$")) . "[$]")
(,(format "%s$" (regexp-quote "$")) . "[/$]")
(,(format "^%s" (regexp-quote "\\(")) . "[$]")
(,(format "%s$" (regexp-quote "\\)")) . "[/$]")
(,(format "^%s" (regexp-quote "\\[")) . "[$$]")
(,(format "%s$" (regexp-quote "\\]")) . "[/$$]")))
2018-01-08 11:20:11 +01:00
(defun anki-editor--wrap-latex (content)
(format "[latex]%s[/latex]" content))
(defun anki-editor--convert-latex-fragment (frag)
(let ((copy frag))
(dolist (map anki-editor--anki-latex-syntax-map)
(setq frag (replace-regexp-in-string (car map) (cdr map) frag t t)))
(if (equal copy frag)
(anki-editor--wrap-latex frag)
frag)))
2017-12-27 17:20:01 +01:00
(defun anki-editor--translate-latex ()
2018-01-08 11:20:11 +01:00
(let (ele-data translated)
(dolist (record anki-editor--replacement-records)
(setq ele-data (cdr record))
(goto-char (point-min))
(when (search-forward (car record) nil t)
(pcase (alist-get 'type ele-data)
('latex-fragment (replace-match (anki-editor--convert-latex-fragment (alist-get 'original ele-data)) t t))
('latex-environment (replace-match (anki-editor--wrap-latex (alist-get 'original ele-data)) t t)))
(add-to-list 'translated record)))
(setq anki-editor--replacement-records (cl-set-difference anki-editor--replacement-records translated))))
2017-12-27 17:20:01 +01:00
;; Utilities
(defun anki-editor--hash (type text)
2018-01-07 10:32:08 +01:00
(sha1 (format "%s %s" (symbol-name type) text)))
2017-12-27 17:20:01 +01:00
2018-01-05 17:09:31 +01:00
(defun anki-editor--set-tags-fix (tags)
(org-set-tags-to tags)
(org-fix-tags-on-the-fly))
2018-01-08 11:20:11 +01:00
(defun anki-editor--replace-node (node replacer)
(let* ((begin (org-element-property :begin node))
(end (- (org-element-property :end node) (org-element-property :post-blank node)))
(original (delete-and-extract-region begin end))
(replacement (funcall replacer original)))
(goto-char begin)
(insert replacement)
(cons original replacement)))
2017-12-27 17:20:01 +01:00
;; anki-connect
2018-01-13 16:57:48 +01:00
;; FIXME: behavior changed, callers need to be updated
(defun anki-editor--anki-connect-invoke (action version &optional params)
2017-12-27 17:20:01 +01:00
(let* ((data `(("action" . ,action)
("version" . ,version)))
(request-body (json-encode
(if params
(add-to-list 'data `("params" . ,params))
data)))
(request-tempfile (make-temp-file "emacs-anki-editor")))
(with-temp-file request-tempfile
(setq buffer-file-coding-system 'utf-8)
(set-buffer-multibyte t)
(insert request-body))
(let* ((response (shell-command-to-string
(format "curl %s:%s --silent -X POST --data-binary @%s"
anki-editor-anki-connect-listening-address
anki-editor-anki-connect-listening-port
request-tempfile)))
2018-01-13 16:57:48 +01:00
error)
2017-12-27 17:20:01 +01:00
(when (file-exists-p request-tempfile) (delete-file request-tempfile))
(condition-case err
2018-01-13 16:57:48 +01:00
(setq response (json-read-from-string response)
error (alist-get 'error response))
(error (setq error (error-message-string err))))
`((result . ,(alist-get 'result response))
(error . ,error)))))
2017-12-27 17:20:01 +01:00
(defun anki-editor--anki-connect-map-note (note)
2018-01-13 16:57:48 +01:00
`(("id" . ,(alist-get 'note-id note))
("deckName" . ,(alist-get 'deck note))
2017-12-27 17:20:01 +01:00
("modelName" . ,(alist-get 'note-type note))
("fields" . ,(alist-get 'fields note))
;; Convert tags to a vector since empty list is identical to nil
;; which will become None in Python, but anki-connect requires it
;; to be type of list.
("tags" . ,(vconcat (alist-get 'tags note)))))
(defun anki-editor--anki-connect-heading-to-note (heading)
(anki-editor--anki-connect-map-note
(anki-editor--heading-to-note heading)))
2018-01-13 16:57:48 +01:00
;;; experimental code
(global-set-key (kbd "C-c a t") #'anki-editor--better-submit)
(defun anki-editor--better-submit ()
(interactive)
(let ((total 0)
(failed 0))
(save-excursion
(goto-char (point-min))
(let (current-tags current-deck current-note-elem current-note)
(while (not (= (point) (point-max)))
(when (org-at-heading-p)
(setq current-tags (org-get-tags))
(cond
((member anki-editor-deck-tag current-tags) (setq current-deck (nth 4 (org-heading-components))))
((member anki-editor-note-tag current-tags) (progn
;; TODO: Put the error reason in property drawer
;; (unless current-deck (error "Please specify a deck !"))
(setq current-note-elem (org-element-at-point)
current-note-elem
(let ((content (buffer-substring
(org-element-property :begin current-note-elem)
(org-element-property :end current-note-elem))))
(with-temp-buffer
(insert content)
(car (org-element-contents (org-element-parse-buffer)))))
;; TODO: trap errors
current-note (anki-editor--heading-to-note current-note-elem))
(add-to-list 'current-note `(deck . ,current-deck))
(setq total (1+ total))
(when (not (anki-editor--anki-connect-save-note current-note))
(setq failed (1+ failed)))))))
(org-next-visible-heading 1))))
(message "Submitted %d notes, with %d failed." total failed)))
(defun anki-editor--anki-connect-save-note (note)
;; TODO: Put error in property drawer
(if (= (alist-get 'note-id note) -1)
(anki-editor--anki-connect-create-note note)
(anki-editor--anki-connect-update-note note)))
(defun anki-editor--anki-connect-create-note (note)
(let ((response (anki-editor--anki-connect-invoke
"addNote" 5 `((note . ,(anki-editor--anki-connect-map-note note))))))
(when (alist-get 'result response)
(org-set-property (substring (symbol-name anki-editor-note-id-prop) 1)
(format "%d" (alist-get 'result response))))
(alist-get 'result response)))
(defun anki-editor--anki-connect-update-note (note)
(let ((response (anki-editor--anki-connect-invoke
"updateNoteFields" 5 `((note . ,(anki-editor--anki-connect-map-note note))))))
;; TODO: Update tags
(not (alist-get 'error response))))
2017-12-27 17:20:01 +01:00
(provide 'anki-editor)
;;; anki-editor.el ends here