1554 lines
47 KiB
Go
1554 lines
47 KiB
Go
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
|
||
}
|
||
}
|