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
3 changes: 2 additions & 1 deletion skills/fessctl/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: fessctl
description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 30 resource types and cross-feature workflows like initial crawl setup.
description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 31 resource types and cross-feature workflows like initial crawl setup.
license: Apache-2.0
version: 0.1.0
---
Expand Down Expand Up @@ -61,6 +61,7 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl
| Label types | references/features/labeltype.md |
| User tags | references/features/tagtype.md |
| Themes | references/features/theme.md |
| Dictionaries | references/features/dict.md |
| Key match | references/features/keymatch.md |
| Boost document | references/features/boostdoc.md |
| Elevate word | references/features/elevateword.md |
Expand Down
66 changes: 66 additions & 0 deletions skills/fessctl/references/features/dict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Dictionaries (fessctl `dict`)

## What it is
Fess keeps its analyzer dictionaries as text files in the search engine's dictionary directory: synonyms, character mappings, Japanese (kuromoji) user words, stop words, protected words and stemmer overrides. The Admin UI edits them under **System → Dictionary**; the admin API lists the files and manages both their entries and the whole file.

`fessctl dict list` shows the files (`type`, `id`, `path`, `timestamp`). Every other command is `fessctl dict <type> <sub> <dict_id> ...`, where `<dict_id>` is the `id` from `dict list` (a URL-safe base64 of the path, e.g. `c3lub255bS50eHQ=` for `synonym.txt`). Which files exist depends on the search engine image; most languages have `stopwords`/`protwords`/`stemmeroverride` files, `ja` has `kuromoji` and `mapping`, and there is one `synonym.txt`.

Available in all supported versions (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0) with the same fields, except for `upload` (see Gotchas).

## Subcommand surface
| Subcommand | Purpose | Required arguments |
| --- | --- | --- |
| `dict list` | List the dictionary files; `--type` narrows to one type. | none |
| `dict <type> list` | Page through the entries of one file (`--page`, `--size`). | `<dict_id>` |
| `dict <type> get` | Show one entry. | `<dict_id> <item_id>` |
| `dict <type> create` | Add an entry. | `<dict_id>` and every field option of the type |
| `dict <type> update` | Change an entry; unspecified fields are kept. | `<dict_id> <item_id>` |
| `dict <type> delete` | Delete one entry. | `<dict_id> <item_id>` |
| `dict <type> upload` | Replace the whole file with a local file. Asks for confirmation unless `--yes`. | `<dict_id> <file>` |
| `dict <type> download` | Write the file to standard output, or to `--file` (refuses to overwrite unless `--force`). | `<dict_id>` |

Always reconfirm with `fessctl dict <type> <sub> --help`.

## Types and their fields
| `<type>` | Options of `create` / `update` | API fields |
| --- | --- | --- |
| `synonym` | `--source` (repeatable), `--target` (repeatable) | `inputs`, `outputs` |
| `mapping` | `--source` (repeatable), `--target` | `inputs`, `output` |
| `kuromoji` | `--token`, `--segmentation`, `--reading`, `--pos` | same names |
| `stopwords` | `--word` | `input` |
| `protwords` | `--word` | `input` |
| `stemmeroverride` | `--source`, `--target` | `input`, `output` |

A repeatable option is sent as one value per line (`inputs` is `"a\nb"`).

## Resource JSON shape
`list` returns the entries under `response.settings`, `get` returns one under `response.setting`:
```json
{"id": 2, "dict_id": "c3lub255bS50eHQ=", "inputs": "tv\ntelevision", "outputs": "tv"}
```
`dict list` returns `{"id": "c3lub255bS50eHQ=", "type": "synonym", "path": "synonym.txt", "timestamp": "2026-10-16T12:24:48"}` rows.

