Skip to content

Commit

Permalink
Use more fine-grained automatic conjugation generation.
Browse files Browse the repository at this point in the history
  • Loading branch information
lfyuomr-gylo committed Dec 19, 2022
1 parent a285df9 commit c279194
Show file tree
Hide file tree
Showing 13 changed files with 223 additions and 54 deletions.
42 changes: 26 additions & 16 deletions anki-helper.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,6 @@ actions:
vars:
pronoun: usted
tense: _spanish-tense-imperative.jpeg

- name: AutoConjugationIgnore
skipVoiceover: true
templates:
# For each template a distinct card template is created for each field specified in forFields
# The following meta-variables will be substituted in name, front and back before passing them to Anki:
Expand Down Expand Up @@ -219,22 +216,35 @@ actions:
}
noteProcessing:
# only process notes that have at least one of the conjugation fields empty
- noteFilter: |
AutoConjugationIgnore: AND (
IndicativePresentYo:
OR IndicativePresentTu:
OR IndicativePresentEl:
OR IndicativePresentNosotros:
OR IndicativePresentVosotros:
OR IndicativePresentEllos:
OR ImperativeAffirmativeTu:
OR ImperativeAffirmativeUsted:
)
(IndicativePresentYo:- -tag:conjugation_skip:IndicativePresentYo)
OR (IndicativePresentTu:- -tag:conjugation_skip:IndicativePresentTu)
OR (IndicativePresentEl:- -tag:conjugation_skip:IndicativePresentEl)
OR (IndicativePresentNosotros:- -tag:conjugation_skip:IndicativePresentNosotros)
OR (IndicativePresentVosotros:- -tag:conjugation_skip:IndicativePresentVosotros)
OR (IndicativePresentEllos:- -tag:conjugation_skip:IndicativePresentEllos)
OR (ImperativeAffirmativeTu:- -tag:conjugation_skip:ImperativeAffirmativeTu)
OR (ImperativeAffirmativeUsted:- -tag:conjugation_skip:ImperativeAffirmativeUsted)
# Execute specified command with specified arguments
# and read values of note fields from its stdout.
# Command should print new values of note fields as a JSON object.
overwriteNonEmptyFields: false
# Command should print a JSON array of modification commands.
# Supported commands:
# {"set_field": {"field": "value"}}
# {"add_tag": "tag"}
# {"set_field_if_not_empty": {"field": "value"}}
exec:
command: ./disable_skipped_conjugation.py
stdin: "$$ .Note.Fields | to_json $$"
# only process notes that have at least one of the conjugation fields empty
- noteFilter: |
(IndicativePresentYo: -tag:conjugation_skip:IndicativePresentYo)
OR (IndicativePresentTu: -tag:conjugation_skip:IndicativePresentTu)
OR (IndicativePresentEl: -tag:conjugation_skip:IndicativePresentEl)
OR (IndicativePresentNosotros: -tag:conjugation_skip:IndicativePresentNosotros)
OR (IndicativePresentVosotros: -tag:conjugation_skip:IndicativePresentVosotros)
OR (IndicativePresentEllos: -tag:conjugation_skip:IndicativePresentEllos)
OR (ImperativeAffirmativeTu: -tag:conjugation_skip:ImperativeAffirmativeTu)
OR (ImperativeAffirmativeUsted: -tag:conjugation_skip:ImperativeAffirmativeUsted)
exec:
command: ./conjugate-spanish-verb.sh
args:
Expand Down
8 changes: 8 additions & 0 deletions ankiconnect/ankiconnectmock/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type API struct {
FindCardsFunc func(query string) ([]ankiconnect.CardID, error)
ChangeDeckFunc func(deckName string, noteIDs []ankiconnect.CardID) error
StoreMediaFileFunc func(fileName string, fileData io.Reader, replaceExisting bool) error
AddTagsFn func(noteIDs []ankiconnect.NoteID, tags []string) error
}

var _ ankiconnect.API = (*API)(nil)
Expand Down Expand Up @@ -78,3 +79,10 @@ func (api *API) StoreMediaFile(fileName string, fileData io.Reader, replaceExist
}
panic(errorx.Panic(errorx.NotImplemented.New("Mock behaviour is not set for method StoreMediaFile")))
}

