Des tonnes de modifications notamment VIM / Couleurs / typos

This commit is contained in:
2025-11-11 15:41:51 +01:00
parent 439880b08f
commit 6face7a02f
59 changed files with 7857 additions and 960 deletions

436
internal/api/daily_notes.go Normal file
View File

@ -0,0 +1,436 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// DailyNoteInfo contient les métadonnées d'une daily note
type DailyNoteInfo struct {
Date time.Time
Path string
Exists bool
Title string
DayOfWeek string
DayOfMonth int
}
// CalendarDay représente un jour dans le calendrier
type CalendarDay struct {
Day int
Date time.Time
HasNote bool
IsToday bool
NotePath string
InMonth bool // Indique si le jour appartient au mois affiché
}
// CalendarData contient les données pour le template du calendrier
type CalendarData struct {
Year int
Month time.Month
MonthName string
Weeks [][7]CalendarDay
PrevMonth string // Format: YYYY-MM
NextMonth string // Format: YYYY-MM
CurrentMonth string // Format: YYYY-MM
}
// getDailyNotePath retourne le chemin d'une daily note pour une date donnée
// Format: notes/daily/2025/01/11.md
func (h *Handler) getDailyNotePath(date time.Time) string {
year := date.Format("2006")
month := date.Format("01")
day := date.Format("02")
relativePath := filepath.Join("daily", year, month, fmt.Sprintf("%s.md", day))
return relativePath
}
// getDailyNoteAbsolutePath retourne le chemin absolu d'une daily note
func (h *Handler) getDailyNoteAbsolutePath(date time.Time) string {
relativePath := h.getDailyNotePath(date)
return filepath.Join(h.notesDir, relativePath)
}
// dailyNoteExists vérifie si une daily note existe pour une date donnée
func (h *Handler) dailyNoteExists(date time.Time) bool {
absPath := h.getDailyNoteAbsolutePath(date)
_, err := os.Stat(absPath)
return err == nil
}
// createDailyNote crée une daily note avec un template par défaut
func (h *Handler) createDailyNote(date time.Time) error {
absPath := h.getDailyNoteAbsolutePath(date)
// Créer les dossiers parents si nécessaire
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("impossible de créer les dossiers: %w", err)
}
// Vérifier si le fichier existe déjà
if _, err := os.Stat(absPath); err == nil {
return nil // Fichier existe déjà, ne pas écraser
}
// Formatter les dates
dateStr := date.Format("02-01-2006")
dateTimeStr := date.Format("02-01-2006:15:04")
// Noms des jours en français
dayNames := map[time.Weekday]string{
time.Monday: "Lundi",
time.Tuesday: "Mardi",
time.Wednesday: "Mercredi",
time.Thursday: "Jeudi",
time.Friday: "Vendredi",
time.Saturday: "Samedi",
time.Sunday: "Dimanche",
}
// Noms des mois en français
monthNames := map[time.Month]string{
time.January: "janvier",
time.February: "février",
time.March: "mars",
time.April: "avril",
time.May: "mai",
time.June: "juin",
time.July: "juillet",
time.August: "août",
time.September: "septembre",
time.October: "octobre",
time.November: "novembre",
time.December: "décembre",
}
dayName := dayNames[date.Weekday()]
monthName := monthNames[date.Month()]
// Template de la daily note
template := fmt.Sprintf(`---
title: "Daily Note - %s"
date: "%s"
last_modified: "%s"
tags: [daily]
---
# 📅 %s %d %s %d
## 🎯 Objectifs du jour
-
## 📝 Notes
-
## ✅ Accompli
-
## 💭 Réflexions
-
## 🔗 Liens
-
`, date.Format("2006-01-02"), dateStr, dateTimeStr, dayName, date.Day(), monthName, date.Year())
// Écrire le fichier
if err := os.WriteFile(absPath, []byte(template), 0644); err != nil {
return fmt.Errorf("impossible d'écrire le fichier: %w", err)
}
return nil
}
// handleDailyToday gère GET /api/daily/today - Ouvre ou crée la note du jour
func (h *Handler) handleDailyToday(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
return
}
today := time.Now()
// Créer la note si elle n'existe pas
if !h.dailyNoteExists(today) {
if err := h.createDailyNote(today); err != nil {
h.logger.Printf("Erreur création daily note: %v", err)
http.Error(w, "Erreur lors de la création de la note", http.StatusInternalServerError)
return
}
// Déclencher la ré-indexation
go h.idx.Load(h.notesDir)
}
// Rediriger vers l'endpoint normal de note
notePath := h.getDailyNotePath(today)
http.Redirect(w, r, "/api/notes/"+notePath, http.StatusSeeOther)
}
// handleDailyDate gère GET /api/daily/{YYYY-MM-DD} - Ouvre ou crée la note d'une date spécifique
func (h *Handler) handleDailyDate(w http.ResponseWriter, r *http.Request, dateStr string) {
if r.Method != http.MethodGet {
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
return
}
// Parser la date (format YYYY-MM-DD)
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
http.Error(w, "Format de date invalide (attendu: YYYY-MM-DD)", http.StatusBadRequest)
return
}
// Créer la note si elle n'existe pas
if !h.dailyNoteExists(date) {
if err := h.createDailyNote(date); err != nil {
h.logger.Printf("Erreur création daily note: %v", err)
http.Error(w, "Erreur lors de la création de la note", http.StatusInternalServerError)
return
}
// Déclencher la ré-indexation
go h.idx.Load(h.notesDir)
}
// Rediriger vers l'endpoint normal de note
notePath := h.getDailyNotePath(date)
http.Redirect(w, r, "/api/notes/"+notePath, http.StatusSeeOther)
}
// handleDailyCalendar gère GET /api/daily/calendar/{YYYY}/{MM} - Retourne le HTML du calendrier
func (h *Handler) handleDailyCalendar(w http.ResponseWriter, r *http.Request, yearStr, monthStr string) {
if r.Method != http.MethodGet {
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
return
}
// Parser année et mois
year, err := strconv.Atoi(yearStr)
if err != nil || year < 1900 || year > 2100 {
http.Error(w, "Année invalide", http.StatusBadRequest)
return
}
month, err := strconv.Atoi(monthStr)
if err != nil || month < 1 || month > 12 {
http.Error(w, "Mois invalide", http.StatusBadRequest)
return
}
// Créer les données du calendrier
calendarData := h.buildCalendarData(year, time.Month(month))
// Rendre le template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "daily-calendar.html", calendarData); err != nil {
h.logger.Printf("Erreur template calendrier: %v", err)
http.Error(w, "Erreur de rendu", http.StatusInternalServerError)
}
}
// buildCalendarData construit les données du calendrier pour un mois donné
func (h *Handler) buildCalendarData(year int, month time.Month) *CalendarData {
// Premier jour du mois
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
// Dernier jour du mois
lastDay := firstDay.AddDate(0, 1, -1)
// Date d'aujourd'hui
today := time.Now()
// Noms des mois en français
monthNames := map[time.Month]string{
time.January: "Janvier",
time.February: "Février",
time.March: "Mars",
time.April: "Avril",
time.May: "Mai",
time.June: "Juin",
time.July: "Juillet",
time.August: "Août",
time.September: "Septembre",
time.October: "Octobre",
time.November: "Novembre",
time.December: "Décembre",
}
data := &CalendarData{
Year: year,
Month: month,
MonthName: monthNames[month],
Weeks: make([][7]CalendarDay, 0),
}
// Calculer mois précédent et suivant
prevMonth := firstDay.AddDate(0, -1, 0)
nextMonth := firstDay.AddDate(0, 1, 0)
data.PrevMonth = fmt.Sprintf("%d-%02d", prevMonth.Year(), prevMonth.Month())
data.NextMonth = fmt.Sprintf("%d-%02d", nextMonth.Year(), nextMonth.Month())
data.CurrentMonth = fmt.Sprintf("%d-%02d", year, month)
// Construire les semaines
// Lundi = 0, Dimanche = 6
var week [7]CalendarDay
weekDay := 0
// Jour de la semaine du premier jour (convertir : Dimanche=0 → Lundi=0)
firstWeekday := int(firstDay.Weekday())
if firstWeekday == 0 {
firstWeekday = 7 // Dimanche devient 7
}
firstWeekday-- // Maintenant Lundi=0
// Remplir les jours avant le premier du mois (mois précédent)
prevMonthLastDay := firstDay.AddDate(0, 0, -1)
for i := 0; i < firstWeekday; i++ {
daysBack := firstWeekday - i
date := prevMonthLastDay.AddDate(0, 0, -daysBack+1)
week[i] = CalendarDay{
Day: date.Day(),
Date: date,
HasNote: h.dailyNoteExists(date),
IsToday: isSameDay(date, today),
InMonth: false,
}
}
weekDay = firstWeekday
// Remplir les jours du mois
for day := 1; day <= lastDay.Day(); day++ {
date := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
week[weekDay] = CalendarDay{
Day: day,
Date: date,
HasNote: h.dailyNoteExists(date),
IsToday: isSameDay(date, today),
NotePath: h.getDailyNotePath(date),
InMonth: true,
}
weekDay++
if weekDay == 7 {
data.Weeks = append(data.Weeks, week)
week = [7]CalendarDay{}
weekDay = 0
}
}
// Remplir les jours après le dernier du mois (mois suivant)
if weekDay > 0 {
nextMonthDay := 1
for weekDay < 7 {
date := time.Date(year, month+1, nextMonthDay, 0, 0, 0, 0, time.Local)
week[weekDay] = CalendarDay{
Day: nextMonthDay,
Date: date,
HasNote: h.dailyNoteExists(date),
IsToday: isSameDay(date, today),
InMonth: false,
}
weekDay++
nextMonthDay++
}
data.Weeks = append(data.Weeks, week)
}
return data
}
// handleDailyRecent gère GET /api/daily/recent - Retourne les 7 dernières daily notes
func (h *Handler) handleDailyRecent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
return
}
// Chercher les daily notes des 14 derniers jours (au cas où certaines manquent)
recentNotes := make([]*DailyNoteInfo, 0, 7)
today := time.Now()
for i := 0; i < 14 && len(recentNotes) < 7; i++ {
date := today.AddDate(0, 0, -i)
if h.dailyNoteExists(date) {
dayNames := map[time.Weekday]string{
time.Monday: "Lun",
time.Tuesday: "Mar",
time.Wednesday: "Mer",
time.Thursday: "Jeu",
time.Friday: "Ven",
time.Saturday: "Sam",
time.Sunday: "Dim",
}
info := &DailyNoteInfo{
Date: date,
Path: h.getDailyNotePath(date),
Exists: true,
Title: date.Format("02/01/2006"),
DayOfWeek: dayNames[date.Weekday()],
DayOfMonth: date.Day(),
}
recentNotes = append(recentNotes, info)
}
}
// Rendre le template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "daily-recent.html", map[string]interface{}{
"Notes": recentNotes,
}); err != nil {
h.logger.Printf("Erreur template notes récentes: %v", err)
http.Error(w, "Erreur de rendu", http.StatusInternalServerError)
}
}
// isSameDay vérifie si deux dates sont le même jour
func isSameDay(d1, d2 time.Time) bool {
y1, m1, day1 := d1.Date()
y2, m2, day2 := d2.Date()
return y1 == y2 && m1 == m2 && day1 == day2
}
// handleDaily route les requêtes /api/daily/*
func (h *Handler) handleDaily(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/daily")
path = strings.TrimPrefix(path, "/")
// /api/daily/today
if path == "today" || path == "" {
h.handleDailyToday(w, r)
return
}
// /api/daily/recent
if path == "recent" {
h.handleDailyRecent(w, r)
return
}
// /api/daily/calendar/{YYYY}/{MM}
if strings.HasPrefix(path, "calendar/") {
parts := strings.Split(strings.TrimPrefix(path, "calendar/"), "/")
if len(parts) == 2 {
h.handleDailyCalendar(w, r, parts[0], parts[1])
return
}
}
// /api/daily/{YYYY-MM-DD}
if len(path) == 10 && path[4] == '-' && path[7] == '-' {
h.handleDailyDate(w, r, path)
return
}
http.NotFound(w, r)
}

