Commit avant changement d'agent vers devstral

This commit is contained in:
2025-11-13 17:00:47 +01:00
parent a09b73e4f1
commit cc1d6880a7
25 changed files with 2903 additions and 89 deletions

View File

@ -58,6 +58,53 @@ func (h *Handler) getDailyNoteAbsolutePath(date time.Time) string {
return filepath.Join(h.notesDir, relativePath)
}
// translateWeekday traduit un jour de la semaine
func (h *Handler) translateWeekday(r *http.Request, weekday time.Weekday) string {
dayKeys := map[time.Weekday]string{
time.Monday: "calendar.monday",
time.Tuesday: "calendar.tuesday",
time.Wednesday: "calendar.wednesday",
time.Thursday: "calendar.thursday",
time.Friday: "calendar.friday",
time.Saturday: "calendar.saturday",
time.Sunday: "calendar.sunday",
}
return h.t(r, dayKeys[weekday])
}
// translateWeekdayShort traduit un jour de la semaine (version courte)
func (h *Handler) translateWeekdayShort(r *http.Request, weekday time.Weekday) string {
dayKeys := map[time.Weekday]string{
time.Monday: "calendar.mon",
time.Tuesday: "calendar.tue",
time.Wednesday: "calendar.wed",
time.Thursday: "calendar.thu",
time.Friday: "calendar.fri",
time.Saturday: "calendar.sat",
time.Sunday: "calendar.sun",
}
return h.t(r, dayKeys[weekday])
}
// translateMonth traduit un nom de mois
func (h *Handler) translateMonth(r *http.Request, month time.Month) string {
monthKeys := map[time.Month]string{
time.January: "calendar.january",
time.February: "calendar.february",
time.March: "calendar.march",
time.April: "calendar.april",
time.May: "calendar.may",
time.June: "calendar.june",
time.July: "calendar.july",
time.August: "calendar.august",
time.September: "calendar.september",
time.October: "calendar.october",
time.November: "calendar.november",
time.December: "calendar.december",
}
return h.t(r, monthKeys[month])
}
// 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)
@ -66,7 +113,7 @@ func (h *Handler) dailyNoteExists(date time.Time) bool {
}
// createDailyNote crée une daily note avec un template par défaut
func (h *Handler) createDailyNote(date time.Time) error {
func (h *Handler) createDailyNote(r *http.Request, date time.Time) error {
absPath := h.getDailyNoteAbsolutePath(date)
// Créer les dossiers parents si nécessaire
@ -84,35 +131,9 @@ func (h *Handler) createDailyNote(date time.Time) error {
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()]
// Traduire le nom du jour et du mois
dayName := h.translateWeekday(r, date.Weekday())
monthName := h.translateMonth(r, date.Month())
// Template de la daily note
template := fmt.Sprintf(`---
@ -159,7 +180,7 @@ func (h *Handler) handleDailyToday(w http.ResponseWriter, r *http.Request) {
// Créer la note si elle n'existe pas
if !h.dailyNoteExists(today) {
if err := h.createDailyNote(today); err != nil {
if err := h.createDailyNote(r, 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
@ -190,7 +211,7 @@ func (h *Handler) handleDailyDate(w http.ResponseWriter, r *http.Request, dateSt
// Créer la note si elle n'existe pas
if !h.dailyNoteExists(date) {
if err := h.createDailyNote(date); err != nil {
if err := h.createDailyNote(r, 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
@ -232,7 +253,7 @@ func (h *Handler) handleDailyCalendar(w http.ResponseWriter, r *http.Request, ye
}
// Créer les données du calendrier
calendarData := h.buildCalendarData(year, time.Month(month))
calendarData := h.buildCalendarData(r, year, time.Month(month))
// Rendre le template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -243,7 +264,7 @@ func (h *Handler) handleDailyCalendar(w http.ResponseWriter, r *http.Request, ye
}
// buildCalendarData construit les données du calendrier pour un mois donné
func (h *Handler) buildCalendarData(year int, month time.Month) *CalendarData {
func (h *Handler) buildCalendarData(r *http.Request, year int, month time.Month) *CalendarData {
// Premier jour du mois
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, time.Local)
@ -253,26 +274,10 @@ func (h *Handler) buildCalendarData(year int, month time.Month) *CalendarData {
// 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],
MonthName: h.translateMonth(r, month),
Weeks: make([][7]CalendarDay, 0),
}
@ -373,22 +378,12 @@ func (h *Handler) handleDailyRecent(w http.ResponseWriter, r *http.Request) {
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()],
DayOfWeek: h.translateWeekdayShort(r, date.Weekday()),
DayOfMonth: date.Day(),
}
recentNotes = append(recentNotes, info)

View File

@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"html/template"
@ -16,6 +17,7 @@ import (
yaml "gopkg.in/yaml.v3"
"github.com/mathieu/personotes/internal/i18n"
"github.com/mathieu/personotes/internal/indexer"
)
@ -39,15 +41,17 @@ type Handler struct {
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) *Handler {
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,
}
}
@ -55,6 +59,12 @@ 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)
@ -278,7 +288,7 @@ func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) {
}
// Générer le contenu Markdown avec la liste de toutes les notes
content := h.generateHomeMarkdown()
content := h.generateHomeMarkdown(r)
// Utiliser le template editor.html pour afficher la page d'accueil
data := struct {
@ -317,12 +327,12 @@ func (h *Handler) handleAbout(w http.ResponseWriter, r *http.Request) {
}
// generateHomeMarkdown génère le contenu Markdown de la page d'accueil
func (h *Handler) generateHomeMarkdown() string {
func (h *Handler) generateHomeMarkdown(r *http.Request) string {
var sb strings.Builder
// En-tête
sb.WriteString("# 📚 Index\n\n")
sb.WriteString("_Mise à jour automatique • " + time.Now().Format("02/01/2006 à 15:04") + "_\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()
@ -339,15 +349,15 @@ func (h *Handler) generateHomeMarkdown() string {
h.generateTagsSection(&sb)
// Section des favoris (après les tags)
h.generateFavoritesSection(&sb)
h.generateFavoritesSection(&sb, r)
// Section des notes récemment modifiées (après les favoris)
h.generateRecentNotesSection(&sb)
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\">📂 Toutes les notes (%d)</h2>\n", noteCount))
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")
@ -390,7 +400,7 @@ func (h *Handler) generateTagsSection(sb *strings.Builder) {
}
// generateFavoritesSection génère la section des favoris avec arborescence dépliable
func (h *Handler) generateFavoritesSection(sb *strings.Builder) {
func (h *Handler) generateFavoritesSection(sb *strings.Builder, r *http.Request) {
favorites, err := h.loadFavorites()
if err != nil || len(favorites.Items) == 0 {
return
@ -398,7 +408,7 @@ func (h *Handler) generateFavoritesSection(sb *strings.Builder) {
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\">⭐ Favoris</h2>\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")
@ -423,7 +433,7 @@ func (h *Handler) generateFavoritesSection(sb *strings.Builder) {
} 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(" <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"))
@ -436,7 +446,7 @@ func (h *Handler) generateFavoritesSection(sb *strings.Builder) {
}
// generateRecentNotesSection génère la section des notes récemment modifiées
func (h *Handler) generateRecentNotesSection(sb *strings.Builder) {
func (h *Handler) generateRecentNotesSection(sb *strings.Builder, r *http.Request) {
recentDocs := h.idx.GetRecentDocuments(5)
if len(recentDocs) == 0 {
@ -445,7 +455,7 @@ func (h *Handler) generateRecentNotesSection(sb *strings.Builder) {
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\">🕒 Récemment modifiés</h2>\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")
@ -464,7 +474,7 @@ func (h *Handler) generateRecentNotesSection(sb *strings.Builder) {
}
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\">\n", doc.Path))
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))
@ -527,7 +537,7 @@ func (h *Handler) generateFavoriteFolderContent(sb *strings.Builder, folderPath
// 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 <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))
@ -1484,3 +1494,60 @@ func (h *Handler) generateFolderViewMarkdown(folderPath string) string {
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
}
}

View File

@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/mathieu/personotes/internal/i18n"
"github.com/mathieu/personotes/internal/indexer"
)
@ -32,7 +33,10 @@ func newTestHandler(t *testing.T, notesDir string) *Handler {
t.Fatalf("impossible d'analyser les templates de test: %v", err)
}
return NewHandler(notesDir, indexer.New(), tpl, log.New(io.Discard, "", 0))
// Create a minimal translator for tests
translator := i18n.New("en")
return NewHandler(notesDir, indexer.New(), tpl, log.New(io.Discard, "", 0), translator)
}
func TestHandler_Search(t *testing.T) {

139
internal/i18n/i18n.go Normal file
View File

@ -0,0 +1,139 @@
package i18n
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Translator manages translations for multiple languages
type Translator struct {
translations map[string]map[string]interface{}
mu sync.RWMutex
defaultLang string
}
// New creates a new Translator with the specified default language
func New(defaultLang string) *Translator {
t := &Translator{
translations: make(map[string]map[string]interface{}),
defaultLang: defaultLang,
}
return t
}
// LoadFromDir loads all translation files from a directory
func (t *Translator) LoadFromDir(dir string) error {
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return fmt.Errorf("failed to list translation files: %w", err)
}
for _, file := range files {
lang := strings.TrimSuffix(filepath.Base(file), ".json")
if err := t.LoadLanguage(lang, file); err != nil {
return fmt.Errorf("failed to load language %s: %w", lang, err)
}
}
return nil
}
// LoadLanguage loads translations for a specific language from a JSON file
func (t *Translator) LoadLanguage(lang, filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read translation file: %w", err)
}
var translations map[string]interface{}
if err := json.Unmarshal(data, &translations); err != nil {
return fmt.Errorf("failed to parse translation file: %w", err)
}
t.mu.Lock()
t.translations[lang] = translations
t.mu.Unlock()
return nil
}
// T translates a key for the given language with optional arguments
// Key format: "section.subsection.key" (e.g., "menu.home")
// Arguments can be passed as a map for variable interpolation
func (t *Translator) T(lang, key string, args ...map[string]string) string {
t.mu.RLock()
defer t.mu.RUnlock()
// Try to get translation for specified language
translation := t.getTranslation(lang, key)
// Fallback to default language if not found
if translation == "" && lang != t.defaultLang {
translation = t.getTranslation(t.defaultLang, key)
}
// Return key if no translation found
if translation == "" {
return key
}
// Interpolate variables if args provided
if len(args) > 0 && args[0] != nil {
for k, v := range args[0] {
placeholder := fmt.Sprintf("{{%s}}", k)
translation = strings.ReplaceAll(translation, placeholder, v)
}
}
return translation
}
// getTranslation retrieves a translation by key using dot notation
func (t *Translator) getTranslation(lang, key string) string {
langTranslations, ok := t.translations[lang]
if !ok {
return ""
}
parts := strings.Split(key, ".")
var current interface{} = langTranslations
for _, part := range parts {
if m, ok := current.(map[string]interface{}); ok {
current = m[part]
} else {
return ""
}
}
if str, ok := current.(string); ok {
return str
}
return ""
}
// GetAvailableLanguages returns a list of loaded languages
func (t *Translator) GetAvailableLanguages() []string {
t.mu.RLock()
defer t.mu.RUnlock()
langs := make([]string, 0, len(t.translations))
for lang := range t.translations {
langs = append(langs, lang)
}
return langs
}
// GetTranslations returns all translations for a specific language
func (t *Translator) GetTranslations(lang string) (map[string]interface{}, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
translations, ok := t.translations[lang]
return translations, ok
}

123
internal/i18n/i18n_test.go Normal file
View File

@ -0,0 +1,123 @@
package i18n
import (
"os"
"path/filepath"
"testing"
)
func TestTranslator(t *testing.T) {
// Create temporary test translations
tmpDir := t.TempDir()
enFile := filepath.Join(tmpDir, "en.json")
enContent := `{
"menu": {
"home": "Home",
"search": "Search"
},
"editor": {
"confirmDelete": "Are you sure you want to delete {{filename}}?"
}
}`
if err := os.WriteFile(enFile, []byte(enContent), 0644); err != nil {
t.Fatal(err)
}
frFile := filepath.Join(tmpDir, "fr.json")
frContent := `{
"menu": {
"home": "Accueil",
"search": "Rechercher"
},
"editor": {
"confirmDelete": "Êtes-vous sûr de vouloir supprimer {{filename}} ?"
}
}`
if err := os.WriteFile(frFile, []byte(frContent), 0644); err != nil {
t.Fatal(err)
}
// Test translator
trans := New("en")
if err := trans.LoadFromDir(tmpDir); err != nil {
t.Fatalf("Failed to load translations: %v", err)
}
tests := []struct {
name string
lang string
key string
args map[string]string
expected string
}{
{
name: "English simple key",
lang: "en",
key: "menu.home",
expected: "Home",
},
{
name: "French simple key",
lang: "fr",
key: "menu.search",
expected: "Rechercher",
},
{
name: "English with interpolation",
lang: "en",
key: "editor.confirmDelete",
args: map[string]string{"filename": "test.md"},
expected: "Are you sure you want to delete test.md?",
},
{
name: "French with interpolation",
lang: "fr",
key: "editor.confirmDelete",
args: map[string]string{"filename": "test.md"},
expected: "Êtes-vous sûr de vouloir supprimer test.md ?",
},
{
name: "Missing key returns key",
lang: "en",
key: "missing.key",
expected: "missing.key",
},
{
name: "Fallback to default language",
lang: "es", // Spanish not loaded, should fallback to English
key: "menu.home",
expected: "Home",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result string
if tt.args != nil {
result = trans.T(tt.lang, tt.key, tt.args)
} else {
result = trans.T(tt.lang, tt.key)
}
if result != tt.expected {
t.Errorf("T(%s, %s) = %s, want %s", tt.lang, tt.key, result, tt.expected)
}
})
}
// Test GetAvailableLanguages
langs := trans.GetAvailableLanguages()
if len(langs) != 2 {
t.Errorf("Expected 2 languages, got %d", len(langs))
}
// Test GetTranslations
enTrans, ok := trans.GetTranslations("en")
if !ok {
t.Error("Expected to find English translations")
}
if enTrans == nil {
t.Error("English translations should not be nil")
}
}