func (api *API) AddTags(noteIDs []ankiconnect.NoteID, tags []string) error {
if behaviour := api.AddTagsFn; behaviour != nil {
return behaviour(noteIDs, tags)
}
panic(errorx.Panic(errorx.NotImplemented.New("Moch behaviour is not set for method AddTags")))
}
18 changes: 16 additions & 2 deletions ankiconnect/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"
"net/url"
"reflect"
"strings"
"time"
)

Expand Down Expand Up @@ -95,8 +96,8 @@ func (api api) UpdateNoteFields(noteID NoteID, fields map[string]FieldUpdate) er
}}
for field, fieldUpdate := range fields {
switch {
case fieldUpdate.Value != "":
params.Note.Fields[field] = fieldUpdate.Value
case fieldUpdate.Value != nil:
params.Note.Fields[field] = *fieldUpdate.Value
case len(fieldUpdate.AudioData) > 0:
fileName := fmt.Sprintf("%x.mp3", md5.Sum(fieldUpdate.AudioData))
params.Note.Audio = append(params.Note.Audio, updateNoteFieldsAudio{
Expand Down Expand Up @@ -137,6 +138,19 @@ func (api api) ChangeDeck(deckName string, cardIDs []CardID) error {
return nil
}

func (api api) AddTags(noteIDs []NoteID, tags []string) error {
if len(tags) == 0 {
return nil
}

params := addTagsParams{
Notes: noteIDs,
Tags: strings.Join(tags, " "),
}
_, err := api.doReq(params, 5)
return err
}

func (api api) doReq(params interface{}, maxAttempts int) (interface{}, error) {
actionName, ok := actionParamsMapping[reflect.TypeOf(params)]
if !ok {
Expand Down
14 changes: 14 additions & 0 deletions ankiconnect/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,17 @@ type storeMediaFileParams struct {
}

type storeMediaFileResult string

//goland:noinspection GoUnusedGlobalVariable
var actionAddTags = declareAction("addTags", addTagsParams{}, addTagsResult{})

type addTagsParams struct {
Notes []NoteID `json:"notes"`
// Tags is a space-separated list of tags, as per
// https://github.com/FooSoft/anki-connect/blob/42a6de39b317a9471e4ad430e13e0f6a5d0d5d58/plugin/__init__.py#L799
Tags string `json:"tags"`
}

type addTagsResult struct {
// nop
}
3 changes: 2 additions & 1 deletion ankiconnect/iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type CardID int64

type FieldUpdate struct {
// one of
Value string
Value *string
AudioData []byte
}

Expand All @@ -35,4 +35,5 @@ type API interface {
CreateModel(params CreateModelParams) error
ChangeDeck(deckName string, noteIDs []CardID) error
StoreMediaFile(fileName string, fileData io.Reader, replaceExisting bool) error
AddTags(noteIDs []NoteID, tags []string) error
}
100 changes: 84 additions & 16 deletions ankihelper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"anki-rest-enhancer/ratelimit"
"anki-rest-enhancer/util/execx"
"anki-rest-enhancer/util/iox"
"anki-rest-enhancer/util/lang"
"anki-rest-enhancer/util/stringx"
"anki-rest-enhancer/util/templatex"
"context"
Expand Down Expand Up @@ -382,25 +383,47 @@ func (h Helper) applyProcessingRule(ctx context.Context, rule ankihelperconf.Not
idx++
throttler.Throttle()

fieldUpdate, err := h.processNote(ctx, rule, note, idx, len(notes))
err := h.processNote(ctx, rule, note, idx, len(notes))
if err != nil {
log.Printf("Skip failed note %d processing, error: %s", noteID, err)
log.Printf("Failed to process note %d, error: %s", noteID, err)
continue
}
if err := h.ankiConnect.UpdateNoteFields(noteID, fieldUpdate); err != nil {
return err
}
}

return nil
}

type noteProcessingModification struct {
// oneof
SetField *map[string]string `json:"set_field"`
SetFieldIfNotEmpty *map[string]string `json:"set_field_if_not_empty"`
AddTag *string `json:"add_tag"`
}

func (m noteProcessingModification) Validate() error {
fieldsSet := 0
if m.SetField != nil {
fieldsSet++
}
if m.AddTag != nil {
fieldsSet++
}
if m.SetFieldIfNotEmpty != nil {
fieldsSet++
}

if fieldsSet != 1 {
return errorx.IllegalFormat.New("invalid note modification command has %d top-level keys instead of one: %+v", fieldsSet, m)
}
return nil
}

func (h Helper) processNote(
ctx context.Context,
rule ankihelperconf.NoteProcessingRule,
note ankiconnect.NoteInfo,
noteIdx, totalNotes int,
) (map[string]ankiconnect.FieldUpdate, error) {
) error {
templateContext := map[string]any{
"Note": map[string]any{
"Fields": note.Fields,
Expand All @@ -415,12 +438,24 @@ func (h Helper) processNote(
case arg.Template != nil:
var argBuilder strings.Builder
if err := arg.Template.Execute(&argBuilder, templateContext); err != nil {
return nil, errorx.IllegalFormat.Wrap(err, "failed to substitute template in argument #%d", i)
return errorx.IllegalFormat.Wrap(err, "failed to substitute template in argument #%d", i)
}
args[i] = argBuilder.String()
}
}

var stdin string
switch {
case rule.Exec.Stdin.PlainString != nil:
stdin = *rule.Exec.Stdin.PlainString
case rule.Exec.Stdin.Template != nil:
var stdinBuilder strings.Builder
if err := rule.Exec.Stdin.Template.Execute(&stdinBuilder, templateContext); err != nil {
return errorx.IllegalFormat.Wrap(err, "failed to substitute template in stdin of the script")
}
stdin = stdinBuilder.String()
}

cmdCtx := ctx
if rule.Timeout > 0 {
// currently this context deadline is not respected by
Expand All @@ -429,24 +464,57 @@ func (h Helper) processNote(
cmdCtx = ctx
}
log.Printf("Executing note processing command [%d/%d]: %s %s", noteIdx, totalNotes, rule.Exec.Command, strings.Join(args, " "))
cmdOut, err := execx.RunAndCollectOutput(cmdCtx, rule.Exec.Command, args...)
cmdOut, err := execx.RunAndCollectOutput(cmdCtx, execx.Params{
Command: rule.Exec.Command,
Args: args,
Stdin: stdin,
})
if err != nil {
return nil, errorx.ExternalError.Wrap(err, "Note population command failed")
return errorx.ExternalError.Wrap(err, "Note population command failed")
}

var commandOutParsed map[string]string
var commandOutParsed []noteProcessingModification
if !stringx.IsBlank(string(cmdOut)) {
if err := json.Unmarshal(cmdOut, &commandOutParsed); err != nil {
return nil, errorx.ExternalError.Wrap(err, "Note processing command's stdout is malformed")
return errorx.ExternalError.Wrap(err, "Note processing command's stdout is malformed")
}
for idx, modification := range commandOutParsed {
if err := modification.Validate(); err != nil {
return errorx.Decorate(err, "Note processing command's stdout contains malformed modification #%d", idx)
}
}
}

fieldValues := make(map[string]ankiconnect.FieldUpdate, len(commandOutParsed))
for field, value := range commandOutParsed {
if rule.OverwriteNonEmptyFields || note.Fields[field] == "" {
fieldValues[field] = ankiconnect.FieldUpdate{Value: value}
fieldUpdates := make(map[string]ankiconnect.FieldUpdate)
var tagsToAdd []string
for _, modification := range commandOutParsed {
switch {
case modification.SetField != nil:
for field, value := range *modification.SetField {
fieldUpdates[field] = ankiconnect.FieldUpdate{Value: lang.New(value)}
}
case modification.SetFieldIfNotEmpty != nil:
for field, value := range *modification.SetFieldIfNotEmpty {
if note.Fields[field] == "" {
fieldUpdates[field] = ankiconnect.FieldUpdate{Value: lang.New(value)}
}
}
case modification.AddTag != nil:
tagsToAdd = append(tagsToAdd, *modification.AddTag)
default:
panic(errorx.IllegalState.New("Unexpected modification: %+v", modification))
}
}

return fieldValues, nil
if len(fieldUpdates) > 0 {
if err := h.ankiConnect.UpdateNoteFields(note.ID, fieldUpdates); err != nil {
return err
}
}
if len(tagsToAdd) > 0 {
if err := h.ankiConnect.AddTags([]ankiconnect.NoteID{note.ID}, tagsToAdd); err != nil {
return err
}
}
return nil
}
2 changes: 1 addition & 1 deletion ankihelperconf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ type NotesOrganizationRule struct {

type NoteProcessingRule struct {
NoteFilter string
OverwriteNonEmptyFields bool
MinPauseBetweenExecutions time.Duration
Timeout time.Duration

Expand All @@ -90,6 +89,7 @@ type NoteProcessingRule struct {
type NoteProcessingExec struct {
Command string
Args []NoteProcessingExecArg
Stdin NoteProcessingExecArg
}

type NoteProcessingExecArg struct {
Expand Down
21 changes: 19 additions & 2 deletions ankihelperconf/templates.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
package ankihelperconf

import "text/template"
import (
"encoding/json"
"github.com/joomcode/errorx"
"strings"
"text/template"
)

const (
templateOpen = "$$"
templateClose = "$$"
)

func ParseTextTemplate(name string, text string) (*template.Template, error) {
return template.New(name).Delims(templateOpen, templateClose).Parse(text)
return template.New(name).Delims(templateOpen, templateClose).
Funcs(template.FuncMap{
"to_json": ToJson,
}).
Parse(text)
}

func ToJson(value any) (string, error) {
var marshalled strings.Builder
if err := json.NewEncoder(&marshalled).Encode(value); err != nil {
return "", errorx.IllegalState.Wrap(err, "failed to marshal to JSON value %+v", value)
}
return marshalled.String(), nil
}
13 changes: 11 additions & 2 deletions ankihelperconf/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,6 @@ func (o YAMLNotesOrganization) Parse() (NotesOrganizationRule, error) {

type YAMLNoteProcessing struct {
NoteFilter string `yaml:"noteFilter"`
OverwriteNonEmptyFields bool `yaml:"overwriteNonEmptyFields"`
MinPauseBetweenExecutions string `yaml:"minPauseBetweenExecutions"`
Timeout string `yaml:"timeout"`
DisableAutoFilterOptimization *bool `yaml:"disableAutoFilterOptimization"`
Expand Down Expand Up @@ -506,14 +505,14 @@ func (np YAMLNoteProcessing) Parse(configDir string) (NoteProcessingRule, error)
NoteFilter: noteFilter,
MinPauseBetweenExecutions: minPauseBetweenExecutions,
Timeout: timeout,
OverwriteNonEmptyFields: np.OverwriteNonEmptyFields,
Exec: exec,
}, nil
}

type YAMLNotesPopulationExec struct {
Command string `yaml:"command"`
Args []string `yaml:"args"`
Stdin string `yaml:"stdin"`
}

func (e YAMLNotesPopulationExec) Parse(configDir string) (NoteProcessingExec, error) {
Expand Down Expand Up @@ -542,9 +541,19 @@ func (e YAMLNotesPopulationExec) Parse(configDir string) (NoteProcessingExec, er
}
}

stdin := NoteProcessingExecArg{PlainString: lang.New(e.Stdin)}
if strings.Contains(e.Stdin, templateOpen) && strings.Contains(e.Stdin, templateClose) {
parsed, err := ParseTextTemplate("stdin", e.Stdin)
if err != nil {
return NoteProcessingExec{}, errorx.IllegalFormat.Wrap(err, "failed to parse stdin template")
}
stdin = NoteProcessingExecArg{Template: parsed}
}

return NoteProcessingExec{
Command: e.Command,
Args: args,
Stdin: stdin,
}, nil
}

Expand Down
Loading

0 comments on commit c279194

Please sign in to comment.