diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index c06f24e..6c03437 100644 --- a/skills/fessctl/SKILL.md +++ b/skills/fessctl/SKILL.md @@ -58,6 +58,7 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Groups | references/features/group.md | | Access tokens | references/features/accesstoken.md | | Label types | references/features/labeltype.md | +| User tags | references/features/tagtype.md | | Key match | references/features/keymatch.md | | Boost document | references/features/boostdoc.md | | Elevate word | references/features/elevateword.md | diff --git a/skills/fessctl/references/features/tagtype.md b/skills/fessctl/references/features/tagtype.md new file mode 100644 index 0000000..a5ee8ca --- /dev/null +++ b/skills/fessctl/references/features/tagtype.md @@ -0,0 +1,49 @@ +# User Tags (fessctl `tagtype`) + +## What it is +A tag type is a named, user-owned list of document URLs. End users create and manage their own tags from the search UI; the admin API lets an administrator list, inspect, create, change, and delete any user's tags. Each tag has a `name`, an `owner` (user name), the tagged documents as `paths` (one URL per line), optional `permissions` (one per line; only the owner can see the tag when empty), an optional `virtual_host`, and a `sort_order`. + +Requires a Fess version that provides `/api/admin/tagtype` (introduced by fess#3551; not in Fess 15.9 or earlier). + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `create` | Register a new tag. | `--name`, `--owner` | +| `update` | Modify an existing tag by ID; unspecified fields (including paths) are preserved. | `` | +| `delete` | Permanently delete a tag by ID. | `` | +| `get` | Show one tag's full detail, including its paths. | `` | +| `list` | Page through all tags (default page size 100); paths are not included. | none | + +Always reconfirm with `fessctl tagtype --help`. + +## Resource JSON shape +```json +{ + "id": "...", + "name": "work", + "owner": "taro", + "paths": "https://www.example.com/a.html\nhttps://www.example.com/b.html", + "permissions": "{user}taro\n{group}developer", + "virtual_host": "", + "sort_order": 0, + "seq_no": 7, + "primary_term": 1 +} +``` +The CLI accepts repeatable `--path` and `--permission` flags and joins them with newlines before posting. + +## Gotchas +- `update` replaces the whole tag on the server. The CLI reads the tag first and sends back every field you did not change, together with its `seq_no`/`primary_term`; if the tag changed in between (e.g. its owner edited it), the update fails with a "changed concurrently" error - re-run it. +- Passing `--path` on `update` replaces the whole path list; it does not append. +- Changing `--name` or `--owner` gives the tag a new ID; `update` reports the new ID. +- `list` omits `paths`; use `get` to see them. + +## Examples +```bash +fessctl tagtype create --name work --owner taro \ + --path "https://www.example.com/a.html" --path "https://www.example.com/b.html" + +fessctl tagtype update TAG_ID --sort-order 1 + +fessctl tagtype list --output json | jq '.response.settings[] | select(.owner == "taro")' +``` diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 4ea43ce..baac4b4 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -962,6 +962,44 @@ def list_labeltypes(self, page: int = 1, size: int = 100) -> dict: params = {"page": page, "size": size} return self.send_request(Action.LIST, url, params=params) + # TagType APIs + + def create_tagtype(self, config: dict) -> dict: + """ + Creates a new TagType. + """ + url = f"{self.base_url}/api/admin/tagtype/setting" + return self.send_request(Action.CREATE, url, json_data=config) + + def update_tagtype(self, config: dict) -> dict: + """ + Updates an existing TagType. + """ + url = f"{self.base_url}/api/admin/tagtype/setting" + return self.send_request(Action.EDIT, url, json_data=config) + + def delete_tagtype(self, config_id: str) -> dict: + """ + Deletes a TagType by ID. + """ + url = f"{self.base_url}/api/admin/tagtype/setting/{config_id}" + return self.send_request(Action.DELETE, url) + + def get_tagtype(self, config_id: str) -> dict: + """ + Retrieves a TagType by ID. + """ + url = f"{self.base_url}/api/admin/tagtype/setting/{config_id}" + return self.send_request(Action.GET, url) + + def list_tagtypes(self, page: int = 1, size: int = 100) -> dict: + """ + Retrieves a list of TagTypes. + """ + url = f"{self.base_url}/api/admin/tagtype/settings" + params = {"page": page, "size": size} + return self.send_request(Action.LIST, url, params=params) + # PathMap APIs def create_pathmap(self, config: dict) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 82b1f97..20c8e4c 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -35,6 +35,7 @@ # from fessctl.commands.stats import stats_app # from fessctl.commands.storage import storage_app # from fessctl.commands.suggest import suggest_app +from fessctl.commands.tagtype import tagtype_app # from fessctl.commands.systeminfo import systeminfo_app from fessctl.commands.user import user_app from fessctl.commands.webauth import webauth_app @@ -71,6 +72,7 @@ # app.add_typer(stats_app, name="stats") # app.add_typer(storage_app, name="storage") # app.add_typer(suggest_app, name="suggest") +app.add_typer(tagtype_app, name="tagtype") # app.add_typer(systeminfo_app, name="systeminfo") app.add_typer(user_app, name="user") app.add_typer(webauth_app, name="webauth") diff --git a/src/fessctl/commands/tagtype.py b/src/fessctl/commands/tagtype.py new file mode 100644 index 0000000..3f4ecb9 --- /dev/null +++ b/src/fessctl/commands/tagtype.py @@ -0,0 +1,227 @@ +import json +from typing import List, Optional + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import ( + format_detail_markdown, + format_list_markdown, + format_result_markdown, +) + +tagtype_app = typer.Typer() + + +@tagtype_app.command("create") +def create_tagtype( + name: str = typer.Option(..., "--name", help="Tag name"), + owner: str = typer.Option(..., "--owner", help="User who owns the tag"), + sort_order: int = typer.Option(0, "--sort-order", help="Sort order"), + paths: Optional[List[str]] = typer.Option( + [], "--path", help="URL of a tagged document"), + permissions: Optional[List[str]] = typer.Option( + [], "--permission", help="Access permissions"), + virtual_host: Optional[str] = typer.Option( + None, "--virtual-host", help="Virtual host"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format: text, json, yaml"), +): + """ + Create a new TagType. + """ + client = FessAPIClient(Settings()) + + config = { + "crud_mode": 1, + "name": name, + "owner": owner, + "sort_order": sort_order, + } + + if paths: + config["paths"] = "\n".join(paths) + if permissions: + config["permissions"] = "\n".join(permissions) + if virtual_host: + config["virtual_host"] = virtual_host + + result = client.create_tagtype(config) + status: int = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + tagtype_id = result.get("response", {}).get("id", "") + typer.echo(format_result_markdown(True, f"TagType '{tagtype_id}' created successfully.", "TagType", "create", tagtype_id)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to create TagType. {message} Status code: {status}", "TagType", "create")) + raise typer.Exit(code=status) + + +@tagtype_app.command("update") +def update_tagtype( + config_id: str = typer.Argument(..., help="TagType ID"), + name: Optional[str] = typer.Option(None, "--name", help="Tag name"), + owner: Optional[str] = typer.Option( + None, "--owner", help="User who owns the tag"), + sort_order: Optional[int] = typer.Option( + None, "--sort-order", help="Sort order"), + paths: Optional[List[str]] = typer.Option( + None, "--path", help="URL of a tagged document"), + permissions: Optional[List[str]] = typer.Option( + None, "--permission", help="Access permissions"), + virtual_host: Optional[str] = typer.Option( + None, "--virtual-host", help="Virtual host"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)"), +): + """ + Update an existing TagType. Changing the name or the owner gives it a new ID. + """ + client = FessAPIClient(Settings()) + result = client.get_tagtype(config_id) + if result.get("response", {}).get("status", 1) != 0: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"TagType with ID '{config_id}' not found. {message}", "TagType", "update")) + raise typer.Exit(code=1) + + # The PUT replaces the whole tag, so start from the current one (including + # its paths, seq_no and primary_term) and override only the given fields. + config = result.get("response", {}).get("setting", {}) + config["crud_mode"] = 2 + + if name is not None: + config["name"] = name + if owner is not None: + config["owner"] = owner + if sort_order is not None: + config["sort_order"] = sort_order + if paths is not None: + config["paths"] = "\n".join(paths) + if permissions is not None: + config["permissions"] = "\n".join(permissions) + if virtual_host is not None: + config["virtual_host"] = virtual_host + + result = client.update_tagtype(config) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + tagtype_id = result.get("response", {}).get("id") or config_id + typer.echo(format_result_markdown(True, f"TagType '{tagtype_id}' updated successfully.", "TagType", "update", tagtype_id)) + else: + message = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to update TagType. {message} Status code: {status}", "TagType", "update")) + raise typer.Exit(code=status) + + +@tagtype_app.command("delete") +def delete_tagtype( + config_id: str = typer.Argument(..., help="TagType ID"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)"), +): + """ + Delete a TagType by ID. + """ + client = FessAPIClient(Settings()) + result = client.delete_tagtype(config_id) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"TagType '{config_id}' deleted successfully.", "TagType", "delete", config_id)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to delete TagType. {message} Status code: {status}", "TagType", "delete")) + raise typer.Exit(code=status) + + +@tagtype_app.command("get") +def get_tagtype( + config_id: str = typer.Argument(..., help="TagType ID"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format: text, json, yaml"), +): + """ + Retrieve a TagType by ID. + """ + client = FessAPIClient(Settings()) + result = client.get_tagtype(config_id) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + tagtype = result.get("response", {}).get("setting", {}) + typer.echo(format_detail_markdown( + f"TagType Details: {tagtype.get('id', '-')}", + tagtype, + [ + ("id", "id"), + ("name", "name"), + ("owner", "owner"), + ("sort_order", "sort_order"), + ("paths", "paths"), + ("permissions", "permissions"), + ("virtual_host", "virtual_host"), + ("seq_no", "seq_no"), + ("primary_term", "primary_term"), + ], + )) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve TagType. {message} Status code: {status}", "TagType", "get")) + raise typer.Exit(code=status) + + +@tagtype_app.command("list") +def list_tagtypes( + page: int = typer.Option(1, "--page", "-p", help="Page number"), + size: int = typer.Option(100, "--size", "-s", help="Page size"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)"), +): + """ + List TagTypes. The paths of each tag are left out; use get to see them. + """ + client = FessAPIClient(Settings()) + result = client.list_tagtypes(page=page, size=size) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + tagtypes = result.get("response", {}).get("settings", []) + if not tagtypes: + typer.echo("No TagTypes found.") + else: + typer.echo(format_list_markdown("TagTypes", tagtypes, [ + ("ID", "id"), ("NAME", "name"), ("OWNER", "owner"), + ])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list TagTypes. {message} Status code: {status}", "TagType", "list")) + raise typer.Exit(code=status) diff --git a/tests/commands/test_tagtype.py b/tests/commands/test_tagtype.py new file mode 100644 index 0000000..209bab0 --- /dev/null +++ b/tests/commands/test_tagtype.py @@ -0,0 +1,109 @@ +import json +import uuid + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClient, FessAPIClientError +from fessctl.commands.tagtype import tagtype_app +from fessctl.config.settings import Settings + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +@pytest.fixture(scope="module") +def tagtype_api(fess_service): + """ + Skips when the Fess under test has no /api/admin/tagtype (not in Fess 15.9 or earlier). + """ + try: + result = FessAPIClient(Settings()).list_tagtypes() + except FessAPIClientError: + result = {} + if result.get("response", {}).get("status") != 0: + pytest.skip("Fess under test does not provide /api/admin/tagtype") + + +def test_tagtype_crud_flow(runner, tagtype_api): + """ + Tests the full Create, Read, Update, Delete (CRUD) flow for TagTypes. + """ + # 1) Create a new tagtype + name = f"tag-{uuid.uuid4().hex[:8]}" + path = "https://www.example.com/doc.html" + result = runner.invoke( + tagtype_app, + ["create", "--name", name, "--owner", "admin", "--path", path, "--output", "json"] + ) + assert result.exit_code == 0, f"Create failed: {result.stdout}" + create_resp = json.loads(result.stdout) + assert create_resp.get("response", {}).get("status") == 0 + tagtype_id = create_resp["response"].get("id") + assert tagtype_id, "No tagtype ID returned on create" + + # 2) Retrieve the created tagtype + result = runner.invoke( + tagtype_app, + ["get", "--output", "json", "--", tagtype_id] + ) + assert result.exit_code == 0, f"Get failed: {result.stdout}" + get_resp = json.loads(result.stdout) + assert get_resp.get("response", {}).get("status") == 0 + setting = get_resp["response"].get("setting", {}) + assert setting.get("id") == tagtype_id + assert setting.get("name") == name + assert setting.get("paths") == path + + # 3) Update the sort order; the paths must be kept + result = runner.invoke( + tagtype_app, + ["update", "--sort-order", "5", "--output", "json", "--", tagtype_id] + ) + assert result.exit_code == 0, f"Update failed: {result.stdout}" + update_resp = json.loads(result.stdout) + assert update_resp.get("response", {}).get("status") == 0 + tagtype_id = update_resp["response"].get("id") or tagtype_id + + # 4) Retrieve again and verify the update + result = runner.invoke( + tagtype_app, + ["get", "--output", "json", "--", tagtype_id] + ) + assert result.exit_code == 0, f"Get after update failed: {result.stdout}" + setting_after = json.loads(result.stdout)["response"].get("setting", {}) + assert setting_after.get("sort_order") == 5 + assert setting_after.get("paths") == path + + # 5) List tagtypes and ensure the tagtype appears + result = runner.invoke( + tagtype_app, + ["list", "--output", "json"] + ) + assert result.exit_code == 0, f"List failed: {result.stdout}" + list_resp = json.loads(result.stdout) + assert list_resp.get("response", {}).get("status") == 0 + ids = [g.get("id") for g in list_resp["response"].get("settings", [])] + assert tagtype_id in ids + + # 6) Delete the tagtype + result = runner.invoke( + tagtype_app, + ["delete", "--output", "json", "--", tagtype_id] + ) + assert result.exit_code == 0, f"Delete failed: {result.stdout}" + del_resp = json.loads(result.stdout) + assert del_resp.get("response", {}).get("status") == 0 + + # 7) Verify that get now fails + result = runner.invoke( + tagtype_app, + ["get", "--", tagtype_id] + ) + assert result.exit_code != 0 + assert "failed to retrieve tagtype" in result.stdout.lower() diff --git a/tests/unit/test_tagtype.py b/tests/unit/test_tagtype.py new file mode 100644 index 0000000..3ec2ae8 --- /dev/null +++ b/tests/unit/test_tagtype.py @@ -0,0 +1,138 @@ +""" +Unit tests for the tagtype command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.tagtype import tagtype_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _stored_tag(): + return { + "response": { + "status": 0, + "setting": { + "id": "tag-001", + "name": "work", + "owner": "taro", + "paths": "https://a.example.com/\nhttps://b.example.com/", + "permissions": "{user}taro", + "virtual_host": "", + "sort_order": 0, + "seq_no": 7, + "primary_term": 1, + "crud_mode": None, + }, + } + } + + +class TestTagTypeUpdate: + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_update_keeps_unchanged_fields(self, mock_client_class, runner): + """The PUT replaces the whole tag, so the paths and seq_no read by GET are sent back.""" + mock_client = Mock() + mock_client.get_tagtype.return_value = _stored_tag() + mock_client.update_tagtype.return_value = {"response": {"status": 0, "id": "tag-001"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(tagtype_app, ["update", "--sort-order", "3", "tag-001"]) + assert result.exit_code == 0, result.stdout + sent = mock_client.update_tagtype.call_args.args[0] + assert sent["crud_mode"] == 2 + assert sent["sort_order"] == 3 + assert sent["paths"] == "https://a.example.com/\nhttps://b.example.com/" + assert sent["permissions"] == "{user}taro" + assert sent["seq_no"] == 7 + assert sent["primary_term"] == 1 + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_update_replaces_paths_and_reports_new_id(self, mock_client_class, runner): + """A rename gives the tag a new id, which is the one reported.""" + mock_client = Mock() + mock_client.get_tagtype.return_value = _stored_tag() + mock_client.update_tagtype.return_value = {"response": {"status": 0, "id": "tag-002"}} + mock_client_class.return_value = mock_client + + result = runner.invoke( + tagtype_app, + ["update", "--name", "home", "--path", "https://c.example.com/", "tag-001"], + ) + assert result.exit_code == 0, result.stdout + sent = mock_client.update_tagtype.call_args.args[0] + assert sent["name"] == "home" + assert sent["paths"] == "https://c.example.com/" + assert "tag-002" in result.stdout + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_update_reports_conflict(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_tagtype.return_value = _stored_tag() + mock_client.update_tagtype.return_value = { + "response": {"status": 1, "message": "The tag was changed concurrently."} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(tagtype_app, ["update", "--sort-order", "1", "tag-001"]) + assert result.exit_code == 1 + assert "changed concurrently" in result.stdout + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_update_missing_tag(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_tagtype.return_value = {"response": {"status": 1, "message": "not found"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(tagtype_app, ["update", "--sort-order", "1", "missing"]) + assert result.exit_code == 1 + mock_client.update_tagtype.assert_not_called() + + +class TestTagTypeCreate: + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_create_joins_paths_and_permissions(self, mock_client_class, runner): + mock_client = Mock() + mock_client.create_tagtype.return_value = {"response": {"status": 0, "id": "tag-001"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(tagtype_app, [ + "create", "--name", "work", "--owner", "taro", + "--path", "https://a.example.com/", "--path", "https://b.example.com/", + "--permission", "{user}taro", + ]) + assert result.exit_code == 0, result.stdout + sent = mock_client.create_tagtype.call_args.args[0] + assert sent == { + "crud_mode": 1, + "name": "work", + "owner": "taro", + "sort_order": 0, + "paths": "https://a.example.com/\nhttps://b.example.com/", + "permissions": "{user}taro", + } + assert "tag-001" in result.stdout + + +class TestTagTypeList: + + @patch("fessctl.commands.tagtype.FessAPIClient") + def test_list_text_output(self, mock_client_class, runner): + mock_client = Mock() + mock_client.list_tagtypes.return_value = { + "response": {"status": 0, "settings": [{"id": "tag-001", "name": "work", "owner": "taro"}]} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(tagtype_app, ["list"]) + assert result.exit_code == 0, result.stdout + assert "tag-001" in result.stdout + assert "taro" in result.stdout