diff --git a/Changelog.org b/Changelog.org index 2c803d6..604402f 100644 --- a/Changelog.org +++ b/Changelog.org @@ -2,6 +2,33 @@ * Changelog +** Unreleased + + 1. Removed variable ~anki-editor-create-decks~, now decks are + created automatically + 2. Identifiers prefixed with ~anki-editor-anki-connect-~ were + renamed to ~anki-editor-api-~ + 3. Added two macros ~anki-editor-api-with-multi~ and + ~anki-editor-api-enqueue~ to make working with multi api calls + easier + 4. Fixed computing hash of media files against file names instead + of file content + 5. Supports literal note fields + 6. Added more utility commands/functions + 7. ~anki-editor-use-math-jax~ was replaced with + ~anki-editor-latex-style~ + 8. Code refactoring + 9. Allows to put one field in heading + 10. Added options to copy custom head tags to card stylings + 1. Variable ~anki-editor-include-default-style~ + 2. Variable ~anki-editor-html-head~ + 3. Command ~anki-editor-copy-styles~ + 4. Command ~anki-editor-remove-styles~ + 11. Updated anki-connect to version 6 + 1. ~anki-editor-api-upgrade~ removed + 12. Added variable ~anki-editor-note-match~ to make matching note + entries customizable + ** v0.3.3 - Reenable support for setting tags with =ANKI_TAGS= property diff --git a/README.org b/README.org index c71c5e5..e915454 100644 --- a/README.org +++ b/README.org @@ -1,40 +1,40 @@ [[http://melpa.org/#/anki-editor][file:http://melpa.org/packages/anki-editor-badge.svg]] -anki-editor -- Emacs minor mode for making Anki cards with Org +anki-editor -- An Emacs minor mode for making Anki cards with Org /Since I'm not a native English speaker, feel free to correct me if -there were any ambiguity or grammatical mistakes ;-)/ +there are any ambiguity or grammatical mistakes ;-)/ * Installation *Requirements* - - [[https://github.com/FooSoft/anki-connect#installation][anki-connect]], - an Anki add-on required by this package to interact with Anki. + - [[https://github.com/FooSoft/anki-connect#installation][AnkiConnect]], + an Anki add-on required by this package to interact with Anki - curl - If you have [[http://melpa.org/][MELPA]] in your =package-archives=, - just =M-x package-install RET anki-editor RET=, or install it + If you have [[http://melpa.org/][MELPA]] in your ~package-archives~, + just ~M-x package-install RET anki-editor RET~, or install it manually by downloading and visiting [[./anki-editor.el][anki-editor.el]] in your - emacs buffer, and =M-x package-install-from-buffer RET=. + emacs buffer, and ~M-x package-install-from-buffer RET~. * Usage ** The Layout of Notes - Now you can compose Anki notes in Org syntax, e.g. lists, code - examples, tables, embedded latex, 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 Anki style. + The power of this mode comes from the builtin HTML export backend + provided by Org, which enables you to use almost all the Org + constructs for writing Anki notes: lists, code blocks, tables, + latex and so on. - The structure of a note is as follow, which is inspired by - =org-drill=. More examples can be found in [[./examples.org][examples.org]]. + The structure of a note is as follows, which is inspired by + ~org-drill~. Check out [[./examples.org][examples.org]] for more examples. #+BEGIN_SRC org - ,* Idiom :vocab:idioms: + ,* Raining :vocab:idioms: :PROPERTIES: :ANKI_DECK: English :ANKI_NOTE_TYPE: Basic (and reversed card) - :ANKI_TAGS: languages european_languages + :ANKI_TAGS: vocab idioms :END: ,** Front (it's) raining cats and dogs @@ -42,47 +42,114 @@ there were any ambiguity or grammatical mistakes ;-)/ it's raining very hard #+END_SRC - - An Anki note is an Org entry with =ANKI_NOTE_TYPE= property - - Anki tags can be set in two ways - 1. With "ANKI_TAGS" property, multiple tags are separated with space - 2. With Org tags [fn:1], this could be turned off if you would like to keep Org tags separated from Anki tags - - Other necessary information (e.g. deck, note type) of a note is - put in the property drawer of the entry - - As the value of =ANKI_DECK= is retrieved with inheritance, you - don't have to set it per note, instead, you could create a deck - entry with this property set and put note entries under it, or - set it per file by ~#+PROPERTY: ANKI_DECK YourDeck~ - - Child entries of a note entry are fields + - Anki deck is provided by ~ANKI_DECK~ property. This property is + retrieved with inheritance, that is to say, it can be put in any + ancestor entries or at top of the file by ~#+PROPERTY: ANKI_DECK + DeckName~. + - ~ANKI_NOTE_TYPE~ property is to specify the Anki note type of a + note and is also required for identifying an Anki note entry. + - Anki tags can be provided in two ways: + 1. With a ~ANKI_TAGS~ property, multiple tags are separated by spaces + 2. With Org tags [fn:1], this could be turned off if you would + like to keep Org tags separated from Anki tags + - Child entries of a note entry are fields. + + Typing all these information by hand could be inefficient and prone + to errors, so this package provides an interactive command + ~anki-editor-insert-note~ to help with this and hooks up + auto-completions for decks, note types and tags etc. ** Commands - | Command | Brief Description | - |------------------------------------+--------------------------------------------------------------------------------------| - | anki-editor-push-notes | Push notes to Anki. Additional arguments can be used to restrict the range of notes. | - | anki-editor-retry-failure-notes | Same as above, except that it only pushes notes that have =ANKI_FAILURE_REASON=. | - | anki-editor-insert-note | Insert a note entry like =M-RET=, interactively. | - | anki-editor-cloze-region | Create a cloze deletion from region. | - | anki-editor-export-subtree-to-html | Export the subtree at point to HTML. | - | anki-editor-convert-region-to-html | Convert and replace region to HTML. | - -** Functions - - | Name | Description | - |------------------------------+------------------------------------------------------------| - | anki-editor-map-note-entries | Simple wrapper that calls =org-map-entries=. | - | anki-editor-note-at-point | Construct an alist representing a note from current entry. | + | Command | Description | + |------------------------------------+--------------------------------------------------------------------------------------------------------------------------------| + | anki-editor-mode | Toggle this minor mode. | + | anki-editor-push-notes | Push notes to Anki. Additional arguments can be used to restrict the range of notes. | + | anki-editor-push-new-notes | Similar to ~anki-editor-push-notes~, but push those that are without ~ANKI_NOTE_ID~. | + | anki-editor-retry-failed-notes | Similar to ~anki-editor-push-notes~, except that it only pushes notes with ~ANKI_FAILURE_REASON~. | + | anki-editor-insert-note | Insert a note entry like ~M-RET~, interactively. When note heading is not provided or is blank, it's used as the first field. | + | anki-editor-delete-notes | Delete notes or the note at point. | + | anki-editor-cloze-dwim | Cloze current active region or a word the under the cursor. | + | anki-editor-export-subtree-to-html | Export the subtree at point to HTML. | + | anki-editor-convert-region-to-html | Convert and replace region to HTML. | + | anki-editor-api-check | Check if correct version of AnkiConnect is running. | + | anki-editor-sync-collections | Synchronize your local anki collection. | + | anki-editor-gui-browse | Open Anki Browser with a query for current note or deck. | + | anki-editor-gui-add-cards | Open Anki Add Cards dialog with presets from current note entry. | ** Variables | Name | Default Value | Description | |-----------------------------------------------+------------------------+----------------------------------------------------------------------------------------------------------| - | anki-editor-anki-connect-listening-address | "127.0.0.1" | The network address AnkiConnect is listening. | - | anki-editor-anki-connect-listening-port | "8765" | The port number AnkiConnect is listening. | - | anki-editor-break-consecutive-braces-in-latex | nil | If non-nil, consecutive `}' will be automatically separated by spaces to prevent early-closing of cloze. | - | anki-editor-create-decks | nil | If non-nil, creates deck before creating a note. | + | anki-editor-api-host | "127.0.0.1" | The network address AnkiConnect is listening. | + | anki-editor-api-port | "8765" | The port number AnkiConnect is listening. | + | anki-editor-break-consecutive-braces-in-latex | nil | If non-nil, consecutive "}" will be automatically separated by spaces to prevent early-closing of cloze. | | anki-editor-ignored-org-tags | '("export" "noexport") | A list of Org tags that are ignored when constructing notes form entries. | | anki-editor-org-tags-as-anki-tags | t | If nil, tags of entries wont't be counted as Anki tags. | | anki-editor-protected-tags | '("marked" "leech") | A list of tags that won't be deleted from Anki even though they're absent in Org entries. | + | anki-editor-latex-style | builtin | The style of latex to translate into. | + | anki-editor-include-default-style | t | Wheter or not to include `org-html-style-default' when using `anki-editor-copy-styles'. | + | anki-editor-html-head | nil | Additional html tags to append to card stylings when using `anki-editor-copy-styles'. | + | anki-editor-note-match | nil | Additional matching string for mapping through anki note headings. | + +** Functions and Macros + +*** anki-editor-map-note-entries + + Simple wrapper that calls ~org-map-entries~ with + ~&ANKI_NOTE_TYPE<>\"\"~ appended to MATCH. + +*** anki-editor-api-call + + Invoke AnkiConnect with ACTION and PARAMS. + +*** anki-editor-api-call-result + + Calls above, returns result field or raise an error. + +*** anki-editor-api-with-multi + + Used in combination with ~anki-editor-api-enqueue~ to queue + multiple api calls and combine them into one 'multi' call at the + end, return the results of these calls in the same order. + + Usage: + #+begin_src elisp + (cl-destructuring-bind (decks models tags notes) + (anki-editor-api-with-multi + ;; The following api calls will be combined into one 'multi' call. + (anki-editor-api-enqueue 'deckNames) + (anki-editor-api-enqueue 'modelNames) + (anki-editor-api-enqueue 'getTags) + (anki-editor-api-enqueue 'findNotes :query "deck:Default")) + (message (concat "decks: %S\n" + "models: %S\n" + "tags: %S\n" + "notes: %S") + decks models tags notes)) + #+end_src + +*** anki-editor-api-enqueue + + 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. + +*** anki-editor-note-at-point + + Make a note struct from current entry. + +*** anki-editor-find-notes + + Find notes with QUERY. + +*** anki-editor-copy-styles + + Copy `org-html-style-default' and `anki-editor-html-head' to Anki card stylings. + +*** anki-editor-remove-styles + + Remove from card stylings html tags generated by this mode. * Limitations @@ -94,45 +161,10 @@ there were any ambiguity or grammatical mistakes ;-)/ ** Working with Anki add-ons - This package may not work well when you are using certain Anki - add-ons especially those who extend the builtin Anki note editor to + This package might not work well with certain Anki add-ons + especially those who extend the builtin Anki note editor to automatically fill note field content (e.g. ~Add note id~). -* Troubleshooting - - In case of a failed operation and this package doesn't provide much - useful information, especially for note creation, don't be - frustrated, see below for some hints. - - 1. Decks don't exist in Anki. This package by default doesn't create - decks for you, when trying out this package with ~examples.org~, - you might find that every single note creation fails, simply - because they're fake decks that might not be in your Anki - collection. If you'd like it to automatically create missing - decks, set ~anki-editor-create-decks~ to ~t~. - - 2. Note is counted as a duplicate. From [[https://apps.ankiweb.net/docs/manual.html#adding-cards-and-notes][Anki docs]] - #+BEGIN_QUOTE - Anki checks the first field for uniqueness, so it will warn you - if you enter two cards with a Front field of “apple” (for - example). The uniqueness check is limited to the current note - type, so if you’re studying multiple languages, two cards with - the same Front would not be listed as duplicates as long as you - had a different note type for each language. - #+END_QUOTE - - If all the above don't help, then we have to go deeper to find out - what goes wrong. Here are some methods: - - - Turn on logging in ~request.el~. Customize ~request-log-level~ to - ~debug~, retry failed actions and switch to buffer - ~ *request-log*~ (there's a leading space, see [[https://www.emacswiki.org/emacs/InvisibleBuffers][invisible buffer]]) - to get logs from ~request.el~. This way we can't inspect the - request payload, since it's dumped into a temp file that's deleted - when request finishes. - - Use a traffic sniffer to inspect communications between Emacs and - Anki. - * Demo [[./demo.gif]] @@ -150,5 +182,5 @@ there were any ambiguity or grammatical mistakes ;-)/ appreciated, thanks for your support :) [fn:1] It should be noted that Org only allows letters, numbers, =_= -and =@= in a tag but Anki allows more, so you may have to edit you +and ~@~ in a tag but Anki allows more, so you may have to edit you Anki tags before they can be used in Org without any surprise. diff --git a/anki-editor.el b/anki-editor.el index 609ad94..7f303b8 100644 --- a/anki-editor.el +++ b/anki-editor.el @@ -1,30 +1,28 @@ ;;; anki-editor.el --- Minor mode for making Anki cards with Org -*- lexical-binding: t; -*- ;; -;; Copyright (C) 2018 Lei Tan +;; Copyright (C) 2018-2019 Lei Tan ;; ;; Description: Make Anki Cards in Org-mode ;; Author: Lei Tan ;; Version: 0.3.3 -;; Package-Requires: ((emacs "25") (request "0.3.0") (dash "2.12.0")) +;; Package-Requires: ((emacs "25.1")) ;; URL: https://github.com/louietan/anki-editor ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;;; Commentary: ;; -;; This package is for people who use Anki as SRS but would like to -;; make cards in Org-mode. +;; 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) ;; -;; With this package, you can make cards from something like: -;; (which is inspired by `org-dirll') -;; -;; * Item :emacs:lisp:programming: +;; * Sample :emacs:lisp:programming: ;; :PROPERTIES: ;; :ANKI_DECK: Computing ;; :ANKI_NOTE_TYPE: Basic ;; :END: ;; ** Front -;; How to hello world in elisp ? +;; How to say "hello world" in elisp? ;; ** Back ;; #+BEGIN_SRC emacs-lisp ;; (message "Hello, world!") @@ -32,13 +30,13 @@ ;; ;; This package extends Org-mode's built-in HTML backend to generate ;; HTML for contents of note fields with specific syntax (e.g. latex) -;; translated to Anki style, then save the note to Anki. +;; translated to Anki style. ;; ;; For this package to work, you have to setup these external dependencies: ;; - curl -;; - AnkiConnect, an Anki addon that runs an HTTP server to expose -;; Anki functions as RESTful APIs, see -;; https://github.com/FooSoft/anki-connect#installation +;; - 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 ;; for installation instructions ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -61,21 +59,10 @@ ;;; Code: (require 'cl-lib) -(require 'dash) (require 'json) (require 'org-element) (require 'ox) (require 'ox-html) -(require 'request) - -(defconst anki-editor-prop-note-type "ANKI_NOTE_TYPE") -(defconst anki-editor-prop-note-id "ANKI_NOTE_ID") -(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-buffer-html-output "*AnkiEditor HTML Output*") -(defconst anki-editor-org-tag-regexp "^\\([[:alnum:]_@#%]+\\)+$") (defgroup anki-editor nil "Customizations for anki-editor." @@ -84,226 +71,258 @@ (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.") - -(defcustom anki-editor-create-decks - nil - "If non-nil, creates deck before creating a note.") +See https://apps.ankiweb.net/docs/manual.html#latex-conflicts." + :type 'boolean) (defcustom anki-editor-org-tags-as-anki-tags t - "If nil, tags of entries wont't be counted as Anki tags.") + "If nil, tags of entries won't be counted as Anki tags." + :type 'boolean) (defcustom anki-editor-protected-tags '("marked" "leech") - "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'." + "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) - "A list of Org tags that are ignored when constructing notes form entries." + "A list of Org tags that are ignored when constructing notes +form entries." :type '(repeat string)) -(defcustom anki-editor-anki-connect-listening-address +(defcustom anki-editor-api-host "127.0.0.1" - "The network address AnkiConnect is listening.") + "The network address AnkiConnect is listening." + :type 'string) -(defcustom anki-editor-anki-connect-listening-port +(defcustom anki-editor-api-port "8765" - "The port number AnkiConnect is listening.") + "The port number AnkiConnect is listening." + :type 'string) + +(defcustom anki-editor-latex-style 'builtin + "The style of latex to translate into." + :type '(radio (const :tag "Built-in" builtin) + (const :tag "MathJax" mathjax))) + +(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) + +(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) -(defcustom anki-editor-use-math-jax nil - "Use Anki's built in MathJax support instead of LaTeX.") ;;; AnkiConnect -(defun anki-editor--anki-connect-action (action &optional params version) - (let (a) - (when version - (push `(version . ,version) a)) - (when params - (push `(params . ,params) a)) - (push `(action . ,action) a))) +(defconst anki-editor-api-version 6) -(defun anki-editor--anki-connect-invoke-queue () - (let (action-queue) - (lambda (&optional action params handler) - (if action - (push (cons (anki-editor--anki-connect-action action params) handler) action-queue) - (when action-queue - (apply #'anki-editor--anki-connect-invoke-multi (nreverse action-queue)) - (setq action-queue nil)))))) +(cl-defun anki-editor--fetch (url + &rest settings + &key + (type "GET") + data success _error + (parser 'buffer-string) + &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*"))) + (when data + (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))) -(defun anki-editor--anki-connect-invoke (action &optional params) + (goto-char (point-min)) + (when success + (apply success (list :data (funcall parser))))) + (kill-buffer responsebuf) + (delete-file tempfile)))) + +(defun anki-editor-api-call (action &rest params) "Invoke AnkiConnect with ACTION and PARAMS." - (let ((request-body (json-encode (anki-editor--anki-connect-action action params 5))) - (request-backend 'curl) + (let ((payload (list :action action :version anki-editor-api-version)) + (_request-backend 'curl) (json-array-type 'list) reply err) - (let ((response (request (format "http://%s:%s" - anki-editor-anki-connect-listening-address - anki-editor-anki-connect-listening-port) - :type "POST" - :parser 'json-read - :data request-body - :success (cl-function (lambda (&key data &allow-other-keys) - (setq reply data))) - :error (cl-function (lambda (&key _ &key error-thrown &allow-other-keys) - (setq err (string-trim (cdr error-thrown))))) - :sync t))) - - ;; HACK: With sync set to t, `request' waits for curl process to - ;; exit, then response data becomes available, but callbacks - ;; might not be called right away but at a later time, that's - ;; why here we manually invoke callbacks to receive the result. - (unless (request-response-done-p response) - (request--curl-callback (get-buffer-process (request-response--buffer response)) "finished\n"))) + (when params + (plist-put payload :params params)) + (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) (when err (error "Error communicating with AnkiConnect using cURL: %s" err)) (or reply (error "Got empty reply from AnkiConnect")))) -(defmacro anki-editor--anki-connect-invoke-result (&rest args) - "Invoke AnkiConnect with ARGS, return the result from response or raise an error." - `(let-alist (anki-editor--anki-connect-invoke ,@args) - (when .error (error .error)) - .result)) +(defun anki-editor-api-call-result (&rest args) + "Invoke AnkiConnect with ARGS, return the result from response +or raise an error." + (let-alist (apply #'anki-editor-api-call args) + (when .error (error .error)) + .result)) -(defun anki-editor--anki-connect-invoke-multi (&rest actions) - (-zip-with (lambda (result handler) - (when-let ((_ (listp result)) - (err (alist-get 'error result))) - (error err)) - (and handler (funcall handler result))) - (anki-editor--anki-connect-invoke-result - "multi" `((actions . ,(mapcar #'car actions)))) - (mapcar #'cdr actions))) +(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 ((pred (listp result)) + (err (alist-get 'error result))) + (error err)) + collect result))) -(defun anki-editor--anki-connect-map-note (note) +(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) "Convert NOTE to the form that AnkiConnect accepts." - (let-alist note - (list (cons "id" .note-id) - (cons "deckName" .deck) - (cons "modelName" .note-type) - (cons "fields" .fields) - ;; 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. - (cons "tags" (vconcat .tags))))) + (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)))) -(defun anki-editor--anki-connect-store-media-file (path) +(defun anki-editor-api--store-media-file (path) "Store media file for PATH, which is an absolute file name. The result is the path to the newly stored media file." - (let* ((hash (secure-hash 'sha1 path)) + (let* ((bytes (with-temp-buffer + (insert-file-contents-literally path) + (buffer-string))) + (hash (secure-hash 'sha1 bytes)) (media-file-name (format "%s-%s%s" (file-name-base path) hash - (file-name-extension path t))) - content) - (when (equal :json-false (anki-editor--anki-connect-invoke-result - "retrieveMediaFile" - `((filename . ,media-file-name)))) - (message "Storing media file to Anki for %s..." path) - (setq content (base64-encode-string - (with-temp-buffer - (insert-file-contents path) - (buffer-string)))) - (anki-editor--anki-connect-invoke-result - "storeMediaFile" - `((filename . ,media-file-name) - (data . ,content)))) + (file-name-extension path t)))) + (when (eq :json-false + (anki-editor-api-call-result 'retrieveMediaFile + :filename media-file-name)) + (message "Storing media file %s to Anki, this might take a while" path) + (anki-editor-api-call-result 'storeMediaFile + :filename media-file-name + :data (base64-encode-string bytes))) media-file-name)) -;;; Org Export Backend +;;; Org export backend (defconst anki-editor--ox-anki-html-backend - (if anki-editor-use-math-jax - (org-export-create-backend - :parent 'html - :transcoders '((latex-fragment . anki-editor--ox-latex-for-mathjax) - (latex-environment . anki-editor--ox-latex-for-mathjax))) - (org-export-create-backend - :parent 'html - :transcoders '((latex-fragment . anki-editor--ox-latex) - (latex-environment . anki-editor--ox-latex))))) + (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 - '(:with-toc nil :anki-editor-mode t)) + '(:with-toc nil :with-properties nil :with-planning nil :anki-editor-mode t)) -(defun anki-editor--translate-latex-delimiters (latex-code) - (catch 'done - (let ((delimiter-map (list (list (cons (format "^%s" (regexp-quote "$$")) "[$$]") - (cons (format "%s$" (regexp-quote "$$")) "[/$$]")) - (list (cons (format "^%s" (regexp-quote "$")) "[$]") - (cons (format "%s$" (regexp-quote "$")) "[/$]")) - (list (cons (format "^%s" (regexp-quote "\\(")) "[$]") - (cons (format "%s$" (regexp-quote "\\)")) "[/$]")) - (list (cons (format "^%s" (regexp-quote "\\[")) "[$$]") - (cons (format "%s$" (regexp-quote "\\]")) "[/$$]")))) - (matched nil)) - (save-match-data - (dolist (pair delimiter-map) - (dolist (delimiter pair) - (when (setq matched (string-match (car delimiter) latex-code)) - (setq latex-code (replace-match (cdr delimiter) t t latex-code)))) - (when matched (throw 'done latex-code))))) - latex-code)) +(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))))) -(defun anki-editor--translate-latex-delimiters-to-anki-mathjax-delimiters (latex-code) - (catch 'done - (let ((delimiter-map (list (list (cons (format "^%s" (regexp-quote "$$")) "\\[") - (cons (format "%s$" (regexp-quote "$$")) "\\]")) - (list (cons (format "^%s" (regexp-quote "$")) "\\(") - (cons (format "%s$" (regexp-quote "$")) "\\)")))) - (matched nil)) - (save-match-data - (dolist (pair delimiter-map) - (dolist (delimiter pair) - (when (setq matched (string-match (car delimiter) latex-code)) - (setq latex-code (replace-match (cdr delimiter) t t latex-code)))) - (when matched (throw 'done latex-code))))) - latex-code)) + (defconst anki-editor--native-latex-delimiters + (with-table '(("$$" "[$$]" + "$$" "[/$$]") + ("$" "[$]" + "$" "[/$]") + ("\\(" "[$]" + "\\)" "[/$]") + ("\\[" "[$$]" + "\\]" "[/$$]")))) -(defun anki-editor--wrap-latex (content) - "Wrap CONTENT with Anki-style latex markers." - (format "

[latex]
%s
[/latex]

" content)) + (defconst anki-editor--mathjax-delimiters + (with-table '(("$$" "\\[" + "$$" "\\]") + ("$" "\\(" + "$" "\\)"))))) -(defun anki-editor--wrap-latex-for-mathjax (content) - "Wrap CONTENT for Anki's native MathJax support." - (format "

%s

" content)) +(defun anki-editor--translate-latex-fragment (latex-code) + (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)) -(defun anki-editor--wrap-div (content) - (format "
%s
" content)) +(defun anki-editor--translate-latex-env (latex-code) + (setq latex-code (replace-regexp-in-string "\n" "
" (org-html-encode-plain-text latex-code))) + (cl-ecase anki-editor-latex-style + (builtin (concat "[latex]
" latex-code "[/latex]")) + (mathjax (concat "\\[
" latex-code "\\]")))) (defun anki-editor--ox-latex (latex _contents _info) "Transcode LATEX from Org to HTML. CONTENTS is nil. INFO is a plist holding contextual information." (let ((code (org-remove-indentation (org-element-property :value latex)))) - (setq code - (pcase (org-element-type latex) - ('latex-fragment (anki-editor--translate-latex-delimiters code)) - ('latex-environment (anki-editor--wrap-latex - (mapconcat #'anki-editor--wrap-div - (split-string (org-html-encode-plain-text code) "\n") - ""))))) - - (if anki-editor-break-consecutive-braces-in-latex - (replace-regexp-in-string "}}" "} } " code) - code))) - -(defun anki-editor--ox-latex-for-mathjax (latex _contents _info) - "Transcode LATEX from Org to HTML. -CONTENTS is nil. INFO is a plist holding contextual information." - (let ((code (org-remove-indentation (org-element-property :value latex)))) - (setq code - (pcase (org-element-type latex) - ('latex-fragment (anki-editor--translate-latex-delimiters-to-anki-mathjax-delimiters code)) - ('latex-environment (anki-editor--wrap-latex-for-mathjax - (mapconcat #'anki-editor--wrap-div - (split-string (org-html-encode-plain-text code) "\n") - ""))))) - + (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))) @@ -311,7 +330,6 @@ CONTENTS is nil. INFO is a plist holding contextual information." (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)) @@ -332,7 +350,7 @@ The implementation is borrowed and simplified from ox-html." (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 - (anki-editor--anki-connect-store-media-file (expand-file-name (url-unhex-string raw-path))))) + (anki-editor-api--store-media-file (expand-file-name (url-unhex-string raw-path))))) (t (throw 'giveup nil)))) (attributes-plist (let* ((parent (org-export-get-parent-element link)) @@ -355,7 +373,7 @@ The implementation is borrowed and simplified from ox-html." ;; Audio file. ((string-suffix-p ".mp3" path t) - (format "[sound:%s]" path)) + (format "[sound:%s]" path)) ;; External link with a description part. ((and path desc) (format "%s" @@ -373,22 +391,89 @@ The implementation is borrowed and simplified from ox-html." (t (throw 'giveup nil))))) (funcall oldfun link desc info))) +(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 + "")))) -;;; Core Functions + +;;; Core primitives + +(defconst anki-editor-prop-note-type "ANKI_NOTE_TYPE") +(defconst anki-editor-prop-note-id "ANKI_NOTE_ID") +(defconst anki-editor-prop-format "ANKI_FORMAT") +(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:]_@#%]+\\)+$") + +(cl-defstruct anki-editor-note + id model deck fields tags) + +(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))))) (defun anki-editor-map-note-entries (func &optional match scope &rest skip) - "Simple wrapper that calls `org-map-entries' with `&ANKI_NOTE_TYPE<>\"\"' appended to MATCH." + "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." ;; 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))) + (org-map-entries func + (concat "+" anki-editor-prop-note-type "<>\"\"" + match + anki-editor-note-match) + scope + skip))) -(defun anki-editor--insert-note-skeleton (prefix deck heading note-type fields) - "Insert a note subtree (skeleton) with HEADING, NOTE-TYPE and FIELDS. +(defun anki-editor--insert-note-skeleton (prefix deck heading type fields) + "Insert a note subtree (skeleton) with HEADING, TYPE and FIELDS. Where the subtree is created depends on PREFIX." (org-insert-heading prefix) (insert heading) - (unless (save-excursion (org-up-heading-safe) ;; don't insert `ANKI_DECK' if some ancestor already has @@ -396,9 +481,7 @@ Where the subtree is created depends on PREFIX." (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)) - - (org-set-property anki-editor-prop-note-type note-type) - + (org-set-property anki-editor-prop-note-type type) (dolist (field fields) (save-excursion (org-insert-heading-respect-content) @@ -407,9 +490,11 @@ Where the subtree is created depends on PREFIX." (defun anki-editor--push-note (note) "Request AnkiConnect for updating or creating NOTE." - (if (= (alist-get 'note-id note) -1) - (anki-editor--create-note note) - (anki-editor--update-note note))) + (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 @@ -418,54 +503,37 @@ Where the subtree is created depends on PREFIX." (defun anki-editor--create-note (note) "Request AnkiConnect for creating NOTE." - (let ((queue (anki-editor--anki-connect-invoke-queue))) - (when anki-editor-create-decks - (funcall queue - 'createDeck - `((deck . ,(alist-get 'deck note))))) - - (funcall queue - 'addNote - `((note . ,(anki-editor--anki-connect-map-note note))) - #'anki-editor--set-note-id) - - (funcall queue))) + (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 and tags of NOTE." - - (let ((queue (anki-editor--anki-connect-invoke-queue))) - (funcall queue - 'updateNoteFields - `((note . ,(anki-editor--anki-connect-map-note note)))) - - (funcall queue - 'notesInfo - `((notes . (,(alist-get 'note-id note)))) - (lambda (result) - ;; update tags - (let* ((existing-note (car result)) - (tags-to-add (-difference (-difference (alist-get 'tags note) - (alist-get 'tags existing-note)) - anki-editor-ignored-org-tags)) - (tags-to-remove (-difference (-difference (alist-get 'tags existing-note) - (alist-get 'tags note)) - anki-editor-protected-tags)) - (tag-queue (anki-editor--anki-connect-invoke-queue))) - - (when tags-to-add - (funcall tag-queue - 'addTags `((notes . (,(alist-get 'note-id note))) - (tags . ,(mapconcat #'identity tags-to-add " "))))) - - (when tags-to-remove - (funcall tag-queue - 'removeTags `((notes . (,(alist-get 'note-id note))) - (tags . ,(mapconcat #'identity tags-to-remove " "))))) - - (funcall tag-queue)))) - - (funcall queue))) + (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 " ")))))) (defun anki-editor--set-failure-reason (reason) "Set failure reason to REASON in property drawer at point." @@ -480,6 +548,7 @@ Where the subtree is created depends on PREFIX." (pcase property ((pred (string= anki-editor-prop-deck)) (anki-editor-deck-names)) ((pred (string= anki-editor-prop-note-type)) (anki-editor-note-types)) + ((pred (string= anki-editor-prop-format)) (list "t" "nil")) ((pred (string-match-p (format "%s\\+?" anki-editor-prop-tags))) (anki-editor-all-tags)) (_ nil))) @@ -489,11 +558,11 @@ Where the subtree is created depends on PREFIX." (defun anki-editor-all-tags () "Get all tags from Anki." - (anki-editor--anki-connect-invoke-result "getTags")) + (anki-editor-api-call-result 'getTags)) (defun anki-editor-deck-names () "Get all decks names from Anki." - (anki-editor--anki-connect-invoke-result "deckNames")) + (anki-editor-api-call-result 'deckNames)) (defun anki-editor--enable-tag-completion () (and anki-editor-mode anki-editor-org-tags-as-anki-tags)) @@ -503,7 +572,7 @@ Where the subtree is created depends on PREFIX." (when (and (anki-editor--enable-tag-completion) (not just-align)) (setq anki-editor--anki-tags-cache (anki-editor-all-tags)) - (unless (-all? #'anki-editor-is-valid-org-tag anki-editor--anki-tags-cache) + (when (cl-notevery #'anki-editor-is-valid-org-tag anki-editor--anki-tags-cache) (warn "Some tags from Anki contain characters that are not valid in Org tags.")))) (defun anki-editor--get-buffer-tags (oldfun) @@ -514,33 +583,62 @@ Where the subtree is created depends on PREFIX." (defun anki-editor-note-types () "Get note types from Anki." - (anki-editor--anki-connect-invoke-result "modelNames")) + (anki-editor-api-call-result 'modelNames)) + +(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 () - "Construct an alist representing a note from current entry." + "Make a note struct from current entry." (let ((org-trust-scanner-tags t) (deck (org-entry-get-with-inheritance anki-editor-prop-deck)) + (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)) - (tags (anki-editor--get-tags)) + (tags (cl-set-difference (anki-editor--get-tags) + anki-editor-ignored-org-tags + :test #'string=)) (fields (anki-editor--build-fields))) - (unless deck (error "No deck specified")) - (unless note-type (error "Missing note type")) - (unless fields (error "Missing fields")) + (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 as the missing field + (push (cons (car missing) + (anki-editor--export-string + (substring-no-properties (org-get-heading t t t)) + format)) + fields))) - `((deck . ,deck) - (note-id . ,(string-to-number (or note-id "-1"))) - (note-type . ,note-type) - (tags . ,tags) - (fields . ,fields)))) + (unless deck (error "Missing deck")) + (unless note-type (error "Missing note type")) + + (make-anki-editor-note :id note-id + :model note-type + :deck deck + :tags tags + :fields fields))) (defun anki-editor--get-tags () (let ((tags (anki-editor--entry-get-multivalued-property-with-inheritance nil anki-editor-prop-tags))) (if anki-editor-org-tags-as-anki-tags - (append tags (org-get-tags-at)) + (append tags (org-get-tags)) tags))) (defun anki-editor--entry-get-multivalued-property-with-inheritance (pom property) @@ -550,44 +648,45 @@ Where the subtree is created depends on PREFIX." (mapcar #'org-entry-restore-space values))) (defun anki-editor--build-fields () - "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." + "Build a list of fields from subheadings of current heading. + +Return a list of cons of (FIELD-NAME . FIELD-CONTENT)." (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 - field-heading))) - (contents-begin (org-element-property :contents-begin field-heading)) - (contents-end (org-element-property :contents-end field-heading))) - - (push (cons field-name - (cond - ((and contents-begin contents-end) (or (org-export-string-as - (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)) - 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 - "")) - (t ""))) - fields) - (org-forward-heading-same-level nil t)))) - (reverse fields)))) + (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)) + 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) + for subelem = (progn + (goto-char eoh) + (org-element-context)) + while (memq (org-element-type subelem) + '(drawer planning property-drawer)) + finally return (org-element-property :begin subelem)) + 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))) + "") + for content = (anki-editor--export-string raw format) + 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))))) ;;; Minor mode @@ -597,7 +696,10 @@ Where the subtree is created depends on PREFIX." (defun anki-editor--concat-multivalued-property-value (prop value) (let ((old-values (org-entry-get-multivalued-property nil prop))) (unless (string-suffix-p prop "+") - (setq old-values (-difference old-values (org-entry-get-multivalued-property nil (concat prop "+"))))) + (setq old-values (cl-set-difference old-values + (org-entry-get-multivalued-property + nil (concat prop "+")) + :test 'string=))) (mapconcat #'org-entry-protect-space (append old-values (list value)) " "))) @@ -620,6 +722,7 @@ Where the subtree is created depends on PREFIX." (defun anki-editor-setup-minor-mode () "Set up this minor mode." + (anki-editor-api-check) (add-hook 'org-property-allowed-value-functions #'anki-editor--get-allowed-values-for-property nil t) (advice-add 'org-set-tags :before #'anki-editor--before-set-tags) (advice-add 'org-get-buffer-tags :around #'anki-editor--get-buffer-tags) @@ -632,8 +735,15 @@ Where the subtree is created depends on PREFIX." ;;; Commands -(defun anki-editor-push-notes (&optional arg match scope) - "Build notes from headings that can be matched by MATCH within SCOPE and push them to Anki. +(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)) + +(defun anki-editor-push-notes (&optional scope match) + "Build notes from headings that match MATCH within SCOPE and push them to Anki. The default search condition `&ANKI_NOTE_TYPE<>\"\"' will always be appended to MATCH. @@ -654,71 +764,98 @@ See doc string of `org-map-entries' for what these different options mean. If one fails, the failure reason will be set in property drawer of that heading." - (interactive "P") + (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)))) + (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)))) - (unless scope - (setq scope (cond - ((region-active-p) 'region) - ((equal arg '(4)) 'tree) - ((equal arg '(16)) 'file) - ((equal arg '(64)) 'agenda) - (t nil)))) +(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 "=\"\""))) - (let* ((total (progn - (message "Counting notes...") - (length (anki-editor-map-note-entries t match scope)))) - (acc 0) - (failed 0)) - (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) - (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))))) - match - scope) - - (message (if (= 0 failed) - (format "Successfully pushed %d notes to Anki." acc) - (format "Pushed %d notes, %d of which are failed. Check property drawers for failure reasons. Once you've fixed the issues, you could use `anki-editor-retry-failure-notes' to re-push the failed notes." - acc failed))))) - -(defun anki-editor-retry-failure-notes (&optional arg scope) - "Retry pushing notes that were failed. +(defun anki-editor-retry-failed-notes (&optional scope) + "Retry pushing notes marked as failed. This command just calls `anki-editor-submit' with match string matching non-empty `ANKI_FAILURE_REASON' properties." - (interactive "P") - (anki-editor-push-notes arg (concat anki-editor-prop-failure-reason "<>\"\"") scope)) + (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)))) + (anki-editor-api-call-result 'deleteNotes + :notes noteids) + (org-entry-delete nil anki-editor-prop-note-id) + (when (called-interactively-p 'interactive) + (message "Deleted note %s" (nth 0 noteids))))) (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...") - (anki-editor--anki-connect-invoke-result "modelFieldNames" `((modelName . ,note-type))))) - (note-heading (read-from-minibuffer "Enter the note heading (optional): "))) +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." + (interactive "P") + (let* ((deck (or (org-entry-get-with-inheritance anki-editor-prop-deck) + (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): "))) (anki-editor--insert-note-skeleton prefix deck - (if (string-blank-p note-heading) - "Item" - note-heading) - note-type - fields))) + heading + type + (if (string-blank-p heading) + (cdr fields) + fields)))) (defun anki-editor-cloze-region (&optional arg hint) "Cloze region with number ARG." @@ -750,27 +887,125 @@ same as how it is used by `M-RET'(org-insert-heading)." (interactive) (org-export-to-buffer anki-editor--ox-anki-html-backend - anki-editor-buffer-html-output nil t nil t anki-editor--ox-export-ext-plist #'html-mode)) + "*AnkiEditor HTML Output*" nil t nil t anki-editor--ox-export-ext-plist #'html-mode)) (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)) -(defun anki-editor-anki-connect-upgrade () - "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. +;;; More utilities -This is useful when new version of this package depends on the -bugfixes or new features of AnkiConnect." +(defun anki-editor-api-check () + "Check if correct version of AnkiConnect is serving." (interactive) - (when (yes-or-no-p "NOTE: This will download the latest codebase of AnkiConnect to your system, which is not guaranteed to be safe or stable. Generally, you don't need this command, this is useful only when new version of this package requires the updates of AnkiConnect that are not released yet. Do you still want to continue?") - (let ((result (anki-editor--anki-connect-invoke-result "upgrade"))) - (when (and (booleanp result) result) - (message "AnkiConnect has been upgraded, you might have to restart Anki to make it in effect."))))) + (let ((ver (condition-case err + (anki-editor-api-call-result 'version) + (error (error "Failed to connect to Anki: %s" (error-message-string err)))))) + (if (<= anki-editor-api-version ver) + (when (called-interactively-p 'interactive) + (message "AnkiConnect v.%d is running" ver)) + (error "anki-editor requires minimal version %d of AnkiConnect installed" + anki-editor-api-version)))) + +(defun anki-editor-sync-collections () + "Synchronizes the local anki collections with ankiweb." + (interactive) + (anki-editor-api-call-result 'sync)) + +(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")))))) + (anki-editor-api-call 'guiBrowse :query (or query ""))) + +(defun anki-editor-gui-add-cards () + "Open Anki Add Cards dialog with presets from current note +entry." + (interactive) + (anki-editor-api-call-result 'guiAddCards + :note (append + (anki-editor-api--note + (anki-editor-note-at-point)) + (list :options '(:closeAfterAdding t))))) + +(defun anki-editor-find-notes (&optional query) + "Find notes with QUERY." + (interactive "sQuery: ") + (let ((nids (anki-editor-api-call-result 'findNotes + :query (or query "")))) + (if (called-interactively-p 'interactive) + (message "%S" nids) + nids))) + +(defvar anki-editor--style-start "\n") +(defvar anki-editor--style-end "\n