Skip to content

Commit 1291758

Browse files
committed
feat(addon): add generic set-field op writing a literal into a chosen field
1 parent 7637578 commit 1291758

4 files changed

Lines changed: 150 additions & 0 deletions

File tree

addon/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ The build script is stdlib-only and runs with bare `python`. Install the built `
3939
| `int-sort` | Sort by rank | `rank` (configurable field) | reorders the deck's new cards |
4040
| `generate-vocab` | Generate vocab cards | `sentence` | creates new vocab notes for words new to you |
4141
| `sync-word-status` | Sync word status to vocab store | `word` (required), `word-reading` (optional) | records each word's status in the vocab store (new card -> `seen`, reviewed/suspended -> `learnt`); writes no field |
42+
| `set-field` | Set field | none (reads no field) | the `target` field, set to a fixed `value` (any string; empty value clears the field; local-only, no backend call) |
4243
| `clear-formatting` | Clear formatting | the `target` field (default `sentence`) | the same `target` field (strips HTML in place; local-only, no backend call) |
4344

4445
Field-writing ops are idempotent (a value is written only when it differs); most accept an `only_if_empty` option. `int-sort`, `generate-vocab`, and `sync-word-status` operate over the whole target deck, not just a selected subset.

addon/src/jp_utils/ops/registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .int_sort import IntSortOperation
1313
from .nplus1 import Nplus1SequenceOperation
1414
from .sentence_furigana import SentenceFuriganaOperation
15+
from .set_field import SetFieldOperation
1516
from .sync_status import SyncWordStatusOperation
1617
from .word_audio import WordAudioOperation
1718
from .word_definition import WordDefinitionOperation
@@ -30,5 +31,6 @@
3031
IntSortOperation(),
3132
GenerateVocabOperation(),
3233
SyncWordStatusOperation(),
34+
SetFieldOperation(),
3335
ClearFormattingOperation(),
3436
]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Set-field operation: write a fixed literal value into a chosen field.
2+
3+
A purely local :class:`FieldOperation` (no backend call) that sets one alias to a
4+
constant the user types. It is deliberately datatype-agnostic - it sets *any*
5+
field with a plain string, so the same op covers Lapis' boolean toggle flags
6+
(``'true'`` / empty for ``IsClickCard`` etc., see T-34), a category label, or any
7+
other fixed value; there is no separate "boolean" kind.
8+
9+
Both halves come from params: ``target`` picks the alias to write (param-driven,
10+
so :meth:`io_spec` validates and displays whichever field is chosen) and ``value``
11+
is the literal to write. Neither has a default - the op does nothing useful until
12+
the user configures both. An *explicit* empty ``value`` clears the target (needed
13+
to unset a boolean flag); a ``value`` that was never set (``None``) writes nothing.
14+
The shared ``only_if_empty`` toggle still applies (skip notes whose target is
15+
already populated). Reading no input alias, it applies to every note in the deck.
16+
"""
17+
18+
from ..client import BackendClient
19+
from ..config import ALIASES
20+
from .base import ONLY_IF_EMPTY, FieldOperation, IOSpec, ParamSpec
21+
22+
23+
class SetFieldOperation(FieldOperation):
24+
key = "set-field"
25+
label = "Set field"
26+
# No static input/output aliases: the target is param-driven (see io_spec), and
27+
# the op reads no field so it applies to every note.
28+
params_spec = (
29+
ParamSpec(
30+
"target",
31+
"Target field",
32+
"choice",
33+
choices=ALIASES,
34+
description="The field to write the value into.",
35+
),
36+
ParamSpec(
37+
"value",
38+
"Value",
39+
"text",
40+
description="The literal value to set. Leave empty to clear the field.",
41+
),
42+
ONLY_IF_EMPTY,
43+
)
44+
45+
def io_spec(self, params: dict | None = None) -> IOSpec:
46+
target = (params or {}).get("target")
47+
return IOSpec(outputs=(target,) if target else ())
48+
49+
def io_display(self, params: dict | None = None) -> str:
50+
params = params or {}
51+
target = params.get("target")
52+
out = "{" + target + "}" if target else "{}"
53+
value = params.get("value")
54+
literal = "(unset)" if value is None else f'"{value}"'
55+
return f"{out}{literal}"
56+
57+
def compute(
58+
self, client: BackendClient, sources: list[dict[str, str]], params: dict | None = None
59+
) -> list[str | None]:
60+
value = (params or {}).get("value")
61+
# An unconfigured value (never set) writes nothing; an explicit "" clears.
62+
return [value] * len(sources)

addon/tests/test_ops_set_field.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for the set-field operation (local literal write into a chosen field)."""
2+
3+
from jp_utils.ops import ConfiguredOp, NoteFields, plan_operations, resolve_params
4+
from jp_utils.ops.set_field import SetFieldOperation
5+
6+
7+
def test_io_spec_targets_the_chosen_alias():
8+
op = SetFieldOperation()
9+
assert op.io_spec({"target": "definition"}).outputs == ("definition",)
10+
# No required inputs - the op applies to every note.
11+
assert op.io_spec({"target": "definition"}).required_inputs == ()
12+
# Unconfigured target writes nothing.
13+
assert op.io_spec().outputs == ()
14+
15+
16+
def test_no_param_defaults_for_target_and_value():
17+
op = SetFieldOperation()
18+
params = resolve_params(op, None)
19+
assert params["target"] is None
20+
assert params["value"] is None
21+
# only_if_empty keeps its shared default.
22+
assert params["only_if_empty"] is True
23+
24+
25+
def test_applicable_to_every_note_regardless_of_fields():
26+
op = SetFieldOperation()
27+
assert op.applicable({}, {"target": "frequency", "value": "x"}) is True
28+
29+
30+
def test_compute_repeats_the_value():
31+
op = SetFieldOperation()
32+
sources = [{}, {"word": "a"}]
33+
assert op.compute(None, sources, {"value": "true"}) == ["true", "true"]
34+
35+
36+
def test_compute_unconfigured_value_writes_nothing():
37+
op = SetFieldOperation()
38+
# value never set -> None per source, which plan_operations skips.
39+
assert op.compute(None, [{}], {"target": "frequency"}) == [None]
40+
41+
42+
def test_plan_writes_value_to_target():
43+
op = SetFieldOperation()
44+
notes = [NoteFields(note_id=1, fields={"frequency": ""})]
45+
params = {"target": "frequency", "value": "true", "only_if_empty": False}
46+
plans = plan_operations(None, [ConfiguredOp(op, params)], notes)
47+
[update] = plans[0].updates
48+
assert update.alias == "frequency"
49+
assert update.value == "true"
50+
51+
52+
def test_plan_idempotent_when_already_equal():
53+
op = SetFieldOperation()
54+
notes = [NoteFields(note_id=1, fields={"frequency": "true"})]
55+
params = {"target": "frequency", "value": "true", "only_if_empty": False}
56+
assert plan_operations(None, [ConfiguredOp(op, params)], notes) == []
57+
58+
59+
def test_explicit_empty_value_clears_the_field():
60+
op = SetFieldOperation()
61+
notes = [NoteFields(note_id=1, fields={"frequency": "true"})]
62+
params = {"target": "frequency", "value": "", "only_if_empty": False}
63+
[plan] = plan_operations(None, [ConfiguredOp(op, params)], notes)
64+
[update] = plan.updates
65+
assert update.value == ""
66+
67+
68+
def test_only_if_empty_skips_populated_target():
69+
op = SetFieldOperation()
70+
notes = [NoteFields(note_id=1, fields={"frequency": "old"})]
71+
params = {"target": "frequency", "value": "new", "only_if_empty": True}
72+
assert plan_operations(None, [ConfiguredOp(op, params)], notes) == []
73+
74+
75+
def test_unconfigured_target_writes_nothing():
76+
op = SetFieldOperation()
77+
notes = [NoteFields(note_id=1, fields={"frequency": "x"})]
78+
params = {"value": "v", "only_if_empty": False}
79+
assert plan_operations(None, [ConfiguredOp(op, params)], notes) == []
80+
81+
82+
def test_io_display_shows_target_and_literal():
83+
op = SetFieldOperation()
84+
assert op.io_display({"target": "frequency", "value": "true"}) == '{frequency} ← "true"'
85+
assert op.io_display({"target": "frequency"}) == "{frequency} ← (unset)"

0 commit comments

Comments
 (0)