package api import ( "bytes" "encoding/json" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "sort" "strings" "time" "github.com/mathieu/personotes/internal/indexer" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" ) // PublicNote représente une note partagée publiquement type PublicNote struct { Path string `json:"path"` Title string `json:"title"` PublishedAt time.Time `json:"published_at"` } // PublicNotesData contient la liste des notes publiques type PublicNotesData struct { Notes []PublicNote `json:"notes"` } // getPublicDirPath retourne le chemin du dossier public HTML func (h *Handler) getPublicDirPath() string { return filepath.Join(h.notesDir, "..", "public") } // getPublicNotesFilePath retourne le chemin du fichier .public.json func (h *Handler) getPublicNotesFilePath() string { return filepath.Join(h.notesDir, ".public.json") } // loadPublicNotes charge les notes publiques depuis le fichier JSON func (h *Handler) loadPublicNotes() (*PublicNotesData, error) { path := h.getPublicNotesFilePath() // Si le fichier n'existe pas, retourner une liste vide if _, err := os.Stat(path); os.IsNotExist(err) { return &PublicNotesData{Notes: []PublicNote{}}, nil } data, err := os.ReadFile(path) if err != nil { return nil, err } var publicNotes PublicNotesData if err := json.Unmarshal(data, &publicNotes); err != nil { return nil, err } // Trier par date de publication (plus récent d'abord) sort.Slice(publicNotes.Notes, func(i, j int) bool { return publicNotes.Notes[i].PublishedAt.After(publicNotes.Notes[j].PublishedAt) }) return &publicNotes, nil } // savePublicNotes sauvegarde les notes publiques dans le fichier JSON func (h *Handler) savePublicNotes(publicNotes *PublicNotesData) error { path := h.getPublicNotesFilePath() data, err := json.MarshalIndent(publicNotes, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } // isPublic vérifie si une note est publique func (h *Handler) isPublic(notePath string) bool { publicNotes, err := h.loadPublicNotes() if err != nil { return false } for _, note := range publicNotes.Notes { if note.Path == notePath { return true } } return false } // ensurePublicDir crée le dossier public s'il n'existe pas func (h *Handler) ensurePublicDir() error { publicDir := h.getPublicDirPath() // Créer public/ if err := os.MkdirAll(publicDir, 0755); err != nil { return err } // Créer public/static/ staticDir := filepath.Join(publicDir, "static") if err := os.MkdirAll(staticDir, 0755); err != nil { return err } return nil } // copyStaticAssets copie les fichiers CSS/fonts nécessaires func (h *Handler) copyStaticAssets() error { publicStaticDir := filepath.Join(h.getPublicDirPath(), "static") // Fichiers à copier filesToCopy := []string{ "static/theme.css", "static/themes.css", } for _, file := range filesToCopy { src := file dst := filepath.Join(publicStaticDir, filepath.Base(file)) if err := copyFile(src, dst); err != nil { h.logger.Printf("Avertissement: impossible de copier %s: %v", file, err) // Continuer même si la copie échoue } } return nil } // copyFile copie un fichier func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) return err } // generateNoteHTML génère le fichier HTML pour une note publique func (h *Handler) generateNoteHTML(notePath string) error { // Lire le fichier Markdown absPath := filepath.Join(h.notesDir, notePath) content, err := os.ReadFile(absPath) if err != nil { return fmt.Errorf("lecture fichier: %w", err) } // Extraire le front matter et le contenu fm, body, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content)) if err != nil { // Continuer avec le contenu complet si erreur body = string(content) } // Déterminer le titre title := filepath.Base(notePath) if fm.Title != "" { title = fm.Title } else { title = strings.TrimSuffix(title, ".md") } // Convertir Markdown en HTML avec goldmark md := goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Typographer, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithHardWraps(), html.WithXHTML(), ), ) var buf bytes.Buffer if err := md.Convert([]byte(body), &buf); err != nil { return fmt.Errorf("conversion markdown: %w", err) } // Générer le HTML complet standalone htmlContent := h.generateStandaloneHTML(title, buf.String(), fm.Tags, fm.Date) // Écrire le fichier HTML - structure plate avec seulement le nom du fichier filename := filepath.Base(notePath) outputPath := filepath.Join(h.getPublicDirPath(), strings.TrimSuffix(filename, ".md")+".html") if err := os.WriteFile(outputPath, []byte(htmlContent), 0644); err != nil { return fmt.Errorf("écriture fichier: %w", err) } h.logger.Printf("HTML généré: %s", outputPath) return nil } // deleteNoteHTML supprime le fichier HTML d'une note func (h *Handler) deleteNoteHTML(notePath string) error { filename := filepath.Base(notePath) outputPath := filepath.Join(h.getPublicDirPath(), strings.TrimSuffix(filename, ".md")+".html") if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { return err } h.logger.Printf("HTML supprimé: %s", outputPath) return nil } // generatePublicIndex génère le fichier index.html avec la liste des notes func (h *Handler) generatePublicIndex() error { publicNotes, err := h.loadPublicNotes() if err != nil { return fmt.Errorf("chargement notes publiques: %w", err) } // Enrichir avec les métadonnées enrichedNotes := []map[string]interface{}{} for _, note := range publicNotes.Notes { filename := filepath.Base(note.Path) item := map[string]interface{}{ "Path": strings.TrimSuffix(filename, ".md") + ".html", "Title": note.Title, "PublishedAt": note.PublishedAt.Format("02/01/2006"), "Tags": []string{}, } // Essayer de lire le fichier pour obtenir les métadonnées absPath := filepath.Join(h.notesDir, note.Path) if content, err := os.ReadFile(absPath); err == nil { if fm, _, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content)); err == nil { if fm.Title != "" { item["Title"] = fm.Title } item["Tags"] = fm.Tags } } enrichedNotes = append(enrichedNotes, item) } // Générer le HTML de l'index htmlContent := h.generateIndexHTML(enrichedNotes) // Écrire index.html indexPath := filepath.Join(h.getPublicDirPath(), "index.html") if err := os.WriteFile(indexPath, []byte(htmlContent), 0644); err != nil { return fmt.Errorf("écriture index.html: %w", err) } h.logger.Printf("Index généré: %s", indexPath) return nil } // handlePublicList retourne la liste des notes publiques func (h *Handler) handlePublicList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed) return } publicNotes, err := h.loadPublicNotes() if err != nil { h.logger.Printf("Erreur chargement notes publiques: %v", err) http.Error(w, "Erreur de chargement", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(publicNotes) } // handlePublicToggle bascule le statut public d'une note + génère/supprime HTML func (h *Handler) handlePublicToggle(w http.ResponseWriter, r *http.Request) { h.logger.Printf("handlePublicToggle appelé") if r.Method != http.MethodPost { h.logger.Printf("Méthode non autorisée: %s", r.Method) http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { h.logger.Printf("Erreur ParseForm: %v", err) http.Error(w, "Formulaire invalide", http.StatusBadRequest) return } path := r.FormValue("path") h.logger.Printf("Chemin reçu: %s", path) if path == "" { h.logger.Printf("Chemin vide") http.Error(w, "Chemin requis", http.StatusBadRequest) return } // Valider que le fichier existe absPath := filepath.Join(h.notesDir, path) if _, err := os.Stat(absPath); os.IsNotExist(err) { http.Error(w, "Fichier introuvable", http.StatusNotFound) return } publicNotes, err := h.loadPublicNotes() if err != nil { h.logger.Printf("Erreur chargement notes publiques: %v", err) http.Error(w, "Erreur de chargement", http.StatusInternalServerError) return } // Vérifier si la note est déjà publique isCurrentlyPublic := false newNotes := []PublicNote{} for _, note := range publicNotes.Notes { if note.Path == path { isCurrentlyPublic = true } else { newNotes = append(newNotes, note) } } if isCurrentlyPublic { // Retirer du public - supprimer le HTML publicNotes.Notes = newNotes if err := h.deleteNoteHTML(path); err != nil { h.logger.Printf("Erreur suppression HTML: %v", err) } } else { // Ajouter au public - générer le HTML title := filepath.Base(path) title = strings.TrimSuffix(title, ".md") // Essayer de lire le titre depuis le fichier if content, err := os.ReadFile(absPath); err == nil { if fm, _, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content)); err == nil && fm.Title != "" { title = fm.Title } } newNote := PublicNote{ Path: path, Title: title, PublishedAt: time.Now(), } publicNotes.Notes = append(publicNotes.Notes, newNote) // Créer les dossiers nécessaires if err := h.ensurePublicDir(); err != nil { h.logger.Printf("Erreur création dossiers: %v", err) http.Error(w, "Erreur de création des dossiers", http.StatusInternalServerError) return } // Copier les assets statiques if err := h.copyStaticAssets(); err != nil { h.logger.Printf("Avertissement copie assets: %v", err) } // Générer le HTML de la note if err := h.generateNoteHTML(path); err != nil { h.logger.Printf("Erreur génération HTML: %v", err) http.Error(w, "Erreur de génération HTML", http.StatusInternalServerError) return } } // Sauvegarder le fichier .public.json if err := h.savePublicNotes(publicNotes); err != nil { h.logger.Printf("Erreur sauvegarde notes publiques: %v", err) http.Error(w, "Erreur de sauvegarde", http.StatusInternalServerError) return } // Régénérer l'index if err := h.generatePublicIndex(); err != nil { h.logger.Printf("Erreur génération index: %v", err) } // Retourner le nouveau statut status := "private" if !isCurrentlyPublic { status = "public" } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": status, "path": path, }) } // generateStandaloneHTML génère un fichier HTML standalone complet func (h *Handler) generateStandaloneHTML(title, content string, tags []string, date string) string { tagsHTML := "" if len(tags) > 0 { tagsHTML = `
` for _, tag := range tags { tagsHTML += fmt.Sprintf(`#%s`, template.HTMLEscapeString(tag)) } tagsHTML += `
` } dateHTML := "" if date != "" { dateHTML = fmt.Sprintf(` %s`, template.HTMLEscapeString(date)) } return fmt.Sprintf(` %s - PersoNotes
Back to public notes

%s

%s
%s
%s
`, template.HTMLEscapeString(title), template.HTMLEscapeString(title), dateHTML, tagsHTML, content) } // generateIndexHTML génère le HTML de la page d'index func (h *Handler) generateIndexHTML(notes []map[string]interface{}) string { notesHTML := "" if len(notes) > 0 { notesHTML = `` } else { notesHTML = `

No public notes yet

` } return fmt.Sprintf(` Public Notes - PersoNotes

Public Notes

Discover my shared notes

%s
`, notesHTML) }