Files
personotes/internal/api/handler.go

1554 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
yaml "gopkg.in/yaml.v3"
"github.com/mathieu/personotes/internal/i18n"
"github.com/mathieu/personotes/internal/indexer"
)
// TreeNode représente un nœud dans l'arborescence des fichiers
type TreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Children []*TreeNode `json:"children,omitempty"`
}
// BacklinkInfo représente une note qui référence la note courante
type BacklinkInfo struct {
Path string `json:"path"`
Title string `json:"title"`
}
// Handler gère toutes les routes de l'API.
type Handler struct {
notesDir string
idx *indexer.Indexer
templates *template.Template
logger *log.Logger
i18n *i18n.Translator
}
// NewHandler construit un handler unifié pour l'API.
func NewHandler(notesDir string, idx *indexer.Indexer, tpl *template.Template, logger *log.Logger, translator *i18n.Translator) *Handler {
return &Handler{
notesDir: notesDir,
idx: idx,
templates: tpl,
logger: logger,
i18n: translator,
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
h.logger.Printf("%s %s", r.Method, path)
// I18n endpoint - serve translation files
if strings.HasPrefix(path, "/api/i18n/") {
h.handleI18n(w, r)
return
}
// REST API v1 endpoints
if strings.HasPrefix(path, "/api/v1/notes") {
h.handleRESTNotes(w, r)
return
}
// Legacy/HTML endpoints
if strings.HasPrefix(path, "/api/search") {
h.handleSearch(w, r)
return
}
if path == "/api/folders/create" {
h.handleCreateFolder(w, r)
return
}
if path == "/api/files/move" {
h.handleMoveFile(w, r)
return
}
if path == "/api/files/delete-multiple" {
h.handleDeleteMultiple(w, r)
return
}
if path == "/api/notes/new-auto" {
h.handleNewNoteAuto(w, r)
return
}
if path == "/api/notes/new-prompt" {
h.handleNewNotePrompt(w, r)
return
}
if path == "/api/notes/create-custom" {
h.handleCreateCustomNote(w, r)
return
}
if path == "/api/home" {
h.handleHome(w, r)
return
}
if path == "/api/about" {
h.handleAbout(w, r)
return
}
if strings.HasPrefix(path, "/api/daily") {
h.handleDaily(w, r)
return
}
if strings.HasPrefix(path, "/api/notes/") {
h.handleNotes(w, r)
return
}
if path == "/api/tree" {
h.handleFileTree(w, r)
return
}
if strings.HasPrefix(path, "/api/favorites") {
h.handleFavorites(w, r)
return
}
if strings.HasPrefix(path, "/api/folder/") {
h.handleFolderView(w, r)
return
}
http.NotFound(w, r)
}
// buildFileTree construit l'arborescence hiérarchique des fichiers et dossiers
func (h *Handler) buildFileTree() (*TreeNode, error) {
root := &TreeNode{
Name: "notes",
Path: "",
IsDir: true,
Children: make([]*TreeNode, 0),
}
err := filepath.WalkDir(h.notesDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Ignorer le répertoire racine lui-même
if path == h.notesDir {
return nil
}
relPath, err := filepath.Rel(h.notesDir, path)
if err != nil {
return err
}
// Ignorer les fichiers cachés
if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Pour les fichiers, ne garder que les .md
if !d.IsDir() && !strings.EqualFold(filepath.Ext(path), ".md") {
return nil
}
// Créer le nœud
node := &TreeNode{
Name: d.Name(),
Path: relPath,
IsDir: d.IsDir(),
Children: make([]*TreeNode, 0),
}
// Trouver le parent et ajouter ce nœud
h.insertNode(root, node, relPath)
return nil
})
if err != nil {
return nil, err
}
// Trier les enfants (dossiers d'abord, puis fichiers, alphabétiquement)
h.sortTreeNode(root)
return root, nil
}
// insertNode insère un nœud dans l'arbre à la bonne position
func (h *Handler) insertNode(root *TreeNode, node *TreeNode, path string) {
parts := strings.Split(filepath.ToSlash(path), "/")
current := root
for i := 0; i < len(parts)-1; i++ {
found := false
for _, child := range current.Children {
if child.Name == parts[i] && child.IsDir {
current = child
found = true
break
}
}
if !found {
// Créer le dossier intermédiaire
newDir := &TreeNode{
Name: parts[i],
Path: strings.Join(parts[:i+1], "/"),
IsDir: true,
Children: make([]*TreeNode, 0),
}
current.Children = append(current.Children, newDir)
current = newDir
}
}
current.Children = append(current.Children, node)
}
// sortTreeNode trie récursivement les enfants d'un nœud
func (h *Handler) sortTreeNode(node *TreeNode) {
if !node.IsDir || len(node.Children) == 0 {
return
}
sort.Slice(node.Children, func(i, j int) bool {
// Les dossiers avant les fichiers
if node.Children[i].IsDir != node.Children[j].IsDir {
return node.Children[i].IsDir
}
// Puis alphabétiquement
return strings.ToLower(node.Children[i].Name) < strings.ToLower(node.Children[j].Name)
})
// Trier récursivement
for _, child := range node.Children {
h.sortTreeNode(child)
}
}
func (h *Handler) handleFileTree(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// Si ce n'est pas une requête HTMX, rediriger vers la page principale
if r.Header.Get("HX-Request") == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
tree, err := h.buildFileTree()
if err != nil {
h.logger.Printf("erreur lors de la construction de l arborescence: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
return
}
data := struct {
Tree *TreeNode
}{
Tree: tree,
}
err = h.templates.ExecuteTemplate(w, "file-tree.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template de l arborescence: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// Si ce n'est pas une requête HTMX (ex: accès direct via URL), rediriger vers la page principale
if r.Header.Get("HX-Request") == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Générer le contenu Markdown avec la liste de toutes les notes
content := h.generateHomeMarkdown(r)
// Utiliser le template editor.html pour afficher la page d'accueil
data := struct {
Filename string
Content string
IsHome bool
Backlinks []BacklinkInfo
Breadcrumb template.HTML
}{
Filename: "🏠 Accueil - Index",
Content: content,
IsHome: true,
Backlinks: nil, // Pas de backlinks pour la page d'accueil
Breadcrumb: h.generateBreadcrumb(""),
}
err := h.templates.ExecuteTemplate(w, "editor.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template home: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handleAbout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// Utiliser le template about.html pour afficher la page À propos
err := h.templates.ExecuteTemplate(w, "about.html", nil)
if err != nil {
h.logger.Printf("erreur d execution du template about: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
// generateHomeMarkdown génère le contenu Markdown de la page d'accueil
func (h *Handler) generateHomeMarkdown(r *http.Request) string {
var sb strings.Builder
// En-tête
sb.WriteString("# 📚 Index\n\n")
sb.WriteString("_" + h.t(r, "home.autoUpdate") + " • " + time.Now().Format("02/01/2006 à 15:04") + "_\n\n")
// Construire l'arborescence
tree, err := h.buildFileTree()
if err != nil {
h.logger.Printf("erreur lors de la construction de l'arbre: %v", err)
sb.WriteString("❌ Erreur lors de la génération de l'index\n")
return sb.String()
}
// Compter le nombre de notes
noteCount := h.countNotes(tree)
// Section des tags (en premier)
h.generateTagsSection(&sb)
// Section des favoris (après les tags)
h.generateFavoritesSection(&sb, r)
// Section des notes récemment modifiées (après les favoris)
h.generateRecentNotesSection(&sb, r)
// Section de toutes les notes avec accordéon
sb.WriteString("<div class=\"home-section\">\n")
sb.WriteString(" <div class=\"home-section-header\" onclick=\"toggleFolder('all-notes')\">\n")
sb.WriteString(fmt.Sprintf(" <h2 class=\"home-section-title\">📂 %s (%d)</h2>\n", h.t(r, "home.allNotes"), noteCount))
sb.WriteString(" </div>\n")
sb.WriteString(" <div class=\"home-section-content\" id=\"folder-all-notes\">\n")
// Générer l'arborescence en Markdown
h.generateMarkdownTree(&sb, tree, 0)
sb.WriteString(" </div>\n")
sb.WriteString("</div>\n")
return sb.String()
}
// generateTagsSection génère la section des tags avec comptage
func (h *Handler) generateTagsSection(sb *strings.Builder) {
tags := h.idx.GetAllTagsWithCount()
if len(tags) == 0 {
return
}
sb.WriteString("<div class=\"home-section\">\n")
sb.WriteString(" <div class=\"home-section-header\" onclick=\"toggleFolder('tags')\">\n")
sb.WriteString(" <h2 class=\"home-section-title\">🏷️ Tags</h2>\n")
sb.WriteString(" </div>\n")
sb.WriteString(" <div class=\"home-section-content\" id=\"folder-tags\">\n")
sb.WriteString(" <div class=\"tags-cloud\">\n")
for _, tc := range tags {
// Créer un lien HTML discret et fonctionnel
sb.WriteString(fmt.Sprintf(
` <a href="#" class="tag-item" hx-get="/api/search?query=tag:%s" hx-target="#search-results" hx-swap="innerHTML"><kbd class="tag-badge">#%s</kbd> <mark class="tag-count">%d</mark></a>`,
tc.Tag, tc.Tag, tc.Count,
))
sb.WriteString("\n")
}
sb.WriteString(" </div>\n")
sb.WriteString(" </div>\n")
sb.WriteString("</div>\n\n")
}
// generateFavoritesSection génère la section des favoris avec arborescence dépliable
func (h *Handler) generateFavoritesSection(sb *strings.Builder, r *http.Request) {
favorites, err := h.loadFavorites()
if err != nil || len(favorites.Items) == 0 {
return
}
sb.WriteString("<div class=\"home-section\">\n")
sb.WriteString(" <div class=\"home-section-header\" onclick=\"toggleFolder('favorites')\">\n")
sb.WriteString(" <h2 class=\"home-section-title\">⭐ " + h.t(r, "favorites.title") + "</h2>\n")
sb.WriteString(" </div>\n")
sb.WriteString(" <div class=\"home-section-content\" id=\"folder-favorites\">\n")
sb.WriteString(" <div class=\"note-tree favorites-tree\">\n")
for _, fav := range favorites.Items {
safeID := "fav-" + strings.ReplaceAll(strings.ReplaceAll(fav.Path, "/", "-"), "\\", "-")
if fav.IsDir {
// Dossier - avec accordéon
sb.WriteString(fmt.Sprintf(" <div class=\"folder indent-level-1\">\n"))
sb.WriteString(fmt.Sprintf(" <div class=\"folder-header\" onclick=\"toggleFolder('%s')\">\n", safeID))
sb.WriteString(fmt.Sprintf(" <span class=\"folder-icon\" id=\"icon-%s\">📁</span>\n", safeID))
sb.WriteString(fmt.Sprintf(" <strong>%s</strong>\n", fav.Title))
sb.WriteString(fmt.Sprintf(" </div>\n"))
sb.WriteString(fmt.Sprintf(" <div class=\"folder-content\" id=\"folder-%s\">\n", safeID))
// Lister le contenu du dossier
h.generateFavoriteFolderContent(sb, fav.Path, 3)
sb.WriteString(fmt.Sprintf(" </div>\n"))
sb.WriteString(fmt.Sprintf(" </div>\n"))
} else {
// Fichier
sb.WriteString(fmt.Sprintf(" <div class=\"file indent-level-1\">\n"))
sb.WriteString(fmt.Sprintf(" <a href=\"#\" hx-get=\"/api/notes/%s\" hx-target=\"#editor-container\" hx-swap=\"innerHTML\" hx-push-url=\"true\">", fav.Path))
sb.WriteString(fmt.Sprintf("📄 %s", fav.Title))
sb.WriteString("</a>\n")
sb.WriteString(fmt.Sprintf(" </div>\n"))
}
}
sb.WriteString(" </div>\n")
sb.WriteString(" </div>\n")
sb.WriteString("</div>\n\n")
}
// generateRecentNotesSection génère la section des notes récemment modifiées
func (h *Handler) generateRecentNotesSection(sb *strings.Builder, r *http.Request) {
recentDocs := h.idx.GetRecentDocuments(5)
if len(recentDocs) == 0 {
return
}
sb.WriteString("<div class=\"home-section\">\n")
sb.WriteString(" <div class=\"home-section-header\" onclick=\"toggleFolder('recent')\">\n")
sb.WriteString(" <h2 class=\"home-section-title\">🕒 " + h.t(r, "home.recentlyModified") + "</h2>\n")
sb.WriteString(" </div>\n")
sb.WriteString(" <div class=\"home-section-content\" id=\"folder-recent\">\n")
sb.WriteString(" <div class=\"recent-notes-container\">\n")
for _, doc := range recentDocs {
// Extraire les premières lignes du corps (max 150 caractères)
preview := doc.Summary
if len(preview) > 150 {
preview = preview[:150] + "..."
}
// Parser la date de modification pour un affichage plus lisible
dateStr := doc.LastModified
if dateStr == "" {
dateStr = doc.Date
}
sb.WriteString(" <div class=\"recent-note-card\">\n")
sb.WriteString(fmt.Sprintf(" <a href=\"#\" class=\"recent-note-link\" hx-get=\"/api/notes/%s\" hx-target=\"#editor-container\" hx-swap=\"innerHTML\" hx-push-url=\"true\">\n", doc.Path))
sb.WriteString(fmt.Sprintf(" <div class=\"recent-note-title\">%s</div>\n", doc.Title))
sb.WriteString(fmt.Sprintf(" <div class=\"recent-note-meta\">\n"))
sb.WriteString(fmt.Sprintf(" <span class=\"recent-note-date\">📅 %s</span>\n", dateStr))
if len(doc.Tags) > 0 {
sb.WriteString(fmt.Sprintf(" <span class=\"recent-note-tags\">"))
for i, tag := range doc.Tags {
if i > 0 {
sb.WriteString(" ")
}
sb.WriteString(fmt.Sprintf("#%s", tag))
}
sb.WriteString("</span>\n")
}
sb.WriteString(" </div>\n")
if preview != "" {
sb.WriteString(fmt.Sprintf(" <div class=\"recent-note-preview\">%s</div>\n", preview))
}
sb.WriteString(" </a>\n")
sb.WriteString(" </div>\n")
}
sb.WriteString(" </div>\n")
sb.WriteString(" </div>\n")
sb.WriteString("</div>\n\n")
}
// generateFavoriteFolderContent génère le contenu d'un dossier favori
func (h *Handler) generateFavoriteFolderContent(sb *strings.Builder, folderPath string, depth int) {
// Construire le chemin absolu
absPath := filepath.Join(h.notesDir, folderPath)
entries, err := os.ReadDir(absPath)
if err != nil {
return
}
indent := strings.Repeat(" ", depth)
indentClass := fmt.Sprintf("indent-level-%d", depth)
for _, entry := range entries {
name := entry.Name()
relativePath := filepath.Join(folderPath, name)
safeID := "fav-" + strings.ReplaceAll(strings.ReplaceAll(relativePath, "/", "-"), "\\", "-")
if entry.IsDir() {
// Sous-dossier
sb.WriteString(fmt.Sprintf("%s<div class=\"folder %s\">\n", indent, indentClass))
sb.WriteString(fmt.Sprintf("%s <div class=\"folder-header\" onclick=\"toggleFolder('%s')\">\n", indent, safeID))
sb.WriteString(fmt.Sprintf("%s <span class=\"folder-icon\" id=\"icon-%s\">📁</span>\n", indent, safeID))
sb.WriteString(fmt.Sprintf("%s <strong>%s</strong>\n", indent, name))
sb.WriteString(fmt.Sprintf("%s </div>\n", indent))
sb.WriteString(fmt.Sprintf("%s <div class=\"folder-content\" id=\"folder-%s\">\n", indent, safeID))
// Récursion pour les sous-dossiers
h.generateFavoriteFolderContent(sb, relativePath, depth+1)
sb.WriteString(fmt.Sprintf("%s </div>\n", indent))
sb.WriteString(fmt.Sprintf("%s</div>\n", indent))
} else if strings.HasSuffix(name, ".md") {
// Fichier markdown
displayName := strings.TrimSuffix(name, ".md")
sb.WriteString(fmt.Sprintf("%s<div class=\"file %s\">\n", indent, indentClass))
sb.WriteString(fmt.Sprintf("%s <a href=\"#\" hx-get=\"/api/notes/%s\" hx-target=\"#editor-container\" hx-swap=\"innerHTML\" hx-push-url=\"true\">", indent, relativePath))
sb.WriteString(fmt.Sprintf("📄 %s", displayName))
sb.WriteString("</a>\n")
sb.WriteString(fmt.Sprintf("%s</div>\n", indent))
}
}
}
// countNotes compte le nombre de fichiers .md dans l'arborescence
func (h *Handler) countNotes(node *TreeNode) int {
count := 0
if !node.IsDir {
count = 1
}
for _, child := range node.Children {
count += h.countNotes(child)
}
return count
}
// generateMarkdownTree génère l'arborescence en HTML avec accordéon
func (h *Handler) generateMarkdownTree(sb *strings.Builder, node *TreeNode, depth int) {
// Ignorer le nœud racine
if depth == 0 {
sb.WriteString("<div class=\"note-tree\">\n")
for _, child := range node.Children {
h.generateMarkdownTree(sb, child, depth+1)
}
sb.WriteString("</div>\n")
return
}
indent := strings.Repeat(" ", depth-1)
// Créer un ID unique basé sur le path
safeID := strings.ReplaceAll(strings.ReplaceAll(node.Path, "/", "-"), "\\", "-")
if node.IsDir {
// Dossier - affichage avec accordéon
// Utiliser des classes CSS au lieu de styles inline
indentClass := fmt.Sprintf("indent-level-%d", depth)
sb.WriteString(fmt.Sprintf("%s<div class=\"folder %s\">\n", indent, indentClass))
sb.WriteString(fmt.Sprintf("%s <div class=\"folder-header\" onclick=\"toggleFolder('%s')\">\n", indent, safeID))
sb.WriteString(fmt.Sprintf("%s <span class=\"folder-icon\" id=\"icon-%s\">📁</span>\n", indent, safeID))
sb.WriteString(fmt.Sprintf("%s <strong>%s</strong>\n", indent, node.Name))
sb.WriteString(fmt.Sprintf("%s </div>\n", indent))
sb.WriteString(fmt.Sprintf("%s <div class=\"folder-content\" id=\"folder-%s\">\n", indent, safeID))
for _, child := range node.Children {
h.generateMarkdownTree(sb, child, depth+1)
}
sb.WriteString(fmt.Sprintf("%s </div>\n", indent))
sb.WriteString(fmt.Sprintf("%s</div>\n", indent))
} else {
// Fichier - créer un lien HTML cliquable avec HTMX
displayName := strings.TrimSuffix(node.Name, ".md")
// Échapper le path pour l'URL
escapedPath := strings.ReplaceAll(node.Path, "\\", "/")
indentClass := fmt.Sprintf("indent-level-%d", depth)
sb.WriteString(fmt.Sprintf("%s<div class=\"file %s\">\n", indent, indentClass))
sb.WriteString(fmt.Sprintf("%s <span class=\"file-icon\">📄</span>\n", indent))
sb.WriteString(fmt.Sprintf("%s <a href=\"#\" hx-get=\"/api/notes/%s\" hx-target=\"#editor-container\" hx-swap=\"innerHTML\">%s</a>\n",
indent, escapedPath, displayName))
sb.WriteString(fmt.Sprintf("%s</div>\n", indent))
}
}
func (h *Handler) handleNewNoteAuto(w http.ResponseWriter, r *http.Request) {
// Generate a unique filename
baseFilename := "nouvelle-note"
filename := ""
for i := 1; ; i++ {
tempFilename := fmt.Sprintf("%s-%d.md", baseFilename, i)
fullPath := filepath.Join(h.notesDir, tempFilename)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
filename = tempFilename
break
}
}
h.createAndRenderNote(w, r, filename)
}
func (h *Handler) handleNewNotePrompt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
err := h.templates.ExecuteTemplate(w, "new-note-prompt.html", nil)
if err != nil {
h.logger.Printf("erreur d execution du template new-note-prompt: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handleCreateCustomNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "lecture du formulaire impossible", http.StatusBadRequest)
return
}
filename := r.FormValue("filename")
if filename == "" {
http.Error(w, "nom de fichier manquant", http.StatusBadRequest)
return
}
// Securite : nettoyer le nom du fichier
filename = filepath.Clean(filename)
if strings.HasPrefix(filename, "..") || !strings.HasSuffix(filename, ".md") {
http.Error(w, "nom de fichier invalide", http.StatusBadRequest)
return
}
fullPath := filepath.Join(h.notesDir, filename)
if _, err := os.Stat(fullPath); err == nil {
http.Error(w, "une note avec ce nom existe deja", http.StatusConflict)
return
}
h.createAndRenderNote(w, r, filename)
}
// createAndRenderNote est une fonction utilitaire pour creer et rendre une nouvelle note
func (h *Handler) createAndRenderNote(w http.ResponseWriter, r *http.Request, filename string) {
// Prepare initial front matter for a new note
now := time.Now()
newFM := indexer.FullFrontMatter{
Title: strings.Title(strings.ReplaceAll(strings.TrimSuffix(filename, filepath.Ext(filename)), "-", " ")),
Date: now.Format("02-01-2006"),
LastModified: now.Format("02-01-2006:15:04"),
Tags: []string{"default"}, // Default tag for new notes
}
fmBytes, err := yaml.Marshal(newFM)
if err != nil {
h.logger.Printf("erreur de marshalling du front matter pour nouvelle note: %v", err)
http.Error(w, "erreur interne lors de la generation du front matter", http.StatusInternalServerError)
return
}
// Combine new front matter with a placeholder body
initialContent := "---\n" + string(fmBytes) + "---\n\n# " + newFM.Title + "\n\nCommencez à écrire votre note ici..."
data := struct {
Filename string
Content string
IsHome bool
Backlinks []BacklinkInfo
}{
Filename: filename,
Content: initialContent,
IsHome: false,
Backlinks: nil, // Pas de backlinks pour une nouvelle note
}
err = h.templates.ExecuteTemplate(w, "editor.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template editeur pour nouvelle note: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// Pas de redirection ici car cet endpoint est utilisé par:
// 1. La sidebar de recherche (HTMX)
// 2. La modale de recherche Ctrl+K (fetch)
// 3. Le link inserter pour créer des backlinks (fetch)
query := strings.TrimSpace(r.URL.Query().Get("query"))
if query == "" {
query = strings.TrimSpace(r.URL.Query().Get("tag"))
}
results := h.idx.SearchDocuments(query)
data := struct {
Query string
Results []indexer.SearchResult
}{
Query: query,
Results: results,
}
err := h.templates.ExecuteTemplate(w, "search-results.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template de recherche: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
filename, err := h.extractFilename(r.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
h.handleGetNote(w, r, filename)
case http.MethodPost:
h.handlePostNote(w, r, filename)
case http.MethodDelete:
h.handleDeleteNote(w, r, filename)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleDeleteNote(w http.ResponseWriter, r *http.Request, filename string) {
fullPath := filepath.Join(h.notesDir, filename)
if err := os.Remove(fullPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "note introuvable", http.StatusNotFound)
return
}
h.logger.Printf("erreur de suppression du fichier %s: %v", filename, err)
http.Error(w, "suppression impossible", http.StatusInternalServerError)
return
}
// Nettoyer les dossiers vides parents
parentDir := filepath.Dir(filename)
if parentDir != "." && parentDir != "" {
h.removeEmptyDirRecursive(parentDir)
}
// Re-indexation en arriere-plan
go func() {
if err := h.idx.Load(h.notesDir); err != nil {
h.logger.Printf("echec de la reindexation post-suppression: %v", err)
}
}()
// Repondre a htmx pour vider l'editeur et rafraichir l'arborescence
h.renderFileTreeOOB(w)
io.WriteString(w, `<p>Note "`+filename+`" supprimée.</p>`)
}
func (h *Handler) handleGetNote(w http.ResponseWriter, r *http.Request, filename string) {
// Si ce n'est pas une requête HTMX (ex: refresh navigateur), rediriger vers la page principale
// Cela évite d'afficher un fragment HTML sans CSS lors d'un Ctrl+F5
if r.Header.Get("HX-Request") == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
fullPath := filepath.Join(h.notesDir, filename)
content, err := os.ReadFile(fullPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
h.logger.Printf("erreur de lecture du fichier %s: %v", filename, err)
http.Error(w, "lecture impossible", http.StatusInternalServerError)
return
}
// Le fichier n'existe pas, créer un front matter initial
now := time.Now()
newFM := indexer.FullFrontMatter{
Title: strings.Title(strings.ReplaceAll(strings.TrimSuffix(filename, filepath.Ext(filename)), "-", " ")),
Date: now.Format("02-01-2006"),
LastModified: now.Format("02-01-2006:15:04"),
Tags: []string{},
}
fmBytes, err := yaml.Marshal(newFM)
if err != nil {
h.logger.Printf("erreur de marshalling du front matter pour nouvelle note: %v", err)
http.Error(w, "erreur interne lors de la generation du front matter", http.StatusInternalServerError)
return
}
// Générer le contenu initial avec front matter
initialContent := "---\n" + string(fmBytes) + "---\n\n# " + newFM.Title + "\n\nCommencez à écrire votre note ici..."
content = []byte(initialContent)
}
// Récupérer les backlinks pour cette note
backlinks := h.idx.GetBacklinks(filename)
backlinkData := h.buildBacklinkData(backlinks)
data := struct {
Filename string
Content string
IsHome bool
Backlinks []BacklinkInfo
Breadcrumb template.HTML
}{
Filename: filename,
Content: string(content),
IsHome: false,
Backlinks: backlinkData,
Breadcrumb: h.generateBreadcrumb(filename),
}
err = h.templates.ExecuteTemplate(w, "editor.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template editeur: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
func (h *Handler) handlePostNote(w http.ResponseWriter, r *http.Request, filename string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "lecture du formulaire impossible", http.StatusBadRequest)
return
}
incomingContent := r.FormValue("content")
fullPath := filepath.Join(h.notesDir, filename)
isNewFile := false
if _, err := os.Stat(fullPath); errors.Is(err, os.ErrNotExist) {
isNewFile = true
}
// Extract existing front matter and body from incoming content
var currentFM indexer.FullFrontMatter
var bodyContent string
var err error
// Use a strings.Reader to pass the content to the extractor
currentFM, bodyContent, err = indexer.ExtractFrontMatterAndBodyFromReader(strings.NewReader(incomingContent))
if err != nil {
h.logger.Printf("erreur d'extraction du front matter: %v", err)
http.Error(w, "erreur interne lors de l'analyse du contenu", http.StatusInternalServerError)
return
}
// Prepare new front matter
newFM := currentFM
// Set Title
if newFM.Title == "" {
newFM.Title = strings.TrimSuffix(filename, filepath.Ext(filename))
newFM.Title = strings.ReplaceAll(newFM.Title, "-", " ")
newFM.Title = strings.Title(newFM.Title)
}
// Set Date (creation date)
now := time.Now()
if isNewFile || newFM.Date == "" { // Check for empty string
newFM.Date = now.Format("02-01-2006")
}
// Set LastModified
newFM.LastModified = now.Format("02-01-2006:15:04")
// Marshal new front matter to YAML
fmBytes, err := yaml.Marshal(newFM)
if err != nil {
h.logger.Printf("erreur de marshalling du front matter: %v", err)
http.Error(w, "erreur interne lors de la generation du front matter", http.StatusInternalServerError)
return
}
// Combine new front matter with body
finalContent := "---\n" + string(fmBytes) + "---\n" + bodyContent
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
h.logger.Printf("erreur de creation du repertoire pour %s: %v", filename, err)
http.Error(w, "creation repertoire impossible", http.StatusInternalServerError)
return
}
if err := os.WriteFile(fullPath, []byte(finalContent), 0o644); err != nil {
h.logger.Printf("erreur d ecriture du fichier %s: %v", filename, err)
http.Error(w, "ecriture impossible", http.StatusInternalServerError)
return
}
// Re-indexation en arriere-plan pour ne pas ralentir la reponse
go func() {
if err := h.idx.Load(h.notesDir); err != nil {
h.logger.Printf("echec de la reindexation post-ecriture: %v", err)
}
}()
// Pour les notes existantes, ne pas recharger le file-tree (évite de fermer les dossiers ouverts)
// Le file-tree sera rechargé uniquement lors de la création de nouveaux fichiers/dossiers
if isNewFile {
// Nouvelle note : mettre à jour le file-tree pour l'afficher
h.renderFileTreeOOB(w)
}
// Répondre avec les statuts de sauvegarde OOB
nowStr := time.Now().Format("15:04:05")
oobStatus := fmt.Sprintf(`
<span id="auto-save-status" hx-swap-oob="true">Enregistré à %s</span>
<span id="save-status" hx-swap-oob="true"></span>`, nowStr)
io.WriteString(w, oobStatus)
}
func (h *Handler) extractFilename(path string) (string, error) {
const prefix = "/api/notes/"
if !strings.HasPrefix(path, prefix) {
return "", errors.New("chemin invalide")
}
rel := strings.TrimPrefix(path, prefix)
rel = strings.TrimSpace(rel)
if rel == "" {
return "", errors.New("fichier manquant")
}
rel = filepath.Clean(rel)
if rel == "." || strings.HasPrefix(rel, "..") {
return "", errors.New("nom de fichier invalide")
}
if filepath.Ext(rel) != ".md" {
return "", errors.New("extension invalide")
}
return rel, nil
}
// renderFileTreeOOB genere le HTML de l'arborescence des fichiers pour un swap out-of-band.
func (h *Handler) renderFileTreeOOB(w http.ResponseWriter) {
tree, err := h.buildFileTree()
if err != nil {
h.logger.Printf("erreur lors de la construction de l arborescence pour OOB: %v", err)
return // Don't fail the main request, just log
}
data := struct {
Tree *TreeNode
}{
Tree: tree,
}
// Render the file tree template into a buffer
var buf strings.Builder
err = h.templates.ExecuteTemplate(&buf, "file-tree.html", data)
if err != nil {
h.logger.Printf("erreur d execution du template de l arborescence pour OOB: %v", err)
return // Don't fail the main request, just log
}
// Wrap it in a div with hx-swap-oob
oobContent := fmt.Sprintf(`<div id="file-tree" hx-swap-oob="true">%s</div>`, buf.String())
io.WriteString(w, oobContent)
}
// handleCreateFolder crée un nouveau dossier
func (h *Handler) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "lecture du formulaire impossible", http.StatusBadRequest)
return
}
folderPath := r.FormValue("path")
if folderPath == "" {
http.Error(w, "chemin du dossier manquant", http.StatusBadRequest)
return
}
// Sécurité : nettoyer le chemin
folderPath = filepath.Clean(folderPath)
if strings.HasPrefix(folderPath, "..") || filepath.IsAbs(folderPath) {
http.Error(w, "chemin invalide", http.StatusBadRequest)
return
}
fullPath := filepath.Join(h.notesDir, folderPath)
// Vérifier si le dossier existe déjà
if _, err := os.Stat(fullPath); err == nil {
http.Error(w, "un dossier avec ce nom existe deja", http.StatusConflict)
return
}
// Créer le dossier
if err := os.MkdirAll(fullPath, 0o755); err != nil {
h.logger.Printf("erreur de creation du dossier %s: %v", folderPath, err)
http.Error(w, "creation du dossier impossible", http.StatusInternalServerError)
return
}
h.logger.Printf("dossier cree: %s", folderPath)
// Rafraîchir l'arborescence
h.renderFileTreeOOB(w)
io.WriteString(w, fmt.Sprintf("Dossier '%s' créé avec succès", folderPath))
}
// handleMoveFile déplace ou renomme un fichier/dossier
func (h *Handler) handleMoveFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "lecture du formulaire impossible", http.StatusBadRequest)
return
}
sourcePath := r.FormValue("source")
destPath := r.FormValue("destination")
if sourcePath == "" || destPath == "" {
http.Error(w, "chemins source et destination requis", http.StatusBadRequest)
return
}
// Sécurité : nettoyer les chemins
sourcePath = filepath.Clean(sourcePath)
destPath = filepath.Clean(destPath)
if strings.HasPrefix(sourcePath, "..") || strings.HasPrefix(destPath, "..") {
http.Error(w, "chemins invalides", http.StatusBadRequest)
return
}
sourceFullPath := filepath.Join(h.notesDir, sourcePath)
destFullPath := filepath.Join(h.notesDir, destPath)
// Vérifier que la source existe
if _, err := os.Stat(sourceFullPath); os.IsNotExist(err) {
http.Error(w, "fichier source introuvable", http.StatusNotFound)
return
}
// Créer le répertoire de destination si nécessaire
destDir := filepath.Dir(destFullPath)
if err := os.MkdirAll(destDir, 0o755); err != nil {
h.logger.Printf("erreur de creation du repertoire de destination %s: %v", destDir, err)
http.Error(w, "creation du repertoire de destination impossible", http.StatusInternalServerError)
return
}
// Déplacer le fichier
if err := os.Rename(sourceFullPath, destFullPath); err != nil {
h.logger.Printf("erreur de deplacement de %s vers %s: %v", sourcePath, destPath, err)
http.Error(w, "deplacement impossible", http.StatusInternalServerError)
return
}
h.logger.Printf("fichier deplace: %s -> %s", sourcePath, destPath)
// Re-indexer
go func() {
if err := h.idx.Load(h.notesDir); err != nil {
h.logger.Printf("echec de la reindexation post-deplacement: %v", err)
}
}()
// Rafraîchir l'arborescence
h.renderFileTreeOOB(w)
io.WriteString(w, fmt.Sprintf("Fichier déplacé de '%s' vers '%s'", sourcePath, destPath))
}
// handleDeleteMultiple supprime plusieurs fichiers/dossiers en une seule opération
func (h *Handler) handleDeleteMultiple(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
w.Header().Set("Allow", "DELETE")
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// For DELETE requests, ParseForm does not read the body. We need to do it manually.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "lecture du corps de la requete impossible", http.StatusBadRequest)
return
}
defer r.Body.Close()
q, err := url.ParseQuery(string(body))
if err != nil {
http.Error(w, "parsing du corps de la requete impossible", http.StatusBadRequest)
return
}
// Récupérer tous les chemins depuis le formulaire (format: paths[]=path1&paths[]=path2)
paths := q["paths[]"]
if len(paths) == 0 {
http.Error(w, "aucun fichier a supprimer", http.StatusBadRequest)
return
}
deleted := make([]string, 0)
errors := make(map[string]string)
affectedDirs := make(map[string]bool) // Pour suivre les dossiers parents affectés
for _, path := range paths {
// Sécurité : nettoyer le chemin
cleanPath := filepath.Clean(path)
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
errors[path] = "chemin invalide"
continue
}
fullPath := filepath.Join(h.notesDir, cleanPath)
// Vérifier si le fichier/dossier existe
info, err := os.Stat(fullPath)
if os.IsNotExist(err) {
errors[path] = "fichier introuvable"
continue
}
// Supprimer (récursivement si c'est un dossier)
if info.IsDir() {
err = os.RemoveAll(fullPath)
} else {
err = os.Remove(fullPath)
// Marquer le dossier parent pour nettoyage
parentDir := filepath.Dir(cleanPath)
if parentDir != "." && parentDir != "" {
affectedDirs[parentDir] = true
}
}
if err != nil {
h.logger.Printf("erreur de suppression de %s: %v", path, err)
errors[path] = "suppression impossible"
continue
}
deleted = append(deleted, path)
h.logger.Printf("element supprime: %s", path)
}
// Nettoyer les dossiers vides (remonter l'arborescence)
h.cleanEmptyDirs(affectedDirs)
// Re-indexer en arrière-plan
go func() {
if err := h.idx.Load(h.notesDir); err != nil {
h.logger.Printf("echec de la reindexation post-suppression multiple: %v", err)
}
}()
// Rafraîchir l'arborescence
h.renderFileTreeOOB(w)
// Créer le message de réponse
var message strings.Builder
if len(deleted) > 0 {
message.WriteString(fmt.Sprintf("<p><strong>%d élément(s) supprimé(s) :</strong></p><ul>", len(deleted)))
for _, p := range deleted {
message.WriteString(fmt.Sprintf("<li>%s</li>", p))
}
message.WriteString("</ul>")
}
if len(errors) > 0 {
message.WriteString(fmt.Sprintf("<p><strong>%d erreur(s) :</strong></p><ul>", len(errors)))
for p, e := range errors {
message.WriteString(fmt.Sprintf("<li>%s: %s</li>", p, e))
}
message.WriteString("</ul>")
}
io.WriteString(w, message.String())
}
// cleanEmptyDirs supprime les dossiers vides en remontant l'arborescence
func (h *Handler) cleanEmptyDirs(affectedDirs map[string]bool) {
// Trier les chemins par profondeur décroissante pour commencer par les plus profonds
dirs := make([]string, 0, len(affectedDirs))
for dir := range affectedDirs {
dirs = append(dirs, dir)
}
// Trier par nombre de "/" décroissant (plus profond en premier)
sort.Slice(dirs, func(i, j int) bool {
return strings.Count(dirs[i], string(filepath.Separator)) > strings.Count(dirs[j], string(filepath.Separator))
})
for _, dir := range dirs {
h.removeEmptyDirRecursive(dir)
}
}
// removeEmptyDirRecursive supprime un dossier s'il est vide, puis remonte vers le parent
func (h *Handler) removeEmptyDirRecursive(relPath string) {
if relPath == "" || relPath == "." {
return
}
fullPath := filepath.Join(h.notesDir, relPath)
// Vérifier si le dossier existe
info, err := os.Stat(fullPath)
if err != nil || !info.IsDir() {
return
}
// Lire le contenu du dossier
entries, err := os.ReadDir(fullPath)
if err != nil {
return
}
// Filtrer pour ne compter que les fichiers .md et les dossiers non-cachés
hasContent := false
for _, entry := range entries {
// Ignorer les fichiers cachés
if strings.HasPrefix(entry.Name(), ".") {
continue
}
// Si c'est un .md ou un dossier, le dossier a du contenu
if entry.IsDir() || strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
hasContent = true
break
}
}
// Si le dossier est vide (ne contient que des fichiers cachés ou non-.md)
if !hasContent {
err = os.Remove(fullPath)
if err == nil {
h.logger.Printf("dossier vide supprime: %s", relPath)
// Remonter au parent
parentDir := filepath.Dir(relPath)
if parentDir != "." && parentDir != "" {
h.removeEmptyDirRecursive(parentDir)
}
}
}
}
// buildBacklinkData transforme une liste de chemins de notes en BacklinkInfo avec titres
func (h *Handler) buildBacklinkData(paths []string) []BacklinkInfo {
if len(paths) == 0 {
return nil
}
result := make([]BacklinkInfo, 0, len(paths))
for _, path := range paths {
// Lire le fichier pour extraire le titre du front matter
fullPath := filepath.Join(h.notesDir, path)
fm, _, err := indexer.ExtractFrontMatterAndBody(fullPath)
title := ""
if err == nil && fm.Title != "" {
title = fm.Title
} else {
// Fallback: dériver le titre du nom de fichier
title = strings.TrimSuffix(filepath.Base(path), ".md")
title = strings.ReplaceAll(title, "-", " ")
title = strings.Title(title)
}
result = append(result, BacklinkInfo{
Path: path,
Title: title,
})
}
return result
}
// handleFolderView affiche le contenu d'un dossier
func (h *Handler) handleFolderView(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
return
}
// Si ce n'est pas une requête HTMX, rediriger vers la page principale
if r.Header.Get("HX-Request") == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Extraire le chemin du dossier depuis l'URL
folderPath := strings.TrimPrefix(r.URL.Path, "/api/folder/")
folderPath = strings.TrimPrefix(folderPath, "/")
// Sécurité : vérifier le chemin
cleanPath := filepath.Clean(folderPath)
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
http.Error(w, "Chemin invalide", http.StatusBadRequest)
return
}
// Construire le chemin absolu
absPath := filepath.Join(h.notesDir, cleanPath)
// Vérifier que c'est bien un dossier
info, err := os.Stat(absPath)
if err != nil || !info.IsDir() {
http.Error(w, "Dossier non trouvé", http.StatusNotFound)
return
}
// Générer le contenu de la page
content := h.generateFolderViewMarkdown(cleanPath)
// Utiliser le template editor.html
data := struct {
Filename string
Content string
IsHome bool
Backlinks []BacklinkInfo
Breadcrumb template.HTML
}{
Filename: cleanPath,
Content: content,
IsHome: true, // Pas d'édition pour une vue de dossier
Backlinks: nil,
Breadcrumb: h.generateBreadcrumb(cleanPath),
}
err = h.templates.ExecuteTemplate(w, "editor.html", data)
if err != nil {
h.logger.Printf("Erreur d'exécution du template folder view: %v", err)
http.Error(w, "Erreur interne", http.StatusInternalServerError)
}
}
// generateBreadcrumb génère un fil d'Ariane HTML cliquable
func (h *Handler) generateBreadcrumb(path string) template.HTML {
if path == "" {
return template.HTML(`<strong>📁 Racine</strong>`)
}
parts := strings.Split(filepath.ToSlash(path), "/")
var sb strings.Builder
sb.WriteString(`<span class="breadcrumb">`)
// Lien racine
sb.WriteString(`<a href="#" hx-get="/api/home" hx-target="#editor-container" hx-swap="innerHTML" hx-push-url="true" class="breadcrumb-link">📁 Racine</a>`)
// Construire les liens pour chaque partie
currentPath := ""
for i, part := range parts {
sb.WriteString(` <span class="breadcrumb-separator"></span> `)
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
// Le dernier élément (fichier) n'est pas cliquable
if i == len(parts)-1 && strings.HasSuffix(part, ".md") {
// C'est un fichier, pas cliquable
displayName := strings.TrimSuffix(part, ".md")
sb.WriteString(fmt.Sprintf(`<strong>%s</strong>`, displayName))
} else {
// C'est un dossier, cliquable
sb.WriteString(fmt.Sprintf(
`<a href="#" hx-get="/api/folder/%s" hx-target="#editor-container" hx-swap="innerHTML" hx-push-url="true" class="breadcrumb-link">📂 %s</a>`,
currentPath, part,
))
}
}
sb.WriteString(`</span>`)
return template.HTML(sb.String())
}
// generateFolderViewMarkdown génère le contenu Markdown pour l'affichage d'un dossier
func (h *Handler) generateFolderViewMarkdown(folderPath string) string {
var sb strings.Builder
// En-tête
if folderPath == "" {
sb.WriteString("# 📁 Racine\n\n")
} else {
folderName := filepath.Base(folderPath)
sb.WriteString(fmt.Sprintf("# 📂 %s\n\n", folderName))
}
sb.WriteString("_Contenu du dossier_\n\n")
// Lister le contenu
absPath := filepath.Join(h.notesDir, folderPath)
entries, err := os.ReadDir(absPath)
if err != nil {
sb.WriteString("❌ Erreur lors de la lecture du dossier\n")
return sb.String()
}
// Séparer dossiers et fichiers
var folders []os.DirEntry
var files []os.DirEntry
for _, entry := range entries {
// Ignorer les fichiers cachés
if strings.HasPrefix(entry.Name(), ".") {
continue
}
if entry.IsDir() {
folders = append(folders, entry)
} else if strings.HasSuffix(entry.Name(), ".md") {
files = append(files, entry)
}
}
// Afficher les dossiers
if len(folders) > 0 {
sb.WriteString("## 📁 Dossiers\n\n")
sb.WriteString("<div class=\"folder-list\">\n")
for _, folder := range folders {
subPath := filepath.Join(folderPath, folder.Name())
sb.WriteString(fmt.Sprintf(
`<div class="folder-item"><a href="#" hx-get="/api/folder/%s" hx-target="#editor-container" hx-swap="innerHTML" hx-push-url="true">📂 %s</a></div>`,
filepath.ToSlash(subPath), folder.Name(),
))
sb.WriteString("\n")
}
sb.WriteString("</div>\n\n")
}
// Afficher les fichiers
if len(files) > 0 {
sb.WriteString(fmt.Sprintf("## 📄 Notes (%d)\n\n", len(files)))
sb.WriteString("<div class=\"file-list\">\n")
for _, file := range files {
filePath := filepath.Join(folderPath, file.Name())
displayName := strings.TrimSuffix(file.Name(), ".md")
// Lire le titre du front matter si possible
fullPath := filepath.Join(h.notesDir, filePath)
fm, _, err := indexer.ExtractFrontMatterAndBody(fullPath)
if err == nil && fm.Title != "" {
displayName = fm.Title
}
sb.WriteString(fmt.Sprintf(
`<div class="file-item"><a href="#" hx-get="/api/notes/%s" hx-target="#editor-container" hx-swap="innerHTML" hx-push-url="true">📄 %s</a></div>`,
filepath.ToSlash(filePath), displayName,
))
sb.WriteString("\n")
}
sb.WriteString("</div>\n\n")
}
if len(folders) == 0 && len(files) == 0 {
sb.WriteString("_Ce dossier est vide_\n")
}
return sb.String()
}
// getLanguage extrait la langue préférée depuis les cookies ou Accept-Language header
func (h *Handler) getLanguage(r *http.Request) string {
// 1. Vérifier le cookie
if cookie, err := r.Cookie("language"); err == nil && cookie.Value != "" {
return cookie.Value
}
// 2. Vérifier l'en-tête Accept-Language
acceptLang := r.Header.Get("Accept-Language")
if acceptLang != "" {
// Parse simple: prendre le premier code de langue
parts := strings.Split(acceptLang, ",")
if len(parts) > 0 {
lang := strings.Split(parts[0], ";")[0]
lang = strings.Split(lang, "-")[0] // "fr-FR" -> "fr"
return strings.TrimSpace(lang)
}
}
// 3. Par défaut: anglais
return "en"
}
// t est un helper pour traduire une clé dans la langue de la requête
func (h *Handler) t(r *http.Request, key string, args ...map[string]string) string {
lang := h.getLanguage(r)
return h.i18n.T(lang, key, args...)
}
// handleI18n sert les fichiers de traduction JSON pour le frontend
func (h *Handler) handleI18n(w http.ResponseWriter, r *http.Request) {
// Extraire le code de langue depuis l'URL: /api/i18n/en ou /api/i18n/fr
lang := strings.TrimPrefix(r.URL.Path, "/api/i18n/")
if lang == "" {
lang = "en"
}
// Récupérer les traductions pour cette langue
translations, ok := h.i18n.GetTranslations(lang)
if !ok {
// Fallback vers l'anglais si la langue n'existe pas
translations, ok = h.i18n.GetTranslations("en")
if !ok {
http.Error(w, "translations not found", http.StatusNotFound)
return
}
}
// Retourner le JSON
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(translations); err != nil {
h.logger.Printf("error encoding translations: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}