Add share, webhook, and conflict management features

This commit is contained in:
2026-05-26 11:00:48 -04:00
parent ed2bb4f7fb
commit 0b611a8735
11 changed files with 1595 additions and 366 deletions
+375
View File
@@ -0,0 +1,375 @@
# Implementation Plan — Remaining Roadmap Items
## 1. 📝 Documentation OpenAPI enrichie (P3) — 5 min
**Goal:** Add `Field(description=...)` to all Pydantic models without descriptions in `backend/main.py`.
**Models to update (lines 89–311):**
| Line | Model | Fields to annotate |
|------|-------|--------------------|
| 89 | `FileContentResponse` | `vault`, `path`, `title`, `tags`, `frontmatter`, `html`, `raw_length`, `extension`, `is_markdown`, `unsupported`, `size_bytes` |
| 103 | `FileRawResponse` | `vault`, `path`, `raw` |
| 110 | `FileSaveResponse` | `status`, `vault`, `path`, `size` |
| 118 | `FileDeleteResponse` | `status`, `vault`, `path` |
| 125 | `SearchResultItem` | `vault`, `path`, `title`, `tags`, `score`, `snippet`, `modified` |
| 136 | `SearchResponse` | `query`, `vault_filter`, `tag_filter`, `count`, `results` (total, offset, limit already have Field) |
| 146 | `TagsResponse` | `vault_filter`, `tags` |
| 152 | `TreeSearchResult` | `vault`, `path`, `name`, `matched_path` (type has Field) |
| 161 | `TreeSearchResponse` | `query`, `vault_filter`, `results` |
| 168 | `AdvancedSearchResultItem` | `vault`, `path`, `title`, `tags`, `score`, `snippet`, `modified` |
| 179 | `SearchFacets` | `tags`, `vaults` (already have default_factory) |
| 185 | `AdvancedSearchResponse` | `results`, `total`, `offset`, `limit`, `facets` (query_time_ms has Field) |
| 196 | `TitleSuggestion` | `vault`, `path`, `title` |
| 203 | `SuggestResponse` | `query`, `suggestions` |
| 209 | `TagSuggestion` | `tag`, `count` |
| 215 | `TagSuggestResponse` | `query`, `suggestions` |
| 221 | `GraphNode` | (all fields already have Field) |
| 231 | `GraphEdge` | (all fields already have Field) |
| 239 | `GraphResponse` | `vault`, `path`, `nodes`, `edges` |
| 247 | `ReloadResponse` | `status`, `vaults` |
| 253 | `HealthResponse` | `status`, `version`, `vaults`, `total_files` |
| 265 | `DirectoryCreateResponse` | `success`, `path` |
| 284 | `DirectoryDeleteResponse` | `success`, `deleted_count` |
| 296 | `FileCreateResponse` | `success`, `path` |
| 307 | `FileRenameResponse` | `success` |
**Dependency:** None. Pure documentation change.
---
## 2. 📊 Dashboard statistiques (P3) — 30 min
### 2a. Backend: `GET /api/dashboard` (new endpoint)
**File:** `backend/main.py` — insert at **line ~2547** (after `/api/diagnostics`)
```python
@app.get("/api/dashboard")
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
from backend.indexer import index, vault_config, path_index
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = []
total_files = 0
total_tags = set()
total_size = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
files = vdata.get("files", [])
file_count = len(files)
total_files += file_count
tags = set()
for f in files:
tags.update(f.get("tags", []))
total_size += f.get("size", 0)
total_tags.update(tags)
vault_stats.append({
"name": vname,
"file_count": file_count,
"tag_count": len(tags),
"total_size_bytes": sum(f.get("size", 0) for f in files),
})
return {
"vaults": vault_stats,
"total_files": total_files,
"total_tags": len(total_tags),
"total_size_bytes": total_size,
}
```
**No new model needed** — return plain dict (or add optional `DashboardResponse` model).
### 2b. Frontend: Insert stats widget in dashboard-home
**File:** `frontend/index.html` — **after line 364** (`</div>` closing bookmarks section, before `<!-- Recently Opened Section -->`)
Add:
```html
<!-- Stats Section -->
<div id="dashboard-stats-section" class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="bar-chart-3" class="dashboard-icon" style="color:var(--accent)"></i>
<h2>Statistiques</h2>
</div>
</div>
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
<div class="dashboard-stats-loading">Chargement...</div>
</div>
</div>
```
**File:** `frontend/app.js` — add `DashboardStatsWidget` module (insert at **line ~3343**, before `DashboardRecentWidget`):
```javascript
const DashboardStatsWidget = {
async load() {
const grid = document.getElementById("dashboard-stats-grid");
if (!grid) return;
grid.innerHTML = '<div class="dashboard-stats-loading">Chargement...</div>';
try {
const data = await api("/api/dashboard");
this.render(data);
} catch (err) {
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
}
},
render(data) {
const grid = document.getElementById("dashboard-stats-grid");
if (!grid) return;
const items = [
{ icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() },
{ icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() },
{ icon: "hard-drive", label: "Taille totale", value: this._formatSize(data.total_size_bytes) },
{ icon: "folder", label: "Vaults", value: data.vaults.length.toString() },
];
grid.innerHTML = items.map(i => `
<div class="stat-card">
<i data-lucide="${i.icon}" class="stat-icon"></i>
<span class="stat-value">${i.value}</span>
<span class="stat-label">${i.label}</span>
</div>
`).join("");
safeCreateIcons();
},
_formatSize(bytes) { /* KB/MB/GB formatter */ }
};
```
**Also update `showWelcome()`** at **line ~5417** — the dashboard rebuild HTML must include the stats section div. And **line ~5490** — add `DashboardStatsWidget.load()` call.
**File:** `frontend/style.css` — add CSS for `.dashboard-stats-grid`, `.stat-card`, `.stat-icon`, `.stat-value`, `.stat-label`.
**Dependency:** Item 1 (Pydantic models) — none. Standalone.
---
## 3. 🔔 Webhooks (P3) — 45 min
### 3a. New backend module: `backend/webhooks.py`
Create full module with:
- `WEBHOOKS_FILE = Path("data/webhooks.json")` — persistence
- `_DEFAULT_WEBHOOKS = []`
- `get_webhooks() -> list` — reads from disk
- `create_webhook(name, url, events, secret=None) -> dict`
- `update_webhook(id, updates) -> dict`
- `delete_webhook(id) -> bool`
- `async def dispatch_webhooks(event_type: str, data: dict)` — calls all webhooks subscribed to `event_type`, sends JSON POST with HMAC-SHA256 signature header if secret is set, timeout 5s, logs failures
- Model: `WebhookConfig` with `id`, `name`, `url`, `events` (list of event type strings), `secret` (optional), `enabled`, `created_at`, `last_fired_at`
### 3b. Backend: CRUD endpoints in `backend/main.py`
Insert at **line ~2470** (before `GET /api/config`):
```python
@app.get("/api/webhooks")
@app.post("/api/webhooks")
@app.patch("/api/webhooks/{webhook_id}")
@app.delete("/api/webhooks/{webhook_id}")
```
Import `from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks`
### 3c. Backend: Hook dispatch_webhooks into file events
Add `await dispatch_webhooks("file_created", {...})` calls alongside each `sse_manager.broadcast(...)` call:
| Line | Event | Add dispatch |
|------|-------|-------------|
| ~1252 | `file_deleted` | `dispatch_webhooks("file_deleted", {"vault":..., "path":...})` |
| ~1330 | `directory_created` | `dispatch_webhooks("directory_created", {...})` |
| ~1401 | `directory_renamed` | `dispatch_webhooks("directory_renamed", {...})` |
| ~1462 | `directory_deleted` | `dispatch_webhooks("directory_deleted", {...})` |
| ~1532 | `file_created` | `dispatch_webhooks("file_created", {...})` |
| ~1607 | `file_renamed` | `dispatch_webhooks("file_renamed", {...})` |
### 3d. Frontend: Webhooks management UI in Configurations modal
**File:** `frontend/index.html` — insert at **line ~633** (after `<!-- À propos -->` section, before `</div>` closing config-content):
```html
<section class="config-section">
<h2>🔔 Webhooks</h2>
<p class="config-description">Notifications HTTP vers des services externes lors des changements de fichiers.</p>
<div id="webhooks-list"></div>
<div class="config-add-pattern">
<input type="text" id="webhook-name-input" placeholder="Nom" class="config-input" style="width:120px">
<input type="text" id="webhook-url-input" placeholder="https://..." class="config-input" style="flex:1">
<button id="webhook-add-btn" class="config-btn-add">Ajouter</button>
</div>
</section>
```
**File:** `frontend/app.js` — in `initConfigModal()` at **line ~3918**, add:
```javascript
loadWebhooks(); // in the open handler
// Event binding for webhook add/save/delete buttons
```
Add functions: `loadWebhooks()`, `renderWebhooks(webhooks)`, `addWebhook()`, `deleteWebhook(id)`, `toggleWebhook(id, enabled)`. All use `api("/api/webhooks", ...)`.
**File:** `frontend/style.css` — add `.webhook-item`, `.webhook-toggle`, `.webhook-delete` styles.
**Dependency:** None on items 1–2. Standalone.
---
## 4. 📤 Publication publique de documents (P3) — 60 min
### 4a. New backend module: `backend/share.py`
Create full module:
- `SHARES_FILE = Path("data/shares.json")`
- ShareToken model: `id`, `vault`, `path`, `token` (64-char hex), `created_by`, `created_at`, `expires_at` (optional, null = never), `access_count`, `last_accessed`
- `create_share(vault, path, created_by, expires_in_hours=None) -> dict` — generates token, stores, returns share info
- `get_share_by_token(token) -> dict | None` — validates expiry, returns share
- `revoke_share(id) -> bool`
- `list_shares(vault_filter=None) -> list` — for admin/settings page
- `record_access(token)` — increments access_count
### 4b. Backend: Endpoints in `backend/main.py`
Insert at **line ~1619** (before `GET /api/file/{vault_name}`):
```python
# Share management
@app.post("/api/share/{vault_name}")
@app.get("/api/shares")
@app.delete("/api/share/{share_id}")
# Public view (no auth required!)
@app.get("/s/{token}")
async def public_share_view(token: str): ...
```
The public view endpoint:
1. Looks up token via `get_share_by_token(token)`
2. Reads the file content
3. Renders markdown with redacted secrets
4. Returns simple HTML page (not SPA) with rendered content
5. Increments access count
### 4c. Frontend: Share button in file actions
**File:** `frontend/app.js` — in `renderFile()`, at **line ~3250** (after pop-out button):
```javascript
const shareBtn = el("button", { class: "btn-action", title: "Partager" }, [icon("share-2", 14), document.createTextNode("Partager")]);
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
```
Add `shareBtn` to the file-actions div at **line ~3300**.
Add `openShareDialog(vault, path)` function that:
- Calls `POST /api/share/{vault}` to create a share
- Shows a modal with the share URL (copyable) and expiration options
- Shows existing shares list with revoke buttons
### 4d. Frontend: Share management in Configurations
**File:** `frontend/index.html` — add share management section in config modal (alongside webhooks).
**File:** `frontend/app.js` — `loadShares()` and `renderShares()` functions.
**File:** `frontend/style.css` — add `.share-dialog`, `.share-url`, `.share-item` styles.
**Dependency:** None. Standalone, but needs item 1 for clean models.
---
## 5. 🔄 Gestion conflits Syncthing (P2) — 45 min
### 5a. Backend: Conflict file detection
**File:** `backend/indexer.py` — add after `_backlink_index`:
```python
def get_conflicts() -> list:
"""Scan all vaults for Syncthing/Nextcloud sync-conflict files."""
conflicts = []
pattern = re.compile(r'\.sync-conflict-(\d{8}-\d{6})\.')
for vname, vdata in index.items():
for f in vdata.get("files", []):
m = pattern.search(f["path"])
if m:
# Find the original file
orig_path = pattern.sub("", f["path"])
conflicts.append({
"vault": vname,
"conflict_path": f["path"],
"original_path": orig_path,
"conflict_date": m.group(1),
"conflict_title": f.get("title", ""),
})
return conflicts
```
### 5b. Backend: Endpoints
**File:** `backend/main.py` — insert at **line ~2547**:
```python
@app.get("/api/conflicts")
async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults."""
...
@app.post("/api/conflicts/resolve")
async def api_conflict_resolve(body: dict, current_user=Depends(require_auth)):
"""Resolve a conflict: keep_local (delete conflict), keep_conflict (replace original)."""
...
```
### 5c. Backend: Diff endpoint
```python
@app.get("/api/conflicts/diff")
async def api_conflict_diff(vault: str, original: str, conflict: str, current_user=Depends(require_auth)):
"""Return unified diff between original and conflict file."""
import difflib
...
```
### 5d. Frontend: Conflict dashboard widget
**File:** `frontend/index.html` — add `#dashboard-conflicts-section` in dashboard after stats section.
**File:** `frontend/app.js` — add `DashboardConflictsWidget` (pattern similar to recent/bookmarks):
- `load()` → `GET /api/conflicts`
- `render()` → shows conflict cards with file names and dates
- Click → opens diff modal showing side-by-side comparison
- Action buttons: "Garder l'original", "Garder le conflit"
**File:** `frontend/style.css` — add `.conflict-card`, `.conflict-diff`, `.conflict-actions` styles.
**Dependency:** None. Standalone.
---
## Execution Order (optimal)
1. **Item 1** — OpenAPI docs (quick win, no risk)
2. **Item 2** — Dashboard stats (standalone, visible result)
3. **Item 3** — Webhooks (new module + integration, most code)
4. **Item 4** — Public shares (new module + public view, security-sensitive)
5. **Item 5** — Syncthing conflicts (standalone, nice-to-have)
**Total estimated effort:** ~3 hours
## Files Summary
| File | Action | Items |
|------|--------|-------|
| `backend/main.py` | Edit | 1 (models), 2a (endpoint), 3b+c (webhook CRUD+dispatch), 4b (share+public view), 5b+c (conflicts) |
| `backend/webhooks.py` | **Create** | 3a |
| `backend/share.py` | **Create** | 4a |
| `backend/indexer.py` | Edit | 5a (get_conflicts) |
| `frontend/index.html` | Edit | 2b, 3d, 4d, 5d (dashboard + config sections) |
| `frontend/app.js` | Edit | 2b, 3d, 4c, 5d (widgets + share button + webhook UI) |
| `frontend/style.css` | Edit | 2b, 3d, 4c, 5d (all new CSS classes) |