2018-06-10 15:13:00 +02:00
|
|
|
;;; anki-editor.el --- Minor mode for making Anki cards with Org -*- lexical-binding: t; -*-
|
2018-01-15 15:11:02 +01:00
|
|
|
;;
|
2019-09-23 09:41:07 +02:00
|
|
|
;; Copyright (C) 2018-2019 Lei Tan <louietanlei[at]gmail[dot]com>
|
2018-01-15 15:11:02 +01:00
|
|
|
;;
|
2018-01-28 10:29:35 +01:00
|
|
|
;; Description: Make Anki Cards in Org-mode
|
2018-06-10 15:13:00 +02:00
|
|
|
;; Author: Lei Tan
|
2018-07-29 05:12:23 +02:00
|
|
|
;; Version: 0.3.3
|
2020-02-10 12:51:02 +01:00
|
|
|
;; Package-Requires: ((emacs "25.1"))
|
2018-01-15 15:11:02 +01:00
|
|
|
;; URL: https://github.com/louietan/anki-editor
|
|
|
|
;;
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
;;
|
|
|
|
;;; Commentary:
|
2019-11-01 15:59:15 +01:00
|
|
|
;;
|
2019-09-23 11:13:10 +02:00
|
|
|
;; This package is for users of both Emacs and Anki, who'd like to
|
|
|
|
;; make Anki cards in Org mode. With this package, Anki cards can be
|
|
|
|
;; made from an Org buffer like below: (inspired by org-drill)
|
2018-01-15 15:11:02 +01:00
|
|
|
;;
|
2019-09-23 11:13:10 +02:00
|
|
|
;; * Sample :emacs:lisp:programming:
|
2018-06-10 15:13:00 +02:00
|
|
|
;; :PROPERTIES:
|
|
|
|
;; :ANKI_DECK: Computing
|
|
|
|
;; :ANKI_NOTE_TYPE: Basic
|
|
|
|
;; :END:
|
|
|
|
;; ** Front
|
2019-09-23 11:13:10 +02:00
|
|
|
;; How to say "hello world" in elisp?
|
2018-06-10 15:13:00 +02:00
|
|
|
;; ** Back
|
|
|
|
;; #+BEGIN_SRC emacs-lisp
|
|
|
|
;; (message "Hello, world!")
|
|
|
|
;; #+END_SRC
|
2018-01-28 10:29:35 +01:00
|
|
|
;;
|
2018-06-10 15:13:00 +02:00
|
|
|
;; This package extends Org-mode's built-in HTML backend to generate
|
|
|
|
;; HTML for contents of note fields with specific syntax (e.g. latex)
|
2019-09-23 11:13:10 +02:00
|
|
|
;; translated to Anki style.
|
2018-01-28 10:29:35 +01:00
|
|
|
;;
|
|
|
|
;; For this package to work, you have to setup these external dependencies:
|
|
|
|
;; - curl
|
2019-09-23 11:13:10 +02:00
|
|
|
;; - AnkiConnect, an Anki addon that runs an RPC server over HTTP to expose
|
|
|
|
;; Anki functions as APIs,
|
|
|
|
;; see https://github.com/FooSoft/anki-connect#installation
|
2018-06-10 15:13:00 +02:00
|
|
|
;; for installation instructions
|
2018-01-15 15:11:02 +01:00
|
|
|
;;
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
;;
|
|
|
|
;; This program is free software: you can redistribute it and/or modify
|
2017-12-27 17:20:01 +01:00
|
|
|
;; it under the terms of the GNU General Public License as published by
|
2018-01-15 15:11:02 +01:00
|
|
|
;; the Free Software Foundation, either version 3 of the License, or (at
|
|
|
|
;; your option) any later version.
|
2017-12-27 17:20:01 +01:00
|
|
|
;;
|
2018-01-15 15:11:02 +01:00
|
|
|
;; 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.
|
2017-12-27 17:20:01 +01:00
|
|
|
;;
|
|
|
|
;; You should have received a copy of the GNU General Public License
|
2018-01-15 15:11:02 +01:00
|
|
|
;; along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
;;
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
;;
|
|
|
|
;;; Code:
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-05-07 15:09:52 +02:00
|
|
|
(require 'cl-lib)
|
2017-12-27 17:20:01 +01:00
|
|
|
(require 'json)
|
|
|
|
(require 'org-element)
|
2018-02-04 02:10:40 +01:00
|
|
|
(require 'ox)
|
2018-05-22 15:09:36 +02:00
|
|
|
(require 'ox-html)
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-01-21 12:22:42 +01:00
|
|
|
(defgroup anki-editor nil
|
|
|
|
"Customizations for anki-editor."
|
|
|
|
:group 'org)
|
|
|
|
|
2018-02-04 04:22:55 +01:00
|
|
|
(defcustom anki-editor-break-consecutive-braces-in-latex
|
|
|
|
nil
|
|
|
|
"If non-nil, consecutive `}' will be automatically separated by spaces to prevent early-closing of cloze.
|
|
|
|
See https://apps.ankiweb.net/docs/manual.html#latex-conflicts.")
|
|
|
|
|
2018-07-29 05:12:23 +02:00
|
|
|
(defcustom anki-editor-org-tags-as-anki-tags
|
|
|
|
t
|
2019-09-23 11:13:10 +02:00
|
|
|
"If nil, tags of entries won't be counted as Anki tags.")
|
2018-07-29 05:12:23 +02:00
|
|
|
|
2018-07-29 07:00:12 +02:00
|
|
|
(defcustom anki-editor-protected-tags
|
|
|
|
'("marked" "leech")
|
2019-09-23 11:13:10 +02:00
|
|
|
"A list of tags that won't be deleted from Anki even though
|
|
|
|
they're absent in Org entries, such as special tags `marked',
|
|
|
|
`leech'."
|
2018-07-29 07:00:12 +02:00
|
|
|
:type '(repeat string))
|
|
|
|
|
|
|
|
(defcustom anki-editor-ignored-org-tags
|
|
|
|
(append org-export-select-tags org-export-exclude-tags)
|
2019-09-23 11:13:10 +02:00
|
|
|
"A list of Org tags that are ignored when constructing notes
|
|
|
|
form entries."
|
2018-07-29 07:00:12 +02:00
|
|
|
:type '(repeat string))
|
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defcustom anki-editor-api-host
|
2018-01-18 10:21:49 +01:00
|
|
|
"127.0.0.1"
|
2018-02-04 02:10:40 +01:00
|
|
|
"The network address AnkiConnect is listening.")
|
2018-01-18 10:21:49 +01:00
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defcustom anki-editor-api-port
|
2018-01-18 10:21:49 +01:00
|
|
|
"8765"
|
2018-02-04 02:10:40 +01:00
|
|
|
"The port number AnkiConnect is listening.")
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2019-11-11 16:03:55 +01:00
|
|
|
(defcustom anki-editor-latex-style 'builtin
|
|
|
|
"The style of latex to translate into."
|
|
|
|
:type '(radio (const :tag "Built-in" builtin)
|
|
|
|
(const :tag "MathJax" mathjax)))
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-01-19 13:32:28 +01:00
|
|
|
|
2019-11-01 15:59:15 +01:00
|
|
|
;;; AnkiConnect
|
2018-05-17 17:09:50 +02:00
|
|
|
|
2019-11-03 10:24:00 +01:00
|
|
|
(defconst anki-editor-api-version 5)
|
|
|
|
|
2020-02-10 12:51:02 +01:00
|
|
|
(cl-defun anki-editor--fetch (url
|
|
|
|
&rest settings
|
|
|
|
&key type data success error parser
|
|
|
|
&allow-other-keys)
|
|
|
|
"This is a simplistic little function to make http requests using cURL.
|
|
|
|
The api is borrowed from request.el. It exists because
|
|
|
|
request.el's sync mode calls cURL asynchronously under the hood,
|
|
|
|
which doesn't work on some machines (like mine) where the process
|
|
|
|
sentinel never gets called. After some debugging of Emacs, it
|
|
|
|
seems that in 'process.c' the pselect syscall to the file
|
|
|
|
descriptor of inotify used by 'autorevert' always returns a
|
|
|
|
nonzero value and causes 'status_notify' never being called. To
|
|
|
|
determine whether it's a bug in Emacs and make a patch requires
|
|
|
|
more digging."
|
|
|
|
(let ((tempfile (make-temp-file "emacs-anki-editor"))
|
|
|
|
(responsebuf (generate-new-buffer " *anki-editor-curl*")))
|
|
|
|
(with-temp-file tempfile
|
|
|
|
(setq buffer-file-coding-system 'utf-8)
|
|
|
|
(set-buffer-multibyte t)
|
|
|
|
(insert data))
|
|
|
|
(unwind-protect
|
|
|
|
(with-current-buffer responsebuf
|
|
|
|
(apply #'call-process "curl" nil t nil (list
|
|
|
|
url
|
|
|
|
"--silent"
|
|
|
|
"-X" type
|
|
|
|
"--data-binary"
|
|
|
|
(concat "@" tempfile)))
|
|
|
|
|
|
|
|
(goto-char (point-min))
|
|
|
|
(apply success (list :data (funcall parser))))
|
|
|
|
(kill-buffer responsebuf)
|
|
|
|
(delete-file tempfile))))
|
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defun anki-editor-api-call (action &rest params)
|
2018-06-10 15:13:00 +02:00
|
|
|
"Invoke AnkiConnect with ACTION and PARAMS."
|
2019-11-02 17:22:27 +01:00
|
|
|
(let ((payload (list :action action :version anki-editor-api-version))
|
2018-05-17 17:09:50 +02:00
|
|
|
(request-backend 'curl)
|
|
|
|
(json-array-type 'list)
|
|
|
|
reply err)
|
2019-11-02 17:22:27 +01:00
|
|
|
|
|
|
|
(when params
|
|
|
|
(plist-put payload :params params))
|
|
|
|
|
2020-02-10 12:51:02 +01:00
|
|
|
(anki-editor--fetch (format "http://%s:%s"
|
|
|
|
anki-editor-api-host
|
|
|
|
anki-editor-api-port)
|
|
|
|
:type "POST"
|
|
|
|
:parser 'json-read
|
|
|
|
:data (json-encode payload)
|
|
|
|
:success (cl-function (lambda (&key data &allow-other-keys)
|
|
|
|
(setq reply data)))
|
|
|
|
:error (cl-function (lambda (&key error-thrown &allow-other-keys)
|
|
|
|
(setq err (string-trim (cdr error-thrown)))))
|
|
|
|
:sync t)
|
2018-05-13 09:52:37 +02:00
|
|
|
(when err (error "Error communicating with AnkiConnect using cURL: %s" err))
|
|
|
|
(or reply (error "Got empty reply from AnkiConnect"))))
|
2018-01-19 13:32:28 +01:00
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defun anki-editor-api-call-result (&rest args)
|
2019-09-23 11:13:10 +02:00
|
|
|
"Invoke AnkiConnect with ARGS, return the result from response
|
|
|
|
or raise an error."
|
2019-11-02 17:22:27 +01:00
|
|
|
(let-alist (apply #'anki-editor-api-call args)
|
|
|
|
(when .error (error .error))
|
|
|
|
.result))
|
2018-01-19 13:32:28 +01:00
|
|
|
|
2019-11-01 15:59:15 +01:00
|
|
|
(defmacro anki-editor-api-with-multi (&rest body)
|
|
|
|
"Use in combination with `anki-editor-api-enqueue' to combine
|
|
|
|
multiple api calls into a single 'multi' call, return the results
|
|
|
|
of these calls in the same order."
|
|
|
|
`(let (--anki-editor-var-multi-actions--
|
|
|
|
--anki-editor-var-multi-results--)
|
|
|
|
,@body
|
|
|
|
(setq --anki-editor-var-multi-results--
|
|
|
|
(anki-editor-api-call-result
|
|
|
|
'multi
|
|
|
|
:actions (nreverse
|
|
|
|
;; Here we make a vector from the action list,
|
|
|
|
;; or `json-encode' will consider it as an association list.
|
|
|
|
(vconcat
|
|
|
|
--anki-editor-var-multi-actions--))))
|
|
|
|
(cl-loop for result in --anki-editor-var-multi-results--
|
|
|
|
do (when-let ((_ (listp result))
|
|
|
|
(err (alist-get 'error result)))
|
|
|
|
(error err))
|
|
|
|
collect result)))
|
|
|
|
|
|
|
|
(defmacro anki-editor-api-enqueue (action &rest params)
|
|
|
|
"Like `anki-editor-api-call', but is only used in combination
|
|
|
|
with `anki-editor-api-with-multi'. Instead of sending the
|
|
|
|
request directly, it simply queues the request."
|
|
|
|
`(let ((action (list :action ,action))
|
|
|
|
(params (list ,@params)))
|
|
|
|
(when params
|
|
|
|
(plist-put action :params params))
|
|
|
|
(push action --anki-editor-var-multi-actions--)))
|
|
|
|
|
|
|
|
(defun anki-editor-api--note (note)
|
2018-02-04 02:10:40 +01:00
|
|
|
"Convert NOTE to the form that AnkiConnect accepts."
|
2019-11-01 15:59:15 +01:00
|
|
|
(list
|
|
|
|
:id (string-to-number (or (anki-editor-note-id note) "0"))
|
|
|
|
:deckName (anki-editor-note-deck note)
|
|
|
|
:modelName (anki-editor-note-model note)
|
|
|
|
:fields (anki-editor-note-fields note)
|
|
|
|
;; Convert tags to a vector since empty list is identical to nil
|
|
|
|
;; which will become None in Python, but AnkiConnect requires it
|
|
|
|
;; to be type of list.
|
|
|
|
:tags (vconcat (anki-editor-note-tags note))))
|
2018-01-19 13:32:28 +01:00
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defun anki-editor-api--store-media-file (path)
|
2018-02-07 17:27:44 +01:00
|
|
|
"Store media file for PATH, which is an absolute file name.
|
|
|
|
The result is the path to the newly stored media file."
|
2019-11-02 17:20:51 +01:00
|
|
|
(let* ((bytes (with-temp-buffer
|
|
|
|
(insert-file-contents-literally path)
|
|
|
|
(buffer-string)))
|
|
|
|
(hash (secure-hash 'sha1 bytes))
|
2018-02-07 17:27:44 +01:00
|
|
|
(media-file-name (format "%s-%s%s"
|
|
|
|
(file-name-base path)
|
|
|
|
hash
|
2019-11-02 17:22:27 +01:00
|
|
|
(file-name-extension path t))))
|
|
|
|
(when (eq :json-false
|
|
|
|
(anki-editor-api-call-result 'retrieveMediaFile
|
|
|
|
:filename media-file-name))
|
2019-09-23 09:41:07 +02:00
|
|
|
(message "Storing media file %s to Anki, this might take a while" path)
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'storeMediaFile
|
|
|
|
:filename media-file-name
|
|
|
|
:data (base64-encode-string bytes)))
|
2018-02-07 17:27:44 +01:00
|
|
|
media-file-name))
|
|
|
|
|
2018-05-17 17:09:50 +02:00
|
|
|
|
2019-11-03 10:24:00 +01:00
|
|
|
;;; Org export backend
|
2018-05-07 15:09:52 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(defconst anki-editor--ox-anki-html-backend
|
2019-11-03 10:24:00 +01:00
|
|
|
(org-export-create-backend
|
|
|
|
:parent 'html
|
|
|
|
:transcoders '((latex-fragment . anki-editor--ox-latex)
|
|
|
|
(latex-environment . anki-editor--ox-latex))))
|
2018-01-14 08:53:27 +01:00
|
|
|
|
2018-07-08 08:58:46 +02:00
|
|
|
(defconst anki-editor--ox-export-ext-plist
|
|
|
|
'(:with-toc nil :anki-editor-mode t))
|
|
|
|
|
2019-11-03 10:24:00 +01:00
|
|
|
(cl-macrolet ((with-table (table)
|
|
|
|
`(cl-loop for delims in ,table
|
|
|
|
collect
|
|
|
|
(list (concat "^" (regexp-quote (cl-first delims)))
|
|
|
|
(cl-second delims)
|
|
|
|
(concat (regexp-quote (cl-third delims)) "$")
|
|
|
|
(cl-fourth delims)))))
|
|
|
|
|
|
|
|
(defconst anki-editor--native-latex-delimiters
|
|
|
|
(with-table '(("$$" "[$$]"
|
|
|
|
"$$" "[/$$]")
|
|
|
|
("$" "[$]"
|
|
|
|
"$" "[/$]")
|
|
|
|
("\\(" "[$]"
|
|
|
|
"\\)" "[/$]")
|
|
|
|
("\\[" "[$$]"
|
|
|
|
"\\]" "[/$$]"))))
|
|
|
|
|
|
|
|
(defconst anki-editor--mathjax-delimiters
|
|
|
|
(with-table '(("$$" "\\["
|
|
|
|
"$$" "\\]")
|
|
|
|
("$" "\\("
|
|
|
|
"$" "\\)")))))
|
|
|
|
|
|
|
|
(defun anki-editor--translate-latex-fragment (latex-code)
|
2019-11-11 16:03:55 +01:00
|
|
|
(cl-loop for delims in (cl-ecase anki-editor-latex-style
|
|
|
|
(builtin anki-editor--native-latex-delimiters)
|
|
|
|
(mathjax anki-editor--mathjax-delimiters))
|
|
|
|
for matches = (string-match (cl-first delims) latex-code)
|
|
|
|
when matches
|
|
|
|
do
|
|
|
|
(setq latex-code (replace-match (cl-second delims) t t latex-code))
|
|
|
|
(string-match (cl-third delims) latex-code)
|
|
|
|
(setq latex-code (replace-match (cl-fourth delims) t t latex-code))
|
|
|
|
until matches
|
|
|
|
finally return latex-code))
|
2019-11-03 10:24:00 +01:00
|
|
|
|
|
|
|
(defun anki-editor--translate-latex-env (latex-code)
|
|
|
|
(setq latex-code (replace-regexp-in-string "\n" "<br>" (org-html-encode-plain-text latex-code)))
|
2019-11-11 16:03:55 +01:00
|
|
|
(cl-ecase anki-editor-latex-style
|
|
|
|
(builtin (concat "[latex]<br>" latex-code "[/latex]"))
|
|
|
|
(mathjax (concat "\\[<br>" latex-code "\\]"))))
|
2018-05-07 15:09:52 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(defun anki-editor--ox-latex (latex _contents _info)
|
|
|
|
"Transcode LATEX from Org to HTML.
|
|
|
|
CONTENTS is nil. INFO is a plist holding contextual information."
|
2018-06-16 10:59:32 +02:00
|
|
|
(let ((code (org-remove-indentation (org-element-property :value latex))))
|
2019-11-03 10:24:00 +01:00
|
|
|
(setq code (cl-ecase (org-element-type latex)
|
|
|
|
(latex-fragment (anki-editor--translate-latex-fragment code))
|
|
|
|
(latex-environment (anki-editor--translate-latex-env code))))
|
2019-06-07 14:03:40 +02:00
|
|
|
(if anki-editor-break-consecutive-braces-in-latex
|
|
|
|
(replace-regexp-in-string "}}" "} } " code)
|
|
|
|
code)))
|
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(defun anki-editor--ox-html-link (oldfun link desc info)
|
|
|
|
"When LINK is a link to local file, transcodes it to html and stores the target file to Anki, otherwise calls OLDFUN for help.
|
|
|
|
The implementation is borrowed and simplified from ox-html."
|
|
|
|
(or (catch 'giveup
|
2018-07-08 08:58:46 +02:00
|
|
|
(unless (plist-get info :anki-editor-mode)
|
|
|
|
(throw 'giveup nil))
|
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(let* ((type (org-element-property :type link))
|
|
|
|
(raw-path (org-element-property :path link))
|
|
|
|
(desc (org-string-nw-p desc))
|
|
|
|
(path
|
|
|
|
(cond
|
|
|
|
((string= type "file")
|
|
|
|
;; Possibly append `:html-link-home' to relative file
|
|
|
|
;; name.
|
|
|
|
(let ((inhibit-message nil)
|
|
|
|
(home (and (plist-get info :html-link-home)
|
|
|
|
(org-trim (plist-get info :html-link-home)))))
|
|
|
|
(when (and home
|
|
|
|
(plist-get info :html-link-use-abs-url)
|
|
|
|
(file-name-absolute-p raw-path))
|
|
|
|
(setq raw-path (concat (file-name-as-directory home) raw-path)))
|
|
|
|
;; storing file to Anki and return the modified path
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api--store-media-file (expand-file-name (url-unhex-string raw-path)))))
|
2018-06-11 12:00:19 +02:00
|
|
|
(t (throw 'giveup nil))))
|
|
|
|
(attributes-plist
|
|
|
|
(let* ((parent (org-export-get-parent-element link))
|
|
|
|
(link (let ((container (org-export-get-parent link)))
|
|
|
|
(if (and (eq (org-element-type container) 'link)
|
|
|
|
(org-html-inline-image-p link info))
|
|
|
|
container
|
|
|
|
link))))
|
|
|
|
(and (eq (org-element-map parent 'link 'identity info t) link)
|
|
|
|
(org-export-read-attribute :attr_html parent))))
|
|
|
|
(attributes
|
|
|
|
(let ((attr (org-html--make-attribute-string attributes-plist)))
|
|
|
|
(if (org-string-nw-p attr) (concat " " attr) ""))))
|
|
|
|
(cond
|
|
|
|
;; Image file.
|
|
|
|
((and (plist-get info :html-inline-images)
|
|
|
|
(org-export-inline-image-p
|
|
|
|
link (plist-get info :html-inline-image-rules)))
|
|
|
|
(org-html--format-image path attributes-plist info))
|
2018-01-22 14:57:35 +01:00
|
|
|
|
2018-08-12 03:52:53 +02:00
|
|
|
;; Audio file.
|
|
|
|
((string-suffix-p ".mp3" path t)
|
2019-11-02 17:22:27 +01:00
|
|
|
(format "[sound:%s]" path))
|
2018-08-12 03:52:53 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
;; External link with a description part.
|
|
|
|
((and path desc) (format "<a href=\"%s\"%s>%s</a>"
|
|
|
|
(org-html-encode-plain-text path)
|
|
|
|
attributes
|
|
|
|
desc))
|
2018-06-08 17:04:27 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
;; External link without a description part.
|
|
|
|
(path (let ((path (org-html-encode-plain-text path)))
|
|
|
|
(format "<a href=\"%s\"%s>%s</a>"
|
|
|
|
path
|
|
|
|
attributes
|
|
|
|
(org-link-unescape path))))
|
2018-01-16 13:00:19 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(t (throw 'giveup nil)))))
|
|
|
|
(funcall oldfun link desc info)))
|
2018-01-31 16:08:24 +01:00
|
|
|
|
2018-01-06 14:03:05 +01:00
|
|
|
|
2019-11-03 10:24:00 +01:00
|
|
|
;;; Core primitives
|
|
|
|
|
|
|
|
(defconst anki-editor-prop-note-type "ANKI_NOTE_TYPE")
|
|
|
|
(defconst anki-editor-prop-note-id "ANKI_NOTE_ID")
|
|
|
|
(defconst anki-editor-prop-exporter "ANKI_EXPORTER")
|
|
|
|
(defconst anki-editor-prop-deck "ANKI_DECK")
|
|
|
|
(defconst anki-editor-prop-tags "ANKI_TAGS")
|
|
|
|
(defconst anki-editor-prop-tags-plus (concat anki-editor-prop-tags "+"))
|
|
|
|
(defconst anki-editor-prop-failure-reason "ANKI_FAILURE_REASON")
|
|
|
|
(defconst anki-editor-org-tag-regexp "^\\([[:alnum:]_@#%]+\\)+$")
|
|
|
|
(defconst anki-editor-exporter-raw "raw")
|
|
|
|
(defconst anki-editor-exporter-default "default")
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2019-11-01 15:59:15 +01:00
|
|
|
(cl-defstruct anki-editor-note
|
|
|
|
id model deck fields tags)
|
|
|
|
|
2018-07-06 15:10:03 +02:00
|
|
|
(defun anki-editor-map-note-entries (func &optional match scope &rest skip)
|
2019-09-23 11:13:10 +02:00
|
|
|
"Simple wrapper that calls `org-map-entries' with
|
|
|
|
`&ANKI_NOTE_TYPE<>\"\"' appended to MATCH."
|
2018-07-06 15:10:03 +02:00
|
|
|
;; disable property inheritance temporarily, or all subheadings of a
|
|
|
|
;; note heading will be counted as note headings as well
|
|
|
|
(let ((org-use-property-inheritance nil))
|
|
|
|
(org-map-entries func (concat match "&" anki-editor-prop-note-type "<>\"\"") scope skip)))
|
2018-01-14 08:53:27 +01:00
|
|
|
|
2018-06-08 17:04:27 +02:00
|
|
|
(defun anki-editor--insert-note-skeleton (prefix deck heading note-type fields)
|
2018-05-06 08:01:06 +02:00
|
|
|
"Insert a note subtree (skeleton) with HEADING, NOTE-TYPE and FIELDS.
|
|
|
|
Where the subtree is created depends on PREFIX."
|
|
|
|
(org-insert-heading prefix)
|
2018-01-22 14:57:35 +01:00
|
|
|
(insert heading)
|
2018-06-08 17:04:27 +02:00
|
|
|
(unless (save-excursion
|
|
|
|
(org-up-heading-safe)
|
|
|
|
;; don't insert `ANKI_DECK' if some ancestor already has
|
|
|
|
;; the same value
|
|
|
|
(and (not (string-blank-p deck))
|
|
|
|
(string= deck (org-entry-get-with-inheritance anki-editor-prop-deck))))
|
|
|
|
(org-set-property anki-editor-prop-deck deck))
|
2018-05-06 17:04:02 +02:00
|
|
|
(org-set-property anki-editor-prop-note-type note-type)
|
2018-01-22 14:57:35 +01:00
|
|
|
(dolist (field fields)
|
|
|
|
(save-excursion
|
|
|
|
(org-insert-heading-respect-content)
|
|
|
|
(org-do-demote)
|
2018-02-04 02:10:40 +01:00
|
|
|
(insert field))))
|
2018-01-22 14:57:35 +01:00
|
|
|
|
2018-07-01 16:06:43 +02:00
|
|
|
(defun anki-editor--push-note (note)
|
2018-02-04 02:10:40 +01:00
|
|
|
"Request AnkiConnect for updating or creating NOTE."
|
2019-11-01 15:59:15 +01:00
|
|
|
(cond
|
|
|
|
((null (anki-editor-note-id note))
|
|
|
|
(anki-editor--create-note note))
|
|
|
|
(t
|
|
|
|
(anki-editor--update-note note))))
|
2018-01-14 08:53:27 +01:00
|
|
|
|
2018-05-17 17:09:50 +02:00
|
|
|
(defun anki-editor--set-note-id (id)
|
|
|
|
(unless id
|
|
|
|
(error "Note creation failed for unknown reason"))
|
|
|
|
(org-set-property anki-editor-prop-note-id (number-to-string id)))
|
|
|
|
|
2018-01-14 08:53:27 +01:00
|
|
|
(defun anki-editor--create-note (note)
|
2018-02-04 02:10:40 +01:00
|
|
|
"Request AnkiConnect for creating NOTE."
|
2019-11-01 15:59:15 +01:00
|
|
|
(thread-last
|
|
|
|
(anki-editor-api-with-multi
|
|
|
|
(anki-editor-api-enqueue 'createDeck
|
|
|
|
:deck (anki-editor-note-deck note))
|
|
|
|
(anki-editor-api-enqueue 'addNote
|
|
|
|
:note (anki-editor-api--note note)))
|
|
|
|
(nth 1)
|
|
|
|
(anki-editor--set-note-id)))
|
2018-01-14 08:53:27 +01:00
|
|
|
|
|
|
|
(defun anki-editor--update-note (note)
|
2018-02-04 02:10:40 +01:00
|
|
|
"Request AnkiConnect for updating fields and tags of NOTE."
|
2019-11-01 15:59:15 +01:00
|
|
|
(let* ((oldnote (caar (anki-editor-api-with-multi
|
|
|
|
(anki-editor-api-enqueue 'notesInfo
|
|
|
|
:notes (list (anki-editor-note-id note)))
|
|
|
|
(anki-editor-api-enqueue 'updateNoteFields
|
|
|
|
:note (anki-editor-api--note note)))))
|
|
|
|
(tagsadd (cl-set-difference (anki-editor-note-tags note)
|
|
|
|
(alist-get 'tags oldnote)
|
|
|
|
:test 'string=))
|
|
|
|
(tagsdel (thread-first (alist-get 'tags oldnote)
|
|
|
|
(cl-set-difference (anki-editor-note-tags note) :test 'string=)
|
|
|
|
(cl-set-difference anki-editor-protected-tags :test 'string=))))
|
|
|
|
(anki-editor-api-with-multi
|
|
|
|
(when tagsadd
|
|
|
|
(anki-editor-api-enqueue 'addTags
|
|
|
|
:notes (list (anki-editor-note-id note))
|
|
|
|
:tags (mapconcat #'identity tagsadd " ")))
|
|
|
|
(when tagsdel
|
|
|
|
(anki-editor-api-enqueue 'removeTags
|
|
|
|
:notes (list (anki-editor-note-id note))
|
|
|
|
:tags (mapconcat #'identity tagsdel " "))))))
|
2018-04-15 09:36:58 +02:00
|
|
|
|
2018-01-14 08:53:27 +01:00
|
|
|
(defun anki-editor--set-failure-reason (reason)
|
2018-01-19 13:21:06 +01:00
|
|
|
"Set failure reason to REASON in property drawer at point."
|
2018-05-06 17:04:02 +02:00
|
|
|
(org-entry-put nil anki-editor-prop-failure-reason reason))
|
2018-01-14 08:53:27 +01:00
|
|
|
|
|
|
|
(defun anki-editor--clear-failure-reason ()
|
2018-01-19 13:21:06 +01:00
|
|
|
"Clear failure reason in property drawer at point."
|
2018-05-06 17:04:02 +02:00
|
|
|
(org-entry-delete nil anki-editor-prop-failure-reason))
|
2018-04-15 09:31:45 +02:00
|
|
|
|
2018-05-17 17:09:50 +02:00
|
|
|
(defun anki-editor--get-allowed-values-for-property (property)
|
|
|
|
"Get allowed values for PROPERTY."
|
|
|
|
(pcase property
|
|
|
|
((pred (string= anki-editor-prop-deck)) (anki-editor-deck-names))
|
|
|
|
((pred (string= anki-editor-prop-note-type)) (anki-editor-note-types))
|
2019-10-01 16:04:43 +02:00
|
|
|
((pred (string= anki-editor-prop-exporter)) (list anki-editor-exporter-raw anki-editor-exporter-default))
|
2018-09-23 20:41:15 +02:00
|
|
|
((pred (string-match-p (format "%s\\+?" anki-editor-prop-tags))) (anki-editor-all-tags))
|
2018-05-17 17:09:50 +02:00
|
|
|
(_ nil)))
|
|
|
|
|
2018-05-06 10:46:54 +02:00
|
|
|
(defun anki-editor-is-valid-org-tag (tag)
|
|
|
|
"Check if string TAG can be used as an Org tag."
|
|
|
|
(string-match-p anki-editor-org-tag-regexp tag))
|
|
|
|
|
2018-05-06 08:01:06 +02:00
|
|
|
(defun anki-editor-all-tags ()
|
|
|
|
"Get all tags from Anki."
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'getTags))
|
2018-05-06 08:01:06 +02:00
|
|
|
|
2018-05-06 17:04:02 +02:00
|
|
|
(defun anki-editor-deck-names ()
|
|
|
|
"Get all decks names from Anki."
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'deckNames))
|
2018-05-06 17:04:02 +02:00
|
|
|
|
2018-07-29 05:12:23 +02:00
|
|
|
(defun anki-editor--enable-tag-completion ()
|
|
|
|
(and anki-editor-mode anki-editor-org-tags-as-anki-tags))
|
|
|
|
|
2018-05-06 08:01:06 +02:00
|
|
|
(defun anki-editor--before-set-tags (&optional _ just-align)
|
2018-07-08 08:58:46 +02:00
|
|
|
"Fetch and cache tags from Anki."
|
2018-07-29 05:12:23 +02:00
|
|
|
(when (and (anki-editor--enable-tag-completion)
|
|
|
|
(not just-align))
|
2018-07-29 11:18:59 +02:00
|
|
|
(setq anki-editor--anki-tags-cache (anki-editor-all-tags))
|
2019-11-01 15:59:15 +01:00
|
|
|
(when (cl-notevery #'anki-editor-is-valid-org-tag anki-editor--anki-tags-cache)
|
2018-07-29 11:18:59 +02:00
|
|
|
(warn "Some tags from Anki contain characters that are not valid in Org tags."))))
|
2018-05-06 08:01:06 +02:00
|
|
|
|
|
|
|
(defun anki-editor--get-buffer-tags (oldfun)
|
|
|
|
"Append tags from Anki to the result of applying OLDFUN."
|
2018-07-08 08:58:46 +02:00
|
|
|
(append (funcall oldfun)
|
2018-07-29 05:12:23 +02:00
|
|
|
(when (anki-editor--enable-tag-completion)
|
2018-07-08 08:58:46 +02:00
|
|
|
(mapcar #'list anki-editor--anki-tags-cache))))
|
2018-05-06 08:01:06 +02:00
|
|
|
|
2018-05-06 17:04:02 +02:00
|
|
|
(defun anki-editor-note-types ()
|
|
|
|
"Get note types from Anki."
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'modelNames))
|
2018-05-06 17:04:02 +02:00
|
|
|
|
2018-07-01 16:06:43 +02:00
|
|
|
(defun anki-editor-note-at-point ()
|
2019-11-01 15:59:15 +01:00
|
|
|
"Make a note struct from current entry."
|
2018-05-13 09:52:37 +02:00
|
|
|
(let ((org-trust-scanner-tags t)
|
2018-05-17 17:09:50 +02:00
|
|
|
(deck (org-entry-get-with-inheritance anki-editor-prop-deck))
|
|
|
|
(note-id (org-entry-get nil anki-editor-prop-note-id))
|
|
|
|
(note-type (org-entry-get nil anki-editor-prop-note-type))
|
2019-11-01 15:59:15 +01:00
|
|
|
(tags (cl-set-difference (anki-editor--get-tags)
|
|
|
|
anki-editor-ignored-org-tags
|
|
|
|
:test 'string=))
|
2018-07-01 16:06:43 +02:00
|
|
|
(fields (anki-editor--build-fields)))
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-05-06 17:04:02 +02:00
|
|
|
(unless deck (error "No deck specified"))
|
2018-01-14 08:53:27 +01:00
|
|
|
(unless note-type (error "Missing note type"))
|
|
|
|
(unless fields (error "Missing fields"))
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2019-11-01 15:59:15 +01:00
|
|
|
(make-anki-editor-note :id note-id
|
|
|
|
:model note-type
|
|
|
|
:deck deck
|
|
|
|
:tags tags
|
|
|
|
:fields fields)))
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-07-29 05:12:23 +02:00
|
|
|
(defun anki-editor--get-tags ()
|
2018-09-23 20:41:15 +02:00
|
|
|
(let ((tags (anki-editor--entry-get-multivalued-property-with-inheritance
|
2018-07-29 05:12:23 +02:00
|
|
|
nil
|
|
|
|
anki-editor-prop-tags)))
|
|
|
|
(if anki-editor-org-tags-as-anki-tags
|
2019-11-01 15:59:15 +01:00
|
|
|
(append tags (org-get-tags))
|
2018-07-29 05:12:23 +02:00
|
|
|
tags)))
|
|
|
|
|
2018-09-23 20:41:15 +02:00
|
|
|
(defun anki-editor--entry-get-multivalued-property-with-inheritance (pom property)
|
|
|
|
"Return a list of values in a multivalued property with inheritance."
|
|
|
|
(let* ((value (org-entry-get pom property t))
|
|
|
|
(values (and value (split-string value))))
|
|
|
|
(mapcar #'org-entry-restore-space values)))
|
|
|
|
|
2018-07-01 16:06:43 +02:00
|
|
|
(defun anki-editor--build-fields ()
|
2019-09-23 09:41:07 +02:00
|
|
|
"Build a list of fields from subheadings of current heading,
|
|
|
|
each element of which is a cons cell, the car of which is field
|
|
|
|
name and the cdr of which is field content."
|
2018-07-01 16:06:43 +02:00
|
|
|
(save-excursion
|
|
|
|
(let (fields
|
|
|
|
(point-of-last-child (point)))
|
|
|
|
(when (org-goto-first-child)
|
|
|
|
(while (/= point-of-last-child (point))
|
|
|
|
(setq point-of-last-child (point))
|
|
|
|
(let* ((inhibit-message t) ;; suppress echo message from `org-babel-exp-src-block'
|
|
|
|
(field-heading (org-element-at-point))
|
|
|
|
(field-name (substring-no-properties
|
|
|
|
(org-element-property
|
|
|
|
:raw-value
|
2018-07-06 15:10:03 +02:00
|
|
|
field-heading)))
|
|
|
|
(contents-begin (org-element-property :contents-begin field-heading))
|
2019-10-01 16:04:43 +02:00
|
|
|
(contents-end (org-element-property :contents-end field-heading))
|
|
|
|
(exporter (or (org-entry-get-with-inheritance anki-editor-prop-exporter)
|
|
|
|
anki-editor-exporter-default))
|
|
|
|
(end-of-header (org-element-property :contents-begin field-heading))
|
|
|
|
raw-content
|
|
|
|
content-elem)
|
|
|
|
(when (string= exporter anki-editor-exporter-raw)
|
|
|
|
;; contents-begin includes drawers and scheduling data,
|
|
|
|
;; which we'd like to ignore, here we skip these
|
|
|
|
;; elements and reset contents-begin.
|
|
|
|
(while (progn
|
|
|
|
(goto-char end-of-header)
|
|
|
|
(setq content-elem (org-element-context))
|
|
|
|
(memq (car content-elem) '(drawer planning property-drawer)))
|
|
|
|
(setq end-of-header (org-element-property :end content-elem)))
|
|
|
|
(setq contents-begin (org-element-property :begin content-elem)))
|
|
|
|
(setq raw-content (or (and contents-begin
|
|
|
|
contents-end
|
|
|
|
(buffer-substring
|
|
|
|
contents-begin
|
|
|
|
;; in case the buffer is narrowed,
|
|
|
|
;; e.g. by `org-map-entries' when
|
|
|
|
;; scope is `tree'
|
|
|
|
(min (point-max) contents-end)))
|
|
|
|
""))
|
2018-07-01 16:06:43 +02:00
|
|
|
(push (cons field-name
|
2019-10-01 16:04:43 +02:00
|
|
|
(pcase exporter
|
|
|
|
((pred (string= anki-editor-exporter-raw))
|
|
|
|
raw-content)
|
|
|
|
((pred (string= anki-editor-exporter-default))
|
|
|
|
(or (org-export-string-as
|
|
|
|
raw-content
|
|
|
|
anki-editor--ox-anki-html-backend
|
|
|
|
t
|
|
|
|
anki-editor--ox-export-ext-plist)
|
|
|
|
;; 8.2.10 version of
|
|
|
|
;; `org-export-filter-apply-functions'
|
|
|
|
;; returns nil for an input of empty string,
|
|
|
|
;; which will cause AnkiConnect to fail
|
|
|
|
""))
|
|
|
|
(_ (error "Invalid exporter: %s" exporter))))
|
2018-07-01 16:06:43 +02:00
|
|
|
fields)
|
|
|
|
(org-forward-heading-same-level nil t))))
|
|
|
|
(reverse fields))))
|
2018-02-04 02:10:40 +01:00
|
|
|
|
2018-05-17 17:09:50 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
;;; Minor mode
|
2018-02-04 02:10:40 +01:00
|
|
|
|
2018-07-08 08:58:46 +02:00
|
|
|
(defvar-local anki-editor--anki-tags-cache nil)
|
|
|
|
|
2018-09-23 20:41:15 +02:00
|
|
|
(defun anki-editor--concat-multivalued-property-value (prop value)
|
|
|
|
(let ((old-values (org-entry-get-multivalued-property nil prop)))
|
|
|
|
(unless (string-suffix-p prop "+")
|
2019-11-01 15:59:15 +01:00
|
|
|
(setq old-values (cl-set-difference old-values
|
|
|
|
(org-entry-get-multivalued-property
|
|
|
|
nil (concat prop "+"))
|
|
|
|
:test 'string=)))
|
2018-09-23 20:41:15 +02:00
|
|
|
(mapconcat #'org-entry-protect-space
|
|
|
|
(append old-values (list value))
|
|
|
|
" ")))
|
|
|
|
|
|
|
|
(setq org-properties-postprocess-alist
|
|
|
|
(append org-properties-postprocess-alist
|
|
|
|
(list (cons anki-editor-prop-tags
|
|
|
|
(lambda (value)
|
|
|
|
(anki-editor--concat-multivalued-property-value anki-editor-prop-tags value)))
|
|
|
|
(cons anki-editor-prop-tags-plus
|
|
|
|
(lambda (value)
|
|
|
|
(anki-editor--concat-multivalued-property-value anki-editor-prop-tags-plus value))))))
|
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
;;;###autoload
|
|
|
|
(define-minor-mode anki-editor-mode
|
2019-06-07 13:58:20 +02:00
|
|
|
"anki-editor-mode"
|
2018-06-11 12:00:19 +02:00
|
|
|
:lighter " anki-editor"
|
|
|
|
(if anki-editor-mode (anki-editor-setup-minor-mode)
|
|
|
|
(anki-editor-teardown-minor-mode)))
|
2018-02-04 02:10:40 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(defun anki-editor-setup-minor-mode ()
|
|
|
|
"Set up this minor mode."
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-check)
|
2018-07-06 15:42:24 +02:00
|
|
|
(add-hook 'org-property-allowed-value-functions #'anki-editor--get-allowed-values-for-property nil t)
|
2018-06-11 12:00:19 +02:00
|
|
|
(advice-add 'org-set-tags :before #'anki-editor--before-set-tags)
|
2018-07-08 08:58:46 +02:00
|
|
|
(advice-add 'org-get-buffer-tags :around #'anki-editor--get-buffer-tags)
|
2018-06-11 12:00:19 +02:00
|
|
|
(advice-add 'org-html-link :around #'anki-editor--ox-html-link))
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
(defun anki-editor-teardown-minor-mode ()
|
|
|
|
"Tear down this minor mode."
|
2018-07-08 08:58:46 +02:00
|
|
|
(remove-hook 'org-property-allowed-value-functions #'anki-editor--get-allowed-values-for-property t))
|
2018-01-08 11:20:11 +01:00
|
|
|
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
;;; Commands
|
2018-02-04 04:22:55 +01:00
|
|
|
|
2019-09-23 09:41:07 +02:00
|
|
|
(defun anki-editor-push-notes (&optional scope match)
|
2019-09-23 11:13:10 +02:00
|
|
|
"Build notes from headings that match MATCH within SCOPE and push them to Anki.
|
2017-12-27 17:20:01 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
The default search condition `&ANKI_NOTE_TYPE<>\"\"' will always
|
|
|
|
be appended to MATCH.
|
2018-05-22 15:09:36 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
For notes that already exist in Anki (i.e. has `ANKI_NOTE_ID'
|
|
|
|
property), only their fields and tags will be updated, change of
|
|
|
|
deck or note type are currently not supported.
|
2018-05-22 15:09:36 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
If SCOPE is not specified, the following rules are applied to
|
|
|
|
determine the scope:
|
2018-05-22 15:09:36 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
- If there's an active region, it will be set to `region'
|
|
|
|
- If called with prefix `C-u', it will be set to `tree'
|
|
|
|
- If called with prefix double `C-u', it will be set to `file'
|
|
|
|
- If called with prefix triple `C-u', will be set to `agenda'
|
2018-02-07 17:27:44 +01:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
See doc string of `org-map-entries' for what these different options mean.
|
2018-05-17 17:09:50 +02:00
|
|
|
|
2018-06-11 12:00:19 +02:00
|
|
|
If one fails, the failure reason will be set in property drawer
|
|
|
|
of that heading."
|
2019-09-23 09:41:07 +02:00
|
|
|
(interactive (list (cond
|
|
|
|
((region-active-p) 'region)
|
|
|
|
((equal current-prefix-arg '(4)) 'tree)
|
|
|
|
((equal current-prefix-arg '(16)) 'file)
|
|
|
|
((equal current-prefix-arg '(64)) 'agenda)
|
|
|
|
(t nil))))
|
|
|
|
|
|
|
|
(let ((total (progn
|
|
|
|
(message "Counting notes...")
|
|
|
|
(length (anki-editor-map-note-entries t match scope))))
|
|
|
|
(acc 0)
|
|
|
|
(failed 0))
|
2018-07-06 15:10:03 +02:00
|
|
|
(anki-editor-map-note-entries
|
|
|
|
(lambda ()
|
|
|
|
(message "[%d/%d] Processing notes in buffer \"%s\", wait a moment..."
|
|
|
|
(cl-incf acc) total (buffer-name))
|
|
|
|
(anki-editor--clear-failure-reason)
|
2019-09-22 14:23:39 +02:00
|
|
|
(condition-case-unless-debug err
|
2018-07-06 15:10:03 +02:00
|
|
|
(anki-editor--push-note (anki-editor-note-at-point))
|
|
|
|
(error (cl-incf failed)
|
|
|
|
(anki-editor--set-failure-reason (error-message-string err)))))
|
2019-09-23 09:41:07 +02:00
|
|
|
match scope)
|
|
|
|
|
|
|
|
(message
|
|
|
|
(cond
|
|
|
|
((zerop total) "Nothing to push")
|
|
|
|
((zerop failed) (format "Pushed %d notes to Anki successfully" acc))
|
|
|
|
(t (format "Pushed %d notes in total, among which %d were failed. Check property drawers for failure reasons.
|
|
|
|
When the issues are resolved, you could repush the failed ones with `anki-editor-retry-failed-notes'."
|
|
|
|
acc failed))))))
|
|
|
|
|
|
|
|
(defun anki-editor-push-new-notes (&optional scope)
|
|
|
|
"Push note entries without ANKI_NOTE_ID in SCOPE to Anki."
|
|
|
|
(interactive)
|
|
|
|
(anki-editor-push-notes scope (concat anki-editor-prop-note-id "=\"\"")))
|
2018-06-11 12:00:19 +02:00
|
|
|
|
2019-09-23 09:41:07 +02:00
|
|
|
(defun anki-editor-retry-failed-notes (&optional scope)
|
|
|
|
"Retry pushing notes marked as failed.
|
2018-06-11 12:00:19 +02:00
|
|
|
This command just calls `anki-editor-submit' with match string
|
|
|
|
matching non-empty `ANKI_FAILURE_REASON' properties."
|
2019-09-23 09:41:07 +02:00
|
|
|
(interactive)
|
|
|
|
(anki-editor-push-notes scope (concat anki-editor-prop-failure-reason "<>\"\"")))
|
|
|
|
|
|
|
|
(defun anki-editor-delete-notes (noteids)
|
|
|
|
"Delete notes in NOTEIDS or the note at point."
|
|
|
|
(interactive (list (list (org-entry-get nil anki-editor-prop-note-id))))
|
|
|
|
(when (or (not (called-interactively-p 'interactive))
|
|
|
|
(yes-or-no-p (format "Do you really want to delete note %s? The deletion can't be undone. " (nth 0 noteids))))
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'deleteNotes
|
|
|
|
:notes noteids)
|
2019-09-23 09:41:07 +02:00
|
|
|
(org-entry-delete nil anki-editor-prop-note-id)
|
|
|
|
(when (called-interactively-p 'interactive)
|
|
|
|
(message "Deleted note %s" (nth 0 noteids)))))
|
2018-06-11 12:00:19 +02:00
|
|
|
|
|
|
|
(defun anki-editor-insert-note (&optional prefix)
|
|
|
|
"Insert a note interactively.
|
|
|
|
|
|
|
|
Where the note subtree is placed depends on PREFIX, which is the
|
|
|
|
same as how it is used by `M-RET'(org-insert-heading)."
|
|
|
|
(interactive "P")
|
|
|
|
(message "Fetching note types...")
|
|
|
|
(let* ((deck (or (org-entry-get-with-inheritance anki-editor-prop-deck)
|
|
|
|
(progn
|
|
|
|
(message "Fetching decks...")
|
|
|
|
(completing-read "Choose a deck: "
|
|
|
|
(sort (anki-editor-deck-names) #'string-lessp)))))
|
|
|
|
(note-type (completing-read "Choose a note type: "
|
|
|
|
(sort (anki-editor-note-types) #'string-lessp)))
|
|
|
|
(fields (progn
|
|
|
|
(message "Fetching note fields...")
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'modelFieldNames
|
|
|
|
:modelName note-type)))
|
2018-06-11 12:00:19 +02:00
|
|
|
(note-heading (read-from-minibuffer "Enter the note heading (optional): ")))
|
|
|
|
|
|
|
|
(anki-editor--insert-note-skeleton prefix
|
|
|
|
deck
|
|
|
|
(if (string-blank-p note-heading)
|
|
|
|
"Item"
|
|
|
|
note-heading)
|
|
|
|
note-type
|
|
|
|
fields)))
|
|
|
|
|
2018-09-05 15:21:15 +02:00
|
|
|
(defun anki-editor-cloze-region (&optional arg hint)
|
2018-06-11 12:00:19 +02:00
|
|
|
"Cloze region with number ARG."
|
2018-09-05 15:21:15 +02:00
|
|
|
(interactive "p\nsHint (optional): ")
|
2018-06-11 12:00:19 +02:00
|
|
|
(unless (region-active-p) (error "No active region"))
|
2018-12-31 07:41:55 +01:00
|
|
|
(anki-editor-cloze (region-beginning) (region-end) arg hint))
|
2018-09-05 15:21:15 +02:00
|
|
|
|
|
|
|
(defun anki-editor-cloze-dwim (&optional arg hint)
|
|
|
|
"Cloze current active region or a word the under the cursor"
|
|
|
|
(interactive "p\nsHint (optional): ")
|
|
|
|
(cond
|
2018-12-31 07:41:55 +01:00
|
|
|
((region-active-p) (anki-editor-cloze (region-beginning) (region-end) arg hint))
|
2018-09-05 15:21:15 +02:00
|
|
|
((thing-at-point 'word) (let ((bounds (bounds-of-thing-at-point 'word)))
|
|
|
|
(anki-editor-cloze (car bounds) (cdr bounds) arg hint)))
|
|
|
|
(t (error "Nothing to create cloze from"))))
|
|
|
|
|
|
|
|
(defun anki-editor-cloze (begin end arg hint)
|
|
|
|
"Cloze region from BEGIN to END with number ARG."
|
|
|
|
(let ((region (buffer-substring begin end)))
|
2018-06-11 12:00:19 +02:00
|
|
|
(save-excursion
|
2018-09-05 15:21:15 +02:00
|
|
|
(delete-region begin end)
|
2018-06-11 12:00:19 +02:00
|
|
|
(insert (with-output-to-string
|
|
|
|
(princ (format "{{c%d::%s" (or arg 1) region))
|
|
|
|
(unless (string-blank-p hint) (princ (format "::%s" hint)))
|
|
|
|
(princ "}}"))))))
|
|
|
|
|
|
|
|
(defun anki-editor-export-subtree-to-html ()
|
|
|
|
"Export subtree of the element at point to HTML."
|
|
|
|
(interactive)
|
|
|
|
(org-export-to-buffer
|
|
|
|
anki-editor--ox-anki-html-backend
|
2019-11-03 10:24:00 +01:00
|
|
|
"*AnkiEditor HTML Output*" nil t nil t anki-editor--ox-export-ext-plist #'html-mode))
|
2018-06-11 12:00:19 +02:00
|
|
|
|
|
|
|
(defun anki-editor-convert-region-to-html ()
|
|
|
|
"Convert and replace region to HTML."
|
|
|
|
(interactive)
|
|
|
|
(org-export-replace-region-by anki-editor--ox-anki-html-backend))
|
|
|
|
|
2019-09-23 09:41:07 +02:00
|
|
|
|
|
|
|
;;; More utilities
|
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defun anki-editor-api-check ()
|
2019-09-23 09:41:07 +02:00
|
|
|
"Check if correct version of AnkiConnect is serving."
|
|
|
|
(interactive)
|
2019-11-02 17:22:27 +01:00
|
|
|
(let ((ver (anki-editor-api-call-result 'version)))
|
|
|
|
(if (<= anki-editor-api-version ver)
|
2019-09-23 09:41:07 +02:00
|
|
|
(when (called-interactively-p 'interactive)
|
|
|
|
(message "AnkiConnect v.%d is running" ver))
|
|
|
|
(error "anki-editor requires minimal version %d of AnkiConnect installed"
|
2019-11-02 17:22:27 +01:00
|
|
|
anki-editor-api-version))))
|
2019-09-23 09:41:07 +02:00
|
|
|
|
2019-11-02 17:22:27 +01:00
|
|
|
(defun anki-editor-api-upgrade ()
|
2018-06-11 12:00:19 +02:00
|
|
|
"Upgrade AnkiConnect to the latest version.
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
This is useful when new version of this package depends on the
|
|
|
|
bugfixes or new features of AnkiConnect."
|
|
|
|
(interactive)
|
2019-09-23 09:41:07 +02:00
|
|
|
(when (yes-or-no-p "This is going to download the latest AnkiConnect from the Internet to your computer, do you want to continue? ")
|
2019-11-02 17:22:27 +01:00
|
|
|
(let ((result (anki-editor-api-call-result 'upgrade)))
|
2018-06-11 12:00:19 +02:00
|
|
|
(when (and (booleanp result) result)
|
2019-09-23 09:41:07 +02:00
|
|
|
(message "AnkiConnect has been upgraded, you might have to restart Anki for the changes to take effect.")))))
|
2018-02-04 02:10:40 +01:00
|
|
|
|
2019-09-23 09:41:07 +02:00
|
|
|
(defun anki-editor-sync-collections ()
|
|
|
|
"Synchronizes the local anki collections with ankiweb."
|
|
|
|
(interactive)
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'sync))
|
2019-09-23 09:41:07 +02:00
|
|
|
|
|
|
|
(defun anki-editor-gui-browse (&optional query)
|
|
|
|
"Open Anki Browser with QUERY.
|
|
|
|
When called interactively, it will try to set QUERY to current
|
|
|
|
note or deck."
|
|
|
|
(interactive (list (pcase (org-entry-get-with-inheritance anki-editor-prop-note-id)
|
|
|
|
((and (pred stringp) nid) (format "nid:%s" nid))
|
|
|
|
(_ (format "deck:%s"
|
|
|
|
(or (org-entry-get-with-inheritance anki-editor-prop-deck)
|
|
|
|
"current"))))))
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call 'guiBrowse :query (or query "")))
|
2019-09-23 09:41:07 +02:00
|
|
|
|
|
|
|
(defun anki-editor-gui-add-cards ()
|
|
|
|
"Open Anki Add Cards dialog with presets from current note
|
|
|
|
entry."
|
|
|
|
(interactive)
|
2019-11-02 17:22:27 +01:00
|
|
|
(anki-editor-api-call-result 'guiAddCards
|
2019-11-01 15:59:15 +01:00
|
|
|
:note (append
|
|
|
|
(anki-editor-api--note
|
|
|
|
(anki-editor-note-at-point))
|
|
|
|
(list :options '(:closeAfterAdding t)))))
|
2019-09-23 09:41:07 +02:00
|
|
|
|
|
|
|
(defun anki-editor-find-notes (&optional query)
|
|
|
|
"Find notes with QUERY."
|
|
|
|
(interactive "sQuery: ")
|
2019-11-02 17:22:27 +01:00
|
|
|
(let ((nids (anki-editor-api-call-result 'findNotes
|
2019-11-01 15:59:15 +01:00
|
|
|
:query (or query ""))))
|
2019-09-23 09:41:07 +02:00
|
|
|
(if (called-interactively-p 'interactive)
|
|
|
|
(message "%S" nids)
|
|
|
|
nids)))
|
2018-01-22 14:57:35 +01:00
|
|
|
|
2017-12-27 17:20:01 +01:00
|
|
|
(provide 'anki-editor)
|
|
|
|
|
2018-01-15 15:11:02 +01:00
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
2017-12-27 17:20:01 +01:00
|
|
|
;;; anki-editor.el ends here
|