## Gotchas
- **`upload` is impossible on Fess 15.6 to 15.8.** Those servers route the upload only as PUT and cannot read a multipart PUT (the error is "File is required"); 15.9 added POST, and 14.x always used POST. `fessctl` therefore refuses an upload on 15.6 to 15.8 with an explanatory error. Use `download`, edit, and the Admin UI there.
- `upload` replaces the whole file, including entries added with `create`; `download` first when unsure. The file is in the engine's plain-text dictionary format; `download` one to see it (a synonym rule is a line such as `a,b=>c,d`).
- Entry IDs are positions in the file. Deleting an entry can shift the IDs of the ones after it, and a new entry may reuse an ID, so look an entry up again (by its content) before updating or deleting it after any change.
- Fess before 15.9 reports the ID of a new entry as `0`; `create` then prints no ID. Find it with `list`.
- `update` reads the entry first and sends it back with only the given options changed (the API's PUT replaces the whole entry). A repeatable option such as `--target` replaces all the values; it does not append.
- `list` and `get` of an unknown `<dict_id>`: `list` shows nothing (exit 0), `get` fails. `create` in an unknown dictionary fails on 15.9 but reports success without storing anything on earlier versions.
- Fess validates entries: synonym and mapping sources may not contain `,` or `=>` (15.9), a mapping source may not already exist, a kuromoji token may be registered once, and the number of words in `--segmentation` and `--reading` must match.
- Error messages of `download` and `upload` on a bad `<dict_id>` mention "protwords" for every type; that is the server's wording.

## Examples
```bash
fessctl dict list --type synonym
fessctl dict synonym create c3lub255bS50eHQ= --source tv --source television --target tv
fessctl dict synonym list c3lub255bS50eHQ=
fessctl dict synonym update c3lub255bS50eHQ= 2 --target "tv set"
fessctl dict synonym delete c3lub255bS50eHQ= 2

fessctl dict kuromoji create amEva3Vyb21vamkudHh0 \
--token 令和 --segmentation 令和 --reading レイワ --pos 名詞-固有名詞-一般

fessctl dict stopwords download ZW4vc3RvcHdvcmRzLnR4dA== --file stopwords.txt
fessctl dict stopwords upload ZW4vc3RvcHdvcmRzLnR4dA== stopwords.txt --yes
```
111 changes: 111 additions & 0 deletions src/fessctl/api/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
Expand All @@ -16,6 +17,22 @@ class Action(Enum):
GET = "get"
START = "start"
STOP = "stop"
UPLOAD = "upload"


# Name of the multipart form field that carries the file, per dictionary type.
DICT_UPLOAD_FIELDS = {
"synonym": "synonymFile",
"mapping": "charMappingFile",
"kuromoji": "kuromojiFile",
"stopwords": "stopwordsFile",
"protwords": "protwordsFile",
"stemmeroverride": "stemmerOverrideFile",
}


# Seconds to allow for uploading or downloading a dictionary file.
FILE_TRANSFER_TIMEOUT = 60.0


class FessAPIClientError(Exception):
Expand Down Expand Up @@ -68,6 +85,7 @@ def send_request(
json_data: dict | None = None,
params: dict | None = None,
is_admin: bool = True,
files: dict | None = None,
) -> dict:
headers = self.admin_api_headers if is_admin else self.search_api_headers
try:
Expand Down Expand Up @@ -103,6 +121,14 @@ def send_request(
response = httpx.get(
url, headers=headers, params=params, timeout=self.timeout
)
elif action == Action.UPLOAD:
# httpx sets the multipart Content-Type (with its boundary) itself.
upload_headers = {
k: v for k, v in headers.items() if k != "Content-Type"}
response = httpx.post(
url, headers=upload_headers, files=files, params=params,
timeout=max(self.timeout, FILE_TRANSFER_TIMEOUT)
)
elif action == Action.START or action == Action.STOP:
if self._major_version <= 14:
response = httpx.post(
Expand Down Expand Up @@ -185,6 +211,91 @@ def ping(self) -> dict:
url = f"{self.base_url}/api/v1/health"
return self.send_request(Action.GET, url, is_admin=False)

# Dictionary APIs

def get_dicts(self) -> dict:
"""
Retrieves the dictionary files (type, id, path and timestamp).
"""
url = f"{self.base_url}/api/admin/dict"
return self.send_request(Action.GET, url)

def list_dict_items(self, dict_type: str, dict_id: str, page: int = 1, size: int = 100) -> dict:
"""
Retrieves a page of the entries of a dictionary file.
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/settings/{dict_id}"
params = {"page": page, "size": size}
return self.send_request(Action.LIST, url, params=params)

def get_dict_item(self, dict_type: str, dict_id: str, item_id: int) -> dict:
"""
Retrieves an entry of a dictionary file by ID.
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/setting/{dict_id}/{item_id}"
return self.send_request(Action.GET, url)

def create_dict_item(self, dict_type: str, dict_id: str, config: dict) -> dict:
"""
Creates an entry in a dictionary file.
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/setting/{dict_id}"
return self.send_request(Action.CREATE, url, json_data=config)

def update_dict_item(self, dict_type: str, dict_id: str, config: dict) -> dict:
"""
Replaces an entry of a dictionary file (config carries the entry's ID).
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/setting/{dict_id}"
return self.send_request(Action.EDIT, url, json_data=config)

def delete_dict_item(self, dict_type: str, dict_id: str, item_id: int) -> dict:
"""
Deletes an entry of a dictionary file by ID.
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/setting/{dict_id}/{item_id}"
return self.send_request(Action.DELETE, url)

def upload_dict(self, dict_type: str, dict_id: str, file_path: str) -> dict:
"""
Replaces a dictionary file with the content of a local file.

Fess 15.6 to 15.8 only route the upload as PUT and cannot read a multipart
PUT, so no upload is possible there (fixed in 15.9, which accepts POST).
"""
if (15, 6) <= (self._major_version, self._minor_version) < (15, 9):
return {"response": {
"status": 1,
"message": "Dictionary upload is not supported by Fess 15.6 to 15.8 "
"(the server cannot read a multipart PUT); it needs Fess 15.9 or 14.x.",
}}
url = f"{self.base_url}/api/admin/dict/{dict_type}/upload/{dict_id}"
with open(file_path, "rb") as f:
files = {DICT_UPLOAD_FIELDS[dict_type]: (
os.path.basename(file_path), f.read(), "application/octet-stream")}
return self.send_request(Action.UPLOAD, url, files=files)

def download_dict(self, dict_type: str, dict_id: str) -> bytes:
"""
Downloads a dictionary file. Raises FessAPIClientError when Fess does not
return the file (it answers with a JSON error instead).
"""
url = f"{self.base_url}/api/admin/dict/{dict_type}/download/{dict_id}"
try:
response = httpx.get(
url, headers=self.admin_api_headers,
timeout=max(self.timeout, FILE_TRANSFER_TIMEOUT))
except httpx.RequestError as e:
raise FessAPIClientError(
status_code=-1, content=f"Network error: {str(e)}") from e
if response.status_code != 200 or response.headers.get("content-type", "").startswith("application/json"):
try:
message = response.json().get("response", {}).get("message", "")
except ValueError:
message = response.text
raise FessAPIClientError(status_code=response.status_code, content=message)
return response.content

# Role APIs

def create_role(
Expand Down
2 changes: 2 additions & 0 deletions src/fessctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from fessctl.commands.boostdoc import boostdoc_app
from fessctl.commands.crawlinginfo import crawlinginfo_app
from fessctl.commands.dataconfig import dataconfig_app
from fessctl.commands.dict import dict_app
# from fessctl.commands.documents import documents_app
from fessctl.commands.duplicatehost import duplicatehost_app
from fessctl.commands.elevateword import elevateword_app
Expand Down Expand Up @@ -50,6 +51,7 @@
app.add_typer(boostdoc_app, name="boostdoc")
app.add_typer(crawlinginfo_app, name="crawlinginfo")
app.add_typer(dataconfig_app, name="dataconfig")
app.add_typer(dict_app, name="dict")
# app.add_typer(documents_app, name="documents")
app.add_typer(duplicatehost_app, name="duplicatehost")
app.add_typer(elevateword_app, name="elevateword")
Expand Down
Loading
Loading