refactor: #85 T6b extrait mutations fichiers-dossiers vers backend/routers (comportement inchange)
This commit is contained in:
+11
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.27.7**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.27.8**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.27.8] — 2026-09-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.27.7] — 2026-09-26
|
## [2.27.7] — 2026-09-26
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -38,6 +42,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
|
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
|
||||||
|
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
|
||||||
|
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
|
||||||
|
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
|
||||||
|
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
|
||||||
|
`schemas.py`.
|
||||||
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
|
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
|
||||||
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
|
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
|
||||||
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
|
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.7).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.8).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.27.7 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.27.8 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.7).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.8).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.27.8 | Last updated: September 2026*
|
||||||
|
|||||||
+9
-575
@@ -10,7 +10,6 @@ import string
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import frontmatter
|
import frontmatter
|
||||||
import mistune
|
import mistune
|
||||||
@@ -25,10 +24,7 @@ from backend.collab import authenticate_websocket, collab_manager
|
|||||||
from backend.history import (
|
from backend.history import (
|
||||||
get_bookmarks,
|
get_bookmarks,
|
||||||
record_open,
|
record_open,
|
||||||
remove_recent,
|
|
||||||
toggle_bookmark,
|
toggle_bookmark,
|
||||||
update_bookmarks_after_rename,
|
|
||||||
update_history_after_rename,
|
|
||||||
)
|
)
|
||||||
from backend.image_processor import preprocess_images
|
from backend.image_processor import preprocess_images
|
||||||
from backend.indexer import (
|
from backend.indexer import (
|
||||||
@@ -83,36 +79,6 @@ from backend.search import (
|
|||||||
from backend.semantic_search import init_semantic_index
|
from backend.semantic_search import init_semantic_index
|
||||||
from backend.services.backups import get_backup_dir as service_get_backup_dir
|
from backend.services.backups import get_backup_dir as service_get_backup_dir
|
||||||
from backend.services.errors import ServiceError
|
from backend.services.errors import ServiceError
|
||||||
from backend.services.mutations import (
|
|
||||||
batch_upload_files as service_batch_upload_files,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
create_directory as service_create_directory,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
create_file as service_create_file,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
delete_directory as service_delete_directory,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
delete_file as service_delete_file,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
edit_file as service_edit_file,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
edit_xlsx_cells as service_edit_xlsx_cells,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
move_path as service_move_path,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
rename_directory as service_rename_directory,
|
|
||||||
)
|
|
||||||
from backend.services.mutations import (
|
|
||||||
rename_file as service_rename_file,
|
|
||||||
)
|
|
||||||
from backend.services.recent import humanize_mtime, list_recent
|
from backend.services.recent import humanize_mtime, list_recent
|
||||||
from backend.services.sanitizer import sanitize_html
|
from backend.services.sanitizer import sanitize_html
|
||||||
from backend.services.vaults import (
|
from backend.services.vaults import (
|
||||||
@@ -143,117 +109,7 @@ class VaultInfo(BaseModel):
|
|||||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||||
|
|
||||||
|
|
||||||
class FileSaveResponse(BaseModel):
|
# Filesystem mutation + search / suggest / graph models : voir backend.schemas (#85 T5, T6b)
|
||||||
"""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")
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# Search / suggest / graph models : voir backend.schemas (#85 T5)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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'")
|
|
||||||
|
|
||||||
|
|
||||||
class BackupEntry(BaseModel):
|
class BackupEntry(BaseModel):
|
||||||
"""A single backup version of a file."""
|
"""A single backup version of a file."""
|
||||||
@@ -630,7 +486,7 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
|
|||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
# Auth router
|
# Auth router
|
||||||
from backend.audit import log_file_delete, log_file_save
|
from backend.audit import log_file_delete
|
||||||
from backend.auth.middleware import (
|
from backend.auth.middleware import (
|
||||||
check_vault_access,
|
check_vault_access,
|
||||||
require_admin,
|
require_admin,
|
||||||
@@ -654,14 +510,13 @@ from backend.bookslm_routes import router as bookslm_router
|
|||||||
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
||||||
from backend.routers.backups import router as backups_router
|
from backend.routers.backups import router as backups_router
|
||||||
from backend.routers.files_read import router as files_read_router
|
from backend.routers.files_read import router as files_read_router
|
||||||
|
from backend.routers.files_write import router as files_write_router
|
||||||
from backend.routers.health import router as health_router
|
from backend.routers.health import router as health_router
|
||||||
from backend.routers.search import router as search_router
|
from backend.routers.search import router as search_router
|
||||||
from backend.routers.sharing import router as sharing_router
|
from backend.routers.sharing import router as sharing_router
|
||||||
from backend.routers.webhooks import router as webhooks_router
|
from backend.routers.webhooks import router as webhooks_router
|
||||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
from backend.saved_searches import delete_saved, get_saved, save_search
|
||||||
from backend.share import update_shares_after_rename
|
|
||||||
from backend.skills_routes import router as skills_router
|
from backend.skills_routes import router as skills_router
|
||||||
from backend.webhooks import dispatch_webhooks
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(ai_router)
|
app.include_router(ai_router)
|
||||||
@@ -671,6 +526,7 @@ app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
|||||||
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||||
|
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
||||||
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
|
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
|
||||||
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
|
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
|
||||||
|
|
||||||
@@ -1271,439 +1127,17 @@ async def api_guide_download(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
|
||||||
async def api_file_save(
|
|
||||||
vault_name: str,
|
|
||||||
path: str = Query(..., description="Relative path to file"),
|
|
||||||
body: dict = Body(...),
|
|
||||||
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Save (overwrite) a file's content.
|
|
||||||
|
|
||||||
Expects a JSON body with a ``content`` key containing the new text.
|
|
||||||
The path is validated against traversal attacks before writing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
path: Relative file path within the vault.
|
|
||||||
body: JSON body with ``content`` string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``FileSaveResponse`` confirming the write.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
content = body.get("content", "")
|
|
||||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
|
||||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
|
||||||
|
|
||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
|
||||||
async def api_file_xlsx_save(
|
|
||||||
vault_name: str,
|
|
||||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
|
||||||
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Apply cell edits to an .xlsx workbook.
|
|
||||||
|
|
||||||
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
|
||||||
scalar values, max 500 per request). A backup is created before the
|
|
||||||
workbook is rewritten.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
sheet = body.get("sheet")
|
|
||||||
cells = body.get("cells")
|
|
||||||
if not isinstance(sheet, str) or not sheet:
|
|
||||||
raise HTTPException(status_code=400, detail="Feuille manquante")
|
|
||||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
|
||||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
|
||||||
for ref, value in cells.items():
|
|
||||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
|
||||||
|
|
||||||
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
|
|
||||||
log_file_save(
|
|
||||||
current_user["username"], vault_name, path,
|
|
||||||
sum(len(str(v)) for v in cells.values()),
|
|
||||||
current_user.get("_request_ip", "unknown"),
|
|
||||||
)
|
|
||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
|
||||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
|
||||||
"""Delete a file from the vault.
|
|
||||||
|
|
||||||
The path is validated against traversal attacks before deletion.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
path: Relative file path within the vault.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``FileDeleteResponse`` confirming the deletion.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_delete_file(vault_name, path)
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
|
||||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await remove_single_file(vault_name, path)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_deleted", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
|
|
||||||
from backend.plugins import emit_file_deleted
|
|
||||||
emit_file_deleted(vault_name, path)
|
|
||||||
|
|
||||||
# Remove from recent files
|
|
||||||
remove_recent(current_user["username"], vault_name, path)
|
|
||||||
|
|
||||||
# Dispatch webhooks
|
|
||||||
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
|
||||||
|
|
||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Directory management endpoints
|
# File & directory mutations : voir backend.routers.files_write (#85 T6b)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
|
|
||||||
async def api_directory_create(
|
|
||||||
vault_name: str,
|
|
||||||
body: DirectoryCreateRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Create a new directory in a vault.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
body: Request body with directory path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DirectoryCreateResponse confirming creation.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_create_directory(vault_name, body.path)
|
|
||||||
|
|
||||||
# Update path_index with the new directory
|
|
||||||
from backend.indexer import _index_lock
|
|
||||||
from backend.indexer import path_index as _path_idx
|
|
||||||
with _index_lock:
|
|
||||||
if vault_name not in _path_idx:
|
|
||||||
_path_idx[vault_name] = []
|
|
||||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
|
||||||
# Build all parent segments
|
|
||||||
parts = body.path.split("/")
|
|
||||||
for i in range(1, len(parts) + 1):
|
|
||||||
seg_path = "/".join(parts[:i])
|
|
||||||
if seg_path and seg_path not in existing:
|
|
||||||
existing.add(seg_path)
|
|
||||||
_path_idx[vault_name].append({
|
|
||||||
"path": seg_path,
|
|
||||||
"name": parts[i - 1],
|
|
||||||
"type": "directory",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_created", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": result["path"],
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
|
||||||
|
|
||||||
return {"success": True, "path": result["path"]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
|
||||||
async def api_directory_rename(
|
|
||||||
vault_name: str,
|
|
||||||
body: DirectoryRenameRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Rename a directory in a vault.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
body: Request body with current path and new name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DirectoryRenameResponse with old and new paths.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_rename_directory(vault_name, body.path, body.new_name)
|
|
||||||
old_path_str = result["old_path"]
|
|
||||||
new_path_str = result["new_path"]
|
|
||||||
|
|
||||||
# Update index for all files in the directory
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_renamed", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
|
||||||
async def api_directory_delete(
|
|
||||||
vault_name: str,
|
|
||||||
path: str = Query(..., description="Relative path to directory"),
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Delete a directory and all its contents from a vault.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
path: Relative directory path within the vault.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DirectoryDeleteResponse with count of deleted files.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_delete_directory(vault_name, path, recursive=True)
|
|
||||||
file_count = result["deleted_count"]
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_deleted", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": result["path"],
|
|
||||||
"deleted_count": file_count,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
|
||||||
|
|
||||||
return {"success": True, "deleted_count": file_count}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# File creation and rename endpoints
|
# File creation and rename endpoints : voir backend.routers.files_write (#85 T6b)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.post("/api/file/{vault_name}", response_model=FileCreateResponse)
|
# ---------------------------------------------------------------------------
|
||||||
async def api_file_create(
|
# File creation and rename endpoints : voir backend.routers.files_write (#85 T6b)
|
||||||
vault_name: str,
|
# ---------------------------------------------------------------------------
|
||||||
body: FileCreateRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Create a new file in a vault.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
body: Request body with file path and initial content.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FileCreateResponse confirming creation.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_create_file(vault_name, body.path, body.content)
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await update_single_file(vault_name, result["path"])
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_created", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": result["path"],
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
|
||||||
from backend.plugins import emit_file_created
|
|
||||||
emit_file_created(vault_name, result["path"])
|
|
||||||
|
|
||||||
return {"success": True, "path": result["path"]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
|
|
||||||
async def api_batch_upload(
|
|
||||||
vault_name: str,
|
|
||||||
body: BatchUploadRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Upload multiple files and directories (recursively) into a vault.
|
|
||||||
|
|
||||||
Accepts base64 encoded or plain text files with relative directory paths.
|
|
||||||
Creates missing parent folders safely.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Target vault name.
|
|
||||||
body: BatchUploadRequest with target_dir and files list.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BatchUploadResponse with summary of uploaded files and errors.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for f in body.files:
|
|
||||||
if f.is_dir:
|
|
||||||
items.append({"path": f.path, "is_dir": True})
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_bytes = b""
|
|
||||||
if f.content is not None:
|
|
||||||
# Check if content is base64 encoded data URI or raw base64
|
|
||||||
content_str = f.content
|
|
||||||
if content_str.startswith("data:") and ";base64," in content_str:
|
|
||||||
content_str = content_str.split(";base64,", 1)[1]
|
|
||||||
try:
|
|
||||||
raw_bytes = base64.b64decode(content_str)
|
|
||||||
except Exception:
|
|
||||||
# Fallback to utf-8 text encoding
|
|
||||||
raw_bytes = f.content.encode("utf-8")
|
|
||||||
|
|
||||||
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
|
|
||||||
|
|
||||||
result = service_batch_upload_files(
|
|
||||||
vault_name,
|
|
||||||
body.target_dir,
|
|
||||||
items,
|
|
||||||
overwrite=body.overwrite,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update index and SSE notifications for uploaded files
|
|
||||||
for path in result["uploaded"]:
|
|
||||||
try:
|
|
||||||
await update_single_file(vault_name, path)
|
|
||||||
await sse_manager.broadcast("file_created", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to post-process upload of {path}: {e}")
|
|
||||||
|
|
||||||
# SSE notification for tree refresh
|
|
||||||
if result["uploaded"] or result["created_dirs"]:
|
|
||||||
await sse_manager.broadcast("tree_updated", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"target_dir": result["target_dir"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
|
||||||
async def api_file_rename(
|
|
||||||
vault_name: str,
|
|
||||||
body: FileRenameRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Rename a file in a vault.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
body: Request body with current path and new name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FileRenameResponse with old and new paths.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_rename_file(vault_name, body.path, body.new_name)
|
|
||||||
old_path_str = result["old_path"]
|
|
||||||
new_path_str = result["new_path"]
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Update bookmarks, history, and shares
|
|
||||||
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_renamed", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
|
||||||
async def api_file_move(
|
|
||||||
vault_name: str,
|
|
||||||
body: FileMoveRequest,
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Move a file or directory to a different parent directory within the same vault.
|
|
||||||
|
|
||||||
Supports both files and directories. The item keeps its original name;
|
|
||||||
only the parent directory changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vault_name: Name of the vault.
|
|
||||||
body: Request body with source_path and destination_dir.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FileMoveResponse with old and new paths.
|
|
||||||
"""
|
|
||||||
if not check_vault_access(vault_name, current_user):
|
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
|
||||||
|
|
||||||
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
|
||||||
old_path_str = result["old_path"]
|
|
||||||
new_path_str = result["new_path"]
|
|
||||||
item_type = result["item_type"]
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
if item_type == "directory":
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
else:
|
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("item_moved", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
"item_type": item_type,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
|
||||||
|
|
||||||
|
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||||
|
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
|
||||||
|
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
|
||||||
|
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
|
||||||
|
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
|
||||||
|
incrémental, SSE, webhooks, plugins, historique).
|
||||||
|
|
||||||
|
La logique métier vit déjà dans :mod:`backend.services.mutations`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend.audit import log_file_delete, log_file_save
|
||||||
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
|
from backend.history import (
|
||||||
|
remove_recent,
|
||||||
|
update_bookmarks_after_rename,
|
||||||
|
update_history_after_rename,
|
||||||
|
)
|
||||||
|
from backend.indexer import handle_file_move, remove_single_file, update_single_file
|
||||||
|
from backend.schemas import (
|
||||||
|
BatchUploadRequest,
|
||||||
|
BatchUploadResponse,
|
||||||
|
DirectoryCreateRequest,
|
||||||
|
DirectoryCreateResponse,
|
||||||
|
DirectoryDeleteResponse,
|
||||||
|
DirectoryRenameRequest,
|
||||||
|
DirectoryRenameResponse,
|
||||||
|
FileCreateRequest,
|
||||||
|
FileCreateResponse,
|
||||||
|
FileDeleteResponse,
|
||||||
|
FileMoveRequest,
|
||||||
|
FileMoveResponse,
|
||||||
|
FileRenameRequest,
|
||||||
|
FileRenameResponse,
|
||||||
|
FileSaveResponse,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
batch_upload_files as service_batch_upload_files,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_directory as service_create_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_file as service_create_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_directory as service_delete_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_file as service_delete_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
edit_file as service_edit_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
edit_xlsx_cells as service_edit_xlsx_cells,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
move_path as service_move_path,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_directory as service_rename_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_file as service_rename_file,
|
||||||
|
)
|
||||||
|
from backend.share import update_shares_after_rename
|
||||||
|
from backend.sse import sse_manager
|
||||||
|
from backend.webhooks import dispatch_webhooks
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate")
|
||||||
|
|
||||||
|
router = APIRouter(tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||||
|
async def api_file_save(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to file"),
|
||||||
|
body: dict = Body(...),
|
||||||
|
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Save (overwrite) a file's content.
|
||||||
|
|
||||||
|
Expects a JSON body with a ``content`` key containing the new text.
|
||||||
|
The path is validated against traversal attacks before writing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
path: Relative file path within the vault.
|
||||||
|
body: JSON body with ``content`` string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``FileSaveResponse`` confirming the write.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
content = body.get("content", "")
|
||||||
|
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
|
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||||
|
|
||||||
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
||||||
|
async def api_file_xlsx_save(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||||
|
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Apply cell edits to an .xlsx workbook.
|
||||||
|
|
||||||
|
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
||||||
|
scalar values, max 500 per request). A backup is created before the
|
||||||
|
workbook is rewritten.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
sheet = body.get("sheet")
|
||||||
|
cells = body.get("cells")
|
||||||
|
if not isinstance(sheet, str) or not sheet:
|
||||||
|
raise HTTPException(status_code=400, detail="Feuille manquante")
|
||||||
|
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||||
|
for ref, value in cells.items():
|
||||||
|
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||||
|
|
||||||
|
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
|
||||||
|
log_file_save(
|
||||||
|
current_user["username"], vault_name, path,
|
||||||
|
sum(len(str(v)) for v in cells.values()),
|
||||||
|
current_user.get("_request_ip", "unknown"),
|
||||||
|
)
|
||||||
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||||
|
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||||
|
"""Delete a file from the vault.
|
||||||
|
|
||||||
|
The path is validated against traversal attacks before deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
path: Relative file path within the vault.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``FileDeleteResponse`` confirming the deletion.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_delete_file(vault_name, path)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
|
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
await remove_single_file(vault_name, path)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("file_deleted", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
|
||||||
|
from backend.plugins import emit_file_deleted
|
||||||
|
emit_file_deleted(vault_name, path)
|
||||||
|
|
||||||
|
# Remove from recent files
|
||||||
|
remove_recent(current_user["username"], vault_name, path)
|
||||||
|
|
||||||
|
# Dispatch webhooks
|
||||||
|
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||||
|
|
||||||
|
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
|
||||||
|
async def api_directory_create(
|
||||||
|
vault_name: str,
|
||||||
|
body: DirectoryCreateRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Create a new directory in a vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with directory path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DirectoryCreateResponse confirming creation.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_create_directory(vault_name, body.path)
|
||||||
|
|
||||||
|
# Update path_index with the new directory
|
||||||
|
from backend.indexer import _index_lock
|
||||||
|
from backend.indexer import path_index as _path_idx
|
||||||
|
with _index_lock:
|
||||||
|
if vault_name not in _path_idx:
|
||||||
|
_path_idx[vault_name] = []
|
||||||
|
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||||
|
# Build all parent segments
|
||||||
|
parts = body.path.split("/")
|
||||||
|
for i in range(1, len(parts) + 1):
|
||||||
|
seg_path = "/".join(parts[:i])
|
||||||
|
if seg_path and seg_path not in existing:
|
||||||
|
existing.add(seg_path)
|
||||||
|
_path_idx[vault_name].append({
|
||||||
|
"path": seg_path,
|
||||||
|
"name": parts[i - 1],
|
||||||
|
"type": "directory",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("directory_created", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||||
|
|
||||||
|
return {"success": True, "path": result["path"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||||
|
async def api_directory_rename(
|
||||||
|
vault_name: str,
|
||||||
|
body: DirectoryRenameRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Rename a directory in a vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with current path and new name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DirectoryRenameResponse with old and new paths.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||||
|
old_path_str = result["old_path"]
|
||||||
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
|
# Update index for all files in the directory
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("directory_renamed", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||||
|
|
||||||
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||||
|
async def api_directory_delete(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to directory"),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Delete a directory and all its contents from a vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
path: Relative directory path within the vault.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DirectoryDeleteResponse with count of deleted files.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_delete_directory(vault_name, path, recursive=True)
|
||||||
|
file_count = result["deleted_count"]
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("directory_deleted", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
"deleted_count": file_count,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||||
|
|
||||||
|
return {"success": True, "deleted_count": file_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
|
||||||
|
async def api_file_create(
|
||||||
|
vault_name: str,
|
||||||
|
body: FileCreateRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Create a new file in a vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with file path and initial content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileCreateResponse confirming creation.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_create_file(vault_name, body.path, body.content)
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
await update_single_file(vault_name, result["path"])
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("file_created", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
||||||
|
from backend.plugins import emit_file_created
|
||||||
|
emit_file_created(vault_name, result["path"])
|
||||||
|
|
||||||
|
return {"success": True, "path": result["path"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
|
||||||
|
async def api_batch_upload(
|
||||||
|
vault_name: str,
|
||||||
|
body: BatchUploadRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Upload multiple files and directories (recursively) into a vault.
|
||||||
|
|
||||||
|
Accepts base64 encoded or plain text files with relative directory paths.
|
||||||
|
Creates missing parent folders safely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Target vault name.
|
||||||
|
body: BatchUploadRequest with target_dir and files list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BatchUploadResponse with summary of uploaded files and errors.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for f in body.files:
|
||||||
|
if f.is_dir:
|
||||||
|
items.append({"path": f.path, "is_dir": True})
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_bytes = b""
|
||||||
|
if f.content is not None:
|
||||||
|
# Check if content is base64 encoded data URI or raw base64
|
||||||
|
content_str = f.content
|
||||||
|
if content_str.startswith("data:") and ";base64," in content_str:
|
||||||
|
content_str = content_str.split(";base64,", 1)[1]
|
||||||
|
try:
|
||||||
|
raw_bytes = base64.b64decode(content_str)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to utf-8 text encoding
|
||||||
|
raw_bytes = f.content.encode("utf-8")
|
||||||
|
|
||||||
|
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
|
||||||
|
|
||||||
|
result = service_batch_upload_files(
|
||||||
|
vault_name,
|
||||||
|
body.target_dir,
|
||||||
|
items,
|
||||||
|
overwrite=body.overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update index and SSE notifications for uploaded files
|
||||||
|
for path in result["uploaded"]:
|
||||||
|
try:
|
||||||
|
await update_single_file(vault_name, path)
|
||||||
|
await sse_manager.broadcast("file_created", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to post-process upload of {path}: {e}")
|
||||||
|
|
||||||
|
# SSE notification for tree refresh
|
||||||
|
if result["uploaded"] or result["created_dirs"]:
|
||||||
|
await sse_manager.broadcast("tree_updated", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"target_dir": result["target_dir"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||||
|
async def api_file_rename(
|
||||||
|
vault_name: str,
|
||||||
|
body: FileRenameRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Rename a file in a vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with current path and new name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileRenameResponse with old and new paths.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||||
|
old_path_str = result["old_path"]
|
||||||
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
|
# Update bookmarks, history, and shares
|
||||||
|
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
|
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
|
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("file_renamed", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||||
|
|
||||||
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||||
|
async def api_file_move(
|
||||||
|
vault_name: str,
|
||||||
|
body: FileMoveRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Move a file or directory to a different parent directory within the same vault.
|
||||||
|
|
||||||
|
Supports both files and directories. The item keeps its original name;
|
||||||
|
only the parent directory changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with source_path and destination_dir.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileMoveResponse with old and new paths.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||||
|
old_path_str = result["old_path"]
|
||||||
|
new_path_str = result["new_path"]
|
||||||
|
item_type = result["item_type"]
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
if item_type == "directory":
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
else:
|
||||||
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("item_moved", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
"item_type": item_type,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||||
|
|
||||||
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||||
@@ -281,6 +281,136 @@ class FileRawResponse(BaseModel):
|
|||||||
raw: str = Field(description="Raw file content as text")
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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'")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.7"
|
version = "2.27.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.7"
|
version = "2.27.8"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.27.7",
|
"version": "2.27.8",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26
|
> **Version :** 2.27.8 | **Dernière mise à jour :** 2026-09-26
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
|
|
||||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`).
|
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`).
|
||||||
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
||||||
- **Sous-tâches :**
|
- **Sous-tâches :**
|
||||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
||||||
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
||||||
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
||||||
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.27.7",
|
"version": "2.27.8",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
Reference in New Issue
Block a user