fix(joplin):remove flag

This commit is contained in:
27494539a-hub
2026-05-22 21:37:58 +08:00
parent 0528c2214c
commit c30920207c
12 changed files with 1023 additions and 824 deletions
+371 -371
View File
File diff suppressed because it is too large Load Diff
+432 -432
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -125,8 +125,8 @@ For real backend runs, ensure `joplin` is installed and available in `PATH`.
Current validation baseline (Windows + Joplin CLI 3.6.2):
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` �`91 passed`
- `python -m pytest -q cli_anything/joplin/tests` �`118 passed, 1 skipped`
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` ? `96 passed`
- `python -m pytest -q cli_anything/joplin/tests` ? `123 passed, 1 skipped`
## Development notes
@@ -152,7 +152,13 @@ Current validation baseline (Windows + Joplin CLI 3.6.2):
- Some Joplin CLI builds reject `search` outside the REPL with
`"only available in GUI mode"`. The harness surfaces this as a normal
`ok=false` JSON envelope; agents should treat search as best-effort.
- Non-ASCII process arguments on Windows pass through `joplin.cmd` �`cmd.exe`,
- Non-ASCII process arguments on Windows pass through `joplin.cmd` ? `cmd.exe`,
which truncates them to the active code page. Harness JSON state handles
unicode correctly; only argv-forwarded titles are affected. The unicode
workflow test is skipped on Windows.
- `notes remove --permanent` / `notebooks remove --permanent` require Joplin
terminal CLI >= 3.0. Older builds silently ignore unknown options, so the
harness probes `joplin help rmnote` / `rmbook` once per binary and raises
a clear `RuntimeError` rather than letting a "permanent" delete fall
through to a soft trash move. The flag is forwarded as the long-form
`--permanent` (and `--force` for force), never the short `-p` / `-f`.
+2 -2
View File
@@ -65,8 +65,8 @@ cli-anything-joplin --dry-run --project ./demo.joplin-harness.json todos create
Current validation baseline (Windows + Joplin CLI 3.6.2):
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` �`91 passed`
- `python -m pytest -q cli_anything/joplin/tests` �`118 passed, 1 skipped`
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` ? `96 passed`
- `python -m pytest -q cli_anything/joplin/tests` ? `123 passed, 1 skipped`
See `cli_anything/joplin/tests/TEST.md` for the full plan (91 unit + 27 e2e,
2 skipped on Windows) and `cli_anything/joplin/WORKFLOWS.md` for the verified
@@ -121,8 +121,8 @@ CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest -v -s cli_anything/joplin/tests/
Current validation baseline (Windows + Joplin CLI 3.6.2):
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` �`91 passed`
- `python -m pytest -q cli_anything/joplin/tests` �`118 passed, 1 skipped`
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` ? `96 passed`
- `python -m pytest -q cli_anything/joplin/tests` ? `123 passed, 1 skipped`
See `cli_anything/joplin/tests/TEST.md` for the full test plan and
`cli_anything/joplin/WORKFLOWS.md` for the verified workflow inventory.
@@ -123,3 +123,11 @@ tag.add, tag.remove, attach.add, interop.export, note.remove, notebook.remove
binary directory (Unix npm global, Homebrew, nvm), the Windows-style
sibling `node_modules/joplin`, the Unix-style parent `lib/node_modules/joplin`,
and `npm root -g` as a last resort.
- `notes remove --permanent` / `notebooks remove --permanent` require Joplin
terminal CLI >= 3.0. Because Joplin silently ignores unknown options, the
harness probes `joplin help rmnote` / `rmbook` once per binary and raises
a clear error on older builds rather than downgrading a permanent delete
to a trash move. The flag is forwarded as long-form (`--permanent`,
`--force`); the short `-p` is intentionally avoided since Joplin uses
`-p` for `--parent` in `mkbook` and `--permanent` only became available
in 3.x.
@@ -1,3 +1,4 @@
from cli_anything.joplin.core.notes import _supports_permanent
from cli_anything.joplin.utils.joplin_backend import BackendConfig, run_joplin_command, run_joplin_json
@@ -32,9 +33,23 @@ def use_notebook(config: BackendConfig, notebook: str) -> dict:
def remove_notebook(config: BackendConfig, notebook: str, force: bool = True, permanent: bool = False) -> dict:
"""Delete a notebook.
``--permanent`` requires Joplin terminal CLI >= 3.0. Joplin silently
ignores unknown options, so we probe ``joplin help rmbook`` first and
refuse rather than silently turn a permanent delete into a trash move.
"""
args = ["rmbook", notebook]
if force:
args.append("-f")
args.append("--force")
if permanent:
args.append("-p")
if not _supports_permanent(config, "rmbook"):
raise RuntimeError(
"Joplin CLI `rmbook` does not advertise `--permanent` "
"(requires Joplin terminal CLI >= 3.0). Refusing to send the "
"flag because Joplin would silently ignore it and move the "
"notebook to the trash instead of deleting it permanently. "
"Upgrade Joplin or omit `--permanent` to use a soft delete."
)
args.append("--permanent")
return run_joplin_command(args, config)
@@ -1,5 +1,30 @@
from cli_anything.joplin.utils.joplin_backend import BackendConfig, run_joplin_command, run_joplin_json
# Cache feature support per (binary, command) pair. Joplin's CLI silently
# ignores unknown options instead of erroring out, so we must probe `help`
# before emitting --permanent, otherwise a stale binary would happily report
# success while only moving the item to trash.
_PERMANENT_FLAG_SUPPORT_CACHE: dict[str, bool] = {}
def _supports_permanent(config: BackendConfig, command_name: str) -> bool:
"""Return True if the installed Joplin CLI advertises ``--permanent`` for
``command_name`` (`rmnote` / `rmbook`). Joplin >=3.0 supports it; older
builds do not list it in ``joplin help <command>``.
Result is cached per (binary, command) so we only probe once per process.
"""
key = f"{config.binary or 'joplin'}:{command_name}"
if key in _PERMANENT_FLAG_SUPPORT_CACHE:
return _PERMANENT_FLAG_SUPPORT_CACHE[key]
try:
result = run_joplin_command(["help", command_name], config, timeout=30)
supported = "--permanent" in (result.get("stdout") or "")
except Exception:
supported = False
_PERMANENT_FLAG_SUPPORT_CACHE[key] = supported
return supported
def list_notes(
config: BackendConfig,
@@ -43,11 +68,27 @@ def get_note(config: BackendConfig, note_ref: str, verbose: bool = False) -> dic
def remove_note(config: BackendConfig, note_ref: str, force: bool = True, permanent: bool = False) -> dict:
"""Delete a note.
``--permanent`` requires Joplin terminal CLI >= 3.0. Because the Joplin
CLI silently ignores unknown options, we explicitly probe ``joplin help
rmnote`` first when the caller opts into ``permanent=True``; if the flag
is not advertised we raise rather than silently letting the note merely
go to the trash.
"""
args = ["rmnote", note_ref]
if force:
args.append("-f")
args.append("--force")
if permanent:
args.append("-p")
if not _supports_permanent(config, "rmnote"):
raise RuntimeError(
"Joplin CLI `rmnote` does not advertise `--permanent` "
"(requires Joplin terminal CLI >= 3.0). Refusing to send the "
"flag because Joplin would silently ignore it and move the "
"note to the trash instead of deleting it permanently. "
"Upgrade Joplin or omit `--permanent` to use a soft delete."
)
args.append("--permanent")
return run_joplin_command(args, config)
@@ -106,5 +106,5 @@ CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest -v -s cli_anything/joplin/tests/
Current validation baseline (Windows + Joplin CLI 3.6.2):
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` �`91 passed`
- `python -m pytest -q cli_anything/joplin/tests` �`118 passed, 1 skipped`
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` ? `96 passed`
- `python -m pytest -q cli_anything/joplin/tests` ? `123 passed, 1 skipped`
@@ -8,7 +8,7 @@ The suite has two files:
Real-backend classes are skipped automatically when `joplin` is not on `PATH`.
## test_core.py (91 tests)
## test_core.py (96 tests)
Pure Python tests covering the harness surface. Safe to run anywhere without
a Joplin backend.
@@ -41,6 +41,16 @@ Coverage areas:
- Core module argument shapes for: notes (cp/mv/ren/remove/get verbose),
notebooks (rmbook flags), todos (mktodo/toggle/clear/done/undone), tags
(tagnotes via `tag list`)
- `--permanent` flag guard (`rmnote` / `rmbook`): Joplin's CLI silently
ignores unknown options, so the harness probes `joplin help <command>`
once per binary and refuses to send `--permanent` if the installed CLI
doesn't advertise it (would otherwise downgrade a permanent delete to a
trash move on older builds). When supported we send the long-form
`--force` / `--permanent`, never the short `-f` / `-p` (the latter is
`--parent` on `mkbook`). Tests cover: long-form on supported CLI,
no probe when `permanent=False`, clear RuntimeError on unsupported CLI,
cached probe (one help call across N permanent deletes), and the
notebooks variant of the same guard
- JSON envelope shape (success + error)
- JSON contract per command group: notebooks, notes, todos, tags, search,
sync, interop, config, session, attach, status, backend, server, e2ee
@@ -167,3 +177,8 @@ CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest -v -s cli_anything/joplin/tests/
`cmd.exe`, which drops to the active code page. The unicode workflow test
is therefore skipped on Windows. The Python-level unicode handling in the
harness (project JSON, history, REPL) is covered in `test_core.py`.
- `notes/notebooks remove --permanent` is only forwarded as the long-form
`--permanent` flag, and only after `joplin help rmnote`/`rmbook` reports
the flag (Joplin terminal CLI >= 3.0). On older Joplin builds the harness
raises `RuntimeError` rather than silently letting the delete go to the
trash. Omit `--permanent` to use a soft delete on any version.
@@ -563,15 +563,84 @@ def test_backend_run_json_empty_stdout(monkeypatch):
def test_notes_remove_passes_force_and_permanent(monkeypatch):
captured = {}
def fake(args, cfg):
def fake(args, cfg, timeout=120):
# The help-probe call goes through run_joplin_command too; emit a
# stub that advertises --permanent so the probe returns True.
if args[:1] == ["help"]:
return {"returncode": 0, "stdout": "rmnote\n --permanent Deletes permanently.\n", "stderr": ""}
captured["args"] = args
return {"returncode": 0}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(note_core, "run_joplin_command", fake)
note_core.remove_note(joplin_backend.BackendConfig(), "n1", force=True, permanent=True)
assert "rmnote" in captured["args"]
assert "-f" in captured["args"]
assert "-p" in captured["args"]
assert "--force" in captured["args"]
assert "--permanent" in captured["args"]
# Short forms must not be used: Joplin's published terminal reference for
# rmnote pre-dates `--permanent`, and `-p` is also `--parent` elsewhere.
assert "-p" not in captured["args"]
def test_notes_remove_without_permanent_skips_probe(monkeypatch):
"""When permanent=False we must NOT probe `joplin help` -- it would just
be wasted subprocess work."""
calls = []
def fake(args, cfg, timeout=120):
calls.append(args)
return {"returncode": 0, "stdout": "", "stderr": ""}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(note_core, "run_joplin_command", fake)
note_core.remove_note(joplin_backend.BackendConfig(), "n1", force=True, permanent=False)
assert calls == [["rmnote", "n1", "--force"]]
def test_notes_remove_permanent_refuses_when_help_lacks_flag(monkeypatch):
"""Regression for P2: if the installed Joplin CLI doesn't advertise
`--permanent` we must raise a clear error rather than send the flag
(which Joplin would silently ignore, downgrading a permanent delete to
a trash move)."""
sent = []
def fake(args, cfg, timeout=120):
if args[:1] == ["help"]:
# Old Joplin help output - no --permanent listed.
return {"returncode": 0, "stdout": "rmnote\n -f, --force ...\n", "stderr": ""}
sent.append(args)
return {"returncode": 0}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(note_core, "run_joplin_command", fake)
with pytest.raises(RuntimeError) as excinfo:
note_core.remove_note(joplin_backend.BackendConfig(), "n1", force=True, permanent=True)
msg = str(excinfo.value)
assert "--permanent" in msg
assert "Joplin terminal CLI" in msg
# The actual rmnote command must not have been sent.
assert sent == []
def test_notes_remove_permanent_support_cached(monkeypatch):
"""The help probe must be cached per binary so we don't pay a subprocess
cost on every permanent delete."""
help_calls = 0
def fake(args, cfg, timeout=120):
nonlocal help_calls
if args[:1] == ["help"]:
help_calls += 1
return {"returncode": 0, "stdout": "--permanent ...\n", "stderr": ""}
return {"returncode": 0}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(note_core, "run_joplin_command", fake)
cfg = joplin_backend.BackendConfig()
note_core.remove_note(cfg, "n1", permanent=True)
note_core.remove_note(cfg, "n2", permanent=True)
note_core.remove_note(cfg, "n3", permanent=True)
assert help_calls == 1
def test_notes_copy_with_notebook(monkeypatch):
@@ -679,13 +748,58 @@ def test_notebooks_list_uses_json_and_ls_options(monkeypatch):
def test_notebooks_remove_passes_flags(monkeypatch):
captured = {}
monkeypatch.setattr(notebook_core, "run_joplin_command", lambda a, c: captured.setdefault("args", a) or {})
def fake(args, cfg, timeout=120):
captured.setdefault("args", args)
return {"returncode": 0, "stdout": "", "stderr": ""}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(notebook_core, "run_joplin_command", fake)
notebook_core.remove_notebook(joplin_backend.BackendConfig(), "BookA", force=True, permanent=False)
assert captured["args"][:2] == ["rmbook", "BookA"]
assert "-f" in captured["args"]
assert "--force" in captured["args"]
assert "--permanent" not in captured["args"]
assert "-p" not in captured["args"]
def test_notebooks_remove_permanent_refuses_when_help_lacks_flag(monkeypatch):
"""Same defensive check as for notes: refuse rather than silently move to
trash on older Joplin CLI versions."""
sent = []
def fake(args, cfg, timeout=120):
if args[:1] == ["help"]:
return {"returncode": 0, "stdout": "rmbook\n -f, --force ...\n", "stderr": ""}
sent.append(args)
return {"returncode": 0}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
# Both core modules probe through note_core helper; patch there too.
monkeypatch.setattr(note_core, "run_joplin_command", fake)
monkeypatch.setattr(notebook_core, "run_joplin_command", fake)
with pytest.raises(RuntimeError) as excinfo:
notebook_core.remove_notebook(joplin_backend.BackendConfig(), "BookA", force=True, permanent=True)
assert "rmbook" in str(excinfo.value)
assert "--permanent" in str(excinfo.value)
assert sent == []
def test_notebooks_remove_permanent_long_form_when_supported(monkeypatch):
"""When help advertises --permanent we send the long form (NOT `-p`)."""
captured = {}
def fake(args, cfg, timeout=120):
if args[:1] == ["help"]:
return {"returncode": 0, "stdout": "--permanent ...\n", "stderr": ""}
captured["args"] = args
return {"returncode": 0}
note_core._PERMANENT_FLAG_SUPPORT_CACHE.clear()
monkeypatch.setattr(note_core, "run_joplin_command", fake)
monkeypatch.setattr(notebook_core, "run_joplin_command", fake)
notebook_core.remove_notebook(joplin_backend.BackendConfig(), "BookA", force=True, permanent=True)
assert captured["args"] == ["rmbook", "BookA", "--force", "--permanent"]
def test_todos_args_correct(monkeypatch):
captured = []
+2 -2
View File
@@ -98,5 +98,5 @@ When `--json` is enabled, commands return:
Current validation baseline (Windows + Joplin CLI 3.6.2):
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` �`91 passed`
- `python -m pytest -q cli_anything/joplin/tests` �`118 passed, 1 skipped`
- `python -m pytest -q cli_anything/joplin/tests/test_core.py` ? `96 passed`
- `python -m pytest -q cli_anything/joplin/tests` ? `123 passed, 1 skipped`