package api import ( "errors" "fmt" "html/template" "io" "log" "net/http" "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"` } // 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/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 strings.HasPrefix(path, "/api/notes/") { h.handleNotes(w, r) return } if path == "/api/tree" { h.handleFileTree(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 des notes", 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) } } // 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 des Notes\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) // Statistiques sb.WriteString(fmt.Sprintf("**%d note(s) au total**\n\n", noteCount)) sb.WriteString("---\n\n") // Titre de l'arborescence sb.WriteString("## 📂 Toutes les notes\n\n") // 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") } // 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 } // 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) } data := struct { Filename string Content string IsHome bool }{ Filename: filename, Content: string(content), IsHome: false, } 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)) }