305
internal/api/favorites.go Normal file
View File

@ -0,0 +1,305 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"time"
)
// Favorite représente un élément favori (note ou dossier)
type Favorite struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Title string `json:"title"`
AddedAt time.Time `json:"added_at"`
Order int `json:"order"`
}
// FavoritesData contient la liste des favoris
type FavoritesData struct {
Items []Favorite `json:"items"`
}
// getFavoritesFilePath retourne le chemin du fichier de favoris
func (h *Handler) getFavoritesFilePath() string {
return filepath.Join(h.notesDir, ".favorites.json")
}
// loadFavorites charge les favoris depuis le fichier JSON
func (h *Handler) loadFavorites() (*FavoritesData, error) {
path := h.getFavoritesFilePath()
// Si le fichier n'existe pas, retourner une liste vide
if _, err := os.Stat(path); os.IsNotExist(err) {
return &FavoritesData{Items: []Favorite{}}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var favorites FavoritesData
if err := json.Unmarshal(data, &favorites); err != nil {
return nil, err
}
// Trier par ordre
sort.Slice(favorites.Items, func(i, j int) bool {
return favorites.Items[i].Order < favorites.Items[j].Order
})
return &favorites, nil
}
// saveFavorites sauvegarde les favoris dans le fichier JSON
func (h *Handler) saveFavorites(favorites *FavoritesData) error {
path := h.getFavoritesFilePath()
data, err := json.MarshalIndent(favorites, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// handleFavorites route les requêtes /api/favorites/*
func (h *Handler) handleFavorites(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleGetFavorites(w, r)
case http.MethodPost:
h.handleAddFavorite(w, r)
case http.MethodDelete:
h.handleRemoveFavorite(w, r)
case http.MethodPut:
h.handleReorderFavorites(w, r)
default:
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
}
}
// handleGetFavorites retourne la liste des favoris (HTML)
func (h *Handler) handleGetFavorites(w http.ResponseWriter, r *http.Request) {
favorites, err := h.loadFavorites()
if err != nil {
h.logger.Printf("Erreur chargement favoris: %v", err)
// En cas d'erreur, retourner une liste vide plutôt qu'une erreur 500
favorites = &FavoritesData{Items: []Favorite{}}
}
// Enrichir avec les informations des fichiers
enrichedFavorites := []map[string]interface{}{}
for _, fav := range favorites.Items {
absPath := filepath.Join(h.notesDir, fav.Path)
// Vérifier si le fichier/dossier existe toujours
if _, err := os.Stat(absPath); os.IsNotExist(err) {
continue // Skip les favoris qui n'existent plus
}
item := map[string]interface{}{
"Path": fav.Path,
"IsDir": fav.IsDir,
"Title": fav.Title,
"Icon": getIcon(fav.IsDir, fav.Path),
}
enrichedFavorites = append(enrichedFavorites, item)
}
data := map[string]interface{}{
"Favorites": enrichedFavorites,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "favorites.html", data); err != nil {
h.logger.Printf("Erreur template favoris: %v", err)
http.Error(w, "Erreur de rendu", http.StatusInternalServerError)
}
}
// handleAddFavorite ajoute un élément aux favoris
func (h *Handler) handleAddFavorite(w http.ResponseWriter, r *http.Request) {
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")
isDir := r.FormValue("is_dir") == "true"
title := r.FormValue("title")
h.logger.Printf("handleAddFavorite: path='%s', is_dir='%s', title='%s'", path, r.FormValue("is_dir"), title)
if path == "" {
h.logger.Printf("Erreur: chemin vide")
http.Error(w, "Chemin requis", http.StatusBadRequest)
return
}
// Valider que le fichier/dossier existe
absPath := filepath.Join(h.notesDir, path)
if _, err := os.Stat(absPath); os.IsNotExist(err) {
http.Error(w, "Fichier/dossier introuvable", http.StatusNotFound)
return
}
// Si pas de titre, utiliser le nom du fichier
if title == "" {
title = filepath.Base(path)
if !isDir && filepath.Ext(title) == ".md" {
title = title[:len(title)-3]
}
}
favorites, err := h.loadFavorites()
if err != nil {
h.logger.Printf("Erreur chargement favoris: %v", err)
http.Error(w, "Erreur de chargement", http.StatusInternalServerError)
return
}
// Vérifier si déjà en favoris
for _, fav := range favorites.Items {
if fav.Path == path {
http.Error(w, "Déjà en favoris", http.StatusConflict)
return
}
}
// Ajouter le nouveau favori
newFavorite := Favorite{
Path: path,
IsDir: isDir,
Title: title,
AddedAt: time.Now(),
Order: len(favorites.Items),
}
favorites.Items = append(favorites.Items, newFavorite)
if err := h.saveFavorites(favorites); err != nil {
h.logger.Printf("Erreur sauvegarde favoris: %v", err)
http.Error(w, "Erreur de sauvegarde", http.StatusInternalServerError)
return
}
// Retourner la liste mise à jour
h.handleGetFavorites(w, r)
}
// handleRemoveFavorite retire un élément des favoris
func (h *Handler) handleRemoveFavorite(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
// Pour DELETE, il faut lire le body manuellement
body, _ := io.ReadAll(r.Body)
r.Body.Close()
values, _ := url.ParseQuery(string(body))
r.Form = values
}
path := r.FormValue("path")
if path == "" {
http.Error(w, "Chemin requis", http.StatusBadRequest)
return
}
favorites, err := h.loadFavorites()
if err != nil {
h.logger.Printf("Erreur chargement favoris: %v", err)
http.Error(w, "Erreur de chargement", http.StatusInternalServerError)
return
}
// Retirer le favori
newItems := []Favorite{}
found := false
for _, fav := range favorites.Items {
if fav.Path != path {
newItems = append(newItems, fav)
} else {
found = true
}
}
if !found {
http.Error(w, "Favori introuvable", http.StatusNotFound)
return
}
// Réorganiser les ordres
for i := range newItems {
newItems[i].Order = i
}
favorites.Items = newItems
if err := h.saveFavorites(favorites); err != nil {
h.logger.Printf("Erreur sauvegarde favoris: %v", err)
http.Error(w, "Erreur de sauvegarde", http.StatusInternalServerError)
return
}
// Retourner la liste mise à jour
h.handleGetFavorites(w, r)
}
// handleReorderFavorites réorganise l'ordre des favoris
func (h *Handler) handleReorderFavorites(w http.ResponseWriter, r *http.Request) {
var order []string
if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
http.Error(w, "JSON invalide", http.StatusBadRequest)
return
}
favorites, err := h.loadFavorites()
if err != nil {
h.logger.Printf("Erreur chargement favoris: %v", err)
http.Error(w, "Erreur de chargement", http.StatusInternalServerError)
return
}
// Créer un map pour retrouver les favoris rapidement
favMap := make(map[string]*Favorite)
for i := range favorites.Items {
favMap[favorites.Items[i].Path] = &favorites.Items[i]
}
// Réorganiser selon le nouvel ordre
newItems := []Favorite{}
for i, path := range order {
if fav, ok := favMap[path]; ok {
fav.Order = i
newItems = append(newItems, *fav)
}
}
favorites.Items = newItems
if err := h.saveFavorites(favorites); err != nil {
h.logger.Printf("Erreur sauvegarde favoris: %v", err)
http.Error(w, "Erreur de sauvegarde", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// getIcon retourne l'icône appropriée pour un fichier/dossier
func getIcon(isDir bool, path string) string {
if isDir {
return "📁"
}
return "📄"
}

View File

@ -7,6 +7,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
@ -67,6 +68,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
@ -83,6 +88,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
@ -91,6 +104,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleFileTree(w, r)
return
}
if strings.HasPrefix(path, "/api/favorites") {
h.handleFavorites(w, r)
return
}
http.NotFound(w, r)
}
@ -247,7 +264,7 @@ func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) {
Content string
IsHome bool
}{
Filename: "🏠 Accueil - Index des notes",
Filename: "🏠 Accueil - Index",
Content: content,
IsHome: true,
}
@ -259,12 +276,26 @@ func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) {
}
}
func (h *Handler) handleAbout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "methode non supportee", http.StatusMethodNotAllowed)
return
}
// Utiliser le template about.html pour afficher la page À propos
err := h.templates.ExecuteTemplate(w, "about.html", nil)
if err != nil {
h.logger.Printf("erreur d execution du template about: %v", err)
http.Error(w, "erreur interne", http.StatusInternalServerError)
}
}
// generateHomeMarkdown génère le contenu Markdown de la page d'accueil
func (h *Handler) generateHomeMarkdown() string {
var sb strings.Builder
// En-tête
sb.WriteString("# 📚 Index des Notes\n\n")
sb.WriteString("# 📚 Index\n\n")
sb.WriteString("_Mise à jour automatique • " + time.Now().Format("02/01/2006 à 15:04") + "_\n\n")
// Construire l'arborescence
@ -281,12 +312,11 @@ func (h *Handler) generateHomeMarkdown() string {
// 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")
// Section des favoris (après les tags)
h.generateFavoritesSection(&sb)
// Titre de l'arborescence
sb.WriteString("## 📂 Toutes les notes\n\n")
// Titre de l'arborescence avec le nombre de notes
sb.WriteString(fmt.Sprintf("## 📂 Toutes les notes (%d)\n\n", noteCount))
// Générer l'arborescence en Markdown
h.generateMarkdownTree(&sb, tree, 0)
@ -317,6 +347,90 @@ func (h *Handler) generateTagsSection(sb *strings.Builder) {
sb.WriteString("</div>\n\n")
}
// generateFavoritesSection génère la section des favoris avec arborescence dépliable
func (h *Handler) generateFavoritesSection(sb *strings.Builder) {
favorites, err := h.loadFavorites()
if err != nil || len(favorites.Items) == 0 {
return
}
sb.WriteString("## ⭐ Favoris\n\n")
sb.WriteString("<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, 2)
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\">", fav.Path))
sb.WriteString(fmt.Sprintf("📄 %s", fav.Title))
sb.WriteString("</a>\n")
sb.WriteString(fmt.Sprintf(" </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\">", 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
@ -534,6 +648,12 @@ func (h *Handler) handleDeleteNote(w http.ResponseWriter, r *http.Request, filen
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 {
@ -843,3 +963,175 @@ func (h *Handler) handleMoveFile(w http.ResponseWriter, r *http.Request) {
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)
}
}
}
}