package api import ( "errors" "fmt" "html/template" "io" "log" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" yaml "gopkg.in/yaml.v3" "github.com/mathieu/project-notes/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 } // NewHandler construit un handler unifié pour l'API. func NewHandler(notesDir string, idx *indexer.Indexer, tpl *template.Template, logger *log.Logger) *Handler { return &Handler{ notesDir: notesDir, idx: idx, templates: tpl, logger: logger, } } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path h.logger.Printf("%s %s", r.Method, path) // 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 } 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 } 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 } // Générer le contenu Markdown avec la liste de toutes les notes content := h.generateHomeMarkdown() // Utiliser le template editor.html pour afficher la page d'accueil data := struct { Filename string Content string IsHome bool }{ Filename: "🏠 Accueil - Index", Content: content, IsHome: true, } 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() string { var sb strings.Builder // En-tête sb.WriteString("# 📚 Index\n\n") sb.WriteString("_Mise à jour automatique • " + 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) // Titre de l'arborescence avec le nombre de notes sb.WriteString(fmt.Sprintf("## 📂 Toutes les notes (%d)\n\n", noteCount)) // Générer l'arborescence en Markdown h.generateMarkdownTree(&sb, tree, 0) 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("## 🏷️ Tags\n\n") sb.WriteString("
\n") for _, tc := range tags { // Créer un lien HTML discret et fonctionnel sb.WriteString(fmt.Sprintf( `#%s %d`, tc.Tag, tc.Tag, tc.Count, )) sb.WriteString("\n") } sb.WriteString("
\n\n") } // generateFavoritesSection génère la section des favoris avec arborescence dépliable func (h *Handler) generateFavoritesSection(sb *strings.Builder) { favorites, err := h.loadFavorites() if err != nil || len(favorites.Items) == 0 { return } sb.WriteString("## ⭐ Favoris\n\n") sb.WriteString("
\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("
\n")) sb.WriteString(fmt.Sprintf("
\n", safeID)) sb.WriteString(fmt.Sprintf(" 📁\n", safeID)) sb.WriteString(fmt.Sprintf(" %s\n", fav.Title)) sb.WriteString(fmt.Sprintf("
\n")) sb.WriteString(fmt.Sprintf("
\n", safeID)) // Lister le contenu du dossier h.generateFavoriteFolderContent(sb, fav.Path, 2) sb.WriteString(fmt.Sprintf("
\n")) sb.WriteString(fmt.Sprintf("
\n")) } else { // Fichier sb.WriteString(fmt.Sprintf("
\n")) sb.WriteString(fmt.Sprintf(" ", fav.Path)) sb.WriteString(fmt.Sprintf("📄 %s", fav.Title)) sb.WriteString("\n") sb.WriteString(fmt.Sprintf("
\n")) } } sb.WriteString("
\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
\n", indent, indentClass)) sb.WriteString(fmt.Sprintf("%s
\n", indent, safeID)) sb.WriteString(fmt.Sprintf("%s 📁\n", indent, safeID)) sb.WriteString(fmt.Sprintf("%s %s\n", indent, name)) sb.WriteString(fmt.Sprintf("%s
\n", indent)) sb.WriteString(fmt.Sprintf("%s
\n", indent, safeID)) // Récursion pour les sous-dossiers h.generateFavoriteFolderContent(sb, relativePath, depth+1) sb.WriteString(fmt.Sprintf("%s
\n", indent)) sb.WriteString(fmt.Sprintf("%s
\n", indent)) } else if strings.HasSuffix(name, ".md") { // Fichier markdown displayName := strings.TrimSuffix(name, ".md") sb.WriteString(fmt.Sprintf("%s
\n", indent, indentClass)) sb.WriteString(fmt.Sprintf("%s ", indent, relativePath)) sb.WriteString(fmt.Sprintf("📄 %s", displayName)) sb.WriteString("\n") sb.WriteString(fmt.Sprintf("%s
\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("
\n") for _, child := range node.Children { h.generateMarkdownTree(sb, child, depth+1) } sb.WriteString("
\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
\n", indent, indentClass)) sb.WriteString(fmt.Sprintf("%s
\n", indent, safeID)) sb.WriteString(fmt.Sprintf("%s 📁\n", indent, safeID)) sb.WriteString(fmt.Sprintf("%s %s\n", indent, node.Name)) sb.WriteString(fmt.Sprintf("%s
\n", indent)) sb.WriteString(fmt.Sprintf("%s
\n", indent, safeID)) for _, child := range node.Children { h.generateMarkdownTree(sb, child, depth+1) } sb.WriteString(fmt.Sprintf("%s
\n", indent)) sb.WriteString(fmt.Sprintf("%s
\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
\n", indent, indentClass)) sb.WriteString(fmt.Sprintf("%s 📄\n", indent)) sb.WriteString(fmt.Sprintf("%s %s\n", indent, escapedPath, displayName)) sb.WriteString(fmt.Sprintf("%s
\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 }{ Filename: filename, Content: initialContent, IsHome: false, } 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 } 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, `

Note "`+filename+`" supprimée.

`) } func (h *Handler) handleGetNote(w http.ResponseWriter, r *http.Request, filename string) { 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 }{ Filename: filename, Content: string(content), IsHome: false, Backlinks: backlinkData, } 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) } }() // Repondre a htmx pour vider l'editeur et rafraichir l'arborescence h.renderFileTreeOOB(w) // Répondre avec les statuts de sauvegarde OOB nowStr := time.Now().Format("15:04:05") oobStatus := fmt.Sprintf(` Enregistré à %s `, 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(`
%s
`, 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("

%d élément(s) supprimé(s) :

") } if len(errors) > 0 { message.WriteString(fmt.Sprintf("

%d erreur(s) :

") } 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 }