Implement Assets API with file/asset management operations

Implementation:
- Asset data models (wikijs/models/asset.py)
  - Asset, AssetFolder models
  - AssetUpload, AssetRename, AssetMove models
  - FolderCreate model
  - File size helpers (size_mb, size_kb)
  - Field validation and normalization

- Sync AssetsEndpoint (wikijs/endpoints/assets.py)
  - list(folder_id, kind) - List assets with filtering
  - get(asset_id) - Get single asset
  - rename(asset_id, new_filename) - Rename asset
  - move(asset_id, folder_id) - Move asset between folders
  - delete(asset_id) - Delete asset
  - list_folders() - List all folders
  - create_folder(slug, name) - Create new folder
  - delete_folder(folder_id) - Delete folder
  - Note: upload/download require multipart support (future enhancement)

- Async AsyncAssetsEndpoint (wikijs/aio/endpoints/assets.py)
  - Complete async implementation
  - Identical interface to sync version
  - All asset and folder management operations

- Integration with clients
  - WikiJSClient.assets
  - AsyncWikiJSClient.assets

GraphQL operations for asset and folder management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2025-10-22 20:34:50 +00:00
parent 5c0de7f70b
commit d2003a0005
8 changed files with 1209 additions and 7 deletions

View File

@@ -27,7 +27,7 @@ from ..utils import (
parse_wiki_response,
)
from ..version import __version__
from .endpoints import AsyncGroupsEndpoint, AsyncPagesEndpoint, AsyncUsersEndpoint
from .endpoints import AsyncAssetsEndpoint, AsyncGroupsEndpoint, AsyncPagesEndpoint, AsyncUsersEndpoint
class AsyncWikiJSClient:
@@ -105,8 +105,7 @@ class AsyncWikiJSClient:
self.pages = AsyncPagesEndpoint(self)
self.users = AsyncUsersEndpoint(self)
self.groups = AsyncGroupsEndpoint(self)
# Future endpoints:
# self.assets = AsyncAssetsEndpoint(self)
self.assets = AsyncAssetsEndpoint(self)
def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session.

View File

