# wikidot.py

> A Python library for interacting with Wikidot sites (SCP Foundation, etc.)

- Version: 4.5.0
- Python: 3.10+
- Dependencies: httpx, beautifulsoup4, lxml
- License: MIT
- Documentation: https://ukwhatn.github.io/wikidot.py/
- Repository: https://github.com/ukwhatn/wikidot.py

## Installation

```bash
pip install wikidot
# or with uv
uv add wikidot
```

---

## Quick Start

```python
import wikidot

# Unauthenticated client (read-only operations)
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    page = site.page.get("scp-173")
    print(page.title, page.rating)

# Authenticated client (full operations)
with wikidot.Client(username="user", password="pass") as client:
    site = client.site.get("scp-jp")

    # Search pages
    pages = site.pages.search(category="scp", order="rating desc", limit=10)
    for page in pages:
        print(page.fullname, page.rating)
```

---

## Client

Main entry point. Provides authentication and accessor access.

### Import

```python
import wikidot
# or
from wikidot import Client
```

### Constructor

```python
client = wikidot.Client(
    username=None,      # Wikidot username (optional)
    password=None,      # Wikidot password (optional)
    amc_config=None,    # Custom AMC configuration (optional)
    logging_level="WARNING"  # Logging level (optional)
)
```

#### Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `username` | `str \| None` | No | `None` | Wikidot username |
| `password` | `str \| None` | No | `None` | Wikidot password |
| `amc_config` | `AjaxModuleConnectorConfig \| None` | No | `None` | Custom AMC configuration |
| `logging_level` | `str` | No | `"WARNING"` | Logging level |

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `ClientSiteAccessor` | Site operations accessor |
| `user` | `ClientUserAccessor` | User operations accessor |
| `private_message` | `ClientPrivateMessageAccessor` | Private message accessor |
| `account` | `ClientAccountAccessor` | Account-level (Dashboard) settings/profile accessor |
| `is_logged_in` | `bool` | Login status |
| `username` | `str \| None` | Logged-in username |
| `me` | `User \| None` | Logged-in user object |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `login_check()` | `None` | Raise `LoginRequiredException` if not logged in |
| `close()` | `None` | Logout and cleanup resources |
| `__enter__()` | `Client` | Context manager entry |
| `__exit__(...)` | `None` | Context manager exit (auto logout) |

### Usage

```python
# With context manager (recommended)
with wikidot.Client(username="user", password="pass") as client:
    if client.is_logged_in:
        print(f"Logged in as: {client.username}")
        print(f"User ID: {client.me.id}")

    # ... operations ...
# Auto logout on exit

# Without context manager
client = wikidot.Client()
try:
    # ... operations ...
finally:
    client.close()
```

---

## Site

Site operations. Access pages, forums, and members.

### Getting a Site

```python
site = client.site.get("scp-jp")
```

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `client` | `Client` | Parent client |
| `id` | `int` | Site ID |
| `title` | `str` | Site title |
| `unix_name` | `str` | URL identifier (e.g., `scp-jp`) |
| `domain` | `str` | Site domain |
| `ssl_supported` | `bool` | SSL support flag |
| `url` | `str` | Full site URL |
| `page` | `SitePageAccessor` | Single page operations |
| `pages` | `SitePagesAccessor` | Page list operations |
| `forum` | `SiteForumAccessor` | Forum operations |
| `settings` | `SiteSettingsAccessor` | Manage Site (`_admin`) settings — see "Site Settings" |
| `member` | `MemberAccessor` | Site member administration — see "Site Member Administration" |
| `tools` | `SiteToolsAccessor` | Site Tools / Wanted / Orphaned / Drafts / filtered recent changes — see "Site Tools" |
| `members` | `list[SiteMember]` | Member list (cached) |
| `moderators` | `list[SiteMember]` | Moderator list (cached) |
| `admins` | `list[SiteMember]` | Admin list (cached) |
| `applications` | `list[SiteApplication]` | Pending membership applications (login required) |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_thread(thread_id)` | `ForumThread` | Get forum thread by ID |
| `get_threads(thread_ids)` | `ForumThreadCollection` | Get multiple threads |
| `member_lookup(user_name, user_id=None)` | `bool` | Check if user is a member |
| `invite_user(user, text)` | `None` | Invite user to site (login required) |
| `get_recent_changes(limit=None)` | `list[SiteChange]` | Get recent change history |
| `amc_request(bodies, return_exceptions=False)` | `tuple[Response, ...]` | Low-level AMC request (raise or return exceptions per-item) |
| `amc_request_with_retry(bodies, *, batch_size=None, max_retries=None)` | `tuple[Response \| None, ...]` | AMC request with batch splitting and partial-failure retry; still-failed items are `None` |

### SiteChange Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `page_fullname` | `str` | Page fullname |
| `page_title` | `str` | Page title |
| `revision_no` | `int` | Revision number |
| `changed_by` | `AbstractUser` | Editor |
| `changed_at` | `datetime` | Edit date |
| `flags` | `list[str]` | Change flags (N/S/T/R/M/F/A) |
| `comment` | `str \| None` | Edit comment |

Change flags: `N`=New, `S`=Source changed, `T`=Title changed, `R`=Renamed, `M`=Moved, `F`=File, `A`=Deleted

### Usage

```python
site = client.site.get("scp-jp")

print(f"Site: {site.title}")
print(f"URL: {site.url}")
print(f"SSL: {site.ssl_supported}")

# Recent changes
changes = site.get_recent_changes(limit=100)
for change in changes:
    flags_str = "".join(change.flags)
    print(f"[{flags_str}] {change.page_fullname} (rev.{change.revision_no})")
    print(f"    by {change.changed_by.name} at {change.changed_at}")
```

---

## AMC Transport

Low-level details of how requests reach Wikidot's Ajax Module Connector
(`ajax-module-connector.php`). Most callers never need this section — it
matters when a `save_*`/`set_*` call raises `FormErrorsException`, or when
building a raw AMC body for a method that takes `**fields`/`raw_fields`.

### FormErrorsException

Raised when Wikidot's response status is `"form_errors"` / `"form_error"`
(a validation failure, not a transport error). The payload key holding
per-field messages differs by module (`formErrors` for most, `errors` for
`WikiPageAction/savePage`, a plain `message` string for others); the
`errors` property absorbs this and always returns `dict[str, str]`.

```python
from wikidot.common.exceptions import FormErrorsException

try:
    site.settings.save_general(name="")  # empty title is invalid
except FormErrorsException as e:
    for field, message in e.errors.items():
        print(f"{field}: {message}")
```

### Request Body Helpers

`wikidot.util.amc_body` — used internally by every typed `site.settings.*` /
`site.member.*` / `client.account.*` method, and useful when passing raw
fields to a `**fields`/`raw_fields` escape hatch yourself.

| Function | Description |
|----------|-------------|
| `checkbox(value)` | Encode a checkbox-style bool: `"on"` if truthy, else `False` (formToArray semantics — unchecked means the key is omitted, not `"false"`) |
| `flag(value)` | Encode a JS-boolean-style flag: `"true"` if truthy, else `False` (for modules that build the body by hand in JS) |
| `json_param(obj)` | JSON-encode a value (e.g. `categories`/`options`/`addresses`), passing `None` through |
| `omit_falsy(**kwargs)` | Drop `None`/`False` values from a dict (identity comparison, so `0` is kept). The single place enforcing that an unchecked checkbox must be *absent*, not `"false"` |

```python
from wikidot.util.amc_body import checkbox, flag, omit_falsy

body = omit_falsy(
    toolbarTop=checkbox(True),
    toolbarBottom=checkbox(False),  # dropped entirely, not sent as "false"
)
```

---

## Page

Page operations including edit, delete, vote, files, and revisions.

### Getting a Page

```python
# Single page
page = site.page.get("scp-173")

# With option to return None instead of raising exception
page = site.page.get("nonexistent", raise_when_not_found=False)  # Returns None
```

### Creating a Page

```python
page = site.page.create(
    fullname="test:new-page",
    title="New Page Title",
    source="Page content in Wikidot markup",
    comment="Initial creation"
)
```

#### Create Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `fullname` | `str` | Yes | - | Full page name (category:name) |
| `title` | `str` | No | `""` | Page title |
| `source` | `str` | No | `""` | Page source in Wikidot markup |
| `comment` | `str` | No | `""` | Creation comment |
| `force_edit` | `bool` | No | `False` | Force edit if page exists |

### Page Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `id` | `int` | Page ID (lazy loaded) |
| `fullname` | `str` | Full page name (e.g., `scp:scp-173`) |
| `name` | `str` | Page name without category |
| `category` | `str` | Page category |
| `title` | `str` | Page title |
| `rating` | `int \| float` | Rating value |
| `rating_votes` | `int` | Total vote count |
| `rating_percent` | `float` | 5-star rating percentage (if applicable) |
| `size` | `int` | Page size in bytes |
| `tags` | `list[str]` | Tag list (mutable) |
| `created_by` | `AbstractUser` | Page creator |
| `created_at` | `datetime` | Creation date |
| `updated_by` | `AbstractUser` | Last editor |
| `updated_at` | `datetime` | Last update date |
| `parent_fullname` | `str \| None` | Parent page fullname |
| `revisions_count` | `int` | Number of revisions |
| `children_count` | `int` | Number of child pages |
| `comments_count` | `int` | Number of comments |
| `commented_by` | `AbstractUser \| None` | Last commenter |
| `commented_at` | `datetime \| None` | Last comment date |
| `source` | `PageSource` | Page source (lazy loaded) |
| `revisions` | `PageRevisionCollection` | Revision history (lazy loaded) |
| `latest_revision` | `PageRevision` | Latest revision |
| `votes` | `PageVoteCollection` | Vote information (lazy loaded) |
| `discussion` | `ForumThread \| None` | Discussion thread (lazy loaded) |
| `files` | `PageFileCollection` | Attached files (lazy loaded) |
| `metas` | `dict[str, str]` | Meta tags (lazy loaded, settable — diffs the whole dict against current state) |

### Page Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_url()` | `str` | Get page URL |
| `is_id_acquired()` | `bool` | Check if page ID is cached |
| `get_template_source()` | `str` | Get the page's template source |
| `edit(**kwargs)` | `Page` | Edit page (login required) |
| `open_editor(mode="page", section=None, force_lock=False)` | `PageEditSession` | Get an unopened manual edit session — see "PageEditSession" |
| `destroy()` | `None` | Delete page (login required) |
| `rename(new_fullname, fixdeps=None, force=False)` | `Page` | Rename page (login required). `fixdeps`: backlink page IDs to update (see `get_rename_backlinks()`) |
| `get_rename_backlinks()` | `str` | Rendered HTML of pages linking to this page, for building `fixdeps` |
| `set_parent(parent_fullname)` | `Page` | Set parent page (login required) |
| `get_parent_form()` | `str` | Rendered parent-page selection form (raw HTML) |
| `commit_tags()` | `Page` | Save tag changes (login required) |
| `get_tags_form()` | `str` | Rendered tags editing form (raw HTML) |
| `update_tags_by_button(tags)` | `Page` | Update tags via the quick-tag button UI (space-separated tag string, distinct from `commit_tags()`) |
| `set_meta(name, content, all_pages=False)` | `Page` | Set one meta tag directly; `all_pages=True` applies it to every page sharing the template |
| `delete_meta(name, all_pages=False)` | `Page` | Delete one meta tag |
| `get_block_form()` | `str` | Rendered page-block form (raw HTML) |
| `set_block(block=True)` | `Page` | Set/clear the edit-block flag (blocked pages are non-moderator-uneditable, login required) |
| `get_backlinks()` | `str` | Rendered HTML of pages linking to this page |
| `watch()` | `None` | Watch the page for changes (login required) |
| `get_watchers()` | `str` | Rendered HTML of users watching the page |
| `vote(value)` | `int` | Vote (+1/-1), returns new rating (login required) |
| `cancel_vote()` | `int` | Cancel vote, returns new rating (login required) |

### Edit Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `title` | `str \| None` | No | New page title (None = no change) |
| `source` | `str \| None` | No | New page source (None = no change) |
| `comment` | `str` | No | Edit comment |
| `force_edit` | `bool` | No | Force edit even if locked |

### Usage

```python
# Get page information
page = site.page.get("scp-173")
print(f"Title: {page.title}")
print(f"Rating: {page.rating}")
print(f"Tags: {page.tags}")
print(f"URL: {page.get_url()}")

# Get source
print(page.source.wiki_text)

# Edit page (login required)
page = page.edit(
    title="Updated Title",
    source="Updated content",
    comment="Fixed typo"
)

# Modify tags
page.tags.append("new-tag")
page.tags.remove("old-tag")
page = page.commit_tags()

# Vote
new_rating = page.vote(1)   # +1 vote
new_rating = page.vote(-1)  # -1 vote
new_rating = page.cancel_vote()  # Cancel vote

# Set parent page
page = page.set_parent("parent-page")
page = page.set_parent(None)  # Remove parent

# Rename page
page = page.rename("new-fullname")

# Delete page
page.destroy()
```

---

## PageEditSession

A context manager wrapping the lock lifecycle of Wikidot's page editor
(`edit/PageEditModule`): acquire on open, keep alive via `synchronize()`,
release via `release()` (automatic on `__exit__` unless `save()` already
succeeded). Wikidot holds the lock for up to 15 minutes; any code path that
acquires it but doesn't reach a successful save should release it
explicitly. Get one via `page.open_editor(...)`.

### Constructor Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `site` | `Site` | Yes | - | Site the page belongs to |
| `fullname` | `str` | Yes | - | Fullname of the page being edited |
| `page_id` | `int \| None` | No | `None` | Page ID for an existing page; must be `None` when creating a new page |
| `mode` | `Literal["page", "section", "append"]` | No | `"page"` | Edit mode |
| `section` | `int \| None` | No | `None` | Section number; required when `mode="section"` |
| `force_lock` | `bool` | No | `False` | Forcibly take over a lock held by another user when opening |

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `lock_id` / `lock_secret` | `str \| None` | Lock identifiers, set once open |
| `revision_id` | `str` | Revision ID submitted with save/synchronize |
| `time_left` | `int \| None` | Remaining lock time in seconds |
| `is_existing_page` | `bool` | Whether the page already existed when the lock was acquired |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `open()` | `PageEditSession` | Acquire the edit lock. Raises `TargetErrorException` if locked by another user |
| `save(title="", source="", comment="", and_continue=False, range_start=None, range_end=None, tags=None, parent_page=None, dont_notify_watchers=False)` | `dict[str, Any]` | Save the page (`WikiPageAction/savePage`). Raises `FormErrorsException`, `TargetErrorException` (lock lost) |
| `synchronize(since_last_input=0)` | `dict[str, Any]` | Keep the lock alive; call periodically while the editor stays open |
| `preview(title="", source="", page_unix_name=None, range_start=None, range_end=None)` | `dict[str, str]` | Render a preview: `{"body": ..., "title": ...}` |
| `diff(title="", source="", range_start=None, range_end=None)` | `str` | Render a diff of in-progress content against the base revision |
| `check_draft_exists(title="", source="", comment="")` | `bool` | Check whether a draft already exists for this lock |
| `force_lock_intercept()` | `dict[str, Any]` | Forcibly take over a newer lock taken by someone else |
| `recreate_expired_lock()` | `dict[str, Any]` | Recreate an expired lock |
| `release(leave_draft=False)` | `None` | Release the lock. Safe to call multiple times; failures are logged, not raised |

### Usage

```python
with page.open_editor() as ed:
    preview = ed.preview(source="new content")
    print(preview["body"])
    diff_html = ed.diff(source="new content")
    ed.save(source="new content", comment="edit")
# Lock auto-released on exit if save() was never called successfully
```

---

## Page Search (ListPagesModule)

Search pages using ListPagesModule parameters.

### Import

```python
from wikidot import Client
```

### Basic Usage

```python
# Search with keyword arguments
pages = site.pages.search(
    category="scp",
    tags=["safe", "-explained"],
    rating=">100",
    order="rating desc",
    limit=50
)
```

### Search Parameters

#### Selection Parameters

| Parameter | Type | Description | Example |
|-----------|------|-------------|---------|
| `pagetype` | `str` | Page type | `"*"`, `"normal"`, `"hidden"` |
| `category` | `str` | Category filter | `"*"`, `"scp"`, `"."` (current) |
| `tags` | `str \| list[str]` | Tag filter (- for exclude) | `["scp", "euclid"]`, `["-tale"]` |
| `parent` | `str` | Parent page | `"parent-page"`, `"-"` (no parent) |
| `link_to` | `str` | Pages linking to | `"scp-173"` |
| `created_by` | `User \| str` | Author | `"username"` or User object |
| `created_at` | `str` | Creation date | `"2024"`, `"last 7 day"`, `">2024-01-01"` |
| `updated_at` | `str` | Update date | `"last 1 week"`, `"<2024-12-31"` |
| `rating` | `str` | Rating filter | `">50"`, `">=100"` |
| `votes` | `str` | Vote count filter | `">10"`, `">=5"` |
| `name` | `str` | Page name pattern | `"scp-*"`, `"about"` |
| `fullname` | `str` | Exact fullname match | `"scp:scp-173"` |
| `range` | `str` | Range specification | Page range |

#### Ordering Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `order` | `str` | `"created_at desc"` | Sort order |

Valid order values: `created_at`, `updated_at`, `rating`, `name`, `size`, `random` (add `desc` for descending)

#### Pagination Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `offset` | `int` | `0` | Starting position |
| `limit` | `int \| None` | `None` | Maximum results (None = unlimited) |
| `perPage` | `int` | `250` | Results per request (max 250) |

### Usage

```python
# Top rated SCP articles
pages = site.pages.search(
    category="scp",
    order="rating desc",
    limit=10
)
for page in pages:
    print(f"{page.fullname}: +{page.rating}")

# Pages by specific author
user = client.user.get("author-name")
pages = site.pages.search(created_by=user, limit=50)

# Recent pages with specific tags
pages = site.pages.search(
    tags=["safe", "+scp"],
    created_at="last 30 day",
    order="created_at desc"
)

# All pages in a category
pages = site.pages.search(category="component")
```

---

## PageCollection

Collection of pages with bulk operations.

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(fullname)` | `Page \| None` | Find page by fullname |
| `get_page_ids()` | `PageCollection` | Bulk fetch page IDs |
| `get_page_sources()` | `PageCollection` | Bulk fetch page sources |
| `get_page_revisions()` | `PageCollection` | Bulk fetch revision histories |
| `get_page_votes()` | `PageCollection` | Bulk fetch vote information |

### Usage

```python
pages = site.pages.search(category="scp", limit=100)

# Find specific page
page = pages.find("scp-173")

# Bulk operations (efficient)
pages.get_page_ids()
pages.get_page_sources()
pages.get_page_revisions()
pages.get_page_votes()

# Iteration
for page in pages:
    print(f"{page.fullname}: {page.title}")
    print(f"  Source length: {len(page.source.wiki_text)}")
```

---

## PageRevision

Page revision information.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `page` | `Page` | Parent page |
| `id` | `int` | Revision ID |
| `rev_no` | `int` | Revision number |
| `created_by` | `AbstractUser` | Editor |
| `created_at` | `datetime` | Edit date |
| `comment` | `str` | Edit comment |
| `source` | `PageSource` | Revision source (lazy loaded) |
| `html` | `str` | Revision HTML (lazy loaded) |

### Revision Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `revert(force=False)` | `dict[str, Any]` | Revert the page to this revision (login required). `force=True` overrides another user's active edit lock. On a lock conflict the response carries `locks`/`body` with status "ok" — inspect the returned dict rather than relying on an exception |

### PageRevisionCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(id)` | `PageRevision \| None` | Find revision by ID |
| `get_sources()` | `PageRevisionCollection` | Bulk fetch sources |
| `get_htmls()` | `PageRevisionCollection` | Bulk fetch HTML |

### Static Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `PageRevisionCollection.get_diff(page, from_revision_id, to_revision_id, show_type="inline")` | `str` | Rendered HTML diff between two revisions |
| `PageRevisionCollection.acquire(page, options=None, perpage=20, page_no=1)` | `PageRevisionCollection` | Fetch one page of history with server-side change-type filtering. `options` keys: `"all"`/`"source"`/`"title"`/`"move"`/`"tags"`/`"files"`/`"meta"` (no `"new"`, unlike `SiteChange`'s options) |

### Usage

```python
# Get revision history
for revision in page.revisions:
    print(f"Rev {revision.rev_no}: {revision.created_at} by {revision.created_by.name}")
    print(f"  Comment: {revision.comment}")

# Get latest revision
latest = page.latest_revision
print(latest.source.wiki_text)

# Bulk fetch sources
page.revisions.get_sources()
for rev in page.revisions:
    print(len(rev.source.wiki_text))

# Revert to an older revision
old_revision = page.revisions.find(12345)
if old_revision:
    old_revision.revert()

# Server-side filtered/paginated history
from wikidot.module.page_revision import PageRevisionCollection
filtered = PageRevisionCollection.acquire(page, options={"source": True}, perpage=50)
```

---

## PageSource

Page source code container.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `page` | `Page` | Parent page |
| `wiki_text` | `str` | Wikidot markup source |

### Usage

```python
source = page.source
print(source.wiki_text)
```

---

## PageVote

Vote information for a page.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `page` | `Page` | Parent page |
| `user` | `AbstractUser` | Voter |
| `value` | `int` | Vote value (+1, -1, or numeric) |

### PageVoteCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(user)` | `PageVote \| None` | Find vote by user |

### Usage

```python
for vote in page.votes:
    sign = "+" if vote.value > 0 else ""
    print(f"{vote.user.name}: {sign}{vote.value}")

# Find specific user's vote
user = client.user.get("username")
vote = page.votes.find(user)
if vote:
    print(f"User voted: {vote.value}")
```

---

## PageFile

Attached file information.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `page` | `Page` | Parent page |
| `id` | `int` | File ID |
| `name` | `str` | File name |
| `url` | `str` | Download URL |
| `mime_type` | `str` | MIME type |
| `size` | `int` | File size in bytes |

### File Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_rename_form()` | `str` | Rendered rename form (raw HTML) |
| `get_move_form()` | `str` | Rendered move form (raw HTML) |
| `get_info()` | `str` | Rendered file detail view (raw HTML) |
| `rename(new_name, force=False)` | `PageFile` | Rename this file (login required). Raises `WikidotStatusCodeException` (`"file_exists"` / `"name_error"`) |
| `move(destination_page_name, force=False)` | `None` | Move this file to another page (login required). This object's `page` still points at the source — re-fetch from the destination if needed |
| `delete(confirm=False)` | `None` | Delete this file. Destructive; raises `ValueError` unless `confirm=True` |

### PageFileCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(id)` | `PageFile \| None` | Find file by ID |
| `find_by_name(name)` | `PageFile \| None` | Find file by name |

### Static Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `PageFileCollection.check_exists(page, filename)` | `bool` | Check whether a file with that name exists on the page |
| `PageFileCollection.get_upload_form(page)` | `str` | Rendered file upload form (raw HTML; the actual upload is a separate multipart endpoint) |
| `PageFileCollection.get_manager(page)` | `str` | Rendered site-wide file manager view scoped to a page (raw HTML) |
| `PageFileCollection.upload(page, filename, content, *, multikey=None)` | `dict[str, str]` | Upload a file (multipart). **Unverified against a live Wikidot instance.** `multikey` groups multiple uploads for `multi_upload_complete()` |
| `PageFileCollection.multi_upload_complete(page, multikey, filenames)` | `None` | Notify Wikidot that a batch of multipart uploads has finished (login required) |

### Usage

```python
for file in page.files:
    print(f"{file.name}: {file.size} bytes")
    print(f"  URL: {file.url}")
    print(f"  Type: {file.mime_type}")

# Find specific file
image = page.files.find_by_name("image.png")
if image:
    print(f"Image URL: {image.url}")

# Upload and manage files
from wikidot.module.page_file import PageFileCollection
PageFileCollection.upload(page, "notes.txt", b"content")
image.rename("renamed.png")
image.delete(confirm=True)
```

---

## Forum Administration

Forum-wide admin operations (Manage Site's Forum panel), accessed through
`site.forum` alongside the existing category/thread accessors.

### SiteForumAccessor Methods (new)

| Method | Return Type | Description |
|--------|-------------|-------------|
| `activate()` | `None` | Enable the forum for a site that doesn't have one yet |
| `set_default_nesting(max_nest_level)` | `None` | Set the site-wide default reply nesting depth (0-10, 0=flat) |
| `get_layout()` | `ForumLayout` | Fetch the current group/category layout for editing |
| `update_permissions(mutator, default_permissions=None)` | `None` | Fetch → mutate → save forum category permissions (read-modify-write) |
| `create_page_discussion_thread(page_id)` | `ForumThread \| None` | Create a page's discussion thread if it doesn't already have one |

### ForumLayout

The full forum group/category layout (`saveForumLayout`'s read-modify-write
cycle). Never cached — call `ForumLayout.fetch()` again before each edit.

| Method | Return Type | Description |
|--------|-------------|-------------|
| `ForumLayout.fetch(site)` | `ForumLayout` | Fetch the current layout |
| `add_group(name, description="", visible=True)` | `ForumLayoutGroup` | Add a new empty group |
| `add_category(group, name, description="", max_nest_level=None)` | `ForumLayoutCategory` | Add a category to a group in this layout |
| `remove_group(group, *, confirm)` | `None` | Remove a group and all its categories. Destructive; requires `confirm=True` |
| `remove_category(group, category, *, confirm)` | `None` | Remove a single category. Destructive; requires `confirm=True` |
| `save()` | `None` | Send the layout back to Wikidot |

`ForumLayoutGroup` (`name`, `description`, `visible`) and
`ForumLayoutCategory` (`name`, `description`, `max_nest_level`,
`category_id`, `number_threads`) are the group/category elements the above
methods operate on.

### ForumCategoryPermissionsCollection

The full forum-category `permissions` array from a *different* module than
`ForumLayout` (13 fields vs. layout's smaller shape) — always fetch from the
matching module rather than mixing the two. Never cached.

| Method | Return Type | Description |
|--------|-------------|-------------|
| `ForumCategoryPermissionsCollection.fetch(site)` | `ForumCategoryPermissionsCollection` | Fetch current forum category permissions |
| `collection[category_id]` | `ForumCategoryPermissions` | Look up a category by ID (raises `KeyError` if missing) |
| `save(default_permissions=None)` | `None` | Send the array back. `default_permissions` (site-wide) is only sent when explicitly provided — it cannot be fetched and preserved automatically |

`ForumCategoryPermissions.set_permissions(permissions)` updates a single
category's `ForumPermissions` (`None` = inherit the site default).

### Usage

```python
# Enable and configure a forum
site.forum.activate()
site.forum.set_default_nesting(5)

# Edit the group/category layout
layout = site.forum.get_layout()
group = layout.add_group("Announcements", "Site news")
layout.add_category(group, "General", max_nest_level=3)
layout.save()

# Read-modify-write forum category permissions
from wikidot.module.site_permissions import ForumPermissions

site.forum.update_permissions(
    lambda cats: cats[7001].set_permissions(
        ForumPermissions.decode("t:m;p:arm;e:m")
    ),
)
```

---

## ForumCategory

Forum category operations.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `id` | `int` | Category ID |
| `title` | `str` | Category title |
| `description` | `str` | Category description |
| `threads_count` | `int` | Number of threads |
| `posts_count` | `int` | Number of posts |
| `threads` | `ForumThreadCollection` | Threads (lazy loaded, cached) |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `reload_threads()` | `ForumThreadCollection` | Force reload threads |
| `create_thread(title, description, source)` | `ForumThread` | Create new thread (login required) |

### ForumCategoryCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(id)` | `ForumCategory \| None` | Find category by ID |

### Usage

```python
# Get all categories
categories = site.forum.categories
for category in categories:
    print(f"{category.title}: {category.threads_count} threads")

# Get threads in a category
category = categories.find(123)
for thread in category.threads:
    print(f"{thread.title}: {thread.post_count} posts")

# Create new thread (login required)
thread = category.create_thread(
    title="Thread Title",
    description="Thread description",
    source="First post content in Wikidot markup"
)
print(f"Created: {thread.url}")
```

---

## ForumThread

Forum thread operations.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `id` | `int` | Thread ID |
| `title` | `str` | Thread title |
| `description` | `str` | Thread description |
| `created_by` | `AbstractUser` | Thread creator |
| `created_at` | `datetime` | Creation date |
| `post_count` | `int` | Number of posts |
| `category` | `ForumCategory \| None` | Parent category |
| `url` | `str` | Thread URL |
| `posts` | `ForumPostCollection` | Posts (lazy loaded) |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `reply(source, title="", parent_post_id=None)` | `ForumThread` | Reply to thread (login required) |
| `save_meta(title=None, description=None)` | `ForumThread` | Update title/description (login required). Both are always resent — `None` keeps the current locally-known value rather than blanking it |
| `set_sticky(sticky)` | `ForumThread` | Pin/unpin the thread within its category (login required) |
| `set_block(block)` | `ForumThread` | Lock/unlock the thread; locked threads reject new posts (login required) |
| `move(category)` | `ForumThread` | Move the thread to a different `ForumCategory` (login required) |
| `watch()` | `ForumThread` | Start watching the thread for new-post notifications (login required) |

### Static Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_from_id(site, thread_id, category=None)` | `ForumThread` | Get thread by ID |

### Usage

```python
# Get thread by ID
thread = site.get_thread(12345)

print(f"{thread.title}: {thread.post_count} posts")
print(f"URL: {thread.url}")

# Get posts
for post in thread.posts:
    print(f"{post.title} by {post.created_by.name}")

# Reply to thread (login required)
thread.reply(source="Reply content")
thread.reply(source="Reply with title", title="Re: Title")
thread.reply(source="Reply to specific post", parent_post_id=67890)
```

---

## ForumPost

Forum post operations.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `thread` | `ForumThread` | Parent thread |
| `id` | `int` | Post ID |
| `title` | `str` | Post title |
| `text` | `str` | Post content (HTML) |
| `source` | `str` | Post source (Wikidot markup, lazy loaded) |
| `created_by` | `AbstractUser` | Post author |
| `created_at` | `datetime` | Post date |
| `edited_by` | `AbstractUser \| None` | Last editor |
| `edited_at` | `datetime \| None` | Last edit date |
| `parent_id` | `int \| None` | Parent post ID |
| `has_revisions` | `bool` | Whether the post has been edited (`edited_by is not None`) |
| `revisions` | `ForumPostRevisionCollection` | Edit history (lazy loaded) — see "ForumPostRevision" |
| `source` | `str` | Post source in Wikidot syntax (lazy loaded). Raises `NoElementException` if unavailable |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `edit(source, title=None)` | `ForumPost` | Edit post (login required) |
| `delete(*, confirm)` | `None` | Delete the post. Destructive and irreversible; raises `ValueError` unless `confirm=True` |

### ForumPostCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(id)` | `ForumPost \| None` | Find post by ID |

### Usage

```python
for post in thread.posts:
    print(f"{post.title} by {post.created_by.name}")
    print(f"Source: {post.source[:100]}...")

# Edit post (login required)
post = post.edit(source="Updated content", title="Updated title")
```

---

## ForumPostRevision

Forum post edit history. Access via `post.revisions`.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `post` | `ForumPost` | Parent post |
| `id` | `int` | Revision ID |
| `rev_no` | `int` | Revision number (0 = initial version) |
| `created_by` | `AbstractUser` | Editor |
| `created_at` | `datetime` | Edit date |
| `html` | `str \| None` | Revision HTML (lazy loaded) |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `is_html_acquired()` | `bool` | Check if HTML is cached |

### ForumPostRevisionCollection Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `find(id)` | `ForumPostRevision \| None` | Find revision by ID |
| `find_by_rev_no(rev_no)` | `ForumPostRevision \| None` | Find revision by revision number |
| `get_htmls()` | `ForumPostRevisionCollection` | Bulk fetch HTML |

### Static Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `ForumPostRevisionCollection.acquire_all(post)` | `ForumPostRevisionCollection` | Fetch all revisions for one post |
| `ForumPostRevisionCollection.acquire_all_for_posts(posts)` | `dict[int, ForumPostRevisionCollection]` | Bulk fetch revisions for multiple posts, keyed by post ID |

### Usage

```python
for revision in post.revisions:
    print(f"Rev {revision.rev_no} by {revision.created_by.name}")
    print(revision.html)
```

---

## User

User types and operations.

### Import

```python
from wikidot import Client
from wikidot.module.user import User, AbstractUser
```

### User Types

| Type | Description |
|------|-------------|
| `User` | Regular registered user |
| `DeletedUser` | Deleted user account |
| `AnonymousUser` | Anonymous user (with IP) |
| `GuestUser` | Guest user |
| `WikidotUser` | Wikidot system user |

### AbstractUser Properties

| Property | Type | Description |
|----------|------|-------------|
| `client` | `Client` | Parent client |
| `id` | `int \| None` | User ID |
| `name` | `str \| None` | Display name |
| `unix_name` | `str \| None` | URL-safe name |
| `avatar_url` | `str \| None` | Avatar URL |
| `ip` | `str \| None` | IP address (anonymous only) |

### Getting Users

```python
# Single user
user = client.user.get("username")
user = client.user.get("username", raise_when_not_found=False)  # Returns None if not found

# Multiple users
users = client.user.get_bulk(["user1", "user2", "user3"])
```

### Usage

```python
user = client.user.get("username")

print(f"Name: {user.name}")
print(f"ID: {user.id}")
print(f"Avatar: {user.avatar_url}")

# Type checking
if isinstance(user, User):
    print("Regular user")
elif isinstance(user, DeletedUser):
    print("Deleted account")
elif isinstance(user, AnonymousUser):
    print(f"Anonymous: {user.ip}")

# Bulk fetch
users = client.user.get_bulk(["user1", "user2", "user3"])
for user in users:
    if user:
        print(user.name)
```

---

## SiteMember

Site member operations.

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `user` | `User` | User object |
| `joined_at` | `datetime \| None` | Join date |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `to_moderator()` | `None` | Promote to moderator (login required) |
| `remove_moderator()` | `None` | Remove moderator status (login required) |
| `to_admin()` | `None` | Promote to admin (login required) |
| `remove_admin()` | `None` | Remove admin status (login required) |

### Usage

```python
# Member list
for member in site.members:
    print(f"{member.user.name}: joined {member.joined_at}")

# Moderator list
for mod in site.moderators:
    print(f"Mod: {mod.user.name}")

# Admin list
for admin in site.admins:
    print(f"Admin: {admin.user.name}")

# Search specific member
member = site.member_lookup("username")

# Change permissions (login required)
member.to_moderator()
member.remove_moderator()
member.to_admin()
member.remove_admin()
```

---

## Site Settings

Manage Site (`_admin`) settings. Access through `site.settings`
(`SiteSettingsAccessor`). Covers General/Domain/Access policy
(get/save pairs), the seven `categories`-backed areas (permissions,
license, navigation, templates, page rate, per-page discussion,
appearance), and single-shot settings (footer, toolbars, Google
Analytics, autonumeration, pingbacks, API, OpenID, backup, icons,
newsletter).

`save_general`/`save_domain`/`save_access_policy` resubmit the whole form
(Wikidot's own save events aren't diffs): each fetches the current
settings first, and every parameter defaults to `None` meaning "keep the
current value" — pass `""` explicitly to clear a text field.

### General / Domain / Access Policy

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_general()` | `GeneralSettings` | Fetch title/subtitle/language/description/default_page/welcome_page |
| `save_general(name=None, subtitle=None, language=None, description=None, default_page=None, welcome_page=None)` | `str \| None` | Save; returns new unix name if it changed. Raises `FormErrorsException` (e.g. empty title) |
| `get_domain()` | `DomainSettings` | Fetch domain/domain_default/redirects |
| `save_domain(domain=None, redirects=None, domain_default=None)` | `str \| None` | Save; returns new domain if changed. `redirects` supports at most 10 entries |
| `get_access_policy()` | `AccessPolicySettings` | Fetch privacy/by_apply/by_domain/by_password/password/allow_hotlink/landing_page/hide_nav (excludes `viewers`, which cannot be read back) |
| `save_access_policy(privacy=None, by_apply=None, by_domain=None, by_password=None, password=None, allow_hotlink=None, landing_page=None, hide_nav=None, viewers=None)` | `None` | Save. Raises `ValueError` if `privacy` is `None` and cannot be determined. `viewers` (extra allowed users for a private site) is only sent when explicitly provided |

### Categories-backed Settings (thin wrappers over `update_categories`)

| Method | Return Type | Description |
|--------|-------------|-------------|
| `update_categories(module_name, action, event, mutator)` | `None` | The read-modify-write primitive: fetch `categories`, mutate, save back. Every method below wraps this |
| `set_page_permissions(category_name, permissions)` / `use_default_page_permissions(category_name)` | `None` | Explicit `PagePermissions`, or inherit the site default |
| `set_license(category_name, license, other="")` | `None` | `other` required when `license` is `SiteLicense.OTHER` |
| `use_default_license(category_name)` | `None` | Inherit the site default license |
| `set_navigation(category_name, top_bar_page_name, side_bar_page_name)` / `use_default_navigation(category_name)` | `None` | Top/side nav pages, or inherit default |
| `set_template(category_name, template_id)` | `None` | `template_id=None` unsets it |
| `set_page_rate_settings(category_name, rating)` | `None` | Set a `RatingSettings` |
| `set_per_page_discussion(category_name, enabled)` | `None` | `True`/`False` to force, `None` for site default |
| `set_appearance_theme(category_name, theme_id)` / `set_appearance_external_theme(category_name, theme_external_url)` / `use_default_appearance(category_name)` | `None` | Built-in theme, external theme URL, or inherit default |

### Single-shot Settings

| Method | Return Type | Description |
|--------|-------------|-------------|
| `save_custom_footer(source, use=False)` | `None` | Custom footer Wikidot markup |
| `save_toolbars_preference(toolbar_top=False, toolbar_bottom=False, promote=False)` | `None` | Edit-toolbar visibility |
| `save_google_analytics(key, use=False)` | `None` | GA tracking key |
| `add_autonumeration(category_name, override=False)` / `remove_autonumeration(category_name)` / `set_autonumerate_title_format(category_name, title_format)` | `None` | Page auto-numbering. `override=True` confirms past a `"non_numeric"` `WikidotStatusCodeException` |
| `add_pingbacks(category_name, override=False)` / `remove_pingbacks(category_name)` / `set_global_pingback(enabled=False)` | `None` | Outgoing pingbacks per-category / site-wide |
| `save_api_settings(enabled=False, read_1=False, read_2=False, write_1=False, write_2=False)` | `None` | Public API access levels |
| `save_openid(enabled, identity_url="", server_url="")` | `None` | Site-wide OpenID login config |
| `request_backup(backup_sources=False, backup_files=False, backup_type="zip")` | `None` | Request a site backup |
| `delete_backup(*, confirm, backup_sources=False, backup_files=False, backup_type="zip")` | `None` | Destructive; raises `ValueError` unless `confirm=True` |
| `delete_favicon()` / `set_favicon_from_uri(uri)` | `None` | Favicon |
| `delete_ios_icon()` / `set_ios_icon_from_uri(uri)` | `None` | iOS home screen icon |
| `delete_windows_icon()` / `set_windows_icon_from_uri(uri)` / `set_windows_icon_background_color(color)` | `None` | Windows tile icon |
| `preview_newsletter(title, content)` | `tuple[str, str]` | Rendered `(title, content)` preview |
| `send_newsletter(title, content, admins=False, moderators=False, members=False, others=None)` | `None` | Send a newsletter; `others` is a list of extra `AbstractUser \| int` recipients |

### Usage

```python
# Read-modify-write a single field
current = site.settings.get_general()
site.settings.save_general(subtitle="New subtitle")  # other fields kept

# Categories-backed: explicit page permissions for a category
from wikidot.module.site_permissions import PagePermissions

site.settings.set_page_permissions(
    "_default",
    PagePermissions(view={"anonymous", "registered", "member"}, edit={"member"}),
)

# Access policy — privacy is required if it can't be read back
site.settings.save_access_policy(privacy="closed", by_apply=True)
```

---

## Site Categories & Permissions

Types behind the `categories` read-modify-write cycle shared by seven
Manage Site areas. See `wikidot.module.site_category` /
`wikidot.module.site_permissions`.

### SiteCategory / SiteCategoryCollection

`SiteCategoryCollection` is the full `categories` array for a site, keyed by
name; never cached (a new one is fetched on every
`update_categories` call). `collection[name]` looks up a `SiteCategory` by
name (raises `KeyError` if missing); `collection.names()` lists all names.

`SiteCategory` holds the 24-field per-category schema (`permissions`,
`license_id`/`license_other`, `top_bar_page_name`/`side_bar_page_name`,
`template_id`, `per_page_discussion`, `rating`, `autonumerate`,
`enable_pingback_out`/`enable_pingback_in`, theme fields, and each area's
`*_default` inherit flag). `category.set_permissions(*, view=None,
create=None, edit=None, ...)` updates only the specified fields and clears
`permissions_default`.

### PagePermissions / ForumPermissions

Decoded form of Wikidot's compact `permissions` strings (e.g.
`"v:armo;c:m;..."`). Frozen dataclasses of `frozenset[Actor]` per
permission, where `Actor = Literal["anonymous", "registered", "member",
"author"]`.

| Method | Return Type | Description |
|--------|-------------|-------------|
| `PagePermissions.decode(s)` / `.encode()` | `PagePermissions` / `str` | Round-trip the category `permissions` string (9 fields: view/create/edit/move/delete/upload_files/rename_files/replace_files/show_options) |
| `.validate()` | `list[str]` | Check the anonymous⊂registered⊂member containment convention (not auto-enforced) |
| `ForumPermissions.decode(s)` / `.encode()` | `ForumPermissions` / `str` | Round-trip a forum category's `permissions` string (create_threads/add_posts/edit_posts) — a distinct encoding from `PagePermissions` |
| `replace_actors(permissions, **updates)` | `PagePermissions` | Copy of `permissions` with only the given fields replaced |

Unrecognized segments round-trip verbatim through an internal `_unknown`
field rather than being dropped.

### RatingSettings / SiteLicense

`RatingSettings` decodes a category's 4-character `rating` code (e.g.
`"drvM"`): `enabled: bool`, `voters: Literal["registered", "member"]`,
`anonymous: bool`, `kind: Literal["plus_only", "plus_minus", "stars"]`.
`RatingSettings.decode(s)` raises `ValueError` on an unrecognized code
(unlike Page/ForumPermissions, no unknown-but-real variant exists here).

`SiteLicense` is an `Enum` of the 15 known `license_id` values (e.g.
`SiteLicense.CC_ATTRIBUTION_SHAREALIKE_3_0`, `SiteLicense.OTHER`).

---

## Site Member Administration

Site-admin operations distinct from the public-facing `site.members` /
`site.moderators` / `site.admins` properties. Access through `site.member`
(singular, `MemberAccessor`).

### Admin-view Listings

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_members()` / `get_moderators()` / `get_admins()` | `list[SiteMember]` | Admin-panel view (distinct module from the public `site.members` etc.) |

### Membership / Ownership

| Method | Return Type | Description |
|--------|-------------|-------------|
| `remove(user, *, ban=False)` | `None` | Remove a member. `ban=True` removes **and** blocks in one call — Wikidot's "remove and ban" combined flow |
| `change_master(user)` | `None` | Transfer master-admin ownership. Destructive from the caller's side — the caller loses master status |
| `get_moderator_permissions_form(moderator_id)` | `dict[str, Any]` | `{"body": <raw HTML>}` — **未実測** field names, inspect and pass to `save_moderator_permissions` |
| `save_moderator_permissions(**fields)` | `None` | Save raw fields verbatim (unvalidated, unlike the typed `site.settings.*` methods) |

### Invitations

| Method | Return Type | Description |
|--------|-------------|-------------|
| `search_users(query)` | `list[UserSearchResult]` | Search users to invite |
| `send_email_invitations(addresses, message="")` | `None` | `addresses`: list of `(email, name, is_contact)` tuples |
| `delete_email_invitation(invitation_id)` / `resend_email_invitation(invitation_id, message="")` | `None` | Manage a pending email invitation |
| `set_let_users_invite(enabled)` | `None` | Allow/disallow regular members to invite others via email |
| `invite_admin(user)` | `int \| None` | Invite a user to become a site admin |

### User / IP Blocks

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_blocked_users()` | `list[UserBlock]` | Blocked users |
| `get_blocked_ips()` | `list[IpBlock]` | Blocked IP addresses/ranges |
| `block_user(user, reason="")` / `unblock_user(user)` | `None` | `unblock_user` takes a user ID (`userId`), not a block ID |
| `block_ip(ips, reason="")` / `unblock_ip(block_id)` | `None` | `unblock_ip` takes a block ID (`blockId`, from `get_blocked_ips()`), not an IP — asymmetric with `unblock_user` |

### Abuse Flags / Misc

| Method | Return Type | Description |
|--------|-------------|-------------|
| `clear_user_flags(user)` / `clear_page_flags(path)` / `clear_anonymous_flags(address, proxy=False)` | `None` | Clear reported abuse flags |
| `set_members_watching(watch_all=False, selected_categories=None)` | `None` | Configure members' automatic watching of new pages |
| `set_block_link(karma_level, block_link=False)` | `None` | Configure automatic link-blocking by karma level (0-5) |

`UserSearchResult` (`id`, `name`), `UserBlock` (`site`, `user`, `reason`),
`IpBlock` (`site`, `block_id`, `ip`, `reason`) are the row types above.
Both block user/ID and `AbstractUser | int` arguments are accepted
throughout this accessor.

### Usage

```python
# Remove and ban a member
member = site.members[0]
site.member.remove(member.user, ban=True)

# Invite an admin, block an abusive IP
site.member.invite_admin(some_user)
site.member.block_ip("203.0.113.0/24", reason="spam")

for blocked in site.member.get_blocked_users():
    print(blocked.user.name, blocked.reason)
```

---

## Site Tools

Site-wide tooling views. Access through `site.tools`
(`SiteToolsAccessor`). Most methods return raw rendered HTML (markup was
not captured during wire-format research) rather than a parsed structure.

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_overview()` | `str` | Site Tools overview page |
| `get_wanted_pages(page=None, embed=False)` | `str` | Wanted Pages list |
| `get_orphaned_pages()` | `str` | Orphaned Pages list |
| `get_drafts()` | `str` | Drafts list, scoped to Site Tools |
| `get_categories()` | `str` | Category list for `manage:listpages` |
| `expand_category(category_id, include_hidden=False)` | `str` | Page list for a single category |
| `get_recent_changes(category_id=None, page_id=None, options=None, perpage=20, page_no=1)` | `list[SiteChange]` | Server-side filtered recent changes. `options` keys (8): `"all"`/`"source"`/`"title"`/`"tags"`/`"move"`/`"files"`/`"new"`/`"meta"` — differs from page-history's 7 (no `"new"` there) |

### Usage

```python
changes = site.tools.get_recent_changes(options={"new": True, "move": True}, perpage=100)
for change in changes:
    print(change.page_fullname, change.flags)
```

---

## SiteApplication

Membership application operations (login required).

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `site` | `Site` | Parent site |
| `user` | `User` | Applicant |
| `text` | `str` | Application text |
| `created_at` | `datetime` | Application date |

### Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `accept()` | `None` | Accept application |
| `decline()` | `None` | Decline application |

### Usage

```python
# Process applications (login required)
for application in site.applications:
    print(f"Applicant: {application.user.name}")
    print(f"Text: {application.text}")

    application.accept()   # Accept
    # application.decline()  # Or decline
```

---

## PrivateMessage

Private message operations (login required).

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `client` | `Client` | Parent client |
| `id` | `int` | Message ID |
| `sender` | `AbstractUser` | Sender |
| `recipient` | `AbstractUser` | Recipient |
| `subject` | `str` | Subject line |
| `body` | `str` | Message body |
| `created_at` | `datetime` | Send date |

### Accessor Methods

| Method | Return Type | Description |
|--------|-------------|-------------|
| `client.private_message.inbox` | `PrivateMessageInbox` | Get inbox (property) |
| `client.private_message.sentbox` | `PrivateMessageSentBox` | Get sent box (property) |
| `client.private_message.get_message(id)` | `PrivateMessage` | Get message by ID |
| `client.private_message.get_messages(ids)` | `PrivateMessageCollection` | Get multiple messages |
| `client.private_message.send(recipient, subject, body)` | `None` | Send message |
| `client.private_message.save_draft(subject, body, recipient=None)` | `None` | Save a message draft |
| `client.private_message.check_can_send(user)` | `None` | Raises `ForbiddenException` if sending to `user` is not allowed |
| `client.private_message.preview(subject, body, recipient=None)` | `str` | Render a preview without sending |
| `client.private_message.fetch_reply_form_html(reply_message_id)` | `str` | Pre-filled "new message" form HTML for a reply |
| `client.private_message.get_invitations_html(page=1)` / `get_invitation_detail_html(item)` | `str` | Pending site invitations (raw HTML) |
| `client.private_message.get_applications()` | `list[SiteJoinApplication]` | The account's own pending outgoing site-join applications |
| `client.private_message.get_application_detail_html(item)` | `str` | Detail HTML of a single application |
| `client.private_message.get_contacts()` | `list[Contact]` | Contact list |
| `client.private_message.get_contacts_list_html()` | `str` | Contact picker HTML used when composing a message |
| `client.private_message.add_contact(user)` / `remove_contact(user)` | `None` | Manage the contact list (`ContactsAction`) |
| `client.private_message.add_contact_via_profile(user)` | `str` | Add a contact from the user's profile page (a second, module-render-as-action path); returns raw HTML |

### Partial success (`failures`)

`get_messages(ids)` / `inbox` / `sentbox` follow a partial-success
contract: a message that fails to fetch (per-request transport error,
missing body, unparseable markup) is skipped instead of failing the whole
call. The returned collection carries the skipped IDs as
`failures: list[PrivateMessageFetchFailure]` (`id: int`, `error: Exception`),
in request order; successful messages keep the input ID order. If every
message fails, the return value is still a normal (empty) collection with
all IDs in `failures`. Only systemic failures (login missing, listing-page
fetch) raise. Callers that persist or delete fetched messages must check
`failures` to know the fetch was partial.

```python
inbox = client.private_message.inbox
for failure in inbox.failures:
    print(f"Message {failure.id} could not be fetched: {failure.error}")
```

### SiteJoinApplication / Contact

`SiteJoinApplication` (`item_id`, `from_site`, `subject`, `preview`,
`submitted_at`) is the account's own outgoing application to a site;
distinct from `SiteApplication` (a site admin's view of *incoming*
applications). It has no `site_id`, so withdrawing one goes through
`client.site.remove_application(site_id)` with a `site_id` obtained
elsewhere, not through this object. `fetch_detail_html()` fetches its own
detail HTML.

`Contact` (`user`) wraps one contact-list entry; `contact.remove()` removes
it.

### Usage

```python
# Send PM
user = client.user.get("target-user")
client.private_message.send(
    recipient=user,
    subject="Hello",
    body="This is a message."
)

# Inbox
for message in client.private_message.inbox:
    print(f"From: {message.sender.name}")
    print(f"Subject: {message.subject}")
    print(f"Date: {message.created_at}")

# Sent box
for message in client.private_message.sentbox:
    print(f"To: {message.recipient.name}")
    print(f"Subject: {message.subject}")

# Get specific message
message = client.private_message.get_message(12345)
print(message.body)
```

---

## Account

The logged-in user's Wikidot account (`www.wikidot.com/account/settings`,
`/account/recent`), distinct from any single site. Access through
`client.account` (`ClientAccountAccessor`), which exposes `.settings`
(`AccountSettings`), `.profile` (`AccountProfile`), and `.recent`
(`AccountRecentActivity`). All requests go to `www.wikidot.com`, never a
site's own host.

### AccountSettings (`client.account.settings`)

| Method | Return Type | Description |
|--------|-------------|-------------|
| `set_receive_messages(from_)` | `None` | `from_`: `"a"` (all registered), `"mf"` (co-members+contacts), `"f"` (contacts only), `"n"` (nobody) |
| `block_user(user)` / `unblock_user(user)` | `None` | Private-message block list (lives here, not on `ClientPrivateMessageAccessor` — Wikidot groups all `DashboardSettingsAction` together) |
| `start_email_change(email)` / `confirm_email_change(evercode)` | `None` | Two-step email change flow |
| `change_password(old_password, new_password)` | `None` | Change account password |
| `set_language(language)` | `None` | Account UI language |
| `set_receive_digest(receive)` / `set_receive_newsletter(receive)` / `set_receive_invitations(receive)` | `None` | Subscription toggles |
| `set_toolbars(top=False, bottom=False)` | `None` | Account-wide editor toolbar preference (distinct from `site.settings.save_toolbars_preference`, a per-site same-named event) |
| `generate_api_key(read_only=False)` | `str` | Regenerate the account's API key |
| `connect_facebook(fb_user)` / `disconnect_facebook()` | `dict[str, Any]` / `None` | Link/unlink Facebook |
| `get_account_html()` / `get_about_html()` / `get_forum_signature_html()` / `get_toolbars_html()` / `get_newsletter_html()` / `get_messages_html()` / `get_invitations_html()` / `get_facebook_html()` / `get_visibility_html()` / `get_api_html()` | `str` | Raw HTML of each `/account/settings` dashboard tab |

All methods raise `LoginRequiredException` if not logged in; the save/change
methods raise `FormErrorsException` on validation failure.

### AccountProfile (`client.account.profile`)

| Method | Return Type | Description |
|--------|-------------|-------------|
| `change_screen_name(screen_name)` | `None` | Change the display name |
| `save_about(real_name="", gender=None, birthday_day="", birthday_month="", birthday_year="", about="", website="", im_aim="", im_gadu_gadu="", im_google_talk="", im_icq="", im_jabber="", im_msn="", im_yahoo="", location="")` | `None` | Save the "about" bio section. `about` is capped at 200 chars server-side |
| `save_forum_signature(source)` | `None` | Save forum post signature (capped at 400 chars server-side) |
| `preview_forum_signature(source)` | `str` | Render a signature preview without saving |
| `save_profile_visibility(raw_fields)` | `None` | **Unmeasured** field names (observed "no_permission" on non-Pro accounts) — pass exact form fields |
| `delete_avatar()` / `upload_avatar_from_uri(uri)` | `None` / `dict[str, Any]` | Manage avatar |

### AccountRecentActivity (`client.account.recent`)

| Method | Return Type | Description |
|--------|-------------|-------------|
| `get_changes(options=None, limit=None)` | `list[UserChange]` | The account's own recent page edits, across every site it belongs to. `options` keys: `"all"`/`"source"`/`"title"`/`"move"`/`"files"`/`"new"`/`"meta"` (no `"tags"`, unlike page-history options) — raises `ValueError` on an unknown key |
| `get_posts(limit=None)` | `list[RecentPost]` | The account's own recent forum posts, across every site |

`UserChange` (`site_title`, `site_url`, `page_fullname`, `page_title`,
`revision_no`, `changed_at`, `flags`) mirrors `SiteChange` with an added
site column. `RecentPost` (`title`, `url`, `created_at`, `content`) is one
recent post.

### Usage

```python
# Account settings
client.account.settings.set_language("ja")
client.account.settings.change_password("old-pw", "new-pw")
new_key = client.account.settings.generate_api_key()

# Profile
client.account.profile.change_screen_name("New Name")
client.account.profile.save_about(about="Bio text", website="https://example.com")

# Cross-site recent activity
for change in client.account.recent.get_changes(limit=50):
    print(f"[{change.site_title}] {change.page_fullname} rev.{change.revision_no}")
for post in client.account.recent.get_posts(limit=20):
    print(post.title, post.created_at)
```

---

## Dashboard Sites

The account's relationship to sites — creating a site, listing sites it
belongs to, invitations, resigning a role — as distinct from `Site`, which
represents a site's own state independent of any account. Access through
`client.site` alongside the existing `get()` method (`ClientSiteAccessor`).

| Method | Return Type | Description |
|--------|-------------|-------------|
| `client.site.create(name, unixname, subtitle="", language="en", template="standard-template", privacy="open", tos=True)` | `str` | Create a new site; returns its unix name. Raises `FormErrorsException` (e.g. unixname taken) |
| `client.site.my_sites` | `list[DashboardSite]` | Every site the account belongs to (all roles), plus deleted sites |
| `client.site.accept_invitation(invitation_id)` / `throw_away_invitation(invitation_id)` | `None` | Accept or discard a pending site invitation |
| `client.site.remove_application(site_id)` | `None` | Withdraw a pending membership application the account submitted |
| `client.site.restore_site(site_id, confirm_site_name)` | `None` | Restore a deleted site the account administers (typed-name confirmation) |
| `client.site.resign_as_admin(site_id)` / `resign_as_moderator(site_id)` / `sign_off_as_member(site_id)` | `None` | Give up a role on a site |
| `client.site.set_site_storage_limit(site_id, raw_fields)` | `None` | **Unmeasured** field names — pass exact form fields |

`DashboardSite` (one row of the account's site listing: `site_id`, `title`,
`url`, `unix_name`, `tagline`, `activity`, `role`, `deleted`) also exposes
the per-site operations above as instance methods (`site.restore(...)`,
`site.resign_as_admin()`, `site.resign_as_moderator()`,
`site.sign_off_as_member()`, `site.set_storage_limit(...)`) — equivalent
convenience wrappers over the `client.site.*` functions.

`NewSiteTemplate` = `"standard-template" | "blog-template" |
"blank-template" | "default-template" | "notebooks"`.
`NewSitePrivacy` = `"open" | "closed" | "private"`.

### Usage

```python
# Create a new site
unixname = client.site.create("My Site", "my-new-site", template="blank-template")

# List and manage account's sites
for dash_site in client.site.my_sites:
    print(f"{dash_site.title} ({dash_site.role}){' [deleted]' if dash_site.deleted else ''}")
    if dash_site.deleted:
        dash_site.restore(confirm_site_name=dash_site.title)
```

---

## Exception Handling

### Exception Hierarchy

```
WikidotException (base)
├── UnexpectedException           # Internal inconsistency or bug
├── SessionCreateException        # Login failed
├── LoginRequiredException        # Operation requires login
├── AjaxModuleConnectorException  # AMC base exception
│   ├── AMCHttpStatusCodeException  # HTTP status error (e.g., 404, 500)
│   ├── WikidotStatusCodeException  # Wikidot API status error
│   │   └── FormErrorsException     # Validation failure (status "form_errors"/"form_error")
│   └── ResponseDataException       # Response parsing failed
├── NotFoundException             # Resource not found
├── TargetExistsException         # Resource already exists
├── TargetErrorException          # Resource in invalid state
├── ForbiddenException            # Access denied
└── NoElementException            # HTML element not found
```

### Exception Properties

| Exception | Properties | Description |
|-----------|------------|-------------|
| `AMCHttpStatusCodeException` | `status_code: int` | HTTP status code |
| `WikidotStatusCodeException` | `status_code: str`, `response: dict \| None` | Wikidot status, raw AMC response body |
| `FormErrorsException` | `errors: dict[str, str]` (property) | Field name → error message, absorbing the `formErrors`/`errors`/`message` key variance across modules (see "AMC Transport") |

### Usage

```python
from wikidot.common.exceptions import (
    NotFoundException,
    LoginRequiredException,
    SessionCreateException
)

# Handle login failure
try:
    client = wikidot.Client(username="user", password="wrong")
except SessionCreateException:
    print("Login failed")

# Handle missing page
try:
    page = site.page.get("nonexistent")
except NotFoundException:
    print("Page not found")

# Handle login required
try:
    page.edit(source="new content")
except LoginRequiredException:
    print("Please login first")
```

---

## Directory Structure

```
src/wikidot/
├── __init__.py                   # Package entry point
├── common/
│   ├── decorators.py             # @login_required decorator
│   └── exceptions.py             # Exception definitions (incl. FormErrorsException)
├── connector/
│   └── ajax.py                   # AjaxModuleConnectorClient/Config
├── module/
│   ├── client.py                 # Client class and accessors
│   ├── site.py                   # Site class and accessors
│   ├── site_settings.py          # SiteSettingsAccessor, General/Domain/AccessPolicySettings
│   ├── site_category.py          # SiteCategory, SiteCategoryCollection, SiteLicense
│   ├── site_permissions.py       # PagePermissions, ForumPermissions, RatingSettings
│   ├── site_member_admin.py      # MemberAccessor
│   ├── site_block.py             # UserBlock, IpBlock
│   ├── site_tools.py             # SiteToolsAccessor
│   ├── forum_admin.py            # ForumLayout, ForumCategoryPermissionsCollection
│   ├── page.py                   # Page, PageCollection, SearchPagesQuery
│   ├── page_edit_session.py      # PageEditSession
│   ├── page_source.py            # PageSource
│   ├── page_revision.py          # PageRevision, PageRevisionCollection
│   ├── page_votes.py             # PageVote, PageVoteCollection
│   ├── page_file.py              # PageFile, PageFileCollection
│   ├── forum_category.py         # ForumCategory, ForumCategoryCollection
│   ├── forum_thread.py           # ForumThread, ForumThreadCollection
│   ├── forum_post.py             # ForumPost, ForumPostCollection
│   ├── forum_post_revision.py    # ForumPostRevision, ForumPostRevisionCollection
│   ├── user.py                   # User hierarchy
│   ├── private_message.py        # PrivateMessage, SiteJoinApplication, Contact
│   ├── site_member.py            # SiteMember
│   ├── site_application.py       # SiteApplication
│   ├── account.py                # AccountSettings, AccountProfile, AccountRecentActivity
│   ├── dashboard_site.py         # DashboardSite, DashboardSites
│   └── auth.py                   # Authentication handling
└── util/
    ├── amc_body.py                # checkbox/flag/json_param/omit_falsy request-body helpers
    ├── parser/                   # HTML parsers
    ├── quick_module.py           # QuickModule API
    └── string.py                 # String utilities
```

---

## Recipes

### Get Top Rated Pages

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    pages = site.pages.search(
        category="scp",
        order="rating desc",
        limit=10
    )
    for page in pages:
        print(f"{page.fullname}: +{page.rating}")
```

### Find Pages by Tag

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    pages = site.pages.search(
        tags=["safe", "+scp", "-explained"],
        rating=">100"
    )
    for page in pages:
        print(f"{page.fullname}: {page.tags}")
```

### Get Page History

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    page = site.page.get("scp-173")

    for rev in page.revisions:
        print(f"Rev {rev.rev_no}: {rev.comment} by {rev.created_by.name}")
```

### Batch Page Operations

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    pages = site.pages.search(category="scp", limit=100)

    # Bulk fetch all data efficiently
    pages.get_page_ids()
    pages.get_page_sources()
    pages.get_page_votes()

    for page in pages:
        print(f"{page.fullname}: {len(page.source.wiki_text)} chars")
```

### Discussion Thread Access

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")
    page = site.page.get("scp-173")

    if page.discussion:
        thread = page.discussion
        print(f"Comments: {thread.post_count}")

        for post in thread.posts:
            print(f"{post.created_by.name}: {post.title}")
```

### Create and Edit Page

```python
with wikidot.Client(username="user", password="pass") as client:
    site = client.site.get("sandbox")

    # Create new page
    page = site.page.create(
        fullname="test:my-page",
        title="Test Page",
        source="[[=]]\n++ Welcome\n[[/=]]",
        comment="Initial creation"
    )

    # Edit page
    page = page.edit(
        source="[[=]]\n++ Updated Content\n[[/=]]",
        comment="Updated"
    )

    # Add tags
    page.tags.append("test")
    page.tags.append("wip")
    page = page.commit_tags()

    # Delete page
    page.destroy()
```

### Forum Operations

```python
with wikidot.Client(username="user", password="pass") as client:
    site = client.site.get("scp-jp")

    # Get forum categories
    categories = site.forum.categories
    category = categories.find(123)

    if category:
        # Create new thread
        thread = category.create_thread(
            title="New Discussion",
            description="Description of the topic",
            source="First post content here."
        )
        print(f"Created: {thread.url}")

        # Reply to thread
        thread.reply(
            source="Great topic!",
            title="Re: New Discussion"
        )
```

### Process Membership Applications

```python
with wikidot.Client(username="admin", password="pass") as client:
    site = client.site.get("my-site")

    for application in site.applications:
        print(f"Applicant: {application.user.name}")
        print(f"Message: {application.text}")

        # Accept or decline
        application.accept()
```

### Monitor Recent Changes

```python
with wikidot.Client() as client:
    site = client.site.get("scp-jp")

    changes = site.get_recent_changes(limit=50)
    for change in changes:
        flags = "".join(change.flags)
        print(f"[{flags}] {change.page_fullname}")
        print(f"  Rev {change.revision_no} by {change.changed_by.name}")
        print(f"  {change.changed_at}: {change.comment or '(no comment)'}")
```

### Manual Edit Session with Preview

```python
with wikidot.Client(username="user", password="pass") as client:
    site = client.site.get("scp-jp")
    page = site.page.get("scp-173")

    with page.open_editor() as ed:
        preview = ed.preview(source="[[=]]\n++ New content\n[[/=]]")
        print(preview["body"])

        diff_html = ed.diff(source="[[=]]\n++ New content\n[[/=]]")
        print(diff_html)

        ed.save(source="[[=]]\n++ New content\n[[/=]]", comment="Rewrite intro")
```

### Configure Site Permissions and Invite an Admin

```python
with wikidot.Client(username="admin", password="pass") as client:
    site = client.site.get("my-site")

    # Grant registered users edit access on the default category
    from wikidot.module.site_permissions import PagePermissions

    site.settings.set_page_permissions(
        "_default",
        PagePermissions(view={"anonymous", "registered", "member"}, edit={"registered", "member"}),
    )

    # Search for and invite a new admin
    candidates = site.member.search_users("some-user")
    if candidates:
        site.member.invite_admin(candidates[0].id)
```

---

## Reference Links

- Official Documentation: https://ukwhatn.github.io/wikidot.py/
- Repository: https://github.com/ukwhatn/wikidot.py
- PyPI: https://pypi.org/project/wikidot/
- TypeScript version: https://github.com/ukwhatn/wikidot-ts
