Merge branch 'develop'

This commit is contained in:
louie 2018-01-15 23:38:09 +08:00
commit b8c48138b9
2 changed files with 277 additions and 151 deletions

View file

@ -1,33 +1,69 @@
anki-editor -- an Emacs package that helps you create Anki cards in Org-mode anki-editor -- an Emacs package that helps you create Anki cards in Org-mode
* Requirements * Requirements
- [[https://github.com/FooSoft/anki-connect#installation][anki-connect]], an Anki add-on which is required for this package to
interact with Anki. - [[https://github.com/FooSoft/anki-connect#installation][anki-connect]],
an Anki add-on required by this package to interact with Anki.
- curl - curl
* Usage * Usage
1. Download it and put it into your Emacs' =load-path=
2. =(require 'anki-editor)=
3. Write notes in org syntax, e.g. [[./examples.org][examples.org]]
- Headings of deck are tagged with =deck=
- Headings of note are tagged with =note=
- Custom properties of a note heading can be used to specify note
type and tags
- Subheadings of a note heading are fields of its note type
- The contents of field headings will be converted to html by
org-mode's html backend, with the latex syntax translated to
the Anki style
4. Command Cheat Sheet
| Command | Default Keybinding | Description |
|-----------------------------------------------+--------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| =anki-editor-setup-default-keybindings= | N/A | Set up default keybindings. |
| =anki-editor-submit= | C-c a s | Send notes in current buffer to Anki. |
| =anki-editor-insert-deck= | C-c a i d | Insert a deck heading with the same level as current heading. With prefix, only insert the deck name. |
| =anki-editor-insert-note= | C-c a i n | Insert a note heading that's one level lower to current heading. The inserted heading will be structured with the property drawer and subheadings that correspond to the fields of the selected note type. |
| =anki-editor-export-heading-contents-to-html= | C-c a e | Export the contents of the heading at point to HTML. |
| =anki-editor-convert-region-to-html= | N/A | Convert and replace region to HTML. |
*Not a native speaker, hope this README is clear enough. Happy hacking :)* ** Installation
** Demo - Clone this repo and add the directory to your Emacs' =load-path=,
[[./demo.gif]] then =(require 'anki-editor)=.
- Or if you use =package.el=, just download
[[./anki-editor.el][anki-editor.el]] and visit it in Emacs, then
=M-x package-install-file=.
** The Syntax
Now you can compose Anki notes in Org syntax, e.g. lists, code
blocks, tables, latex fragments / environments, when being
submitted to Anki, they will be converted to HTML by Org-mode's
HTML backend with specific markers (e.g. latex) translated to the
Anki style.
The structure of contents is as follow, which is inspired by
=org-drill=. See [[./examples.org][examples.org]] for reference.
#+BEGIN_EXAMPLE
* English :deck:
** Vocabulary
*** Item :note:
:PROPERTIES:
:ANKI_NOTE_TYPE: Basic (and reversed card)
:ANKI_TAGS: vocab idioms
:END:
**** Front
(it's) raining cats and dogs
**** Back
it's raining very hard
#+END_EXAMPLE
- Headings of deck are tagged with =deck=
- Headings of note are tagged with =note=
- Levels are not significant, but note headings must be descendents
of their deck headings
- Custom properties of a note heading can be used to specify note
type and tags
- Subheadings of a note heading are fields of its note type
** Command Cheatsheet
| Command | Keybinding | Brief Description |
|-----------------------------------------------+------------+---------------------------------------------------------------|
| =anki-editor-setup-default-keybindings= | N/A | Set up default keybindings. |
| =anki-editor-submit= | C-c a s | Send notes in current buffer to Anki. |
| =anki-editor-insert-deck= | C-c a i d | Insert a deck heading with the same level as current heading. |
| =anki-editor-insert-note= | C-c a i n | Insert the skeleton of a note. |
| =anki-editor-export-heading-contents-to-html= | C-c a e | Export the contents of the heading at point to HTML. |
| =anki-editor-convert-region-to-html= | N/A | Convert and replace region to HTML. |
| =anki-editor-anki-connect-upgrade= | N/A | Upgrade anki-connect to the latest version. |
*Since I'm not a native English speaker, let me know if there's any ambiguity or grammatical mistakes.*
* Demo
[[./demo.gif]]

View file

@ -1,108 +1,143 @@
;;; anki-editor.el --- Create Anki cards in Org-mode -*- lexical-binding: t; -*- ;;; anki-editor.el --- Create Anki Cards in Org-mode
;; 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, ;; Copyright (C) 2018 Louie Tan <louietanlei@gmail.com>
;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;;
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; Filename: anki-editor.el
;; GNU General Public License for more details. ;; Description: Create Anki Cards in Org-mode
;; Author: Louie Tan
;; Version: 0.1.0
;; URL: https://github.com/louietan/anki-editor
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;;; Commentary:
;;
;; This package is for people who use Anki as SRS but would like to
;; create cards in Org-mode. It does so by using Org-mode's built-in
;; HTML backend to generate HTML with specific syntax (e.g. latex)
;; translated to the Anki style, then sends requests to anki-connect
;; (an Anki addon that runs an HTTP server to expose Anki functions
;; as APIs).
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; 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 ;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <http://www.gnu.org/licenses/>. ;; along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;;; Code:
(require 'json) (require 'json)
(require 'org-element) (require 'org-element)
(defconst anki-editor-note-tag "note") (defvar anki-editor-note-tag "note")
(defconst anki-editor-deck-tag "deck") (defvar anki-editor-deck-tag "deck")
(defconst anki-editor-note-type-prop :ANKI_NOTE_TYPE) (defvar anki-editor-note-type-prop :ANKI_NOTE_TYPE)
(defconst anki-editor-note-tags-prop :ANKI_TAGS) (defvar anki-editor-note-tags-prop :ANKI_TAGS)
(defconst anki-editor-html-output-buffer-name "*anki-editor html output*") (defvar anki-editor-note-id-prop :ANKI_NOTE_ID)
(defconst anki-editor-anki-connect-listening-address "127.0.0.1") (defvar anki-editor-note-failure-reason-prop :ANKI_FAILURE_REASON)
(defconst anki-editor-anki-connect-listening-port "8765") (defvar anki-editor-html-output-buffer-name "*anki-editor html output*")
(defvar anki-editor-anki-connect-listening-address "127.0.0.1")
(defvar anki-editor-anki-connect-listening-port "8765")
;; Commands
;;;###autoload ;;;###autoload
(defun anki-editor-submit () (defun anki-editor-submit ()
"Send notes in current buffer to Anki." "Send notes in current buffer to Anki.
For each note heading, if there's no note id in property drawer,
create a note, otherwise, update fields and tags of the existing
note.
If one fails, the failure reason will be set in property drawer
of that heading."
(interactive) (interactive)
(let* ((tree (org-element-parse-buffer)) (let ((total 0)
(note-headings (anki-editor--get-note-headings tree)) (failed 0))
(total (length note-headings))) (save-excursion
(goto-char (point-min))
(if (null note-headings) (let (current-tags current-deck)
(message "No notes found in current buffer") (while (not (= (point) (point-max)))
(when (org-at-heading-p)
(message "Submitting %d notes to Anki..." total) (setq current-tags (org-get-tags))
(anki-editor--anki-connect-invoke (cond
"addNotes" 5 ((member anki-editor-deck-tag current-tags) (setq current-deck (nth 4 (org-heading-components))))
`(("notes" . ,(mapcar #'anki-editor--anki-connect-heading-to-note ((member anki-editor-note-tag current-tags) (progn
note-headings))) (setq total (1+ total))
(lambda (result) (anki-editor--clear-failure-reason)
(let ((failed (seq-count #'null result))) (condition-case err
(message (format "Submitted %d notes, %d successful, %d failed." total (- total failed) failed)))))))) (anki-editor--process-note-heading current-deck)
(error (progn
(setq failed (1+ failed))
(anki-editor--set-failure-reason (error-message-string err)))))))))
(org-next-visible-heading 1))))
(message (with-output-to-string
(princ (format "Submitted %d notes, with %d failed." total failed))
(when (> failed 0)
(princ " Check property drawers for failure reasons."))))))
;;;###autoload ;;;###autoload
(defun anki-editor-insert-deck (&optional prefix) (defun anki-editor-insert-deck (&optional prefix)
"Insert a deck heading with the same level as current heading. "Insert a deck heading with the same level as current heading.
With prefix, only insert the deck name." With PREFIX, only insert the deck name."
(interactive "P") (interactive "P")
(message "Fetching decks...") (message "Fetching decks...")
(anki-editor--anki-connect-invoke (let* ((response (anki-editor--anki-connect-invoke "deckNames" 5))
"deckNames" 5 nil (err (alist-get 'error response))
(lambda (result) result deckname)
(let (deckname) (when err (error "Error fetching deck names: %s" err))
(setq result (append (sort result #'string-lessp) nil) (setq result (sort (alist-get 'result response) #'string-lessp)
deckname (completing-read "Choose a deck: " result)) deckname (completing-read "Choose a deck: " result))
(unless prefix (org-insert-heading-respect-content)) (unless prefix (org-insert-heading-respect-content))
(insert deckname) (insert deckname)
(unless prefix (anki-editor--set-tags-fix anki-editor-deck-tag)))))) (unless prefix (anki-editor--set-tags-fix anki-editor-deck-tag))))
;;;###autoload ;;;###autoload
(defun anki-editor-insert-note () (defun anki-editor-insert-note ()
"Insert a note heading that's one level lower to current heading. "Insert the skeleton of a note.
The inserted heading will be structured with the property drawer The contents to be insrted are structured with a note heading
and subheadings that correspond to the fields of the selected that's one level lower to the current one as well as subheadings
note type." that correspond to fields."
(interactive) (interactive)
(message "Fetching note types...") (message "Fetching note types...")
(anki-editor--anki-connect-invoke (let* ((response (anki-editor--anki-connect-invoke "modelNames" 5))
"modelNames" 5 nil (err (alist-get 'error response))
(lambda (note-types) (note-types (alist-get 'result response))
(let (note-type note-heading) note-type note-heading fields)
(setq note-types (append (sort note-types #'string-lessp) nil)
note-type (completing-read "Choose a note type: " note-types)) (when err (error "Error fetching note types: %s" err))
(message "Fetching note fields...") (setq note-types (sort note-types #'string-lessp)
(anki-editor--anki-connect-invoke note-type (completing-read "Choose a note type: " note-types))
"modelFieldNames" 5 `((modelName . ,note-type)) (message "Fetching note fields...")
(lambda (fields) (setq response (anki-editor--anki-connect-invoke "modelFieldNames" 5 `((modelName . ,note-type)))
(setq note-heading (read-from-minibuffer "Enter the heading: " "Item")) fields (alist-get 'result response)
(org-insert-heading-respect-content) note-heading (read-from-minibuffer "Enter the heading: " "Item"))
(org-do-demote) (org-insert-heading-respect-content)
(insert note-heading) (org-do-demote)
(anki-editor--set-tags-fix anki-editor-note-tag) (insert note-heading)
(org-set-property (substring (symbol-name anki-editor-note-type-prop) 1) note-type) (anki-editor--set-tags-fix anki-editor-note-tag)
(seq-each (lambda (field) (org-set-property (substring (symbol-name anki-editor-note-type-prop) 1) note-type)
(save-excursion (seq-each (lambda (field)
(org-insert-heading-respect-content) (save-excursion
(org-do-demote) (org-insert-heading-respect-content)
(insert field))) (org-do-demote)
fields) (insert field)))
(org-next-visible-heading 1) fields)
(end-of-line) (org-next-visible-heading 1)
(newline-and-indent))))))) (end-of-line)
(newline-and-indent)))
;;;###autoload ;;;###autoload
(defun anki-editor-export-heading-contents-to-html () (defun anki-editor-export-heading-contents-to-html ()
@ -142,41 +177,98 @@ note type."
(local-set-key (car map) (cdr map))) (local-set-key (car map) (cdr map)))
(message "anki-editor default keybindings have been set")) (message "anki-editor default keybindings have been set"))
;;;###autoload
(defun anki-editor-anki-connect-upgrade ()
"Upgrade anki-connect to the latest version.
;; Core Functions This will display a confirmation dialog box in Anki asking if you
want to continue. The upgrading is done by downloading the latest
code in the master branch of its Github repo.
(defun anki-editor--get-note-headings (data &optional test) This is useful when new version of this package depends on the
(unless test (setq test 'identity)) bugfixes or new features of anki-connect."
(org-element-map data 'headline (interactive)
(lambda (element) (let* ((response (anki-editor--anki-connect-invoke "upgrade" 5))
(let ((tags (org-element-property :tags element))) (result (alist-get 'result response))
(when (and (member anki-editor-note-tag tags) (funcall test element)) (err (alist-get 'error response)))
element))))) (when err (error err))
(when (and (booleanp result) result)
(message "anki-connect has upgraded, you may have to restart Anki to make it in effect."))))
;;; Core Functions
(defun anki-editor--process-note-heading (deck)
(unless deck (error "No deck specified"))
(let (note-elem note)
(setq note-elem (org-element-at-point)
note-elem (let ((content (buffer-substring
(org-element-property :begin note-elem)
(org-element-property :end note-elem))))
(with-temp-buffer
(insert content)
(car (org-element-contents (org-element-parse-buffer)))))
note (anki-editor--heading-to-note note-elem))
(add-to-list 'note `(deck . ,deck))
(anki-editor--save-note note)))
(defun anki-editor--save-note (note)
(if (= (alist-get 'note-id note) -1)
(anki-editor--create-note note)
(anki-editor--update-note note)))
(defun anki-editor--create-note (note)
(let* ((response (anki-editor--anki-connect-invoke
"addNote" 5 `((note . ,(anki-editor--anki-connect-map-note note)))))
(result (alist-get 'result response))
(err (alist-get 'error response)))
(if result
(org-set-property (substring (symbol-name anki-editor-note-id-prop) 1)
(format "%d" (alist-get 'result response)))
(error (or err "Sorry, the operation was unsuccessful and detailed information is unavailable.")))))
(defun anki-editor--update-note (note)
"Update fields and tags of NOTE."
(let* ((response (anki-editor--anki-connect-invoke
"updateNoteFields" 5 `((note . ,(anki-editor--anki-connect-map-note note)))))
(err (alist-get 'error response)))
(when err (error err))
;; update tags
(let (existing-note added-tags removed-tags)
(setq response (anki-editor--anki-connect-invoke "notesInfo" 5 `(("notes" . (,(alist-get 'note-id note)))))
err (alist-get 'error response))
(when err (error err))
(setq existing-note (car (alist-get 'result response))
added-tags (cl-set-difference (alist-get 'tags note) (alist-get 'tags existing-note) :test #'string-equal)
removed-tags (cl-set-difference (alist-get 'tags existing-note) (alist-get 'tags note) :test #'string-equal))
(when added-tags
(anki-editor--anki-connect-invoke "addTags" 5 `(("notes" . (,(alist-get 'note-id note)))
("tags" . ,(mapconcat #'identity added-tags " ")))))
(when removed-tags
(anki-editor--anki-connect-invoke "removeTags" 5 `(("notes" . (,(alist-get 'note-id note)))
("tags" . ,(mapconcat #'identity removed-tags " "))))))))
(defun anki-editor--set-failure-reason (reason)
(org-set-property (substring (symbol-name anki-editor-note-failure-reason-prop) 1) reason))
(defun anki-editor--clear-failure-reason ()
(org-delete-property (substring (symbol-name anki-editor-note-failure-reason-prop) 1)))
(defun anki-editor--heading-to-note (heading) (defun anki-editor--heading-to-note (heading)
(let (deck note-type tags fields) (let (note-id note-type tags fields)
(setq deck (anki-editor--get-deck-name heading) (setq note-id (org-element-property anki-editor-note-id-prop heading)
note-type (org-element-property anki-editor-note-type-prop heading) note-type (org-element-property anki-editor-note-type-prop heading)
tags (org-element-property anki-editor-note-tags-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))) fields (mapcar #'anki-editor--heading-to-note-field (anki-editor--get-subheadings heading)))
(unless deck (error "Please specify a deck !")) (unless note-type (error "Missing note type"))
(unless note-type (error "Please specify a note type !")) (unless fields (error "Missing fields"))
(unless fields (error "Please specify fields !"))
`((deck . ,deck) `((note-id . ,(string-to-number (or note-id "-1")))
(note-type . ,note-type) (note-type . ,note-type)
(tags . ,(and tags (split-string tags " "))) (tags . ,(and tags (split-string tags " ")))
(fields . ,fields)))) (fields . ,fields))))
(defun anki-editor--get-deck-name (element)
(let ((ancestor (anki-editor--find-ancestor
element
(lambda (it)
(member anki-editor-deck-tag (org-element-property :tags it))))))
(and ancestor
(substring-no-properties (org-element-property :raw-value ancestor)))))
(defun anki-editor--get-subheadings (heading) (defun anki-editor--get-subheadings (heading)
(org-element-map (org-element-contents heading) (org-element-map (org-element-contents heading)
'headline 'identity nil nil 'headline)) 'headline 'identity nil nil 'headline))
@ -203,9 +295,9 @@ note type."
(defun anki-editor--buffer-to-html () (defun anki-editor--buffer-to-html ()
(when (> (buffer-size) 0) (when (> (buffer-size) 0)
(save-mark-and-excursion (insert
(mark-whole-buffer) (org-export-string-as
(org-html-convert-region-to-html)))) (delete-and-extract-region (point-min) (point-max)) 'html t))))
(defun anki-editor--replace-latex () (defun anki-editor--replace-latex ()
(let (object type memo) (let (object type memo)
@ -255,18 +347,11 @@ note type."
(add-to-list 'translated record))) (add-to-list 'translated record)))
(setq anki-editor--replacement-records (cl-set-difference anki-editor--replacement-records translated)))) (setq anki-editor--replacement-records (cl-set-difference anki-editor--replacement-records translated))))
;; Utilities ;;; Utilities
(defun anki-editor--hash (type text) (defun anki-editor--hash (type text)
(sha1 (format "%s %s" (symbol-name type) text))) (sha1 (format "%s %s" (symbol-name type) text)))
(defun anki-editor--find-ancestor (element test)
(let ((parent (org-element-property :parent element)))
(and parent
(if (funcall test parent)
parent
(anki-editor--find-ancestor parent test)))))
(defun anki-editor--set-tags-fix (tags) (defun anki-editor--set-tags-fix (tags)
(org-set-tags-to tags) (org-set-tags-to tags)
(org-fix-tags-on-the-fly)) (org-fix-tags-on-the-fly))
@ -280,9 +365,9 @@ note type."
(insert replacement) (insert replacement)
(cons original replacement))) (cons original replacement)))
;; anki-connect ;;; anki-connect
(defun anki-editor--anki-connect-invoke (action version &optional params success) (defun anki-editor--anki-connect-invoke (action version &optional params)
(let* ((data `(("action" . ,action) (let* ((data `(("action" . ,action)
("version" . ,version))) ("version" . ,version)))
(request-body (json-encode (request-body (json-encode
@ -296,23 +381,28 @@ note type."
(set-buffer-multibyte t) (set-buffer-multibyte t)
(insert request-body)) (insert request-body))
(let* ((response (shell-command-to-string (let* ((raw-resp (shell-command-to-string
(format "curl %s:%s --silent -X POST --data-binary @%s" (format "curl %s:%s --silent -X POST --data-binary @%s"
anki-editor-anki-connect-listening-address anki-editor-anki-connect-listening-address
anki-editor-anki-connect-listening-port anki-editor-anki-connect-listening-port
request-tempfile))) request-tempfile)))
anki-error) resp error)
(when (file-exists-p request-tempfile) (delete-file request-tempfile)) (when (file-exists-p request-tempfile) (delete-file request-tempfile))
(condition-case err (condition-case err
(progn (let ((json-array-type 'list))
(setq response (json-read-from-string response) (setq resp (json-read-from-string raw-resp)
anki-error (alist-get 'error response)) error (alist-get 'error resp)))
(when anki-error (error "anki-connect responded with error: %s" anki-error)) (error (setq error
(when success (funcall success (alist-get 'result response)))) (format "Unexpected error communicating with anki-connect: %s, the response was %s"
(error (message "%s" (error-message-string err))))))) (error-message-string err)
(prin1-to-string raw-resp)))))
`((result . ,(alist-get 'result resp))
(error . ,error)))))
(defun anki-editor--anki-connect-map-note (note) (defun anki-editor--anki-connect-map-note (note)
`(("deckName" . ,(alist-get 'deck note)) `(("id" . ,(alist-get 'note-id note))
("deckName" . ,(alist-get 'deck note))
("modelName" . ,(alist-get 'note-type note)) ("modelName" . ,(alist-get 'note-type note))
("fields" . ,(alist-get 'fields note)) ("fields" . ,(alist-get 'fields note))
;; Convert tags to a vector since empty list is identical to nil ;; Convert tags to a vector since empty list is identical to nil
@ -324,7 +414,7 @@ note type."
(anki-editor--anki-connect-map-note (anki-editor--anki-connect-map-note
(anki-editor--heading-to-note heading))) (anki-editor--heading-to-note heading)))
(provide 'anki-editor) (provide 'anki-editor)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; anki-editor.el ends here ;;; anki-editor.el ends here