Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/fessctl/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
49 changes: 49 additions & 0 deletions skills/fessctl/references/features/tagtype.md
Original file line number Diff line number Diff line change
@@ -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. | `<config_id>` |
| `delete` | Permanently delete a tag by ID. | `<config_id>` |
| `get` | Show one tag's full detail, including its paths. | `<config_id>` |
| `list` | Page through all tags (default page size 100); paths are not included. | none |

Always reconfirm with `fessctl tagtype <sub> --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")'
```
38 changes: 38 additions & 0 deletions src/fessctl/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/fessctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
227 changes: 227 additions & 0 deletions src/fessctl/commands/tagtype.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading