Des tonnes de modifications notamment VIM / Couleurs / typos
This commit is contained in:
436
internal/api/daily_notes.go
Normal file
436
internal/api/daily_notes.go
Normal 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
305
internal/api/favorites.go
Normal 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 "📄"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user