diff --git a/anki-editor.el b/anki-editor.el index 20ae246..7b8967c 100644 --- a/anki-editor.el +++ b/anki-editor.el @@ -99,12 +99,6 @@ See https://apps.ankiweb.net/docs/manual.html#latex-conflicts.") ;;; AnkiConnect -(defun anki-editor--anki-connect-invoke-multi (&rest actions) - (-zip-with (lambda (result handler) (and handler (funcall handler result))) - (anki-editor--anki-connect-invoke-result - "multi" `((actions . ,(mapcar #'car actions)))) - (mapcar #'cdr actions))) - (defun anki-editor--anki-connect-action (action &optional params version) (let (a) (when version @@ -163,6 +157,12 @@ See https://apps.ankiweb.net/docs/manual.html#latex-conflicts.") (when .error (error .error)) .result)) +(defun anki-editor--anki-connect-invoke-multi (&rest actions) + (-zip-with (lambda (result handler) (and handler (funcall handler result))) + (anki-editor--anki-connect-invoke-result + "multi" `((actions . ,(mapcar #'car actions)))) + (mapcar #'cdr actions))) + (defun anki-editor--anki-connect-map-note (note) "Convert NOTE to the form that AnkiConnect accepts." (let-alist note @@ -200,160 +200,114 @@ The result is the path to the newly stored media file." media-file-name)) -;;; Minor mode +;;; Org Export Backend -;;;###autoload -(define-minor-mode anki-editor-mode - "anki-eidtor-mode" - :lighter " anki-editor" - (if anki-editor-mode (anki-editor-setup-minor-mode) - (anki-editor-teardown-minor-mode))) +(defconst anki-editor--ox-anki-html-backend + (org-export-create-backend + :parent 'html + :transcoders '((latex-fragment . anki-editor--ox-latex) + (latex-environment . anki-editor--ox-latex)))) -(defun anki-editor-setup-minor-mode () - "Set up this minor mode." - (add-hook 'org-property-allowed-value-functions #'anki-editor--get-allowed-values-for-property) - (advice-add 'org-set-tags :before #'anki-editor--before-set-tags) - (advice-add 'org-html-link :around #'anki-editor--ox-html-link)) +(defconst anki-editor--anki-latex-syntax-map + `((,(format "^%s" (regexp-quote "$$")) . "[$$]") + (,(format "%s$" (regexp-quote "$$")) . "[/$$]") + (,(format "^%s" (regexp-quote "$")) . "[$]") + (,(format "%s$" (regexp-quote "$")) . "[/$]") + (,(format "^%s" (regexp-quote "\\(")) . "[$]") + (,(format "%s$" (regexp-quote "\\)")) . "[/$]") + (,(format "^%s" (regexp-quote "\\[")) . "[$$]") + (,(format "%s$" (regexp-quote "\\]")) . "[/$$]"))) -(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) - (advice-remove 'org-set-tags #'anki-editor--before-set-tags) - (when (advice-member-p 'anki-editor--get-buffer-tags #'org-get-buffer-tags) - (advice-remove 'org-get-buffer-tags #'anki-editor--get-buffer-tags)) - (advice-remove 'org-html-link #'anki-editor--ox-html-link)) +(defun anki-editor--wrap-latex (content) + "Wrap CONTENT with Anki-style latex markers." + (format "[latex]%s[/latex]" content)) + +(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-element-property :value latex)) + (copy code)) + ;; translate delimiters + (dolist (map anki-editor--anki-latex-syntax-map) + (setq code (replace-regexp-in-string (car map) (cdr map) code t t))) + + (when (string= copy code) + (setq code (anki-editor--wrap-latex + (if (eq (org-element-type latex) 'latex-fragment) + code + (format "\n
\n%s
\n" + (org-remove-indentation code)))))) + + (if anki-editor-break-consecutive-braces-in-latex + (replace-regexp-in-string "}}" "} } " code) + code))) + +(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 + (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))) + (message "Storing media file to Anki for %s..." 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))))) + (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)) + + ;; External link with a description part. + ((and path desc) (format "%s" + (org-html-encode-plain-text path) + attributes + desc)) + + ;; External link without a description part. + (path (let ((path (org-html-encode-plain-text path))) + (format "%s" + path + attributes + (org-link-unescape path)))) + + (t (throw 'giveup nil))))) + (funcall oldfun link desc info))) -;;; Commands +;;; Utilities -(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. +(defun anki-editor--get-subheadings (heading) + "Get all the subheadings of HEADING." + (org-element-map (org-element-contents heading) + 'headline 'identity nil nil 'headline)) -The default search condition `&ANKI_NOTE_TYPE<>\"\"' will always -be appended to MATCH. - -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. - -If SCOPE is not specified, the following rules are applied to -determine the scope: - -- 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' - -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") - - (unless scope - (setq scope (cond - ((region-active-p) 'region) - ((equal arg '(4)) 'tree) - ((equal arg '(16)) 'file) - ((equal arg '(64)) 'agenda) - (t nil)))) - (setq match (concat match "&" anki-editor-prop-note-type "<>\"\"")) - - (let ((total (progn - (message "Counting notes...") - (length (org-map-entries t match scope)))) - (acc 0) - (failed 0)) - (org-map-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 err - (anki-editor--process-note-heading) - (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. -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)) - -(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): "))) - - (anki-editor--insert-note-skeleton prefix - deck - (if (string-blank-p note-heading) - "Item" - note-heading) - note-type - fields))) - -(defun anki-editor-cloze-region (&optional arg) - "Cloze region with number ARG." - (interactive "p") - (unless (region-active-p) (error "No active region")) - (let ((region (buffer-substring (region-beginning) (region-end))) - (hint (read-from-minibuffer "Hint (optional): "))) - (save-excursion - (delete-region (region-beginning) (region-end)) - (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 - anki-editor-buffer-html-output nil t nil t nil - #'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. - -This is useful when new version of this package depends on the -bugfixes or new features of AnkiConnect." - (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."))))) ;;; Core Functions @@ -545,113 +499,160 @@ Do nothing when JUST-ALIGN is non-nil." "")))) -;;; Org Export Backend +;;; Minor mode -(defconst anki-editor--ox-anki-html-backend - (org-export-create-backend - :parent 'html - :transcoders '((latex-fragment . anki-editor--ox-latex) - (latex-environment . anki-editor--ox-latex)))) +;;;###autoload +(define-minor-mode anki-editor-mode + "anki-eidtor-mode" + :lighter " anki-editor" + (if anki-editor-mode (anki-editor-setup-minor-mode) + (anki-editor-teardown-minor-mode))) -(defconst anki-editor--anki-latex-syntax-map - `((,(format "^%s" (regexp-quote "$$")) . "[$$]") - (,(format "%s$" (regexp-quote "$$")) . "[/$$]") - (,(format "^%s" (regexp-quote "$")) . "[$]") - (,(format "%s$" (regexp-quote "$")) . "[/$]") - (,(format "^%s" (regexp-quote "\\(")) . "[$]") - (,(format "%s$" (regexp-quote "\\)")) . "[/$]") - (,(format "^%s" (regexp-quote "\\[")) . "[$$]") - (,(format "%s$" (regexp-quote "\\]")) . "[/$$]"))) +(defun anki-editor-setup-minor-mode () + "Set up this minor mode." + (add-hook 'org-property-allowed-value-functions #'anki-editor--get-allowed-values-for-property) + (advice-add 'org-set-tags :before #'anki-editor--before-set-tags) + (advice-add 'org-html-link :around #'anki-editor--ox-html-link)) -(defun anki-editor--wrap-latex (content) - "Wrap CONTENT with Anki-style latex markers." - (format "[latex]%s[/latex]" content)) - -(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-element-property :value latex)) - (copy code)) - ;; translate delimiters - (dolist (map anki-editor--anki-latex-syntax-map) - (setq code (replace-regexp-in-string (car map) (cdr map) code t t))) - - (when (string= copy code) - (setq code (anki-editor--wrap-latex - (if (eq (org-element-type latex) 'latex-fragment) - code - (format "\n
\n%s
\n" - (org-remove-indentation code)))))) - - (if anki-editor-break-consecutive-braces-in-latex - (replace-regexp-in-string "}}" "} } " code) - code))) - -(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 - (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))) - (message "Storing media file to Anki for %s..." 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))))) - (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)) - - ;; External link with a description part. - ((and path desc) (format "%s" - (org-html-encode-plain-text path) - attributes - desc)) - - ;; External link without a description part. - (path (let ((path (org-html-encode-plain-text path))) - (format "%s" - path - attributes - (org-link-unescape path)))) - - (t (throw 'giveup nil))))) - (funcall oldfun link desc info))) +(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) + (advice-remove 'org-set-tags #'anki-editor--before-set-tags) + (when (advice-member-p 'anki-editor--get-buffer-tags #'org-get-buffer-tags) + (advice-remove 'org-get-buffer-tags #'anki-editor--get-buffer-tags)) + (advice-remove 'org-html-link #'anki-editor--ox-html-link)) -;;; Utilities +;;; Commands -(defun anki-editor--get-subheadings (heading) - "Get all the subheadings of HEADING." - (org-element-map (org-element-contents heading) - 'headline 'identity nil nil 'headline)) +(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. + +The default search condition `&ANKI_NOTE_TYPE<>\"\"' will always +be appended to MATCH. + +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. + +If SCOPE is not specified, the following rules are applied to +determine the scope: + +- 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' + +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") + + (unless scope + (setq scope (cond + ((region-active-p) 'region) + ((equal arg '(4)) 'tree) + ((equal arg '(16)) 'file) + ((equal arg '(64)) 'agenda) + (t nil)))) + (setq match (concat match "&" anki-editor-prop-note-type "<>\"\"")) + + (let ((total (progn + (message "Counting notes...") + (length (org-map-entries t match scope)))) + (acc 0) + (failed 0)) + (org-map-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 err + (anki-editor--process-note-heading) + (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. +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)) + +(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): "))) + + (anki-editor--insert-note-skeleton prefix + deck + (if (string-blank-p note-heading) + "Item" + note-heading) + note-type + fields))) + +(defun anki-editor-cloze-region (&optional arg) + "Cloze region with number ARG." + (interactive "p") + (unless (region-active-p) (error "No active region")) + (let ((region (buffer-substring (region-beginning) (region-end))) + (hint (read-from-minibuffer "Hint (optional): "))) + (save-excursion + (delete-region (region-beginning) (region-end)) + (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 + anki-editor-buffer-html-output nil t nil t nil + #'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. + +This is useful when new version of this package depends on the +bugfixes or new features of AnkiConnect." + (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."))))) (provide 'anki-editor)