Download OpenAPI specification:
A local dictionary server for MDX/MDD, StarDict, Slob, DSL and BGL files.
WuWeiDict serves your own dictionary files over HTTP, on your own machine.
Streaming. /api/dicts, /api/search and /api/rescan answer with
NDJSON: one JSON object per line, flushed the moment it is ready. Read them
line by line; do not wait for the body to end. That is the whole point - the
first dictionary's results arrive while the slowest one is still opening.
Every stream opens with a begin line and closes with an end line.
/api/ingest streams Server-Sent Events instead, because it reports
progress rather than results.
Switches. Every boolean query parameter reads the same spellings: off
for 0, false, no, off or an empty value, on for anything else.
Two surfaces. Three read-only endpoints answer a browser extension
cross-origin: /api/dicts, /api/search and /res/. Everything else is
same-origin, is marked internal below, and may change between releases.
An access key, when the server is reachable. On the default
127.0.0.1 bind there is nothing to authenticate and no key is asked for.
On any other address - and Go's wildcard listen is dual-stack, so 0.0.0.0
includes every globally routable IPv6 address the machine holds - AUTH
defaults to requiring one on every endpoint EXCEPT the three read-only ones
above, which stay open to the extensions already granted them. Send it as
Authorization: Bearer <key>, or open the link wudict token prints once
and let the browser keep the cookie it sets. Without it: 401, with
{"auth":"required"}. AUTH=off serves everything to anyone who can reach
the port.
Address it by address. Every request must carry a Host of an IP
literal or localhost; any other name is answered 421 Misdirected Request unless TRUSTED_HOSTS lists it or it is the name in SERVER_IP. This is what stops a web page
from re-pointing its own domain at your machine (DNS rebinding) and
reading the API as same-origin, which no CORS rule can prevent.
And from this origin. A browser states who started a request in
Sec-Fetch-Site. Anything it marks cross-site is answered 403 unless
it addresses one of the three read-only endpoints above (or their
preflight), or is a top-level navigation to a page rather than to /api/.
This closes the state-changing GET: GET /api/rescan and
GET /api/setup?save=1 are otherwise reachable from an <img> tag on any
origin, carrying the cookie, and CORS never blocks a request - only the
reading of its response. Clients that send no fetch metadata (curl, the
Android shell, anything that is not a browser) are unaffected.
Queries the selected dictionaries concurrently and streams one line per
dictionary as it finishes. Lines arrive in completion order, never in
slot order: each hit carries i, its index in the begin line's
slots array.
A dictionary that fails, that cannot serve the requested mode, or that was declined by the memory cap reports that on its own line. None of those is a request failure, and the other dictionaries still answer.
A one-word query that finds nothing in a dictionary is searched there
again with its lemma ("knew" -> "know"), using the lemma data for that
dictionary's language. The answer is a morph line, then a second
hit for each such slot, which replaces its miss. Not for a phrase,
and not for a wildcard pattern containing * or ?.
A search is cancelled after 30 seconds.
| q required | string Example: q=flight The search text. Blank is a 400. |
| mode | string Default: "prefix" Enum: "exact" "prefix" "fts" "wildcard" "fuzzy"
A multi-word |
| hl | boolean Default: false Mark the query's words inside each article body, wrapped in
Off unless you ask, so an article you fetch is the dictionary's own
bytes by default. wudict's own UI sends
|
| format | string Default: "raw" Enum: "raw" "clean" "text"
Ask for |
| dict | string Default: "all" Example: dict=oxford,webster
|
| n | integer >= 1 Default: 20 Maximum results per dictionary. |
curl -sN 'http://127.0.0.1:6888/api/search?q=flight&mode=prefix&format=clean&n=5' # -N is the whole point: without it curl buffers, and the lines that # were sent one at a time arrive together at the end.
"{\"t\":\"begin\",\"i\":0,\"slots\":[{\"dict\":\"oxford\",\"name\":\"oxford\"},{\"dict\":\"webster\",\"name\":\"webster\"}]}\n{\"t\":\"hit\",\"i\":1,\"dict\":\"webster\",\"name\":\"Webster's Revised Unabridged\",\"results\":[{\"Headword\":\"flight\",\"Body\":\"<div>…</div>\"}]}\n{\"t\":\"hit\",\"i\":0,\"dict\":\"oxford\",\"name\":\"Oxford Advanced Learner's\"}\n{\"t\":\"end\",\"i\":0}\n"begin carries the total immediately, from registry ids alone, so a client can show "0 of 105" before any dictionary is opened. Rows follow in completion order.
end carries etag when the answer may be kept. Send it back as If-None-Match and an unchanged list is a 304 with no body, decided without opening any dictionary. There is no ETag header: whether an answer may be kept is known only after its last row.
| If-None-Match | string The |
curl -sN 'http://127.0.0.1:6888/api/dicts' # just the ids, which is what /api/search wants curl -sN 'http://127.0.0.1:6888/api/dicts' | jq -r 'select(.t=="dict") | .dict.id' # has anything changed since the answer that ended with this etag? curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "ac4fd9fecabd8ec4cf544212"' \ 'http://127.0.0.1:6888/api/dicts'
"{\"t\":\"begin\",\"total\":2}\n{\"t\":\"dict\",\"dict\":{\"id\":\"oxford\",\"name\":\"Oxford Advanced Learner's\",\"format\":\"mdx\",\"path\":\"/Users/me/Dicts/oald.mdx\",\"entries\":184000,\"caps\":{\"Exact\":true,\"Prefix\":true,\"FTS\":false,\"Wildcard\":false}}}\n{\"t\":\"end\",\"etag\":\"\\\"ac4fd9fecabd8ec4cf544212\\\"\"}\n"Images, stylesheets, scripts and audio, as the article references them.
path is the whole remainder of the URL and may contain slashes, which
an OpenAPI path parameter cannot express: send it unencoded, exactly as
the article asks for it.
Speex audio is transcoded to WAV and answered as audio/wav. A .spx
that cannot be transcoded, or one supplied in a res/ override folder,
is answered raw as audio/ogg. Files from a res/ folder are served
uncached, so an edit takes effect on reload.
| dict required | string Dictionary id, from /api/dicts. |
| path required | string Example: audio/flight__gb.mp3 The resource path inside the dictionary, subfolders included. |
# the path after the id is the article's own path, slashes and all curl -s 'http://127.0.0.1:6888/res/oxford/audio/flight__gb.mp3' -o flight.mp3
{- "error": "string"
}Same-origin endpoints that serve the web page and the platform shell. Documented for completeness; not a stable contract.
Reads the dictionary folders again, warms the registry, and then answers exactly as /api/dicts does. Use it after adding files on disk.
# picks up files added to the dictionary folders, then lists curl -sN 'http://127.0.0.1:6888/api/rescan' | head -1
{- "t": "DictsBegin",
- "total": 0,
- "groupsProblems": 0
}Copies the built-in guide (wudict-howto.wudict.md and its wudict-howto.wudict.files/) into the folder imports go to - the first dictionary folder - then rescans. The copy is listed in place of the built-in guide. An existing copy is never overwritten. With restore=1 it instead brings the built-in guide back after the user removed it (DELETE /api/library?dict=wudict-howto, which records the removal so it is not written back at the next start).
| restore | string Bring the removed built-in guide back instead of copying it. A switch. |
{- "path": "string"
}Brings one dictionary to the feature state you ask for, streaming Server-Sent Events:
event: progress data: {"done":N,"total":M}
event: done data: {…a /api/dicts row…}
event: error data: {"error":"…"}
Each switch is a state, not a verb: a parameter you leave out keeps whatever that feature is now, so toggling one thing cannot strip another, and a request naming none prepares an unprepared dictionary with its headword index only. Progress is emitted at most every 200 ms.
The change runs as the dictionary's job on the server, not inside the
request: a client that disconnects loses only the stream, and the work
goes on. While a change to a dictionary runs, its /api/dicts row
carries job, and a request for that dictionary follows the running
change instead of starting another - what it asked for is not queued,
and the done it receives carries what the dictionary now has.
| dict required | string Dictionary id. |
| fts | string Full-text index over article text - the largest one. A switch. |
| media | string Pack the dictionary's media into media.db. A switch. |
# add the full-text index to one dictionary and watch it build curl -sN 'http://127.0.0.1:6888/api/ingest?dict=oxford&fts=1' # leave a switch out and it keeps its current state; this only packs media curl -sN 'http://127.0.0.1:6888/api/ingest?dict=oxford&media=1'
{- "error": "string"
}Everything in the library folder, whether or not it is currently enrolled. Listing is not consent: reading this never adds anything to the registry.
curl -s 'http://127.0.0.1:6888/api/library' | jq '.dir, .count'
{- "dir": "string",
- "count": 0,
- "useCached": true,
- "entries": [
- {
- "dir": "string",
- "textDB": "string",
- "mediaDB": "string",
- "name": "string",
- "format": "string",
- "source": "string",
- "sourceExists": true,
- "entries": 0,
- "fullText": true,
- "media": true,
- "size": 0,
- "created": "string"
}
]
}Removes the prepared folder, the original files, or both. Both default to on, which is "remove this dictionary".
Irreversible. Files are unlinked, not moved to a Trash or Recycle Bin, and nothing here can be undone.
From the machine running wudict this is always allowed - it is your
library and your disk. From a browser on another machine it follows
ALLOW_REMOTE_DELETE (default off); with that off, the remote caller
gets a 403 and canDelete: false in GET /api/config.
| dict required | string Dictionary id. |
| prepared | string Default: "1" Delete the prepared library folder; off keeps it. A switch, on when absent. |
| source | string Default: "1" Delete the original dictionary files; off keeps them. A switch, on when absent. |
# remove one dictionary: prepared folder and original files curl -s -X DELETE 'http://127.0.0.1:6888/api/library?dict=oxford' # free the indexes only, keeping the original file curl -s -X DELETE 'http://127.0.0.1:6888/api/library?dict=oxford&source=0' # There is no dry run here - `wudict rm` is the one that previews.
{- "name": "string",
- "folder": "string",
- "sources": [
- "string"
], - "freed": 0,
- "gone": true,
- "note": "string"
}Every prepared folder in the library whose recorded source file is no longer on disk, whatever the reason (deleted, renamed, on a drive that is not connected), except those kept on purpose. A source that was only moved inside a dictionary folder is not listed: a rescan re-links the folder to it. The built-in guide is never listed.
curl -s 'http://127.0.0.1:6888/api/orphans' | jq '.orphans[].name, .size'
{- "orphans": [
- {
- "folder": "string",
- "name": "string",
- "source": "string",
- "size": 0
}
], - "size": 0
}Deletes the prepared folders named in delete and marks those in
keep as standalone (keep = standalone in the folder's info.txt),
after which they are never listed again. Folders are named by
Orphan.folder, never by path, and each is checked again when it is
acted on: one that is no longer an orphan is reported under failed.
A folder named twice, or in both lists, fails too. Body is capped at
1 MiB.
Deleting is irreversible. Files are unlinked, not moved to a Trash
or Recycle Bin. Allowed on the machine running wudict; from another
machine only with ALLOW_REMOTE_DELETE, which also governs keep.
| delete | Array of strings Folder names to delete. |
| keep | Array of strings Folder names to keep for good. |
{- "delete": [
- "string"
], - "keep": [
- "string"
]
}{- "deleted": [
- {
- "folder": "string",
- "name": "string",
- "freed": 0,
- "error": "string"
}
], - "kept": [
- {
- "folder": "string",
- "name": "string",
- "freed": 0,
- "error": "string"
}
], - "failed": [
- {
- "folder": "string",
- "name": "string",
- "freed": 0,
- "error": "string"
}
], - "freed": 0
}One row per language, merging the published catalogue (LEMMA_URL) with what is present in LEMMA_DIR, plus the state of any download running right now. Always answers 200: what is installed is the half of the answer that needs no network, and an unreachable catalogue is reported in error rather than by failing.
| refresh | string Re-read the catalogue instead of using the cached copy. The catalogue is otherwise cached for ten minutes, so a page polling during a download does not ask its host once a second. |
| offline | string Answer without asking the catalogue's host: the list read last - this run, or kept in LEMMA_DIR from an earlier one - or else the copy built into wudict, which applies to the default LEMMA_URL only. Installed files are not read to set |
curl -s 'http://127.0.0.1:6888/api/lemmas' | jq -r '.languages[] | select(.installed) | .code'
{- "dir": "string",
- "url": "string",
- "enabled": true,
- "cacheSize": 0,
- "attribution": "string",
- "error": "string",
- "checked": "2019-08-24T14:15:22Z",
- "offline": true,
- "shadowed": [
- "string"
], - "languages": [
- {
- "code": "string",
- "name": "string",
- "size": 0,
- "localSize": 0,
- "lemmas": 0,
- "heapMB": 0,
- "source": "string",
- "license": "string",
- "installed": true,
- "catalogued": true,
- "builtin": true,
- "mismatch": true,
- "state": "string",
- "done": 0,
- "total": 0,
- "error": "string",
- "dicts": 0
}
]
}Starts a download and returns at once; follow it with GET /api/lemmas.
The download runs in the server, not in this request, so it survives the
page being reloaded, closed, or rotated away on Android.
The catalogue is never taken from the caller: only the configured LEMMA_URL is fetched. A second call for a language already downloading joins the running download instead of starting another.
On success the language is searchable immediately - no restart, because the lemma folder is re-indexed in place.
| code required | string Language code or English name ("pl", "polish"). |
curl -s -X POST 'http://127.0.0.1:6888/api/lemmas?code=pl'
{- "code": "string",
- "state": "string"
}Removes every file in LEMMA_DIR supplying this language, not only the one in use, and re-indexes the folder. Deleting English falls back to the pack compiled into the binary rather than losing the language. Unlike dictionary removal this is offered on every platform: these are files wudict downloaded, not files the user brought.
| code required | string Language code or English name. |
curl -s -X DELETE 'http://127.0.0.1:6888/api/lemmas?code=pl'
{- "code": "string",
- "builtin": true,
- "removed": [
- "string"
]
}Two acts on one route. Without confirm it ACQUIRES an archive and
reports the dictionaries inside it, decompressing nothing: a zip is
answered from its central directory and a 7z from its header, so
listing a multi-gigabyte bundle costs a seek. With confirm it
extracts the ones the caller chose.
A plain dictionary file is read too - an .mdx, an .ifo, a .dsl,
a .slob, a .bgl - and so are the companions lying beside it in the
same folder, because that is the same grouping rule the library uses.
Nothing is unpacked in that case; the files are copied into place.
.zip and .7z are read. A 7z is decoded strictly in archive order on
one goroutine: a 7z folder is a single stream holding several files end
to end, so that order is the difference between reading it once and
decompressing it again per file. A password-protected archive is
refused rather than half-read.
The archive arrives either as a path already on disk (path, used by
the Android shell, which hands over a download without copying it) or
as the request body. A streamed body is spooled inside the destination
folder, never into the temporary directory: on Android that is a small
cache partition, and on every platform it is a different filesystem,
which would make the install a second full copy instead of a rename.
One import runs at a time, everywhere. Extraction outlives this request
and is followed with GET /api/intake, for the same reason a lemma
download is: on Android the page dies whenever the device rotates.
A url is fetched by the server instead, which is how a phone imports:
the shell has no networking of its own, and a download that lived in the
page would die with it. It answers 202 at once and downloads behind the
same poll, sniffing when it lands. Any site, unless IMPORT_URL_HOSTS
restricts it; https only and no address on the local network, unless
IMPORT_INSECURE lifts both; an unfinished download is resumed by the
next attempt.
A url may also name SEVERAL dictionaries: a web folder page (every
link on it to a dictionary, a companion or an archive), a .txt list
(one file name or link per line, the first # line its title), or
several links pasted at once - and a share link
https://legbehindneck.com/wudict#<link> stands for the link after
# (#/dict/x/ is short for a path on that site), or for several
links after # separated by commas. A Google Drive file link
(drive.google.com/file/d/<id>/view) is fetched from Drive's download
address, and named by Drive's answer. Such a job is a collection: ready arrives with nothing
downloaded, one candidate per dictionary sized and dated from the
site's own headers, and each dictionary's media offered in extras.
Confirming downloads and installs each chosen dictionary in turn; one
that fails is named in error and the job still ends done with the
rest installed.
A dictionary the library already holds is reported as such on the
candidate (existing, or elsewhere for one in another configured
folder; unchanged when the files are the same) and, when the caller
picks it, is REPLACED - its folder, or its files in place - rather than
installed a second time. Not picking it is skipping it. Nothing is ever
installed or downloaded under a numbered name.
A downloaded plain file has its companions LOOKED FOR at the sibling
URLs the format tables name - oxford.mdd, oxford.1.mdd, and so on,
stopping at the first that is not there. What is found is reported in
extras and downloaded only when the caller asks for it by index: a
companion can be several hundred megabytes of somebody's phone data,
so it is offered on the screen that asks which dictionaries to install
and never taken on the strength of the probe.
The source file's fate follows IMPORT_KEEP; keep answers it only when
that is ask. A FAILED import always keeps the source, which is policy
and not a setting - deleting somebody's only copy after an import that
did not work cannot be undone.
| path | string An archive already on this machine, read in place. Mutually exclusive with a request body. |
| url | string <uri> An https link the server downloads and then sniffs. Mutually exclusive with |
| name | string File name for a streamed body, used for the extension and for what the page displays. The extension decides which reader is used, so a 7z body must be named |
| confirm | string Any value installs the sniffed job instead of starting a new one. |
| pick | string Comma-separated indexes into |
| keep | string Enum: "0" "1" Whether the caller's own source file survives a SUCCESSFUL import. Ignored unless IMPORT_KEEP is |
| extras | string Comma-separated indexes into |
The archive itself, streamed. Absent when path is given, and when confirming - where the body is instead the selection.
{- "id": "string",
- "state": "",
- "source": "string",
- "host": "string",
- "dir": "string",
- "keep": "ask",
- "candidates": [
- {
- "name": "string",
- "format": "string",
- "files": [
- "string"
], - "size": 0,
- "missing": [
- "string"
], - "existing": "string",
- "unchanged": true,
- "date": "string",
- "elsewhere": "string"
}
], - "extras": [
- {
- "name": "string",
- "size": 0,
- "need": "needed"
}
], - "installed": [
- "string"
], - "done": 0,
- "total": 0,
- "error": "string",
- "collection": true
}The poll. Always 200: an empty state means nothing is happening, which is an answer the page acts on and must not have to tell apart from a request that did not arrive. done and total are bytes.
curl -s 'http://127.0.0.1:6888/api/intake' | jq -r '.state'
{- "id": "string",
- "state": "",
- "source": "string",
- "host": "string",
- "dir": "string",
- "keep": "ask",
- "candidates": [
- {
- "name": "string",
- "format": "string",
- "files": [
- "string"
], - "size": 0,
- "missing": [
- "string"
], - "existing": "string",
- "unchanged": true,
- "date": "string",
- "elsewhere": "string"
}
], - "extras": [
- {
- "name": "string",
- "size": 0,
- "need": "needed"
}
], - "installed": [
- "string"
], - "done": 0,
- "total": 0,
- "error": "string",
- "collection": true
}Stops an extraction and forgets a sniffed archive that was never confirmed. Everything staged is removed; dictionaries already renamed into the library stay, because they are whole. Always 200 - cancelling nothing is not a failure. With id, only that job is cancelled, and only while it is still the current one: a client releasing its own job never cancels one another client started since.
A download that nobody has confirmed and nobody has polled for 75 seconds is not "running" any more: a new import replaces it instead of being refused. A confirmed import is never replaced.
| id | string The job to cancel ( |
curl -s -X DELETE 'http://127.0.0.1:6888/api/intake'
{- "id": "string",
- "state": "",
- "source": "string",
- "host": "string",
- "dir": "string",
- "keep": "ask",
- "candidates": [
- {
- "name": "string",
- "format": "string",
- "files": [
- "string"
], - "size": 0,
- "missing": [
- "string"
], - "existing": "string",
- "unchanged": true,
- "date": "string",
- "elsewhere": "string"
}
], - "extras": [
- {
- "name": "string",
- "size": 0,
- "need": "needed"
}
], - "installed": [
- "string"
], - "done": 0,
- "total": 0,
- "error": "string",
- "collection": true
}Rebuilds each dictionary /api/dicts reports as outdated - prepared by an older build, from a source edited since, or unreadable by this one - with the indexes it already has. A packed media.db the rebuild leaves unpaired is packed again. Dictionaries that are current are not touched, and one whose original files are gone is never outdated, since nothing can rebuild it.
Never started by the server itself: on a large library this is hours of work, so an upgrade only marks the library outdated and waits for this request. It runs in the background indexing lane, one dictionary at a time, and outlives the request; follow it with GET /api/reindex. One job at a time: while one runs, this answers its status instead of starting another.
curl -s -X POST 'http://127.0.0.1:6888/api/reindex'
{- "running": true,
- "done": 0,
- "total": 0,
- "current": "string",
- "currentDone": 0,
- "currentTotal": 0,
- "failed": [
- "string"
], - "canceled": true
}The poll. Always 200; running: false with total: 0 means no rebuild has run in this process.
curl -s 'http://127.0.0.1:6888/api/reindex' | jq '{done, total, current}'
{- "running": true,
- "done": 0,
- "total": 0,
- "current": "string",
- "currentDone": 0,
- "currentTotal": 0,
- "failed": [
- "string"
], - "canceled": true
}Stops the job after the dictionary it is rebuilding now. What is finished stays finished; each rebuild replaces the old data only when it completes, so nothing is left half-built. Always 200.
curl -s -X DELETE 'http://127.0.0.1:6888/api/reindex'
{- "running": true,
- "done": 0,
- "total": 0,
- "current": "string",
- "currentDone": 0,
- "currentTotal": 0,
- "failed": [
- "string"
], - "canceled": true
}Drives the first-run page. Without save it only reports what the folder holds, which is what live typing needs. With save=1 it switches the registry over and writes DICT_DIR. Repeat path for several folders; found is then the deduplicated total across all of them, because overlapping folders must not be counted twice.
| path | string A dictionary folder. Repeatable. |
| save | string On persists the folders to the config file. A switch. |
| useCached | string Enum: "0" "1" On enrolls the previously prepared dictionaries and persists USE_CACHED; off turns that off; absent leaves it alone. A switch. |
# what this folder holds, without changing anything curl -s 'http://127.0.0.1:6888/api/setup?path=~/Dictionaries' # adopt two folders and write them to the config file curl -s 'http://127.0.0.1:6888/api/setup?path=~/Dictionaries&path=/Volumes/Ext/Dicts&save=1'
{- "path": "string",
- "found": 0,
- "dirs": [
- {
- "path": "string",
- "count": 0,
- "total": 0,
- "exists": true
}
], - "saved": true,
- "useCached": true,
- "error": "string"
}What the panel's folders section shows, including which layer set DICT_DIR - so the page can say that saving will not take effect, rather than silently doing nothing.
# where the settings came from, and which folders are scanned curl -s 'http://127.0.0.1:6888/api/config' | jq '.configPath, .dictDirOrigin, .roots'
{- "roots": [
- {
- "path": "string",
- "count": 0,
- "total": 0,
- "exists": true
}
], - "libDir": "string",
- "prepared": 0,
- "useCached": true,
- "configPath": "string",
- "howtoRemoved": true,
- "total": 0,
- "dictDirOrigin": "flag",
- "dictDirEditable": true,
- "configProblems": [
- "string"
], - "revealLabel": "string",
- "canReveal": true,
- "canDelete": true,
- "pathAliases": [
- [
- "string",
- "string"
]
]
}Healed against the dictionaries that exist right now. exists is false on a first run, which is the client's cue to adopt what an older build left in localStorage, once, instead of starting the user over.
curl -s 'http://127.0.0.1:6888/api/prefs' | jq '.dicts'
{- "exists": true,
- "version": 0,
- "dicts": [
- {
- "id": "string",
- "path": "string",
- "name": "string",
- "off": true,
- "pin": true,
- "title": "string"
}
], - "ui": {
- "fontSize": 11,
- "hlOff": true,
- "fastFirst": true,
- "openN": 2,
- "openAll": true,
- "speakOff": true,
- "groupsOff": [
- "string"
], - "full": true,
- "mode": "exact",
- "historyOff": true
}
}Array order is the order of the pinned entries (pin); the others are listed A-Z. Entries record the path as well as the id, because ids are derived from the path: moving a dictionary folder changes every id at once, and the path is what re-attaches the state instead of resetting it. Body is capped at 1 MiB.
Array of objects (DictPref) The whole list, pinned entries in the user's order. Omit it (or send null) to keep the stored list untouched, as when only a UI setting changed. | |
| version | integer The format |
object The UI fields to change. Each field you send replaces the stored one; each field you omit keeps its stored value, so send |
{- "dicts": [
- {
- "id": "string",
- "path": "string",
- "name": "string",
- "off": true,
- "pin": true,
- "title": "string"
}
], - "version": 0,
- "ui": {
- "fontSize": 11,
- "hlOff": true,
- "fastFirst": true,
- "openN": 2,
- "openAll": true,
- "speakOff": true,
- "groupsOff": [
- "string"
], - "full": true,
- "mode": "exact",
- "historyOff": true
}
}{- "exists": true,
- "version": 0,
- "dicts": [
- {
- "id": "string",
- "path": "string",
- "name": "string",
- "off": true,
- "pin": true,
- "title": "string"
}
], - "ui": {
- "fontSize": 11,
- "hlOff": true,
- "fastFirst": true,
- "openN": 2,
- "openAll": true,
- "speakOff": true,
- "groupsOff": [
- "string"
], - "full": true,
- "mode": "exact",
- "historyOff": true
}
}Both lists, most recent first: history.txt and favourites.txt beside wudict.toml, one headword per line. A word is a headword, not one dictionary's article.
curl -s 'http://127.0.0.1:6888/api/words' | jq '.favourites'
{- "favourites": [
- "string"
], - "history": [
- "string"
]
}on moves the word to the front (adding it if new), otherwise removes it. History keeps the latest 500 words; while historyOff is set in the preferences, adding to history does nothing. Line breaks and tabs become spaces and the ends are trimmed. Body is capped at 4 KiB.
| list required | string Enum: "history" "favourites" |
| word required | string A headword, at most 256 characters. |
| on | boolean true adds or moves to the front; false removes. |
{- "list": "history",
- "word": "string",
- "on": true
}{- "favourites": [
- "string"
], - "history": [
- "string"
]
}Two CSS files the user owns, applied to everything wuDict renders. app styles the page itself - its colours, its own layout - and its :root custom properties reach articles too, because they inherit across the shadow boundary. article styles what dictionaries render, in both article flavours (shadow root and sandboxed iframe).
The split is what keeps body{}, p{} and a{} - written with articles in mind - from also restyling the chrome.
dir is where the two files live on disk, beside the wudict.toml in effect, so they can be edited with an ordinary text editor as well; writable is false when there is no config directory at all, and the editor says so instead of failing to save.
curl -s 'http://127.0.0.1:6888/api/style' | jq -r .app
{- "app": "string",
- "article": "string",
- "dir": "string",
- "writable": true
}Omit a field to leave that file untouched: absent and empty are different, and the App tab and the Article tab are one editor sending different bodies. An empty string REMOVES the file, so "I cleared the box" and "there is a file here containing nothing" do not become two states that behave the same but read differently.
Each file is capped at 256 KiB. A stylesheet that hides the app can be stepped around with GET /?style=off, which omits it.
| app | string The chrome stylesheet. |
| article | string The article stylesheet. |
{- "app": "string",
- "article": "string"
}{- "app": "string",
- "article": "string",
- "dir": "string",
- "writable": true
}groups.ini, beside the wudict.toml in effect: a [Section] line is a "Group by" heading (at most 32, names of at most 64 bytes), and Name = text, text a group under it that a dictionary joins when one of the texts (at least 2 characters) is anywhere in its title or file name, in any case. An item between backticks is a regular expression (Go RE2, case-insensitive), read verbatim and tested against the title and the file name separately. ; and # start a comment outside backticks. A group line before any section belongs to [My groups]. While there is no file the built-in list is in effect (custom false); once saved, the file is the whole list.
Ids are the names, trimmed and lower-cased: a dictionary row's groups[].f and groups[].v (GET /api/dicts) are exactly these. problems lists the lines and items that were skipped (an invalid regular expression, a line with no =, a one-character item, more than 500 regular expressions); the rest of the file still applies. At most 100 are listed: a last entry with more stands for the rest. text and msg are clipped to 200 bytes. unusable means the file exists but could not be read: the built-in list is in effect, and problems has one entry, with no line, saying why.
curl -s 'http://127.0.0.1:6888/api/groups' | jq -r .text
{- "text": "string",
- "custom": true,
- "unusable": true,
- "file": "string",
- "writable": true,
- "problems": [
- {
- "line": 0,
- "text": "string",
- "msg": "string",
- "more": 0
}
], - "facets": [
- {
- "id": "string",
- "label": "string",
- "groups": [
- {
- "id": "string",
- "label": "string",
- "items": [
- "string"
]
}
]
}
]
}The body is the whole file, as text. Lines that do not parse are reported in problems, never refused. A body identical to the built-in list removes the file instead, so an unchanged copy does not stop the list from following the built-in one. Capped at 256 KiB. While the current file is unusable, a save is refused unless replace=1 is given: the file is the user's, and the editor could not show it.
| replace | string Value: "1" 1 overwrites a groups.ini that exists but cannot be read. |
{- "text": "string",
- "custom": true,
- "unusable": true,
- "file": "string",
- "writable": true,
- "problems": [
- {
- "line": 0,
- "text": "string",
- "msg": "string",
- "more": 0
}
], - "facets": [
- {
- "id": "string",
- "label": "string",
- "groups": [
- {
- "id": "string",
- "label": "string",
- "items": [
- "string"
]
}
]
}
]
}{- "text": "string",
- "custom": true,
- "unusable": true,
- "file": "string",
- "writable": true,
- "problems": [
- {
- "line": 0,
- "text": "string",
- "msg": "string",
- "more": 0
}
], - "facets": [
- {
- "id": "string",
- "label": "string",
- "groups": [
- {
- "id": "string",
- "label": "string",
- "items": [
- "string"
]
}
]
}
]
}Files the user uploaded so that something they wrote can reference them by URL: an image for a background-image in their custom CSS, a font for an @font-face, an imported stylesheet, a script, a PDF a private dictionary links to. They live beside the two stylesheets, in an assets folder under the same directory GET /api/style reports, and are served from /files/<name>.
No type restriction: the store is "files the page can reach by URL", and an extension allowlist would only mean the feature is missing for whoever needed the entry it lacks. limits is reported rather than assumed, so a client's message about a refused upload cannot drift from the rule that refused it.
curl -s 'http://127.0.0.1:6888/api/files' | jq -r '.files[].name'
{- "files": [
- {
- "name": "string",
- "size": 0,
- "mtime": 0,
- "type": "string",
- "url": "string"
}
], - "dir": "string",
- "writable": true,
- "total": 0,
- "limits": {
- "file": 0,
- "total": 0,
- "count": 0
}
}The request body is the file itself, raw - there is one file per request and no other field, so multipart would add a parser to carry nothing. The name comes from name, and is one path segment of A-Z a-z 0-9 . _ - starting with a letter or digit, at most 64 characters; it is an allowlist, not a cleaning pass, so nothing here can name a path.
Uploading onto an existing name is refused with 409 unless replace=1: a silent overwrite is the one mistake this endpoint can make that the user cannot undo, because their stylesheet already points at that name.
| name required | string The file name, which is also its URL under |
| replace | string Overwrite a file that is already there. A switch. |
curl -s -X POST --data-binary @w1.jpg \ 'http://127.0.0.1:6888/api/files?name=w1.jpg'
{- "files": [
- {
- "name": "string",
- "size": 0,
- "mtime": 0,
- "type": "string",
- "url": "string"
}
], - "dir": "string",
- "writable": true,
- "total": 0,
- "limits": {
- "file": 0,
- "total": 0,
- "count": 0
}
}Removing what is not there is a success: the caller asked for it to be gone and it is. Nothing checks whether a stylesheet still references the name - that is the caller's warning to give, before it asks.
| name required | string The file to remove. |
curl -s -X DELETE 'http://127.0.0.1:6888/api/files?name=w1.jpg'
{- "files": [
- {
- "name": "string",
- "size": 0,
- "mtime": 0,
- "type": "string",
- "url": "string"
}
], - "dir": "string",
- "writable": true,
- "total": 0,
- "limits": {
- "file": 0,
- "total": 0,
- "count": 0
}
}Selecting a dictionary is a statement that it is about to be searched, so its headword index is prepared at once rather than waiting for the first query to discover it is missing. On a large dictionary that first query would otherwise have to search the source format directly, which is what the search budget is too short for.
The request returns as soon as the work is queued; the ingest outlives it. Progress is not reported here - a client learns the dictionary is ready from /api/dicts, where dbSize becomes non-zero.
Idempotent: repeated selection of the same dictionary neither queues a second ingest nor restarts a running one, and a failed preparation is retried only after a cooldown. Does nothing when AUTO_INDEX is off, where preparation is the dictionary panel's job, unless explicit=1 says the user asked for the index itself (browsing); the response then reports indexing: false.
Same-origin only. It is not part of the client API a browser extension reaches (D69): it starts work and writes to the user's library folder.
| dict required | string Dictionary id, as returned by |
| explicit | string
|
curl -s -X POST 'http://127.0.0.1:6888/api/demand?dict=<id>'
{- "indexing": true
}The dictionary's own annotation: the publisher's blurb, the edition, the copyright. Composed from the live sidecar beside the source file where the format ships one (Lingvo's <stem>.ann) and otherwise from the description its header declared, then normalised server-side - plain text escaped, markup reduced to prose elements - so a client renders html as-is and needs no sanitiser of its own.
Same-origin only. It is not part of the client API a browser extension reaches (D69): an annotation names a file on the user's disk. It is also deliberately absent from /api/dicts, which is built without opening any dictionary; this endpoint is fetched lazily, per dictionary.
| dict required | string Dictionary id, as returned by |
curl -s 'http://127.0.0.1:6888/api/about?dict=<id>'
{- "id": "string",
- "name": "string",
- "format": "string",
- "html": "string",
- "source": "string",
- "indexLang": "string",
- "contentsLang": "string"
}The reading view, as opposed to /api/search's answering view: the dictionary as an ordered, addressable list of its own headwords, turned a page at a time. Article bodies are never included - this lists what a dictionary contains, it does not answer with it.
Index-backed only. The order, the page offsets and the letter strip are three reads of the prepared index (idx_entry_w), which is what makes a jump to M in a 2.9 M-entry dictionary a seek rather than a scan; a dictionary that has not been prepared answers 409 instead of a slower browse. Browse order is that index's order - case-insensitive by headword - and NOT the source file's entry order, which is what wudict keys reads.
size is fixed by the server and is not a parameter: the page number is the whole address of a browse view, so ?p=37 has to mean the same place tomorrow as it does today.
Same-origin only. It is not part of the client API a browser extension reaches (D69): that grant is lookup, and this dumps a dictionary a page at a time.
| dict required | string Dictionary id, as returned by |
| p | integer >= 1 Default: 1 1-based page number. Out of range is clamped to the last page rather than refused, so a stale bookmark still opens. |
| at | string Jump to the page holding this word, or the first one after it. Overrides |
curl -s 'http://127.0.0.1:6888/api/browse?dict=<id>&p=1'
{- "dict": "string",
- "name": "string",
- "total": 0,
- "page": 0,
- "pages": 0,
- "size": 0,
- "at": 0,
- "words": [
- "string"
], - "alphabet": [
- {
- "l": "string",
- "o": 0,
- "n": 0
}
]
}Loopback only, and limited to the paths this app already displays: a remote browser must not open a window on someone else's desktop, and an arbitrary path must never reach the shell.
| path required | string An absolute path the app is showing. |
# opens a file manager window on the machine running the server curl -s 'http://127.0.0.1:6888/api/reveal?path=/Users/me/.wudict/db' # Loopback only: the request must come from this machine.
{- "revealed": "string"
}How the Android shell tells the server what the platform just told it - onStop, onTrimMemory, thermal throttling, battery saver - which an exec'd server has no other way of learning.
active is normal. background stops all indexing and closes every
handle that can be reopened, while prepared databases stay open so
returning is instant. restricted closes those too, leaving the process
holding nothing but its listener.
Loopback only, and POST because it changes state: a GET here would be followed by every link prefetcher on the network.
| state required | string Enum: "active" "background" "restricted" |
# what the Android shell sends when the app leaves the screen curl -s -X POST 'http://127.0.0.1:6888/api/power?state=background' curl -s -X POST 'http://127.0.0.1:6888/api/power?state=active' # Loopback only, like /api/reveal.
{- "state": "active"
}