mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-31 02:42:19 +08:00
fix(joplin):remove flag
This commit is contained in:
+432
-432
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user