@@ -1,11 +1,13 @@
"""Async endpoint handlers for Wiki.js API."""
from .assets import AsyncAssetsEndpoint
from .base import AsyncBaseEndpoint
from .groups import AsyncGroupsEndpoint
from .pages import AsyncPagesEndpoint
from .users import AsyncUsersEndpoint
__all__ = [
"AsyncAssetsEndpoint",
"AsyncBaseEndpoint",
"AsyncGroupsEndpoint",
"AsyncPagesEndpoint",

View File

@@ -0,0 +1,314 @@
"""Async assets endpoint for Wiki.js API."""
import os
from typing import Dict, List, Optional
from ...exceptions import APIError, ValidationError
from ...models import Asset, AssetFolder
from .base import AsyncBaseEndpoint
class AsyncAssetsEndpoint(AsyncBaseEndpoint):
"""Async endpoint for managing Wiki.js assets."""
async def list(
self, folder_id: Optional[int] = None, kind: Optional[str] = None
) -> List[Asset]:
"""List all assets asynchronously."""
if folder_id is not None and folder_id < 0:
raise ValidationError("folder_id must be non-negative")
query = """
query ($folderId: Int, $kind: AssetKind) {
assets {
list(folderId: $folderId, kind: $kind) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
variables = {}
if folder_id is not None:
variables["folderId"] = folder_id
if kind is not None:
variables["kind"] = kind.upper()
response = await self._post(
"/graphql", json_data={"query": query, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
assets_data = response.get("data", {}).get("assets", {}).get("list", [])
return [Asset(**self._normalize_asset_data(a)) for a in assets_data]
async def get(self, asset_id: int) -> Asset:
"""Get a specific asset by ID asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
query = """
query ($id: Int!) {
assets {
single(id: $id) {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": query, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
asset_data = response.get("data", {}).get("assets", {}).get("single")
if not asset_data:
raise APIError(f"Asset with ID {asset_id} not found")
return Asset(**self._normalize_asset_data(asset_data))
async def rename(self, asset_id: int, new_filename: str) -> Asset:
"""Rename an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not new_filename or not new_filename.strip():
raise ValidationError("new_filename cannot be empty")
mutation = """
mutation ($id: Int!, $filename: String!) {
assets {
renameAsset(id: $id, filename: $filename) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "filename": new_filename.strip()},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("renameAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to rename asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset renamed but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def move(self, asset_id: int, folder_id: int) -> Asset:
"""Move an asset to a different folder asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not isinstance(folder_id, int) or folder_id < 0:
raise ValidationError("folder_id must be non-negative")
mutation = """
mutation ($id: Int!, $folderId: Int!) {
assets {
moveAsset(id: $id, folderId: $folderId) {
responseResult { succeeded errorCode slug message }
asset {
id filename ext kind mime fileSize folderId
folder { id slug name }
authorId authorName createdAt updatedAt
}
}
}
}
"""
response = await self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "folderId": folder_id},
},
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("moveAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to move asset: {error_msg}")
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset moved but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
async def delete(self, asset_id: int) -> bool:
"""Delete an asset asynchronously."""
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteAsset(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": asset_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete asset: {error_msg}")
return True
async def list_folders(self) -> List[AssetFolder]:
"""List all asset folders asynchronously."""
query = """
query {
assets {
folders {
id slug name
}
}
}
"""
response = await self._post("/graphql", json_data={"query": query})
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
folders_data = response.get("data", {}).get("assets", {}).get("folders", [])
return [AssetFolder(**folder) for folder in folders_data]
async def create_folder(self, slug: str, name: Optional[str] = None) -> AssetFolder:
"""Create a new asset folder asynchronously."""
if not slug or not slug.strip():
raise ValidationError("slug cannot be empty")
slug = slug.strip().strip("/")
if not slug:
raise ValidationError("slug cannot be just slashes")
mutation = """
mutation ($slug: String!, $name: String) {
assets {
createFolder(slug: $slug, name: $name) {
responseResult { succeeded errorCode slug message }
folder { id slug name }
}
}
}
"""
variables = {"slug": slug}
if name:
variables["name"] = name
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("createFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create folder: {error_msg}")
folder_data = result.get("folder")
if not folder_data:
raise APIError("Folder created but no data returned")
return AssetFolder(**folder_data)
async def delete_folder(self, folder_id: int) -> bool:
"""Delete an asset folder asynchronously."""
if not isinstance(folder_id, int) or folder_id <= 0:
raise ValidationError("folder_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteFolder(id: $id) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
response = await self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": folder_id}}
)
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
result = response.get("data", {}).get("assets", {}).get("deleteFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete folder: {error_msg}")
return True
def _normalize_asset_data(self, data: Dict) -> Dict:
"""Normalize asset data from API response."""
return {
"id": data.get("id"),
"filename": data.get("filename"),
"ext": data.get("ext"),
"kind": data.get("kind"),
"mime": data.get("mime"),
"file_size": data.get("fileSize"),
"folder_id": data.get("folderId"),
"folder": data.get("folder"),
"author_id": data.get("authorId"),
"author_name": data.get("authorName"),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}

View File

