package api import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" yaml "gopkg.in/yaml.v3" "github.com/mathieu/project-notes/internal/indexer" ) // REST API Structures // =================== // NoteResponse représente une note dans les réponses API type NoteResponse struct { Path string `json:"path"` Title string `json:"title"` Content string `json:"content,omitempty"` // Full content with front matter Body string `json:"body,omitempty"` // Body without front matter FrontMatter *indexer.FullFrontMatter `json:"frontMatter,omitempty"` LastModified string `json:"lastModified"` Size int64 `json:"size"` } // NoteMetadata représente les métadonnées d'une note (pour la liste) type NoteMetadata struct { Path string `json:"path"` Title string `json:"title"` Tags []string `json:"tags"` LastModified string `json:"lastModified"` Date string `json:"date"` Size int64 `json:"size"` } // ListNotesResponse représente la réponse de liste de notes type ListNotesResponse struct { Notes []NoteMetadata `json:"notes"` Total int `json:"total"` } // ErrorResponse représente une erreur API type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` Code int `json:"code"` } // NoteRequest représente une requête de création/modification de note type NoteRequest struct { Content string `json:"content,omitempty"` Body string `json:"body,omitempty"` FrontMatter *indexer.FullFrontMatter `json:"frontMatter,omitempty"` } // REST API Handlers // ================= // handleRESTNotes route les requêtes REST vers les bons handlers func (h *Handler) handleRESTNotes(w http.ResponseWriter, r *http.Request) { // Extract path after /api/v1/notes/ const prefix = "/api/v1/notes" path := strings.TrimPrefix(r.URL.Path, prefix) path = strings.TrimPrefix(path, "/") // Si pas de path spécifique, c'est une liste if path == "" { switch r.Method { case http.MethodGet: h.handleRESTListNotes(w, r) default: h.sendJSONError(w, "Method not allowed", http.StatusMethodNotAllowed) } return } // Validation du path cleanPath := filepath.Clean(path) if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) { h.sendJSONError(w, "Invalid path", http.StatusBadRequest) return } if filepath.Ext(cleanPath) != ".md" { h.sendJSONError(w, "Only .md files are supported", http.StatusBadRequest) return } // Router selon la méthode HTTP switch r.Method { case http.MethodGet: h.handleRESTGetNote(w, r, cleanPath) case http.MethodPut: h.handleRESTPutNote(w, r, cleanPath) case http.MethodDelete: h.handleRESTDeleteNote(w, r, cleanPath) default: h.sendJSONError(w, "Method not allowed. Supported: GET, PUT, DELETE", http.StatusMethodNotAllowed) } } // handleRESTListNotes liste toutes les notes avec leurs métadonnées // GET /api/v1/notes func (h *Handler) handleRESTListNotes(w http.ResponseWriter, r *http.Request) { notes := []NoteMetadata{} err := filepath.WalkDir(h.notesDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } // Ignorer les dossiers et fichiers cachés if d.IsDir() || strings.HasPrefix(d.Name(), ".") { return nil } // Ne garder que les .md if filepath.Ext(path) != ".md" { return nil } relPath, err := filepath.Rel(h.notesDir, path) if err != nil { return err } // Lire les métadonnées info, err := d.Info() if err != nil { h.logger.Printf("Erreur lecture info fichier %s: %v", relPath, err) return nil // Continue malgré l'erreur } // Extraire le front matter fm, _, err := indexer.ExtractFrontMatterAndBody(path) if err != nil { h.logger.Printf("Erreur extraction front matter %s: %v", relPath, err) // Continuer avec des valeurs par défaut fm = indexer.FullFrontMatter{ Title: strings.TrimSuffix(d.Name(), ".md"), Tags: []string{}, } } notes = append(notes, NoteMetadata{ Path: filepath.ToSlash(relPath), Title: fm.Title, Tags: fm.Tags, LastModified: fm.LastModified, Date: fm.Date, Size: info.Size(), }) return nil }) if err != nil { h.logger.Printf("Erreur lors du listing des notes: %v", err) h.sendJSONError(w, "Failed to list notes", http.StatusInternalServerError) return } response := ListNotesResponse{ Notes: notes, Total: len(notes), } h.sendJSON(w, response, http.StatusOK) } // handleRESTGetNote retourne une note spécifique // GET /api/v1/notes/{path} // Supporte Accept: application/json ou text/markdown func (h *Handler) handleRESTGetNote(w http.ResponseWriter, r *http.Request, notePath string) { fullPath := filepath.Join(h.notesDir, notePath) // Vérifier que le fichier existe info, err := os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { h.sendJSONError(w, "Note not found", http.StatusNotFound) } else { h.logger.Printf("Erreur stat fichier %s: %v", notePath, err) h.sendJSONError(w, "Failed to access note", http.StatusInternalServerError) } return } // Lire le contenu content, err := os.ReadFile(fullPath) if err != nil { h.logger.Printf("Erreur lecture fichier %s: %v", notePath, err) h.sendJSONError(w, "Failed to read note", http.StatusInternalServerError) return } // Extraire front matter et body fm, body, err := indexer.ExtractFrontMatterAndBody(fullPath) if err != nil { h.logger.Printf("Erreur extraction front matter %s: %v", notePath, err) // Continuer sans front matter fm = indexer.FullFrontMatter{} body = string(content) } // Content negotiation accept := r.Header.Get("Accept") // Si client veut du Markdown brut if strings.Contains(accept, "text/markdown") || strings.Contains(accept, "text/plain") { w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(notePath))) w.WriteHeader(http.StatusOK) w.Write(content) return } // Par défaut, retourner du JSON response := NoteResponse{ Path: filepath.ToSlash(notePath), Title: fm.Title, Content: string(content), Body: body, FrontMatter: &fm, LastModified: fm.LastModified, Size: info.Size(), } h.sendJSON(w, response, http.StatusOK) } // handleRESTPutNote crée ou met à jour une note // PUT /api/v1/notes/{path} // Accepte: application/json, text/markdown, ou multipart/form-data func (h *Handler) handleRESTPutNote(w http.ResponseWriter, r *http.Request, notePath string) { fullPath := filepath.Join(h.notesDir, notePath) isNewFile := false if _, err := os.Stat(fullPath); os.IsNotExist(err) { isNewFile = true } var finalContent string contentType := r.Header.Get("Content-Type") // Parser selon le Content-Type if strings.Contains(contentType, "application/json") { // JSON Request var req NoteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.sendJSONError(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return } // Si content fourni directement, l'utiliser if req.Content != "" { finalContent = req.Content } else { // Sinon, construire depuis body + frontMatter var fm indexer.FullFrontMatter if req.FrontMatter != nil { fm = *req.FrontMatter } else { fm = indexer.FullFrontMatter{} } // Remplir les champs manquants now := time.Now() if fm.Title == "" { fm.Title = strings.TrimSuffix(filepath.Base(notePath), ".md") fm.Title = strings.ReplaceAll(fm.Title, "-", " ") fm.Title = strings.Title(fm.Title) } if isNewFile || fm.Date == "" { fm.Date = now.Format("02-01-2006") } fm.LastModified = now.Format("02-01-2006:15:04") if fm.Tags == nil { fm.Tags = []string{} } // Marshal front matter fmBytes, err := yaml.Marshal(fm) if err != nil { h.logger.Printf("Erreur marshalling front matter: %v", err) h.sendJSONError(w, "Failed to generate front matter", http.StatusInternalServerError) return } body := req.Body if body == "" { body = "\n# " + fm.Title + "\n\nVotre contenu ici..." } finalContent = "---\n" + string(fmBytes) + "---\n" + body } } else if strings.Contains(contentType, "text/markdown") || strings.Contains(contentType, "text/plain") { // Raw Markdown bodyBytes, err := io.ReadAll(r.Body) if err != nil { h.sendJSONError(w, "Failed to read body", http.StatusBadRequest) return } finalContent = string(bodyBytes) // Si pas de front matter, en générer un if !strings.HasPrefix(finalContent, "---") { now := time.Now() fm := indexer.FullFrontMatter{ Title: strings.TrimSuffix(filepath.Base(notePath), ".md"), Date: now.Format("02-01-2006"), LastModified: now.Format("02-01-2006:15:04"), Tags: []string{}, } fmBytes, _ := yaml.Marshal(fm) finalContent = "---\n" + string(fmBytes) + "---\n" + finalContent } else { // Mettre à jour le LastModified du front matter existant fm, body, err := indexer.ExtractFrontMatterAndBodyFromReader(strings.NewReader(finalContent)) if err == nil { fm.LastModified = time.Now().Format("02-01-2006:15:04") fmBytes, _ := yaml.Marshal(fm) finalContent = "---\n" + string(fmBytes) + "---\n" + body } } } else { h.sendJSONError(w, "Unsupported Content-Type. Use application/json or text/markdown", http.StatusUnsupportedMediaType) return } // Créer les dossiers parents si nécessaire if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { h.logger.Printf("Erreur création dossier pour %s: %v", notePath, err) h.sendJSONError(w, "Failed to create parent directory", http.StatusInternalServerError) return } // Écrire le fichier if err := os.WriteFile(fullPath, []byte(finalContent), 0o644); err != nil { h.logger.Printf("Erreur écriture fichier %s: %v", notePath, err) h.sendJSONError(w, "Failed to write note", http.StatusInternalServerError) return } // Ré-indexer en arrière-plan go func() { if err := h.idx.Load(h.notesDir); err != nil { h.logger.Printf("Échec ré-indexation après PUT: %v", err) } }() // Lire les métadonnées pour la réponse info, _ := os.Stat(fullPath) fm, body, _ := indexer.ExtractFrontMatterAndBody(fullPath) response := NoteResponse{ Path: filepath.ToSlash(notePath), Title: fm.Title, Content: finalContent, Body: body, FrontMatter: &fm, LastModified: fm.LastModified, Size: info.Size(), } statusCode := http.StatusOK if isNewFile { statusCode = http.StatusCreated } h.sendJSON(w, response, statusCode) } // handleRESTDeleteNote supprime une note // DELETE /api/v1/notes/{path} func (h *Handler) handleRESTDeleteNote(w http.ResponseWriter, r *http.Request, notePath string) { fullPath := filepath.Join(h.notesDir, notePath) // Vérifier que le fichier existe if _, err := os.Stat(fullPath); os.IsNotExist(err) { h.sendJSONError(w, "Note not found", http.StatusNotFound) return } // Supprimer le fichier if err := os.Remove(fullPath); err != nil { h.logger.Printf("Erreur suppression fichier %s: %v", notePath, err) h.sendJSONError(w, "Failed to delete note", http.StatusInternalServerError) return } // Ré-indexer en arrière-plan go func() { if err := h.idx.Load(h.notesDir); err != nil { h.logger.Printf("Échec ré-indexation après DELETE: %v", err) } }() // Réponse de succès response := map[string]interface{}{ "message": "Note deleted successfully", "path": filepath.ToSlash(notePath), } h.sendJSON(w, response, http.StatusOK) } // Utility functions // ================= // sendJSON envoie une réponse JSON func (h *Handler) sendJSON(w http.ResponseWriter, data interface{}, statusCode int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(statusCode) encoder := json.NewEncoder(w) encoder.SetIndent("", " ") if err := encoder.Encode(data); err != nil { h.logger.Printf("Erreur encodage JSON: %v", err) } } // sendJSONError envoie une erreur JSON func (h *Handler) sendJSONError(w http.ResponseWriter, message string, statusCode int) { response := ErrorResponse{ Error: http.StatusText(statusCode), Message: message, Code: statusCode, } h.sendJSON(w, response, statusCode) }