- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
`resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
`chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
(6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
`docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
1109 lines
42 KiB
Python
1109 lines
42 KiB
Python
"""Additional Pydantic response models for the ObsiGate REST API.
|
|
|
|
These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are
|
|
intentionally permissive (``extra="allow"``) so that adding a new field to an
|
|
existing endpoint never breaks response validation — the model documents the
|
|
stable, public shape while still accepting internal additions.
|
|
|
|
Models whose endpoints are already typed in :mod:`backend.main` (e.g.
|
|
``FileContentResponse``) live there; this module only holds the ones used to
|
|
fill the gaps.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class StatusResponse(BaseModel):
|
|
"""Generic ``{"status": "..."}`` acknowledgement."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
status: str = Field(default="ok", description="Operation status")
|
|
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""Standard FastAPI error envelope."""
|
|
|
|
detail: str | list[dict[str, Any]] | None = Field(
|
|
default=None,
|
|
description="Human-readable error message, or a list of validation errors",
|
|
examples=["Vault 'Notes' not found"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recent files & bookmarks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RecentFileItem(BaseModel):
|
|
"""A recently opened or recently modified file."""
|
|
|
|
path: str = Field(description="Relative path within the vault")
|
|
title: str = Field(description="File title")
|
|
vault: str = Field(description="Vault name")
|
|
mtime: float | int | str | None = Field(default=None, description="Modification timestamp")
|
|
mtime_human: str | None = Field(default=None, description="Human-readable modification time")
|
|
mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time")
|
|
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
|
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
|
preview: str | None = Field(default=None, description="Short content preview")
|
|
bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked")
|
|
|
|
|
|
class RecentResponse(BaseModel):
|
|
"""Response for ``GET /api/recent``."""
|
|
|
|
files: list[RecentFileItem]
|
|
total: int = Field(description="Total number of files in the selected mode")
|
|
limit: int = Field(description="Applied limit")
|
|
mode: str = Field(description="'opened' or 'modified'")
|
|
|
|
model_config = ConfigDict(
|
|
json_schema_extra={
|
|
"example": {
|
|
"files": [
|
|
{
|
|
"path": "notes/Accueil.md",
|
|
"title": "Accueil",
|
|
"vault": "TestVault",
|
|
"mtime": 1750000000.0,
|
|
"mtime_human": "il y a 2 h",
|
|
"size_bytes": 1024,
|
|
"tags": ["#accueil"],
|
|
"preview": "# Bienvenue…",
|
|
"bookmarked": False,
|
|
}
|
|
],
|
|
"total": 1,
|
|
"limit": 20,
|
|
"mode": "opened",
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
class BookmarkFileItem(BaseModel):
|
|
"""A bookmarked file."""
|
|
|
|
path: str = Field(description="Relative path within the vault")
|
|
title: str = Field(description="File title")
|
|
vault: str = Field(description="Vault name")
|
|
mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp")
|
|
mtime_human: str | None = Field(default=None, description="Human-readable bookmark time")
|
|
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
|
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
|
bookmarked: bool | None = Field(default=True, description="Always true for this endpoint")
|
|
|
|
|
|
class BookmarksResponse(BaseModel):
|
|
"""Response for ``GET /api/bookmarks``."""
|
|
|
|
files: list[BookmarkFileItem]
|
|
total: int = Field(description="Number of bookmarked files")
|
|
|
|
|
|
class BookmarkToggleResponse(BaseModel):
|
|
"""Response for ``POST /api/bookmarks/toggle``."""
|
|
|
|
bookmarked: bool = Field(description="New bookmark state")
|
|
|
|
|
|
class SavedSearch(BaseModel):
|
|
"""A persisted search definition."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
id: str = Field(description="Unique identifier (millisecond timestamp)")
|
|
query: str = Field(description="Search query text")
|
|
vault: str = Field(default="all", description="Vault filter")
|
|
case_sensitive: bool = Field(default=False)
|
|
whole_word: bool = Field(default=False)
|
|
regex: bool = Field(default=False)
|
|
include_paths: str = Field(default="")
|
|
exclude_paths: str = Field(default="")
|
|
created_at: float = Field(description="Creation timestamp (epoch seconds)")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backups & diffs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BackupsResponse(BaseModel):
|
|
"""Response for ``GET /api/file/{vault}/backups``."""
|
|
|
|
vault: str
|
|
path: str
|
|
backups: list[dict[str, Any]] = Field(description="Backups, newest first")
|
|
|
|
|
|
class BacklinksResponse(BaseModel):
|
|
"""Response for ``GET /api/file/{vault}/backlinks``."""
|
|
|
|
vault: str
|
|
path: str
|
|
backlinks: list[dict[str, Any]] = Field(description="Files linking to the target")
|
|
total: int
|
|
|
|
|
|
class ChatMessageItem(BaseModel):
|
|
"""One chat message (``GET/POST /api/file/{vault}/chat``)."""
|
|
|
|
id: str = Field(description="Message id")
|
|
user: str = Field(description="Author username")
|
|
text: str = Field(description="Message body")
|
|
ts: float = Field(description="Unix timestamp")
|
|
|
|
|
|
class ChatHistoryResponse(BaseModel):
|
|
"""Response for ``GET /api/file/{vault}/chat``."""
|
|
|
|
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
|
|
|
|
|
|
class ChatMessageResponse(BaseModel):
|
|
"""Response for ``POST /api/file/{vault}/chat``."""
|
|
|
|
message: ChatMessageItem
|
|
status: str = Field(description="'ok'")
|
|
|
|
|
|
class BackupsListResponse(BaseModel):
|
|
"""Response for ``GET /api/backups``."""
|
|
|
|
backups: list[dict[str, Any]] = Field(description="All backups across vaults")
|
|
total: int
|
|
total_size_bytes: int
|
|
|
|
|
|
class BackupsDeletedResponse(BaseModel):
|
|
"""Response for backup deletion / purge endpoints."""
|
|
|
|
deleted: int = Field(description="Number of backup files deleted")
|
|
|
|
|
|
class BackupContentResponse(BaseModel):
|
|
"""Response for ``GET /api/backups/content``."""
|
|
|
|
content: str = Field(description="Backup file content (truncated to 100 KB)")
|
|
name: str = Field(description="Backup file name")
|
|
size: int = Field(description="Returned content size in characters")
|
|
|
|
|
|
class BackupsCompressResponse(BaseModel):
|
|
"""Response for ``POST /api/backups/compress``."""
|
|
|
|
compressed: int = Field(description="Number of backups processed")
|
|
saved_bytes: int = Field(description="Bytes saved by gzip compression")
|
|
dry_run: bool
|
|
|
|
|
|
class BackupsAutoResponse(BaseModel):
|
|
"""Response for ``POST /api/backups/auto``."""
|
|
|
|
backed_up: int = Field(description="Number of files backed up")
|
|
since_hours: int | float = Field(description="Look-back window in hours")
|
|
|
|
|
|
class DiffResponse(BaseModel):
|
|
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
version: int = Field(description="Backup version timestamp (left/old side)")
|
|
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
|
|
diff: str = Field(description="Unified diff (empty if no changes)")
|
|
|
|
|
|
class RestoreRequest(BaseModel):
|
|
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
version: int = Field(description="Timestamp of the backup version to restore")
|
|
|
|
|
|
class RestoreResponse(BaseModel):
|
|
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
success: bool = Field(description="Whether restore succeeded")
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
restored_from: int = Field(description="Timestamp of the backup used")
|
|
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
|
|
|
|
|
|
class BackupEntry(BaseModel):
|
|
"""A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
timestamp: int = Field(description="Unix timestamp of when the backup was created")
|
|
datetime: str = Field(description="ISO 8601 datetime string")
|
|
size: int = Field(description="File size in bytes")
|
|
filename: str = Field(description="Backup filename on disk")
|
|
|
|
|
|
class BackupListResponse(BaseModel):
|
|
"""Response listing all available backups for a file (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
backups: list[BackupEntry] = Field(description="Available backups, newest first")
|
|
|
|
|
|
class DiffRequest(BaseModel):
|
|
"""Request parameters for generating a diff (#85 — extrait de backend.main, inchangé)."""
|
|
|
|
version: int = Field(description="Timestamp of the backup version to compare")
|
|
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Files — browse / read (#85 — extrait de backend.main, inchangé)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BrowseItem(BaseModel):
|
|
"""A single entry (file or directory) returned by the browse endpoint."""
|
|
|
|
name: str = Field(description="File or directory name")
|
|
path: str = Field(description="Relative path within vault")
|
|
type: str = Field(description="'file' or 'directory'")
|
|
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
|
size: int | None = Field(default=None, description="File size in bytes")
|
|
extension: str | None = Field(default=None, description="File extension")
|
|
|
|
|
|
class BrowseResponse(BaseModel):
|
|
"""Paginated directory listing for a vault."""
|
|
|
|
vault: str
|
|
path: str
|
|
items: list[BrowseItem]
|
|
|
|
|
|
class FileContentResponse(BaseModel):
|
|
"""Rendered file content with metadata."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
title: str = Field(description="File title (from frontmatter or filename)")
|
|
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
|
|
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
|
|
html: str = Field(description="Rendered HTML content")
|
|
raw_length: int = Field(description="Length of raw file content in characters")
|
|
extension: str = Field(description="File extension (e.g. .md, .txt)")
|
|
is_markdown: bool = Field(description="Whether the file is markdown")
|
|
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
|
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
|
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
|
is_image: bool | None = Field(default=None, description="True for image files")
|
|
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
|
|
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
|
|
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
|
|
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
|
|
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
|
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
|
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
|
xlsx_readonly: bool | None = Field(
|
|
default=None,
|
|
description=(
|
|
"True when the table is served read-only (.xls/.ods, #153 A16): "
|
|
"the viewer hides the editable-cell wiring and the save/structure "
|
|
"endpoints refuse the format"
|
|
),
|
|
)
|
|
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
|
|
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
|
|
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
|
),
|
|
)
|
|
xlsx_revision: str | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
|
"client sends it back as the `if_match` of a write so a change made "
|
|
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
|
),
|
|
)
|
|
xlsx_lossy_features: list[str] | None = Field(
|
|
default=None,
|
|
description=(
|
|
"Workbook parts an openpyxl save would drop (#153 A1) — e.g. "
|
|
"cached_values, slicers, form_controls, connections, custom_xml, "
|
|
"signature, rich_comments, macros. Empty/absent = nothing at risk."
|
|
),
|
|
)
|
|
is_json: bool | None = Field(default=None, description="True for JSON files")
|
|
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
|
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
|
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
|
|
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
|
|
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
|
|
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
|
|
|
|
|
class XlsxDashboardNamedRange(BaseModel):
|
|
"""One named range of a workbook (#153 A17)."""
|
|
|
|
name: str = Field(description="Range name as declared in the workbook")
|
|
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
|
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
|
|
|
|
|
class XlsxDashboardSheetKpi(BaseModel):
|
|
"""One KPI card of a sheet dashboard (#153 A17)."""
|
|
|
|
label: str = Field(description="A1 reference of the numeric cell")
|
|
value: float = Field(description="Numeric value of the cell")
|
|
|
|
|
|
class XlsxDashboardSheet(BaseModel):
|
|
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
|
|
|
name: str = Field(description="Sheet name")
|
|
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
|
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
|
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
|
formulas: int = Field(description="Cells whose value is a formula")
|
|
numeric: int = Field(description="Cells carrying a numeric value")
|
|
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
|
|
|
|
|
class XlsxDashboardResponse(BaseModel):
|
|
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
|
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
|
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
|
|
|
|
|
class XlsxSheetWindowResponse(BaseModel):
|
|
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
|
|
|
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
|
|
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
|
|
sheet, whatever the window.
|
|
"""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
sheet: str = Field(description="Sheet name (as shown in the tab)")
|
|
offset: int = Field(description="0-based index of the first returned row")
|
|
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
|
|
rows: int = Field(description="Rows actually returned in this window")
|
|
cols: int = Field(description="Columns of the rendered window")
|
|
total_rows: int = Field(description="Rows the sheet declares")
|
|
total_cols: int = Field(description="Columns the sheet declares")
|
|
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
|
|
max_cols: int = Field(description="Column cap of the renderer (40)")
|
|
truncated: bool = Field(
|
|
description="True when the sheet exceeds the 500x40 render caps"
|
|
)
|
|
has_more: bool = Field(description="True when rows remain after this window")
|
|
html: str = Field(description="Rendered HTML table for the window")
|
|
|
|
|
|
class FileRawResponse(BaseModel):
|
|
"""Raw text content of a file."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
raw: str = Field(description="Raw file content as text")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Files — mutations (#85 — extrait de backend.main, inchangé)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FileSaveResponse(BaseModel):
|
|
"""Confirmation after saving a file."""
|
|
|
|
status: str = Field(description="Always 'ok'")
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
size: int = Field(description="Size of saved content in characters")
|
|
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
|
# client can chain writes without re-reading (absent on non-spreadsheets).
|
|
revision: str | None = Field(
|
|
default=None,
|
|
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
|
)
|
|
|
|
|
|
class FileDeleteResponse(BaseModel):
|
|
"""Confirmation after deleting a file."""
|
|
|
|
status: str = Field(description="Always 'ok'")
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path within the vault")
|
|
|
|
|
|
class DirectoryCreateRequest(BaseModel):
|
|
"""Request to create a new directory."""
|
|
|
|
path: str = Field(description="Relative path of the new directory")
|
|
|
|
|
|
class DirectoryCreateResponse(BaseModel):
|
|
"""Response after creating a directory."""
|
|
|
|
success: bool = Field(description="Whether creation succeeded")
|
|
path: str = Field(description="Path of the created directory")
|
|
|
|
|
|
class DirectoryRenameRequest(BaseModel):
|
|
"""Request to rename a directory."""
|
|
|
|
path: str = Field(description="Current path of the directory")
|
|
new_name: str = Field(description="New name for the directory")
|
|
|
|
|
|
class DirectoryRenameResponse(BaseModel):
|
|
"""Response after renaming a directory."""
|
|
|
|
success: bool = Field(description="Whether rename succeeded")
|
|
old_path: str = Field(description="Original directory path")
|
|
new_path: str = Field(description="New directory path")
|
|
|
|
|
|
class DirectoryDeleteResponse(BaseModel):
|
|
"""Response after deleting a directory."""
|
|
|
|
success: bool = Field(description="Whether deletion succeeded")
|
|
deleted_count: int = Field(description="Number of files recursively deleted")
|
|
|
|
|
|
class FileCreateRequest(BaseModel):
|
|
"""Request to create a new file."""
|
|
|
|
path: str = Field(description="Relative path of the new file")
|
|
content: str = Field(default="", description="Initial content")
|
|
|
|
|
|
class FileCreateResponse(BaseModel):
|
|
"""Response after creating a file."""
|
|
|
|
success: bool = Field(description="Whether creation succeeded")
|
|
path: str = Field(description="Path of the created file")
|
|
|
|
|
|
class BatchUploadFileItem(BaseModel):
|
|
"""A single file/dir entry in a batch upload request."""
|
|
|
|
path: str = Field(description="Relative path of the item within the batch")
|
|
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
|
|
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
|
|
|
|
|
|
class BatchUploadRequest(BaseModel):
|
|
"""Request payload for batch file/directory upload."""
|
|
|
|
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
|
|
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
|
|
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
|
|
|
|
|
|
class BatchUploadResponse(BaseModel):
|
|
"""Response from batch file/directory upload."""
|
|
|
|
success: bool = Field(description="True if all files uploaded without error")
|
|
vault: str = Field(description="Vault name")
|
|
target_dir: str = Field(description="Target directory")
|
|
uploaded: list[str] = Field(description="List of created/updated file paths")
|
|
created_dirs: list[str] = Field(description="List of created directory paths")
|
|
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
|
|
total_files: int = Field(description="Total uploaded files count")
|
|
|
|
|
|
class FileRenameRequest(BaseModel):
|
|
"""Request to rename a file."""
|
|
|
|
path: str = Field(description="Current path of the file")
|
|
new_name: str = Field(description="New name for the file")
|
|
|
|
|
|
class FileRenameResponse(BaseModel):
|
|
"""Response after renaming a file."""
|
|
|
|
success: bool = Field(description="Whether rename succeeded")
|
|
old_path: str
|
|
new_path: str
|
|
|
|
|
|
class FileMoveRequest(BaseModel):
|
|
"""Request to move a file or directory to a different parent directory."""
|
|
|
|
source_path: str = Field(description="Current relative path of the file/directory")
|
|
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
|
|
|
|
|
|
class FileMoveResponse(BaseModel):
|
|
"""Response after moving a file or directory."""
|
|
|
|
success: bool = Field(description="Whether move succeeded")
|
|
old_path: str = Field(description="Original path")
|
|
new_path: str = Field(description="New path after move")
|
|
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vaults & history (#85 — extrait de backend.main, inchangé)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class VaultInfo(BaseModel):
|
|
"""Summary information about a configured vault."""
|
|
|
|
name: str = Field(description="Display name of the vault")
|
|
file_count: int = Field(description="Number of indexed files")
|
|
tag_count: int = Field(description="Number of unique tags")
|
|
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
|
|
|
|
|
class BookmarkToggleRequest(BaseModel):
|
|
"""Request to toggle a bookmark on a file."""
|
|
|
|
vault: str
|
|
path: str
|
|
title: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SearchResultItem(BaseModel):
|
|
"""A single search result."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
title: str = Field(description="File title")
|
|
tags: list[str] = Field(description="File tags")
|
|
score: int = Field(description="Relevance score")
|
|
snippet: str = Field(description="Content excerpt with highlights")
|
|
modified: str = Field(description="ISO 8601 modification timestamp")
|
|
|
|
|
|
class SearchResponse(BaseModel):
|
|
"""Full-text search response with optional pagination."""
|
|
|
|
query: str = Field(description="Original search query")
|
|
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
|
|
tag_filter: str | None = Field(default=None, description="Tag filter applied")
|
|
count: int = Field(description="Number of results in this response")
|
|
total: int = Field(default=0, description="Total results before pagination")
|
|
offset: int = Field(default=0, description="Current pagination offset")
|
|
limit: int = Field(default=200, description="Page size")
|
|
results: list[SearchResultItem] = Field(description="Search result items")
|
|
|
|
|
|
class TagsResponse(BaseModel):
|
|
"""Tag aggregation response."""
|
|
|
|
vault_filter: str | None = Field(default=None, description="Vault filter applied")
|
|
tags: dict[str, int] = Field(description="Tag name → count mapping")
|
|
|
|
|
|
class TreeSearchResult(BaseModel):
|
|
"""A single tree search result item."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Full relative path")
|
|
name: str = Field(description="File or directory name")
|
|
type: str = Field(description="'file' or 'directory'")
|
|
matched_path: str = Field(description="Path segment that matched the query")
|
|
|
|
|
|
class TreeSearchResponse(BaseModel):
|
|
"""Tree search response with matching paths."""
|
|
|
|
query: str = Field(description="Search query")
|
|
vault_filter: str = Field(description="Vault filter applied")
|
|
results: list[TreeSearchResult] = Field(description="Matching files and directories")
|
|
|
|
|
|
class VaultPathEntry(BaseModel):
|
|
"""A single indexed path (file or directory) in a vault."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Full relative path")
|
|
name: str = Field(description="File or directory name")
|
|
type: str = Field(description="'file' or 'directory'")
|
|
|
|
|
|
class VaultPathsResponse(BaseModel):
|
|
"""Flat list of every indexed path in a vault (capped)."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
count: int = Field(description="Number of returned entries")
|
|
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
|
|
|
|
|
|
class AdvancedSearchResultItem(BaseModel):
|
|
"""A single advanced search result with highlighted snippet."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
title: str = Field(description="File title")
|
|
tags: list[str] = Field(description="File tags")
|
|
score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
|
|
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
|
|
snippet: str = Field(description="Content excerpt with <mark> highlights")
|
|
modified: str = Field(description="ISO 8601 modification timestamp")
|
|
extension: str = Field(default="", description="File extension")
|
|
|
|
|
|
class SearchFacets(BaseModel):
|
|
"""Faceted counts for search results."""
|
|
|
|
tags: dict[str, int] = Field(default_factory=dict)
|
|
vaults: dict[str, int] = Field(default_factory=dict)
|
|
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
|
|
|
|
|
class AdvancedSearchResponse(BaseModel):
|
|
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
|
|
|
results: list[AdvancedSearchResultItem] = Field(description="Search results")
|
|
total: int = Field(description="Total number of matching results")
|
|
offset: int = Field(description="Current pagination offset")
|
|
limit: int = Field(description="Page size")
|
|
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
|
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
|
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
|
|
|
|
|
class TitleSuggestion(BaseModel):
|
|
"""A file title suggestion for autocomplete."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Relative file path")
|
|
title: str = Field(description="File title")
|
|
tags: list[str] = Field(default_factory=list, description="File tags")
|
|
|
|
|
|
class SuggestResponse(BaseModel):
|
|
"""Autocomplete suggestions for file titles."""
|
|
|
|
query: str = Field(description="Original query string")
|
|
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
|
|
|
|
|
|
class TagSuggestion(BaseModel):
|
|
"""A tag suggestion for autocomplete."""
|
|
|
|
tag: str = Field(description="Tag name")
|
|
count: int = Field(description="Number of files with this tag")
|
|
|
|
|
|
class TagSuggestResponse(BaseModel):
|
|
"""Autocomplete suggestions for tags."""
|
|
|
|
query: str = Field(description="Original query string")
|
|
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
|
|
|
|
|
|
class GraphNode(BaseModel):
|
|
"""A single node in the graph view."""
|
|
|
|
id: str = Field(description="Unique node identifier")
|
|
name: str = Field(description="Display name")
|
|
type: str = Field(description="'vault', 'directory', or 'file'")
|
|
path: str = Field(description="Relative path within vault")
|
|
size: int = Field(default=0, description="File size in bytes")
|
|
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
|
|
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
|
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
|
|
|
|
|
class GraphEdge(BaseModel):
|
|
"""An edge between two nodes in the graph view."""
|
|
|
|
source: str = Field(description="Source node ID")
|
|
target: str = Field(description="Target node ID")
|
|
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
|
|
|
|
|
class GraphResponse(BaseModel):
|
|
"""Graph data for a vault or directory."""
|
|
|
|
vault: str = Field(description="Vault name")
|
|
path: str = Field(description="Root path for the graph")
|
|
scope: str = Field(default="directory", description="'directory' or 'full'")
|
|
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
|
|
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
|
|
|
|
|
class ReloadResponse(BaseModel):
|
|
"""Index reload confirmation with per-vault stats."""
|
|
|
|
status: str = Field(description="Reload status ('ok' or 'error')")
|
|
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PDF
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class PdfInfoResponse(BaseModel):
|
|
"""Response for ``GET /api/file/{vault}/pdf/info``."""
|
|
|
|
vault: str
|
|
path: str
|
|
pages: int = Field(description="Page count")
|
|
title: str = Field(description="PDF title (metadata or filename)")
|
|
author: str = Field(default="", description="PDF author")
|
|
size_bytes: int = Field(description="File size in bytes")
|
|
|
|
model_config = ConfigDict(
|
|
json_schema_extra={
|
|
"example": {
|
|
"vault": "TestVault",
|
|
"path": "docs/rapport.pdf",
|
|
"pages": 12,
|
|
"title": "Rapport annuel",
|
|
"author": "ObsiGate",
|
|
"size_bytes": 524288,
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search & replace
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ReplaceMatch(BaseModel):
|
|
"""A single file affected by a find/replace operation."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
vault: str
|
|
path: str
|
|
title: str | None = None
|
|
match_count: int | None = Field(default=None, description="Occurrences found (dry run)")
|
|
replacements: int | None = Field(default=None, description="Occurrences replaced")
|
|
preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)")
|
|
|
|
|
|
class ReplaceResponse(BaseModel):
|
|
"""Response for ``POST /api/search/replace``."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches")
|
|
replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements")
|
|
total_matches: int | None = Field(default=None, description="Total matches (dry run)")
|
|
total_replacements: int = Field(description="Total replacements performed or previewed")
|
|
dry_run: bool | None = Field(default=None, description="True when no file was written")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vaults, attachments & settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class VaultStatsResponse(BaseModel):
|
|
"""Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
status: str = Field(default="ok")
|
|
vault: str
|
|
stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault")
|
|
|
|
|
|
class VaultActionResponse(BaseModel):
|
|
"""Response for ``DELETE /api/vaults/{vault}``."""
|
|
|
|
status: str = Field(default="ok")
|
|
vault: str
|
|
|
|
|
|
class VaultStatusEntry(BaseModel):
|
|
"""Per-vault status entry."""
|
|
|
|
file_count: int
|
|
tag_count: int
|
|
path: str = ""
|
|
watching: bool = False
|
|
|
|
|
|
class VaultsStatusResponse(BaseModel):
|
|
"""Response for ``GET /api/vaults/status``."""
|
|
|
|
vaults: dict[str, VaultStatusEntry]
|
|
watcher_active: bool
|
|
sse_clients: int
|
|
|
|
|
|
class AttachmentRescanResponse(BaseModel):
|
|
"""Response for ``POST /api/attachments/rescan/{vault}``."""
|
|
|
|
status: str = Field(default="ok")
|
|
vault: str
|
|
attachment_count: int
|
|
|
|
|
|
class AttachmentStatsResponse(BaseModel):
|
|
"""Response for ``GET /api/attachments/stats``."""
|
|
|
|
vaults: dict[str, Any] = Field(description="Vault name → attachment statistics")
|
|
|
|
|
|
class VaultSettingsResponse(BaseModel):
|
|
"""Response for the per-vault display settings endpoints."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree")
|
|
|
|
|
|
class AllVaultSettingsResponse(BaseModel):
|
|
"""Response for ``GET /api/vaults/settings/all``."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class VaultFileEntry(BaseModel):
|
|
"""A file entry returned by the vault home listing."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
name: str
|
|
path: str
|
|
vault: str
|
|
size: int = 0
|
|
modified: float = 0
|
|
modified_iso: str | None = None
|
|
extension: str = ""
|
|
rel_dir: str | None = None
|
|
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
|
|
|
|
|
|
class VaultFilesResponse(BaseModel):
|
|
"""Response for ``GET /api/vault/{vault}/files``."""
|
|
|
|
vault: str
|
|
directory: str = ""
|
|
recursive: bool = True
|
|
count: int
|
|
files: list[VaultFileEntry]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration, AI keys & diagnostics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AppConfigResponse(BaseModel):
|
|
"""Application configuration (``GET/POST /api/config``)."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class AIKeysResponse(BaseModel):
|
|
"""Masked AI provider keys (``GET /api/config/ai-keys``)."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class AIKeyDeleteResponse(BaseModel):
|
|
"""Response for ``DELETE /api/config/ai-keys/{provider}``."""
|
|
|
|
status: str = Field(default="deleted")
|
|
key: str = Field(description="Deleted environment variable name")
|
|
|
|
|
|
class AITestResponse(BaseModel):
|
|
"""Response for ``POST /api/config/ai-keys/test`` (provider → status)."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
class AIModelsResponse(BaseModel):
|
|
"""Response for ``GET /api/config/ai-models``."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
models: list[str] = Field(default_factory=list, description="Available model identifiers")
|
|
source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'")
|
|
count: int | None = Field(default=None, description="Number of live models")
|
|
error: str | None = Field(default=None, description="Provider/network error, if any")
|
|
note: str | None = Field(default=None, description="Explanatory note when using fallback")
|
|
capabilities: dict[str, dict[str, bool]] = Field(
|
|
default_factory=dict,
|
|
description="Per-model capability flags (chat, embeddings, vision, …)",
|
|
)
|
|
|
|
|
|
class DiagnosticsResponse(BaseModel):
|
|
"""Response for ``GET /api/diagnostics``."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
index: dict[str, Any] = Field(default_factory=dict)
|
|
inverted_index: dict[str, Any] = Field(default_factory=dict)
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class DashboardVaultStat(BaseModel):
|
|
"""Per-vault dashboard statistics."""
|
|
|
|
name: str
|
|
file_count: int
|
|
tag_count: int
|
|
total_size_bytes: int
|
|
image_count: int = 0
|
|
|
|
|
|
class DashboardResponse(BaseModel):
|
|
"""Response for ``GET /api/dashboard``."""
|
|
|
|
vaults: list[DashboardVaultStat]
|
|
total_files: int
|
|
total_tags: int
|
|
total_size_bytes: int
|
|
total_images: int = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# System / health (#85 — extrait de backend.main, comportement inchangé)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
"""Application health status.
|
|
|
|
Déplacé depuis :mod:`backend.main` sans modification : pas de
|
|
``extra="allow"`` ici, pour préserver la validation actuelle des
|
|
réponses (les champs enrichis de ``/api/health/detailed`` restent
|
|
filtrés comme avant).
|
|
"""
|
|
|
|
status: str = Field(description="Health status ('ok' or 'error')")
|
|
version: str = Field(description="Application version (x.y.z — latest release tag)")
|
|
vaults: int = Field(description="Number of configured vaults")
|
|
total_files: int = Field(description="Total indexed files across all vaults")
|
|
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
|
|
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
|
|
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
|
|
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
|
|
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhooks, sharing & conflicts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WebhookModel(BaseModel):
|
|
"""A configured webhook."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
id: str = Field(description="Webhook UUID")
|
|
name: str
|
|
url: str = Field(description="Target HTTP(S) URL")
|
|
events: list[str] = Field(default_factory=list, description="Subscribed event types")
|
|
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret")
|
|
enabled: bool = True
|
|
created_at: str | None = None
|
|
last_fired_at: str | None = None
|
|
|
|
|
|
class ShareModel(BaseModel):
|
|
"""A public document share."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
id: str
|
|
token: str = Field(description="Opaque share token")
|
|
vault: str
|
|
path: str
|
|
url: str | None = Field(default=None, description="Relative public URL (/s/{token})")
|
|
created_by: str | None = None
|
|
created_at: str | None = None
|
|
expires_at: str | None = None
|
|
access_count: int = 0
|
|
last_accessed: str | None = None
|
|
|
|
|
|
class ConflictEntry(BaseModel):
|
|
"""A Syncthing sync-conflict file."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
vault: str
|
|
path: str
|
|
original_path: str | None = None
|
|
|
|
|
|
class ConflictsResponse(BaseModel):
|
|
"""Response for ``GET /api/conflicts``."""
|
|
|
|
conflicts: list[ConflictEntry]
|
|
total: int
|
|
|
|
|
|
class ConflictResolveResponse(BaseModel):
|
|
"""Response for ``POST /api/conflicts/resolve``."""
|
|
|
|
status: str = Field(default="resolved")
|
|
action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI status & BooksLM context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AIProviderStatus(BaseModel):
|
|
"""Availability of a single AI provider."""
|
|
|
|
available: bool
|
|
model: str | None = Field(default=None, description="Default model when the provider is configured")
|
|
|
|
|
|
class AIAutocompleteStatus(BaseModel):
|
|
"""Status of the local Ollama autocomplete backend."""
|
|
|
|
available: bool = False
|
|
server_ok: bool = False
|
|
model_loaded: bool = False
|
|
model: str = ""
|
|
error: str | None = None
|
|
|
|
|
|
class AIStatusResponse(BaseModel):
|
|
"""Response for ``GET /api/ai/status``."""
|
|
|
|
configured: bool = Field(description="True when at least one provider has an API key")
|
|
default_provider: str
|
|
providers: dict[str, AIProviderStatus]
|
|
autocomplete: AIAutocompleteStatus
|
|
|
|
|
|
class BooksLMContextFile(BaseModel):
|
|
"""A single document included in the BooksLM context."""
|
|
|
|
path: str
|
|
title: str
|
|
content: str
|
|
type: str = Field(default="markdown", description="'markdown' (and future types)")
|
|
|
|
|
|
class BooksLMContextResponse(BaseModel):
|
|
"""Response for ``POST /api/ai/bookslm/context``."""
|
|
|
|
files: list[BooksLMContextFile]
|
|
total_chars: int
|
|
file_count: int
|
|
directory_tree: str = ""
|
|
max_total_chars: int = Field(default=200000, description="Configured context character limit")
|
|
max_files: int = Field(default=200, description="Configured file-count limit")
|
|
scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")
|
|
|