feat: export Markdown notes (ARQ/Redis) + backup/restore BDD — v0.5.2
- Volume data/ (bind mount ./data) remplace le volume uploads nommé
data/notes/ → .md auto-générés, data/uploads/ → médias, data/backup/ → dumps
- Service Redis (redis:7-alpine) + worker ARQ (backend-worker)
- notes_markdown.py : frontmatter YAML + contenu + pièces jointes (liens relatifs)
Nom : YYYY-MM-DD_slug-titre_shortid.md, rotation si titre modifié
- api/notes.py : publie export_note_markdown / remove_note_markdown sur Redis
après chaque create / update / delete / add_attachment / delete_attachment
- api/admin.py : POST /backup, GET /backups, POST /restore/{filename} (pg_dump/pg_restore)
- Backend Dockerfile : postgresql-client ; requirements : arq==0.26.1
- ConfigPage : section "Base de données" avec sauvegarde + liste + restauration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.notes import NoteItem
|
||||
|
||||
|
||||
def _slugify(text: str, max_len: int = 50) -> str:
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(c for c in text if unicodedata.category(c) != "Mn")
|
||||
text = text.lower()
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
text = re.sub(r"[\s_-]+", "-", text)
|
||||
return text.strip("-")[:max_len]
|
||||
|
||||
|
||||
def _note_filename(note: NoteItem) -> str:
|
||||
date = note.created_at.strftime("%Y-%m-%d")
|
||||
raw = note.title or note.content[:40]
|
||||
slug = _slugify(raw) or "sans-titre"
|
||||
short_id = str(note.id)[:8]
|
||||
return f"{date}_{slug}_{short_id}.md"
|
||||
|
||||
|
||||
def _render(note: NoteItem) -> str:
|
||||
lines = ["---"]
|
||||
lines.append(f"id: {note.id}")
|
||||
if note.title:
|
||||
lines.append(f'title: "{note.title}"')
|
||||
if note.category:
|
||||
lines.append(f"category: {note.category}")
|
||||
if note.tags:
|
||||
tags_str = ", ".join(f'"{t}"' for t in note.tags)
|
||||
lines.append(f"tags: [{tags_str}]")
|
||||
lines.append(f"created_at: {note.created_at.isoformat()}")
|
||||
if note.gps_lat is not None and note.gps_lon is not None:
|
||||
lines.append(f'gps: "{note.gps_lat}, {note.gps_lon}"')
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(note.content)
|
||||
|
||||
images = [a for a in note.attachments if a.file_type == "image" and a.file_path]
|
||||
audios = [a for a in note.attachments if a.file_type == "audio" and a.file_path]
|
||||
|
||||
if images or audios:
|
||||
lines.append("")
|
||||
lines.append("## Pièces jointes")
|
||||
lines.append("")
|
||||
for att in images:
|
||||
# file_path est relatif à UPLOAD_DIR, ex: "images/originals/uuid.webp"
|
||||
rel = f"../uploads/{att.file_path}"
|
||||
name = att.original_name or "image"
|
||||
lines.append(f"")
|
||||
for att in audios:
|
||||
rel = f"../uploads/{att.file_path}"
|
||||
name = att.original_name or "audio"
|
||||
lines.append(f"[{name}]({rel})")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def save_note_markdown(note: NoteItem) -> None:
|
||||
notes_dir = settings.notes_md_path
|
||||
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||
short_id = str(note.id)[:8]
|
||||
# Supprime l'ancienne version (titre peut avoir changé)
|
||||
for old in notes_dir.glob(f"*_{short_id}.md"):
|
||||
old.unlink()
|
||||
(notes_dir / _note_filename(note)).write_text(_render(note), encoding="utf-8")
|
||||
|
||||
|
||||
def delete_note_markdown(note_id: str) -> None:
|
||||
notes_dir = settings.notes_md_path
|
||||
if not notes_dir.exists():
|
||||
return
|
||||
short_id = note_id[:8]
|
||||
for f in notes_dir.glob(f"*_{short_id}.md"):
|
||||
f.unlink()
|
||||
Reference in New Issue
Block a user