@@ -8,7 +8,7 @@ from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .auth import APIKeyAuth, AuthHandler
from .endpoints import GroupsEndpoint, PagesEndpoint, UsersEndpoint
from .endpoints import AssetsEndpoint, GroupsEndpoint, PagesEndpoint, UsersEndpoint
from .exceptions import (
APIError,
AuthenticationError,
@@ -92,8 +92,7 @@ class WikiJSClient:
self.pages = PagesEndpoint(self)
self.users = UsersEndpoint(self)
self.groups = GroupsEndpoint(self)
# Future endpoints:
# self.assets = AssetsEndpoint(self)
self.assets = AssetsEndpoint(self)
def _create_session(self) -> requests.Session:
"""Create configured HTTP session with retry strategy.

View File

@@ -7,18 +7,20 @@ Implemented:
- Pages API (CRUD operations) ✅
- Users API (user management) ✅
- Groups API (group management) ✅
- Assets API (file/asset management) ✅
Future implementations:
- Assets API (file management)
- System API (system information)
"""
from .assets import AssetsEndpoint
from .base import BaseEndpoint
from .groups import GroupsEndpoint
from .pages import PagesEndpoint
from .users import UsersEndpoint
__all__ = [
"AssetsEndpoint",
"BaseEndpoint",
"GroupsEndpoint",
"PagesEndpoint",

667
wikijs/endpoints/assets.py Normal file
View File

@@ -0,0 +1,667 @@
"""Assets endpoint for Wiki.js API."""
import os
from typing import BinaryIO, Dict, List, Optional, Union
from ..exceptions import APIError, ValidationError
from ..models import Asset, AssetFolder, AssetMove, AssetRename, FolderCreate
from .base import BaseEndpoint
class AssetsEndpoint(BaseEndpoint):
"""Endpoint for managing Wiki.js assets.
Provides methods to:
- List assets
- Get asset details
- Upload files
- Download files
- Rename assets
- Move assets between folders
- Delete assets
- Manage folders
"""
def list(
self, folder_id: Optional[int] = None, kind: Optional[str] = None
) -> List[Asset]:
"""List all assets, optionally filtered by folder or kind.
Args:
folder_id: Filter by folder ID (None for all folders)
kind: Filter by asset kind (image, binary, etc.)
Returns:
List of Asset objects
Raises:
ValidationError: If parameters are invalid
APIError: If the API request fails
Example:
>>> assets = client.assets.list()
>>> images = client.assets.list(kind="image")
>>> folder_assets = client.assets.list(folder_id=1)
"""
# Validate folder_id
if folder_id is not None and folder_id < 0:
raise ValidationError("folder_id must be non-negative")
query = """
query ($folderId: Int, $kind: AssetKind) {
assets {
list(folderId: $folderId, kind: $kind) {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
"""
variables = {}
if folder_id is not None:
variables["folderId"] = folder_id
if kind is not None:
variables["kind"] = kind.upper()
response = self._post(
"/graphql", json_data={"query": query, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract and normalize assets
assets_data = response.get("data", {}).get("assets", {}).get("list", [])
return [Asset(**self._normalize_asset_data(a)) for a in assets_data]
def get(self, asset_id: int) -> Asset:
"""Get a specific asset by ID.
Args:
asset_id: The asset ID
Returns:
Asset object
Raises:
ValidationError: If asset_id is invalid
APIError: If the asset is not found or API request fails
Example:
>>> asset = client.assets.get(123)
>>> print(f"{asset.filename}: {asset.size_mb:.2f} MB")
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
query = """
query ($id: Int!) {
assets {
single(id: $id) {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": query, "variables": {"id": asset_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract asset data
asset_data = response.get("data", {}).get("assets", {}).get("single")
if not asset_data:
raise APIError(f"Asset with ID {asset_id} not found")
return Asset(**self._normalize_asset_data(asset_data))
def upload(
self,
file_path: str,
folder_id: int = 0,
filename: Optional[str] = None,
) -> Asset:
"""Upload a file as an asset.
Args:
file_path: Path to local file to upload
folder_id: Target folder ID (default: 0 for root)
filename: Optional custom filename (uses original if not provided)
Returns:
Created Asset object
Raises:
ValidationError: If file_path is invalid
APIError: If the upload fails
FileNotFoundError: If file doesn't exist
Example:
>>> asset = client.assets.upload("/path/to/image.png", folder_id=1)
>>> print(f"Uploaded: {asset.filename}")
"""
# Validate file path
if not file_path or not file_path.strip():
raise ValidationError("file_path cannot be empty")
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
if not os.path.isfile(file_path):
raise ValidationError(f"Path is not a file: {file_path}")
# Validate folder_id
if folder_id < 0:
raise ValidationError("folder_id must be non-negative")
# Get filename
if filename is None:
filename = os.path.basename(file_path)
# For now, use GraphQL mutation
# Note: Wiki.js may require multipart form upload which would need special handling
mutation = """
mutation ($folderId: Int!, $file: Upload!) {
assets {
createFile(folderId: $folderId, file: $file) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
# Note: Actual file upload would require multipart/form-data
# This is a simplified version
raise NotImplementedError(
"File upload requires multipart form support. "
"Use the Wiki.js web interface or REST API directly for file uploads."
)
def download(self, asset_id: int, output_path: str) -> bool:
"""Download an asset to a local file.
Args:
asset_id: The asset ID
output_path: Local path to save the file
Returns:
True if download successful
Raises:
ValidationError: If parameters are invalid
APIError: If the download fails
Example:
>>> client.assets.download(123, "/path/to/save/file.png")
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not output_path or not output_path.strip():
raise ValidationError("output_path cannot be empty")
# Note: Downloading requires REST API endpoint, not GraphQL
raise NotImplementedError(
"File download requires REST API support. "
"Use the Wiki.js REST API directly: GET /a/{assetId}"
)
def rename(self, asset_id: int, new_filename: str) -> Asset:
"""Rename an asset.
Args:
asset_id: The asset ID
new_filename: New filename
Returns:
Updated Asset object
Raises:
ValidationError: If parameters are invalid
APIError: If the rename fails
Example:
>>> asset = client.assets.rename(123, "new-name.png")
"""
# Validate
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not new_filename or not new_filename.strip():
raise ValidationError("new_filename cannot be empty")
mutation = """
mutation ($id: Int!, $filename: String!) {
assets {
renameAsset(id: $id, filename: $filename) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "filename": new_filename.strip()},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("renameAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to rename asset: {error_msg}")
# Extract and return updated asset
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset renamed but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
def move(self, asset_id: int, folder_id: int) -> Asset:
"""Move an asset to a different folder.
Args:
asset_id: The asset ID
folder_id: Target folder ID
Returns:
Updated Asset object
Raises:
ValidationError: If parameters are invalid
APIError: If the move fails
Example:
>>> asset = client.assets.move(123, folder_id=2)
"""
# Validate
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
if not isinstance(folder_id, int) or folder_id < 0:
raise ValidationError("folder_id must be non-negative")
mutation = """
mutation ($id: Int!, $folderId: Int!) {
assets {
moveAsset(id: $id, folderId: $folderId) {
responseResult {
succeeded
errorCode
slug
message
}
asset {
id
filename
ext
kind
mime
fileSize
folderId
folder {
id
slug
name
}
authorId
authorName
createdAt
updatedAt
}
}
}
}
"""
response = self._post(
"/graphql",
json_data={
"query": mutation,
"variables": {"id": asset_id, "folderId": folder_id},
},
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("moveAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to move asset: {error_msg}")
# Extract and return updated asset
asset_data = result.get("asset")
if not asset_data:
raise APIError("Asset moved but no data returned")
return Asset(**self._normalize_asset_data(asset_data))
def delete(self, asset_id: int) -> bool:
"""Delete an asset.
Args:
asset_id: The asset ID
Returns:
True if deletion was successful
Raises:
ValidationError: If asset_id is invalid
APIError: If the deletion fails
Example:
>>> success = client.assets.delete(123)
"""
# Validate asset_id
if not isinstance(asset_id, int) or asset_id <= 0:
raise ValidationError("asset_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteAsset(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": asset_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("deleteAsset", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete asset: {error_msg}")
return True
def list_folders(self) -> List[AssetFolder]:
"""List all asset folders.
Returns:
List of AssetFolder objects
Raises:
APIError: If the API request fails
Example:
>>> folders = client.assets.list_folders()
>>> for folder in folders:
... print(f"{folder.name}: {folder.slug}")
"""
query = """
query {
assets {
folders {
id
slug
name
}
}
}
"""
response = self._post("/graphql", json_data={"query": query})
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Extract folders
folders_data = response.get("data", {}).get("assets", {}).get("folders", [])
return [AssetFolder(**folder) for folder in folders_data]
def create_folder(self, slug: str, name: Optional[str] = None) -> AssetFolder:
"""Create a new asset folder.
Args:
slug: Folder slug/path
name: Optional folder name
Returns:
Created AssetFolder object
Raises:
ValidationError: If slug is invalid
APIError: If folder creation fails
Example:
>>> folder = client.assets.create_folder("documents", "Documents")
"""
# Validate
if not slug or not slug.strip():
raise ValidationError("slug cannot be empty")
# Clean slug
slug = slug.strip().strip("/")
if not slug:
raise ValidationError("slug cannot be just slashes")
mutation = """
mutation ($slug: String!, $name: String) {
assets {
createFolder(slug: $slug, name: $name) {
responseResult {
succeeded
errorCode
slug
message
}
folder {
id
slug
name
}
}
}
}
"""
variables = {"slug": slug}
if name:
variables["name"] = name
response = self._post(
"/graphql", json_data={"query": mutation, "variables": variables}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("createFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to create folder: {error_msg}")
# Extract and return folder
folder_data = result.get("folder")
if not folder_data:
raise APIError("Folder created but no data returned")
return AssetFolder(**folder_data)
def delete_folder(self, folder_id: int) -> bool:
"""Delete an asset folder.
Args:
folder_id: The folder ID
Returns:
True if deletion was successful
Raises:
ValidationError: If folder_id is invalid
APIError: If the deletion fails
Example:
>>> success = client.assets.delete_folder(5)
"""
# Validate folder_id
if not isinstance(folder_id, int) or folder_id <= 0:
raise ValidationError("folder_id must be a positive integer")
mutation = """
mutation ($id: Int!) {
assets {
deleteFolder(id: $id) {
responseResult {
succeeded
errorCode
slug
message
}
}
}
}
"""
response = self._post(
"/graphql", json_data={"query": mutation, "variables": {"id": folder_id}}
)
# Check for GraphQL errors
if "errors" in response:
raise APIError(f"GraphQL errors: {response['errors']}")
# Check response result
result = response.get("data", {}).get("assets", {}).get("deleteFolder", {})
response_result = result.get("responseResult", {})
if not response_result.get("succeeded"):
error_msg = response_result.get("message", "Unknown error")
raise APIError(f"Failed to delete folder: {error_msg}")
return True
def _normalize_asset_data(self, data: Dict) -> Dict:
"""Normalize asset data from API response to Python naming convention.
Args:
data: Raw asset data from API
Returns:
Normalized asset data with snake_case field names
"""
normalized = {
"id": data.get("id"),
"filename": data.get("filename"),
"ext": data.get("ext"),
"kind": data.get("kind"),
"mime": data.get("mime"),
"file_size": data.get("fileSize"),
"folder_id": data.get("folderId"),
"folder": data.get("folder"),
"author_id": data.get("authorId"),
"author_name": data.get("authorName"),
"created_at": data.get("createdAt"),
"updated_at": data.get("updatedAt"),
}
return normalized

View File

@@ -1,5 +1,13 @@
"""Data models for wikijs-python-sdk."""
from .asset import (
Asset,
AssetFolder,
AssetMove,
AssetRename,
AssetUpload,
FolderCreate,
)
from .base import BaseModel
from .group import (
Group,
@@ -15,7 +23,13 @@ from .page import Page, PageCreate, PageUpdate
from .user import User, UserCreate, UserGroup, UserUpdate
__all__ = [
"Asset",
"AssetFolder",
"AssetMove",
"AssetRename",
"AssetUpload",
"BaseModel",
"FolderCreate",
"Group",
"GroupAssignUser",
"GroupCreate",

205
wikijs/models/asset.py Normal file
View File

@@ -0,0 +1,205 @@
"""Data models for Wiki.js assets."""
from typing import Optional
from pydantic import Field, field_validator
from .base import BaseModel, TimestampedModel
class AssetFolder(BaseModel):
"""Asset folder model."""
id: int = Field(..., description="Folder ID")
slug: str = Field(..., description="Folder slug/path")
name: Optional[str] = Field(None, description="Folder name")
class Config:
"""Pydantic configuration."""
populate_by_name = True
class Asset(TimestampedModel):
"""Wiki.js asset model.
Represents a file asset (image, document, etc.) in Wiki.js.
Attributes:
id: Asset ID
filename: Original filename
ext: File extension
kind: Asset kind (image, binary, etc.)
mime: MIME type
file_size: File size in bytes
folder_id: Parent folder ID
folder: Parent folder information
author_id: ID of user who uploaded
author_name: Name of user who uploaded
created_at: Upload timestamp
updated_at: Last update timestamp
"""
id: int = Field(..., description="Asset ID")
filename: str = Field(..., min_length=1, description="Original filename")
ext: str = Field(..., description="File extension")
kind: str = Field(..., description="Asset kind (image, binary, etc.)")
mime: str = Field(..., description="MIME type")
file_size: int = Field(
..., alias="fileSize", ge=0, description="File size in bytes"
)
folder_id: Optional[int] = Field(
None, alias="folderId", description="Parent folder ID"
)
folder: Optional[AssetFolder] = Field(None, description="Parent folder")
author_id: Optional[int] = Field(None, alias="authorId", description="Author ID")
author_name: Optional[str] = Field(
None, alias="authorName", description="Author name"
)
@field_validator("filename")
@classmethod
def validate_filename(cls, v: str) -> str:
"""Validate filename."""
if not v or not v.strip():
raise ValueError("Filename cannot be empty")
return v.strip()
@property
def size_mb(self) -> float:
"""Get file size in megabytes."""
return self.file_size / (1024 * 1024)
@property
def size_kb(self) -> float:
"""Get file size in kilobytes."""
return self.file_size / 1024
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetUpload(BaseModel):
"""Model for uploading a new asset.
Attributes:
file_path: Local path to file to upload
folder_id: Target folder ID (default: 0 for root)
filename: Optional custom filename (uses file_path name if not provided)
"""
file_path: str = Field(..., alias="filePath", description="Local file path")
folder_id: int = Field(default=0, alias="folderId", description="Target folder ID")
filename: Optional[str] = Field(None, description="Custom filename")
@field_validator("file_path")
@classmethod
def validate_file_path(cls, v: str) -> str:
"""Validate file path."""
if not v or not v.strip():
raise ValueError("File path cannot be empty")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetRename(BaseModel):
"""Model for renaming an asset.
Attributes:
asset_id: Asset ID to rename
new_filename: New filename
"""
asset_id: int = Field(..., alias="assetId", description="Asset ID")
new_filename: str = Field(
..., alias="newFilename", min_length=1, description="New filename"
)
@field_validator("asset_id")
@classmethod
def validate_asset_id(cls, v: int) -> int:
"""Validate asset ID."""
if v <= 0:
raise ValueError("Asset ID must be positive")
return v
@field_validator("new_filename")
@classmethod
def validate_filename(cls, v: str) -> str:
"""Validate filename."""
if not v or not v.strip():
raise ValueError("Filename cannot be empty")
return v.strip()
class Config:
"""Pydantic configuration."""
populate_by_name = True
class AssetMove(BaseModel):
"""Model for moving an asset to a different folder.
Attributes:
asset_id: Asset ID to move
folder_id: Target folder ID
"""
asset_id: int = Field(..., alias="assetId", description="Asset ID")
folder_id: int = Field(..., alias="folderId", description="Target folder ID")
@field_validator("asset_id")
@classmethod
def validate_asset_id(cls, v: int) -> int:
"""Validate asset ID."""
if v <= 0:
raise ValueError("Asset ID must be positive")
return v
@field_validator("folder_id")
@classmethod
def validate_folder_id(cls, v: int) -> int:
"""Validate folder ID."""
if v < 0:
raise ValueError("Folder ID must be non-negative")
return v
class Config:
"""Pydantic configuration."""
populate_by_name = True
class FolderCreate(BaseModel):
"""Model for creating a new folder.
Attributes:
slug: Folder slug/path
name: Optional folder name
"""
slug: str = Field(..., min_length=1, description="Folder slug/path")
name: Optional[str] = Field(None, description="Folder name")
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str) -> str:
"""Validate slug."""
if not v or not v.strip():
raise ValueError("Slug cannot be empty")
# Remove leading/trailing slashes
v = v.strip().strip("/")
if not v:
raise ValueError("Slug cannot be just slashes")
return v
class Config:
"""Pydantic configuration."""
populate_by_name = True