anki-editor/anki-editor.el

1084 lines
46 KiB
EmacsLisp
Raw Normal View History

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
;;
;; Description: Make Anki Cards in Org-mode
2018-06-10 15:13:00 +02:00
;; Author: Lei Tan
;; 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
;;
2018-01-15 15:11:02 +01:00
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;;; 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-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.
;;
;; 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)
(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.
2020-02-23 08:37:40 +01:00
See https://apps.ankiweb.net/docs/manual.html#latex-conflicts."
:type 'boolean)
(defcustom anki-editor-org-tags-as-anki-tags
t
2020-02-23 08:37:40 +01:00
"If nil, tags of entries won't be counted as Anki tags."
:type 'boolean)
(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'."
: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."
: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"
2020-02-23 08:37:40 +01:00
"The network address AnkiConnect is listening."
:type 'string)
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"
2020-02-23 08:37:40 +01:00
"The port number AnkiConnect is listening."
:type 'string)
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
2020-03-01 17:10:53 +01:00
(defcustom anki-editor-include-default-style t
"Wheter or not to include `org-html-style-default' when using `anki-editor-copy-styles'.
For example, you might want to turn this off when you are going to
provide your custom styles in `anki-editor-html-head'."
:type 'boolean)
(defcustom anki-editor-html-head nil
"Additional html tags to append to card stylings when using `anki-editor-copy-styles'.
For example, you can put custom styles or scripts in this variable."
:type 'string)
2020-03-09 14:36:27 +01:00
(defcustom anki-editor-note-match nil
"Additional matching string for mapping through anki note headings.
A leading logical operator like `+' or `&' is required."
:type 'string)
2018-01-19 13:32:28 +01:00
2019-11-01 15:59:15 +01:00
;;; AnkiConnect
2020-03-09 11:08:33 +01:00
(defconst anki-editor-api-version 6)
2019-11-03 10:24:00 +01:00
2020-02-10 12:51:02 +01:00
(cl-defun anki-editor--fetch (url
&rest settings
2020-02-24 06:39:31 +01:00
&key
(type "GET")
data success _error
(parser 'buffer-string)
2020-02-10 12:51:02 +01:00
&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*")))
2020-02-24 06:39:31 +01:00
(when data
(with-temp-file tempfile
(setq buffer-file-coding-system 'utf-8)
(set-buffer-multibyte t)
(insert data)))
2020-02-10 12:51:02 +01:00
(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))
2020-02-24 06:39:31 +01:00
(when success
(apply success (list :data (funcall parser)))))
2020-02-10 12:51:02 +01:00
(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))
2020-02-23 08:37:40 +01: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--
2020-02-23 08:37:40 +01:00
do (when-let ((pred (listp result))
2019-11-01 15:59:15 +01:00
(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."
(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))
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))))
(defconst anki-editor--ox-export-ext-plist
2020-02-24 06:39:31 +01:00
'(:with-toc nil :with-properties nil :with-planning nil :anki-editor-mode t))
(defconst anki-editor--audio-extensions
'(".mp3" ".3gp" ".flac" ".m4a" ".oga" ".ogg" ".opus" ".spx" ".wav"))
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))))
(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
(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
;; Audio file.
((cl-some (lambda (string) (string-suffix-p string path t))
anki-editor--audio-extensions)
(format "[sound:%s]" path))
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-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
2020-02-24 06:39:31 +01:00
(defun anki-editor--export-string (src fmt)
(cl-ecase fmt
('nil src)
('t (or (org-export-string-as src
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
""))))
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")
2020-02-24 06:39:31 +01:00
(defconst anki-editor-prop-format "ANKI_FORMAT")
2019-11-03 10:24:00 +01:00
(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:]_@#%]+\\)+$")
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)
2020-02-24 06:39:31 +01:00
(defvar anki-editor--collection-data-updated nil
"Whether or not collection data is updated from Anki. Used by `anki-editor--with-collection-data-updated' to avoid unnecessary updates.")
;; The following variables should only be used inside `anki-editor--with-collection-data-updated'.
(defvar anki-editor--model-names nil
"Note types from Anki.")
(defvar anki-editor--model-fields nil
"Alist of (NOTE-TYPE . FIELDS).")
(defmacro anki-editor--with-collection-data-updated (&rest body)
"Execute BODY with collection data updated from Anki.
Note that since we have no idea of whether BODY will update collection
data, BODY might read out-dated data. This doesn't matter right now
as note types won't change in BODY."
(declare (indent defun) (debug t))
`(if anki-editor--collection-data-updated
(progn ,@body)
(cl-destructuring-bind (models)
(anki-editor-api-with-multi
(anki-editor-api-enqueue 'modelNames))
(unwind-protect
(progn
(setq anki-editor--collection-data-updated t
anki-editor--model-names models
anki-editor--model-fields
(cl-loop for flds in (eval `(anki-editor-api-with-multi
,@(cl-loop for mod in models
collect `(anki-editor-api-enqueue 'modelFieldNames :modelName ,mod))))
for mod in models
collect (cons mod flds)))
,@body)
(setq anki-editor--collection-data-updated nil)))))
2018-07-06 15:10:03 +02:00
(defun anki-editor-map-note-entries (func &optional match scope &rest skip)
2020-03-09 14:36:27 +01:00
"Simple wrapper that calls `org-map-entries' with entries that match
`ANKI_NOTE_TYPE<>\"\"', `anki-editor-note-match' and MATCH.
A leading logical operator like `+' or `&' is required in 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))
2020-03-09 14:36:27 +01:00
(org-map-entries func
(concat "+" anki-editor-prop-note-type "<>\"\""
match
anki-editor-note-match)
scope
skip)))
2020-02-24 06:39:31 +01:00
(defun anki-editor--insert-note-skeleton (prefix deck heading type fields)
"Insert a note subtree (skeleton) with HEADING, TYPE and FIELDS.
2018-05-06 08:01:06 +02:00
Where the subtree is created depends on PREFIX."
(org-insert-heading prefix)
2018-01-22 14:57:35 +01:00
(insert heading)
(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))
2020-02-24 06:39:31 +01:00
(org-set-property anki-editor-prop-note-type 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
(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))))
(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)))
(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)))
(defun anki-editor--update-note (note)
"Request AnkiConnect for updating fields, deck, 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 (string-to-number
(anki-editor-note-id note))))
2019-11-01 15:59:15 +01:00
(anki-editor-api-enqueue 'updateNoteFields
:note (anki-editor-api--note note)))))
2019-11-01 15:59:15 +01:00
(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
(anki-editor-api-enqueue 'changeDeck
:cards (alist-get 'cards oldnote)
:deck (anki-editor-note-deck note))
2019-11-01 15:59:15 +01:00
(when tagsadd
(anki-editor-api-enqueue 'addTags
:notes (list (string-to-number
(anki-editor-note-id note)))
2019-11-01 15:59:15 +01:00
:tags (mapconcat #'identity tagsadd " ")))
(when tagsdel
(anki-editor-api-enqueue 'removeTags
:notes (list (string-to-number
(anki-editor-note-id note)))
2019-11-01 15:59:15 +01:00
:tags (mapconcat #'identity tagsdel " "))))))
(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."
(org-entry-put nil anki-editor-prop-failure-reason reason))
(defun anki-editor--clear-failure-reason ()
2018-01-19 13:21:06 +01:00
"Clear failure reason in property drawer at point."
(org-entry-delete nil anki-editor-prop-failure-reason))
2018-04-15 09:31:45 +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))
2020-02-24 06:39:31 +01:00
((pred (string= anki-editor-prop-format)) (list "t" "nil"))
2018-09-23 20:41:15 +02:00
((pred (string-match-p (format "%s\\+?" anki-editor-prop-tags))) (anki-editor-all-tags))
(_ nil)))
(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
(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))
(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)
"Fetch and cache tags from Anki."
(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."
(append (funcall oldfun)
(when (anki-editor--enable-tag-completion)
(mapcar #'list anki-editor--anki-tags-cache))))
2018-05-06 08:01:06 +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))
2020-02-24 06:39:31 +01:00
(defun anki-editor-entry-format ()
(read (or (org-entry-get-with-inheritance anki-editor-prop-format t) "t")))
(defun anki-editor-toggle-format ()
"Cycle ANKI_FROMAT through \"nil\" and \"t\"."
(interactive)
(let ((val (pcase (org-entry-get nil anki-editor-prop-format nil t)
('nil "nil")
("nil" "t")
("t" nil)
(_ "nil"))))
(if val
(org-entry-put nil anki-editor-prop-format val)
(org-entry-delete nil anki-editor-prop-format))))
(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)
(deck (org-entry-get-with-inheritance anki-editor-prop-deck))
2020-02-24 06:39:31 +01:00
(format (anki-editor-entry-format))
(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
2020-02-24 06:39:31 +01:00
:test #'string=))
(fields (anki-editor--build-fields))
(content-before-subheading)
(content-before-subheading-raw))
;; get contents before first subheading (skipping drawers and planning)
;; FIXME refactor
(save-excursion
(let* ((element (org-element-at-point))
(begin (cl-loop for eoh = (org-element-property :contents-begin element)
then (org-element-property :end subelem)
while eoh
for subelem = (progn
(goto-char eoh)
(org-element-context))
while (memq (org-element-type subelem)
'(drawer planning property-drawer))
finally return (and eoh (org-element-property :begin subelem))))
(end (cl-loop for eoh = (org-element-property :contents-begin element)
then (org-element-property :end nextelem)
while eoh
for nextelem = (progn
(goto-char eoh)
(org-element-at-point))
while (not (memq (org-element-type nextelem) '(headline)))
finally return (and eoh (org-element-property :begin nextelem))))
(raw (or (and begin
end
(buffer-substring-no-properties
begin
;; in case the buffer is narrowed,
;; e.g. by `org-map-entries' when
;; scope is `tree'
(min (point-max) end)))
""))
(content (anki-editor--export-string raw (anki-editor-entry-format))))
(setq content-before-subheading content)
(setq content-before-subheading-raw (string-trim raw))))
2017-12-27 17:20:01 +01:00
2020-02-24 06:39:31 +01:00
(anki-editor--with-collection-data-updated
(when-let ((missing (cl-set-difference
(alist-get note-type anki-editor--model-fields nil nil #'string=)
(mapcar #'car fields)
:test #'string=)))
;; use heading and/or text before subheading for the missing field(s)
;; FIXME refactor
(if (and (equal 1 (length missing))
(equal "" content-before-subheading-raw))
(push (cons (car missing)
(anki-editor--export-string
(substring-no-properties (org-get-heading t t t))
format))
fields)
(if (equal 1 (length missing))
(push (cons (car missing)
content-before-subheading)
fields)
(progn
(push (cons (nth 1 missing)
content-before-subheading)
fields)
(push (cons (car missing)
(anki-editor--export-string
(substring-no-properties (org-get-heading t t t))
format))
fields))))))
2020-02-24 06:39:31 +01:00
(unless deck (error "Missing deck"))
(unless note-type (error "Missing note type"))
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
(defun anki-editor--get-tags ()
2018-09-23 20:41:15 +02:00
(let ((tags (anki-editor--entry-get-multivalued-property-with-inheritance
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))
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)))
(defun anki-editor--build-fields ()
2020-02-10 16:23:44 +01:00
"Build a list of fields from subheadings of current heading.
Return a list of cons of (FIELD-NAME . FIELD-CONTENT)."
(save-excursion
2020-02-10 16:23:44 +01:00
(cl-loop with inhibit-message = t ; suppress echo message from `org-babel-exp-src-block'
initially (unless (org-goto-first-child)
(cl-return))
for last-pt = (point)
for element = (org-element-at-point)
for heading = (substring-no-properties
(org-element-property :raw-value element))
2020-02-24 06:39:31 +01:00
for format = (anki-editor-entry-format)
;; contents-begin includes drawers and scheduling data,
;; which we'd like to ignore, here we skip these
;; elements and reset contents-begin.
for begin = (cl-loop for eoh = (org-element-property :contents-begin element)
then (org-element-property :end subelem)
while eoh
2020-02-24 06:39:31 +01:00
for subelem = (progn
(goto-char eoh)
(org-element-context))
while (memq (org-element-type subelem)
'(drawer planning property-drawer))
finally return (and eoh (org-element-property :begin subelem)))
2020-02-10 16:23:44 +01:00
for end = (org-element-property :contents-end element)
for raw = (or (and begin
end
(buffer-substring-no-properties
begin
;; in case the buffer is narrowed,
;; e.g. by `org-map-entries' when
;; scope is `tree'
(min (point-max) end)))
"")
2020-02-24 06:39:31 +01:00
for content = (anki-editor--export-string raw format)
2020-02-10 16:23:44 +01:00
collect (cons heading content)
;; proceed to next field entry and check last-pt to
;; see if it's already the last entry
do (org-forward-heading-same-level nil t)
until (= last-pt (point)))))
2018-02-04 02:10:40 +01:00
2018-06-11 12:00:19 +02:00
;;; Minor mode
2018-02-04 02:10:40 +01: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
"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)
(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."
(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
2020-02-24 10:16:20 +01:00
(defvar anki-editor--note-markers nil)
(defun anki-editor--collect-note-marker ()
(message "Scanning notes %d (%s@%d), wait a moment..."
(length anki-editor--note-markers) (buffer-name) (point))
(push (point-marker) anki-editor--note-markers))
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-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))))
2020-02-24 10:16:20 +01:00
(unwind-protect
(progn
(anki-editor-map-note-entries #'anki-editor--collect-note-marker match scope)
(setq anki-editor--note-markers (reverse anki-editor--note-markers))
(let ((count 0)
(failed 0))
(save-excursion
(anki-editor--with-collection-data-updated
(cl-loop with bar-width = 30
for marker in anki-editor--note-markers
for progress = (/ (float (cl-incf count)) (length anki-editor--note-markers))
do
(goto-char marker)
(message "Uploading notes in buffer %s%s [%s%s] %d/%d (%.2f%%)"
(marker-buffer marker)
(if (zerop failed)
""
(propertize (format " %d failed" failed)
'face `(:foreground "red")))
(make-string (truncate (* bar-width progress)) ?#)
(make-string (- bar-width (truncate (* bar-width progress))) ?.)
count
(length anki-editor--note-markers)
(* 100 progress))
(anki-editor--clear-failure-reason)
(condition-case-unless-debug err
(anki-editor--push-note (anki-editor-note-at-point))
(error (cl-incf failed)
(anki-editor--set-failure-reason (error-message-string err))))
;; free marker
(set-marker marker nil))))
(message
(cond
((zerop (length anki-editor--note-markers)) "Nothing to push")
((zerop failed) (format "Successfully pushed %d notes to Anki" count))
(t (format "Pushed %d notes to Anki, with %d failed. Check property drawers for details.
When you have fixed those issues, try re-push the failed ones with `anki-editor-retry-failed-notes'."
count failed))))))
;; clean up markers
(cl-loop for m in anki-editor--note-markers
do (set-marker m nil)
finally do (setq anki-editor--note-markers nil))))
2019-09-23 09:41:07 +02:00
(defun anki-editor-push-note-at-point ()
(interactive)
(anki-editor--push-note (anki-editor-note-at-point)))
2019-09-23 09:41:07 +02:00
(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
2020-02-24 06:39:31 +01:00
same as how it is used by `M-RET'(org-insert-heading).
When note heading is not provided, it is used as the first field."
2018-06-11 12:00:19 +02:00
(interactive "P")
(let* ((deck (or (org-entry-get-with-inheritance anki-editor-prop-deck)
2020-02-24 06:39:31 +01:00
(completing-read "Deck: " (sort (anki-editor-deck-names) #'string-lessp))))
(type (completing-read "Note type: " (sort (anki-editor-note-types) #'string-lessp)))
(fields (anki-editor-api-call-result 'modelFieldNames :modelName type))
(heading (read-from-minibuffer "Note heading (optional): ")))
2018-06-11 12:00:19 +02:00
(anki-editor--insert-note-skeleton prefix
deck
2020-02-24 06:39:31 +01:00
heading
type
(if (string-blank-p heading)
(cdr fields)
fields))))
2018-06-11 12:00:19 +02:00
(defun anki-editor-cloze-region (&optional arg hint)
2018-06-11 12:00:19 +02:00
"Cloze region with number ARG."
(interactive "p\nsHint (optional): ")
2018-06-11 12:00:19 +02:00
(unless (region-active-p) (error "No active region"))
(anki-editor-cloze (region-beginning) (region-end) arg hint))
(defun anki-editor-cloze-dwim (&optional arg hint)
"Cloze current active region or a word the under the cursor"
(interactive "p\nsHint (optional): ")
(cond
((region-active-p) (anki-editor-cloze (region-beginning) (region-end) arg hint))
((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
(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)
2020-02-24 10:40:41 +01:00
(let ((ver (condition-case err
(anki-editor-api-call-result 'version)
(error (error "Failed to connect to Anki: %s" (error-message-string err))))))
2019-11-02 17:22:27 +01:00
(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
(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
2020-03-01 17:10:53 +01:00
(defvar anki-editor--style-start "</style>\n<!-- {{ Emacs Org-mode -->")
(defvar anki-editor--style-end "<!-- Emacs Org-mode }} -->\n<style>")
(defun anki-editor-copy-styles ()
"Copy `org-html-style-default' and `anki-editor-html-head' to Anki card stylings."
(interactive)
(let ((head (concat (org-element-normalize-string anki-editor--style-start)
(org-element-normalize-string (format "<!-- Updated: %s -->" (current-time-string)))
(when anki-editor-include-default-style
(org-element-normalize-string org-html-style-default))
(org-element-normalize-string anki-editor-html-head)
anki-editor--style-end)))
(cl-loop for model in (anki-editor-note-types)
for style = (let* ((css (alist-get 'css (anki-editor-api-call-result 'modelStyling :modelName model)))
(start (string-match
(regexp-quote anki-editor--style-start)
css))
(end (string-match
(regexp-quote anki-editor--style-end)
css)))
(if (and start end)
(progn
(cl-incf end (length anki-editor--style-end))
;; skip whitespaces
(when-let ((newend (string-match "[[:graph:]]" css end)))
(setq end newend))
(concat
(substring css 0 start)
(substring css end)))
css))
do
(message "Updating styles for \"%s\"..." model)
(anki-editor-api-call-result 'updateModelStyling
:model (list :name model
:css (concat (concat head "\n\n") style)))
finally do (message "Updating styles...Done"))))
(defun anki-editor-remove-styles ()
"Remove from card stylings html tags generated by this mode."
(interactive)
(cl-loop for model in (anki-editor-note-types)
for css = (alist-get 'css (anki-editor-api-call-result 'modelStyling :modelName model))
for start = (string-match
(regexp-quote anki-editor--style-start)
css)
for end = (string-match
(regexp-quote anki-editor--style-end)
css)
if (and start end)
do
(cl-incf end (length anki-editor--style-end))
;; also remove whitespaces
(when-let ((newend (string-match "[[:graph:]]" css end)))
(setq end newend))
(message "Resetting styles for \"%s\"..." model)
(anki-editor-api-call-result
'updateModelStyling
:model (list :name model
:css (concat
(substring css 0 start)
(substring css end))))
finally do (message "Resetting styles...Done")))
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