feat(web): pages locales aide / statistiques / réglages (serveur HTTP 127.0.0.1)
Nouveau module webserver.nim : mini-serveur HTTP local (port libre a partir de 38943) qui sert des pages HTML generes en Nim - aide (nouvelle entree de menu tray), statistiques et reglages. La page reglages enregistre via POST (config.json) et l'application recharge la configuration au tick suivant. Menu Aide/Statistiques/Reglages ouvrent le navigateur par defaut, avec repli sur les dialogues natifs si le serveur est indisponible. Refactor config/stats (loadConfigFrom/saveConfigTo, loadStatsFrom/saveStatsTo) pour tester sans toucher aux fichiers reels. 8 suites de tests (60 tests), ajout de test_webserver avec integration HTTP reelle.
This commit is contained in:
@@ -43,6 +43,9 @@ prend soin de votre santé autant que de votre productivité, dans une interface
|
||||
et la configuration est stockée au même endroit (pratique sur clé USB).
|
||||
- **Mise à jour automatique** (optionnelle) : vérification en arrière-plan,
|
||||
notification si une version plus récente est disponible.
|
||||
- **Pages web locales** : aide, statistiques et réglages présentés sous forme de
|
||||
pages HTML servies localement par l'application (127.0.0.1) et ouvertes dans
|
||||
le navigateur par défaut — aucune donnée ne quitte l'ordinateur.
|
||||
|
||||
## Prérequis
|
||||
|
||||
@@ -151,7 +154,9 @@ src/
|
||||
├── i18n.nim # chaînes localisées (français / anglais), détection langue
|
||||
├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim)
|
||||
├── floating.nim # fenêtre flottante compacte (toujours au-dessus)
|
||||
└── updater.nim # comparaison de versions + récupération distante
|
||||
├── updater.nim # comparaison de versions + récupération distante
|
||||
└── webserver.nim # serveur HTTP local (127.0.0.1) : pages aide / stats /
|
||||
# réglages, routage, rendu HTML, formulaire de sauvegarde
|
||||
```
|
||||
|
||||
Séparation des responsabilités : `timer`, `stats` et `updater` sont purs
|
||||
@@ -167,11 +172,29 @@ Windows.
|
||||
| Menu contextuel | Clic droit |
|
||||
| Démarrer / mettre en pause | Double-clic |
|
||||
|
||||
## Pages web locales (aide, statistiques, réglages)
|
||||
|
||||
Depuis le menu de l'icône, « Aide », « Statistiques » et « Réglages » ouvrent
|
||||
des pages HTML **servies localement** par l'application elle-même :
|
||||
|
||||
- Le menu **Aide** explique le fonctionnement de l'application (périodes,
|
||||
pauses, rappels santé, raccourcis, astuces).
|
||||
- **Statistiques** affiche les pomodoros du jour / semaine, la série de jours
|
||||
consécutifs, le total et l'historique des 7 derniers jours.
|
||||
- **Réglages** présente un formulaire complet (durées, objectif, son, langue,
|
||||
profil actif, rappels santé) : l'enregistrement est appliqué immédiatement
|
||||
par l'application.
|
||||
|
||||
L'application lance un petit serveur sur `127.0.0.1` (premier port libre à
|
||||
partir de `38943`), uniquement accessible en local. Aucune donnée ne quitte
|
||||
l'ordinateur. Si le serveur est indisponible, les menus retombent sur les
|
||||
dialogues natifs.
|
||||
|
||||
## Tests
|
||||
|
||||
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`) sont
|
||||
testés unitairement, ainsi que l'intégration Win32 (`tray`). Un exécutable
|
||||
unique les lance toutes et affiche un résumé :
|
||||
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`,
|
||||
`webserver`) sont testés unitairement, ainsi que l'intégration Win32
|
||||
(`tray`). Un exécutable unique les lance toutes et affiche un résumé :
|
||||
|
||||
```bash
|
||||
run_tests.cmd
|
||||
@@ -187,6 +210,7 @@ nim c -r tests/test_notifications.nim
|
||||
nim c -r tests/test_tray_integration.nim
|
||||
nim c -r tests/test_updater.nim
|
||||
nim c -r tests/test_floating.nim
|
||||
nim c -r tests/test_webserver.nim
|
||||
```
|
||||
|
||||
La correspondance exigence → contrôle → résultat est détaillée dans
|
||||
|
||||
+12
-1
@@ -34,6 +34,17 @@ concret effectué, et le résultat observé.
|
||||
| 15 | Mise à jour (comparaison de versions) | `tests/test_updater.nim` (5 tests) | 5 tests **OK** |
|
||||
| 16 | Fenêtre flottante (toujours au-dessus, texte agrandi) | `tests/test_floating.nim` (show/update/hide une vraie fenêtre Win32) + `floatingWindow` round-trip | **OK** (fenêtre créée/mise à jour/détruite sans erreur) |
|
||||
|
||||
## Fonctionnalité ajoutée (pages web locales)
|
||||
|
||||
| # | Exigence | Contrôle concret | Résultat observé |
|
||||
|---|----------|------------------|------------------|
|
||||
| 17 | Section Aide expliquant le fonctionnement | menu tray « Aide » (`IdHelpPage`) + page `/help` générée (fr/en) | **OK** (`tests/test_webserver.nim` : page rendue, GET HTTP réel) |
|
||||
| 18 | Statistiques présentées en page web locale | page `/stats` (jour/semaine/streak/total, 7 jours) servie par le serveur local | **OK** (GET réel via `httpclient` dans le test d'intégration) |
|
||||
| 19 | Réglages en page web locale avec sauvegarde | page `/settings` (formulaire + rappels santé) ; POST `/settings` écrit `config.json` et pose le drapeau de rechargement | **OK** (POST réel : valeurs relues dans le fichier, page mise à jour) |
|
||||
| 20 | Serveur local 127.0.0.1, port libre à partir de 38943 | `startWebServer()` dans `tests/test_webserver.nim` | **OK** (port 38943, requêtes GET/POST traitées) |
|
||||
| 21 | Aucune donnée ne quitte l'ordinateur | serveur lié à `127.0.0.1` uniquement | **OK** (relecture de code) |
|
||||
| 22 | Repli sur les dialogues natifs si serveur indisponible | branche `gServerPort > 0` dans `nimpulse.nim` | Compile ; logique vérifiée par relecture |
|
||||
|
||||
## Contraintes techniques
|
||||
|
||||
| Contrainte | Contrôle concret | Résultat observé |
|
||||
@@ -77,4 +88,4 @@ run_tests.cmd
|
||||
|
||||
(ou `nim c -d:release --app:gui src/nimpulse.nim` pour le binaire)
|
||||
|
||||
51 tests au total, tous verts (7 suites).
|
||||
60 tests au total, tous verts (8 suites).
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ setlocal enabledelayedexpansion
|
||||
set PASS=0
|
||||
set FAIL=0
|
||||
|
||||
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating) do (
|
||||
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating test_webserver) do (
|
||||
echo.
|
||||
echo === %%t ===
|
||||
nim c -r --hints:off tests\%%t.nim
|
||||
|
||||
+17
-8
@@ -156,12 +156,11 @@ proc getActiveProfile*(cfg: Config): Profile =
|
||||
# Chargement / sauvegarde
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc loadConfig*(): Config =
|
||||
## Charge la configuration depuis le disque. En cas d'erreur ou de fichier
|
||||
## manquant, on conserve des valeurs par défaut (jamais de crash au boot).
|
||||
proc loadConfigFrom*(path: string): Config =
|
||||
## Charge la configuration depuis le chemin path. En cas d'erreur ou de
|
||||
## fichier manquant, on conserve des valeurs par défaut (jamais de crash).
|
||||
result = defaultConfig()
|
||||
try:
|
||||
let path = configPath()
|
||||
if not fileExists(path):
|
||||
return
|
||||
let n = parseFile(path)
|
||||
@@ -206,10 +205,16 @@ proc loadConfig*(): Config =
|
||||
# Fichier corrompu : on garde les valeurs par défaut.
|
||||
discard
|
||||
|
||||
proc saveConfig*(cfg: Config) =
|
||||
## Sauvegarde la configuration au format JSON (pretty-printé).
|
||||
proc loadConfig*(): Config =
|
||||
## Charge la configuration depuis le disque (configPath()).
|
||||
loadConfigFrom(configPath())
|
||||
|
||||
proc saveConfigTo*(cfg: Config, path: string) =
|
||||
## Sauvegarde la configuration au format JSON (pretty-printé) dans path.
|
||||
try:
|
||||
ensureConfigDir()
|
||||
let dir = path.parentDir
|
||||
if dir.len > 0:
|
||||
createDir(dir)
|
||||
var n = newJObject()
|
||||
n["workMin"] = %cfg.workMin
|
||||
n["shortBreakMin"] = %cfg.shortBreakMin
|
||||
@@ -245,10 +250,14 @@ proc saveConfig*(cfg: Config) =
|
||||
rem.add o
|
||||
n["healthReminders"] = rem
|
||||
|
||||
writeFile(configPath(), n.pretty)
|
||||
writeFile(path, n.pretty)
|
||||
except:
|
||||
discard
|
||||
|
||||
proc saveConfig*(cfg: Config) =
|
||||
## Sauvegarde la configuration sur le disque (configPath()).
|
||||
saveConfigTo(cfg, configPath())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Démarrage automatique (registre)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ type Strings* = object
|
||||
menuSettings*: string
|
||||
menuStats*: string
|
||||
menuExportCsv*: string
|
||||
menuHelp*: string
|
||||
menuQuit*: string
|
||||
|
||||
# Notifications.
|
||||
@@ -109,6 +110,7 @@ type Strings* = object
|
||||
settingsSave*: string
|
||||
settingsCancel*: string
|
||||
settingsSaved*: string
|
||||
webServerError*: string
|
||||
settingsMinutes*: string
|
||||
|
||||
# Libellés génériques.
|
||||
@@ -154,6 +156,7 @@ const fr* = Strings(
|
||||
menuSettings: "Paramètres",
|
||||
menuStats: "Statistiques",
|
||||
menuExportCsv: "Exporter les statistiques (CSV)",
|
||||
menuHelp: "Aide",
|
||||
menuQuit: "Quitter",
|
||||
|
||||
notifWorkDone: "Travail terminé",
|
||||
@@ -217,6 +220,7 @@ const fr* = Strings(
|
||||
settingsSave: "Enregistrer",
|
||||
settingsCancel: "Annuler",
|
||||
settingsSaved: "Paramètres enregistrés.",
|
||||
webServerError: "Le serveur web local n'a pas pu démarrer.",
|
||||
settingsMinutes: "min",
|
||||
|
||||
btnOk: "OK",
|
||||
@@ -256,6 +260,7 @@ const en* = Strings(
|
||||
menuSettings: "Settings",
|
||||
menuStats: "Statistics",
|
||||
menuExportCsv: "Export statistics (CSV)",
|
||||
menuHelp: "Help",
|
||||
menuQuit: "Quit",
|
||||
|
||||
notifWorkDone: "Work session complete",
|
||||
@@ -319,6 +324,7 @@ const en* = Strings(
|
||||
settingsSave: "Save",
|
||||
settingsCancel: "Cancel",
|
||||
settingsSaved: "Settings saved.",
|
||||
webServerError: "The local web server could not be started.",
|
||||
settingsMinutes: "min",
|
||||
|
||||
btnOk: "OK",
|
||||
|
||||
+31
-2
@@ -23,6 +23,7 @@ import i18n
|
||||
import dialogs
|
||||
import updater
|
||||
import floating
|
||||
import webserver
|
||||
|
||||
const
|
||||
TimerId = 1'i64
|
||||
@@ -39,6 +40,7 @@ var
|
||||
gTimer: PomodoroTimer
|
||||
gStats: Stats
|
||||
gHwnd: HWND
|
||||
gServerPort = 0 ## Port du serveur web local (0 = non démarré).
|
||||
gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé
|
||||
## entre le thread de mise à jour et la boucle
|
||||
## de messages).
|
||||
@@ -87,11 +89,20 @@ proc stopTimer() =
|
||||
|
||||
proc quitApp() =
|
||||
saveConfig(gCfg)
|
||||
stopWebServer()
|
||||
hideFloating()
|
||||
removeTrayIcon(gHwnd)
|
||||
UnregisterHotKey(gHwnd, HotkeyId)
|
||||
DestroyWindow(gHwnd)
|
||||
|
||||
proc openWebPage(path: string) =
|
||||
## Ouvre une page servie localement dans le navigateur par défaut.
|
||||
if gServerPort == 0:
|
||||
discard MessageBox(gHwnd, S.webServerError, S.appName, MB_ICONERROR)
|
||||
return
|
||||
let url = "http://127.0.0.1:" & $gServerPort & path
|
||||
discard ShellExecuteW(0, "open", url, nil, nil, SW_SHOWNORMAL)
|
||||
|
||||
proc openSettings() =
|
||||
## Ouvre le panneau de réglages graphique.
|
||||
if showSettingsDialog(gCfg):
|
||||
@@ -143,6 +154,12 @@ proc onTick() =
|
||||
of evWorkCompleted: onWorkCompleted()
|
||||
of evBreakCompleted: onBreakCompleted()
|
||||
of evNone: discard
|
||||
# Réglages sauvegardés depuis la page web : rechargement en douceur.
|
||||
if webserver.consumeSettingsChanged():
|
||||
gCfg = loadConfig()
|
||||
setLang(resolveLang(gCfg))
|
||||
applyHotkey()
|
||||
showToast(gHwnd, S.appName, S.settingsSaved)
|
||||
refreshTray()
|
||||
|
||||
proc skipBreak() =
|
||||
@@ -159,8 +176,17 @@ proc handleCommand(cmd: int) =
|
||||
of IdStop: stopTimer()
|
||||
of IdSkipBreak: skipBreak()
|
||||
of IdExtendBreak: extendBreak()
|
||||
of IdSettings: openSettings()
|
||||
of IdStats: showStatsDialog()
|
||||
of IdHelpPage: openWebPage("/help")
|
||||
of IdSettings:
|
||||
if gServerPort > 0:
|
||||
openWebPage("/settings")
|
||||
else:
|
||||
openSettings() # repli sur le dialogue natif si le serveur est indisponible
|
||||
of IdStats:
|
||||
if gServerPort > 0:
|
||||
openWebPage("/stats")
|
||||
else:
|
||||
showStatsDialog()
|
||||
of IdExportCsv: exportStatsCsv()
|
||||
of IdAutoStart:
|
||||
gCfg.autoStart = not gCfg.autoStart
|
||||
@@ -275,6 +301,9 @@ proc main() =
|
||||
# Icône dans le tray.
|
||||
discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
|
||||
|
||||
# Serveur web local (pages aide / statistiques / réglages).
|
||||
gServerPort = startWebServer()
|
||||
|
||||
# Minuteur (1 tick par seconde).
|
||||
discard SetTimer(gHwnd, TimerId, 1000, nil)
|
||||
|
||||
|
||||
+14
-5
@@ -16,11 +16,10 @@ proc todayStr*(): string =
|
||||
proc initStats*(): Stats =
|
||||
Stats(daily: initTable[string, int]())
|
||||
|
||||
proc loadStats*(): Stats =
|
||||
proc loadStatsFrom*(path: string): Stats =
|
||||
## Charge les stats depuis le disque (jamais de crash si corrompu).
|
||||
result = initStats()
|
||||
try:
|
||||
let path = statsPath()
|
||||
if not fileExists(path):
|
||||
return
|
||||
let n = parseFile(path)
|
||||
@@ -31,18 +30,28 @@ proc loadStats*(): Stats =
|
||||
except:
|
||||
discard
|
||||
|
||||
proc saveStats*(st: Stats) =
|
||||
proc loadStats*(): Stats =
|
||||
## Charge les stats depuis le disque (statsPath()).
|
||||
loadStatsFrom(statsPath())
|
||||
|
||||
proc saveStatsTo*(st: Stats, path: string) =
|
||||
try:
|
||||
ensureConfigDir()
|
||||
let dir = path.parentDir
|
||||
if dir.len > 0:
|
||||
createDir(dir)
|
||||
var n = newJObject()
|
||||
var daily = newJObject()
|
||||
for k, v in st.daily:
|
||||
daily[k] = %v
|
||||
n["daily"] = daily
|
||||
writeFile(statsPath(), n.pretty)
|
||||
writeFile(path, n.pretty)
|
||||
except:
|
||||
discard
|
||||
|
||||
proc saveStats*(st: Stats) =
|
||||
## Sauvegarde les stats sur le disque (statsPath()).
|
||||
saveStatsTo(st, statsPath())
|
||||
|
||||
proc recordPomodoro*(st: var Stats) =
|
||||
## Incrémente le compteur du jour courant puis sauvegarde.
|
||||
let today = todayStr()
|
||||
|
||||
@@ -21,6 +21,7 @@ const
|
||||
IdStats* = 1004
|
||||
IdQuit* = 1005
|
||||
IdExportCsv* = 1006
|
||||
IdHelpPage* = 1007
|
||||
IdAutoStart* = 1500
|
||||
IdDoNotDisturb* = 1501
|
||||
IdSkipBreak* = 1502
|
||||
@@ -196,6 +197,8 @@ proc showTrayMenu*(hwnd: HWND, cfg: Config, t: PomodoroTimer) =
|
||||
AppendMenu(menu, mfStr, int64(IdStats), S.menuStats)
|
||||
AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv)
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
AppendMenu(menu, mfStr, int64(IdHelpPage), S.menuHelp)
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit)
|
||||
|
||||
var pt: POINT
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
## webserver.nim
|
||||
## ==============
|
||||
## Serveur HTTP local (127.0.0.1) qui sert des pages web générées en Nim :
|
||||
## aide, statistiques et réglages. Les pages sont ouvertes dans le navigateur
|
||||
## par défaut de l'utilisateur (ShellExecuteW côté application).
|
||||
##
|
||||
## Le serveur tourne dans un thread dédié. Il lit/écrit directement les
|
||||
## fichiers config.json et stats.json (pas de partage de mémoire GC entre
|
||||
## threads) et signale une sauvegarde des réglages via le hook
|
||||
## onSettingsSavedHook (l'application l'utilise pour recharger la config).
|
||||
##
|
||||
## Aucune dépendance Win32 : ce module est testable unitairement.
|
||||
|
||||
import std/[net, nativesockets, strutils, uri, json, os, tables]
|
||||
import config
|
||||
import stats
|
||||
import i18n
|
||||
|
||||
const
|
||||
DefaultPort* = 38943 ## Port de base du serveur local.
|
||||
PortAttempts = 6 ## Nombre de ports essayés si le premier est pris.
|
||||
MaxRequestSize = 32768 ## Taille maximale d'une requête (en-têtes + corps).
|
||||
|
||||
const
|
||||
MaxDataDirLen = 512 ## Longueur max du chemin de surcharge (tests).
|
||||
|
||||
var
|
||||
running = false
|
||||
gPort = 0
|
||||
keepSock: Socket ## Référence maintenue vivante (évite la fermeture GC).
|
||||
serverThread: Thread[SocketHandle]
|
||||
dataDirBuf: array[MaxDataDirLen, char] ## Surcharge (tests), mémoire non-GC :
|
||||
## lisible sans risque par le thread serveur.
|
||||
settingsChangedFlag = false ## Posé par le thread serveur après une
|
||||
## sauvegarde ; consommé par le thread principal.
|
||||
|
||||
proc setDataDirOverride*(dir: string) =
|
||||
## Surcharge le répertoire de données (utile pour les tests). À appeler
|
||||
## AVANT startWebServer(). Le thread serveur n'accède qu'à un buffer
|
||||
## non-GC, ce qui reste sûr avec --threads:on.
|
||||
zeroMem(addr dataDirBuf[0], MaxDataDirLen)
|
||||
if dir.len > 0:
|
||||
let n = min(dir.len, MaxDataDirLen - 1)
|
||||
copyMem(addr dataDirBuf[0], cstring(dir), n)
|
||||
|
||||
proc dataDir(): string {.gcsafe.} =
|
||||
## Répertoire des données : la surcharge (tests) si définie, sinon configDir().
|
||||
if dataDirBuf[0] != '\0':
|
||||
result = newString(0)
|
||||
for c in dataDirBuf:
|
||||
if c == '\0': break
|
||||
result.add c
|
||||
else:
|
||||
result = configDir()
|
||||
|
||||
proc configFile(): string {.gcsafe.} = dataDir() / "config.json"
|
||||
proc statsFile(): string {.gcsafe.} = dataDir() / "stats.json"
|
||||
|
||||
proc markSettingsChanged() {.gcsafe.} =
|
||||
## Posé par le thread serveur (booléen simple, sûr sans verrou).
|
||||
settingsChangedFlag = true
|
||||
|
||||
proc consumeSettingsChanged*(): bool =
|
||||
## Consulté par le thread principal : true si des réglages ont été sauvés
|
||||
## depuis le dernier appel.
|
||||
result = settingsChangedFlag
|
||||
settingsChangedFlag = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers HTTP / HTML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc htmlEscape*(s: string): string =
|
||||
## Échappe les caractères HTML (& < > " ').
|
||||
result = ""
|
||||
for c in s:
|
||||
case c
|
||||
of '&': result.add "&"
|
||||
of '<': result.add "<"
|
||||
of '>': result.add ">"
|
||||
of '"': result.add """
|
||||
of '\'': result.add "'"
|
||||
else: result.add c
|
||||
|
||||
type
|
||||
HttpRequest* = object
|
||||
verb*: string ## GET / POST
|
||||
path*: string ## chemin décodé (sans la query)
|
||||
body*: string ## corps (POST)
|
||||
|
||||
proc contentLengthFrom(headers: string): int =
|
||||
## Extrait Content-Length des en-têtes (0 si absent).
|
||||
for line in headers.split("\r\n"):
|
||||
let ci = line.find(':')
|
||||
if ci > 0 and line[0 ..< ci].toLowerAscii().strip() == "content-length":
|
||||
try: return parseInt(line[ci + 1 .. ^1].strip())
|
||||
except ValueError: return 0
|
||||
return 0
|
||||
|
||||
proc parseRequest*(data: string): HttpRequest =
|
||||
## Parse une requête HTTP (ligne de requête + en-têtes + corps déjà lus).
|
||||
let headerEnd = data.find("\r\n\r\n")
|
||||
let headerPart = if headerEnd >= 0: data[0 ..< headerEnd] else: data
|
||||
let lines = headerPart.split("\r\n")
|
||||
if lines.len > 0:
|
||||
let parts = lines[0].split(' ')
|
||||
if parts.len >= 2:
|
||||
result.verb = parts[0]
|
||||
let target = parts[1]
|
||||
let qi = target.find('?')
|
||||
if qi >= 0:
|
||||
result.path = decodeUrl(target[0 ..< qi])
|
||||
else:
|
||||
result.path = decodeUrl(target)
|
||||
if headerEnd >= 0:
|
||||
let bodyLen = contentLengthFrom(headerPart)
|
||||
if bodyLen > 0 and data.len >= headerEnd + 4 + bodyLen:
|
||||
result.body = data[headerEnd + 4 ..< headerEnd + 4 + bodyLen]
|
||||
|
||||
proc parseForm*(body: string): Table[string, string] =
|
||||
## Décode un corps application/x-www-form-urlencoded.
|
||||
for pair in body.split('&'):
|
||||
if pair.len == 0: continue
|
||||
let eq = pair.find('=')
|
||||
if eq < 0:
|
||||
result[decodeUrl(pair)] = ""
|
||||
else:
|
||||
result[decodeUrl(pair[0 ..< eq])] = decodeUrl(pair[eq + 1 .. ^1])
|
||||
|
||||
proc httpResponse(body: string, status = "200 OK",
|
||||
contentType = "text/html; charset=utf-8"): string =
|
||||
"HTTP/1.1 " & status & "\r\n" &
|
||||
"Content-Type: " & contentType & "\r\n" &
|
||||
"Content-Length: " & $body.len & "\r\n" &
|
||||
"Cache-Control: no-store\r\n" &
|
||||
"Connection: close\r\n\r\n" & body
|
||||
|
||||
proc t(lang: Lang, fr, en: string): string =
|
||||
## Traduit selon la langue résolue.
|
||||
if lang == langFr: fr else: en
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gabarit de page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc pageShell(lang: Lang, title, body: string): string =
|
||||
let nav = "<nav><a href=\"/help\">" & t(lang, "Aide", "Help") &
|
||||
"</a> <a href=\"/stats\">" & t(lang, "Statistiques", "Statistics") &
|
||||
"</a> <a href=\"/settings\">" & t(lang, "Réglages", "Settings") &
|
||||
"</a></nav>"
|
||||
result = """<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NimPulse — """ & title & """</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark}
|
||||
body{font-family:system-ui,'Segoe UI',sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.65;color:#1f2937;background:#fff}
|
||||
@media (prefers-color-scheme: dark){
|
||||
body{background:#111827;color:#e5e7eb}
|
||||
h1{color:#5eead4} h2{color:#99f6e4;border-color:#374151}
|
||||
code,pre{background:#1f2937} .card{background:#1f2937;border-color:#374151}
|
||||
th,td{border-color:#374151} .bar{background:#374151} .muted{color:#9ca3af}
|
||||
input,select,button{color-scheme:dark}
|
||||
}
|
||||
h1{color:#0f766e} h2{margin-top:2rem;color:#115e59;border-bottom:1px solid #e5e7eb;padding-bottom:.3rem}
|
||||
code,pre{background:#f3f4f6;padding:.1rem .35rem;border-radius:4px;font-size:.92em}
|
||||
table{border-collapse:collapse;width:100%;margin:.6rem 0}
|
||||
th,td{border:1px solid #e5e7eb;padding:.4rem .6rem;text-align:left}
|
||||
nav{display:flex;gap:1.2rem;margin-bottom:1.6rem;padding:.6rem .8rem;border-radius:8px;background:#f0fdfa;border:1px solid #99f6e4}
|
||||
nav a{color:#0f766e;text-decoration:none;font-weight:600}
|
||||
.card{background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:1rem 1.2rem;margin:1rem 0}
|
||||
.bar{background:#e5e7eb;border-radius:6px;height:14px;overflow:hidden;min-width:30px;display:inline-block;vertical-align:middle}
|
||||
.bar>div{background:#14b8a6;height:100%}
|
||||
.muted{color:#6b7280;font-size:.92em}
|
||||
.btn{display:inline-block;background:#0f766e;color:#fff;padding:.45rem .95rem;border-radius:6px;text-decoration:none;border:0;font-size:.95rem;cursor:pointer}
|
||||
.btn:hover{background:#115e59}
|
||||
label{display:block;margin:.55rem 0 .15rem;font-weight:600}
|
||||
input[type=text],input[type=number],select{width:100%;max-width:320px;padding:.35rem .5rem;border:1px solid #d1d5db;border-radius:6px}
|
||||
input[type=checkbox]{width:auto}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 1.5rem}
|
||||
@media (max-width:640px){.grid{grid-template-columns:1fr}}
|
||||
.kbd{background:#f3f4f6;border:1px solid #d1d5db;border-bottom-width:2px;border-radius:4px;padding:.05rem .4rem;font-family:ui-monospace,monospace;font-size:.9em}
|
||||
.ok{color:#047857;font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""" & nav & body & "</body></html>"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page d'aide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc renderHelp*(cfg: Config): string =
|
||||
let lang = resolveLang(cfg)
|
||||
var b = ""
|
||||
b.add "<h1>" & t(lang,
|
||||
"Aide — Comment fonctionne NimPulse",
|
||||
"Help — How NimPulse works") & "</h1>"
|
||||
|
||||
b.add "<div class=\"card\"><h2>" & t(lang, "Le principe", "The concept") & "</h2><p>" &
|
||||
t(lang,
|
||||
"NimPulse est un minuteur Pomodoro qui vit dans la zone de notification (tray). " &
|
||||
"Il alterne des périodes de travail concentré et des pauses, et profite des pauses " &
|
||||
"pour vous rappeler de prendre soin de votre santé (marcher, boire de l'eau, vous étirer…).",
|
||||
"NimPulse is a Pomodoro timer living in the system tray. It alternates focused work " &
|
||||
"sessions with breaks, and uses breaks to remind you to take care of your health " &
|
||||
"(walk, drink water, stretch…).") & "</p></div>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Premiers pas", "Getting started") & "</h2><ul>" &
|
||||
t(lang,
|
||||
"<li>Au premier lancement, un assistant vous guide (langue, profil, rappels santé).</li>" &
|
||||
"<li>L'application démarre minimisée : son icône colorée se trouve dans la zone de notification.</li>" &
|
||||
"<li>Un clic droit sur l'icône ouvre le menu ; un double-clic démarre ou met en pause.</li>" &
|
||||
"<li>Le raccourci global <span class=\"kbd\">Ctrl+Alt+P</span> démarre/met en pause sans toucher la souris.</li>",
|
||||
"<li>On first launch, a wizard guides you (language, profile, health reminders).</li>" &
|
||||
"<li>The app starts minimized: its colored icon lives in the notification area.</li>" &
|
||||
"<li>Right-click the icon for the menu; double-click to start or pause.</li>" &
|
||||
"<li>The global shortcut <span class=\"kbd\">Ctrl+Alt+P</span> starts/pauses without touching the mouse.</li>") &
|
||||
"</ul>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Les périodes", "The periods") & "</h2><p>" &
|
||||
t(lang,
|
||||
"Un cycle classique : travail (25 min), pause courte (5 min), et après plusieurs cycles, " &
|
||||
"une pause longue (15 min). Le nombre de cycles avant la pause longue est configurable, " &
|
||||
"ainsi que les durées via les profils :",
|
||||
"A classic cycle: work (25 min), short break (5 min), and after several cycles a long " &
|
||||
"break (15 min). The number of cycles before the long break is configurable, as are the " &
|
||||
"durations via the profiles:") & "</p><table><tr><th>" &
|
||||
t(lang, "Profil", "Profile") & "</th><th>" & t(lang, "Travail", "Work") & "</th><th>" &
|
||||
t(lang, "Pause courte", "Short break") & "</th><th>" & t(lang, "Pause longue", "Long break") & "</th><th>" &
|
||||
t(lang, "Cycles", "Cycles") & "</th></tr>" &
|
||||
"<tr><td>" & t(lang, "Classique", "Classic") & "</td><td>25 min</td><td>5 min</td><td>15 min</td><td>4</td></tr>" &
|
||||
"<tr><td>Deep Work</td><td>50 min</td><td>10 min</td><td>20 min</td><td>4</td></tr>" &
|
||||
"<tr><td>Sprint</td><td>15 min</td><td>3 min</td><td>10 min</td><td>6</td></tr></table>" &
|
||||
"<p>" & t(lang,
|
||||
"Le profil actif se choisit dans le menu de l'icône (« Profils »).",
|
||||
"The active profile is chosen from the icon menu (“Profiles”).") & "</p>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Pendant une pause", "During a break") & "</h2><ul>" &
|
||||
t(lang,
|
||||
"<li>« Sauter la pause » passe directement au travail suivant.</li>" &
|
||||
"<li>« Prolonger la pause » ajoute 5 minutes.</li>" &
|
||||
"<li>Les rappels santé défilent dans les notifications pendant les pauses, sans répéter deux fois le même.</li>",
|
||||
"<li>“Skip break” jumps straight to the next work session.</li>" &
|
||||
"<li>“Extend break” adds 5 minutes.</li>" &
|
||||
"<li>Health reminders rotate through notifications during breaks, never repeating the same one twice.</li>") &
|
||||
"</ul>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2><p>" &
|
||||
t(lang,
|
||||
"Les rappels sont entièrement modifiables depuis la page Réglages : ajoutez, modifiez, " &
|
||||
"supprimez ou désactivez vos propres activités. Seuls les rappels activés sont affichés.",
|
||||
"Reminders are fully editable from the Settings page: add, edit, remove or disable your " &
|
||||
"own activities. Only enabled reminders are shown.") & "</p>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Statistiques et objectif", "Statistics and goal") & "</h2><p>" &
|
||||
t(lang,
|
||||
"La page Statistiques affiche vos pomodoros du jour, de la semaine, votre série de jours " &
|
||||
"consécutifs et le total. Vous pouvez définir un objectif journalier dans les réglages : " &
|
||||
"une notification vous prévient quand il est atteint. Le menu de l'icône permet aussi " &
|
||||
"d'exporter l'historique en CSV.",
|
||||
"The Statistics page shows your pomodoros for today, this week, your streak of consecutive " &
|
||||
"days and the total. You can set a daily goal in settings: a notification tells you when " &
|
||||
"it is reached. The icon menu also lets you export the history as CSV.") & "</p>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Réglages", "Settings") & "</h2><ul>" &
|
||||
t(lang,
|
||||
"<li><b>Durées</b> : valeurs de base du minuteur (si aucun profil ne correspond).</li>" &
|
||||
"<li><b>Objectif journalier</b> : nombre de pomodoros par jour (0 = aucun).</li>" &
|
||||
"<li><b>Son</b> : joue un son en fin de période.</li>" &
|
||||
"<li><b>Ne pas déranger</b> : suspend les rappels santé (le minuteur continue).</li>" &
|
||||
"<li><b>Démarrage avec Windows</b> : lance NimPulse à l'ouverture de session.</li>" &
|
||||
"<li><b>Langue</b> : automatique, français ou anglais.</li>" &
|
||||
"<li><b>Fenêtre flottante</b> : petit panneau toujours au-dessus affichant la phase et le temps restant.</li>",
|
||||
"<li><b>Durations</b>: base timer values (when no profile matches).</li>" &
|
||||
"<li><b>Daily goal</b>: pomodoros per day (0 = none).</li>" &
|
||||
"<li><b>Sound</b>: plays a sound at the end of a period.</li>" &
|
||||
"<li><b>Do not disturb</b>: suspends health reminders (the timer keeps running).</li>" &
|
||||
"<li><b>Start with Windows</b>: launches NimPulse at logon.</li>" &
|
||||
"<li><b>Language</b>: automatic, French or English.</li>" &
|
||||
"<li><b>Floating window</b>: small always-on-top panel showing the phase and remaining time.</li>") &
|
||||
"</ul>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Astuces", "Tips") & "</h2><ul>" &
|
||||
t(lang,
|
||||
"<li>En <b>mode portable</b> (fichier vide <code>portable</code> à côté de l'exécutable), " &
|
||||
"la configuration et les statistiques sont stockées au même endroit que l'application — pratique sur clé USB.</li>" &
|
||||
"<li>Lancer NimPulse une seconde fois ne crée pas de doublon (mono-instance).</li>" &
|
||||
"<li>Ces pages web (aide, statistiques, réglages) sont servies localement par l'application : " &
|
||||
"aucune donnée ne quitte votre ordinateur.</li>",
|
||||
"<li>In <b>portable mode</b> (empty file <code>portable</code> next to the executable), " &
|
||||
"configuration and statistics are stored next to the app — handy on USB sticks.</li>" &
|
||||
"<li>Launching NimPulse a second time does not create a duplicate (single instance).</li>" &
|
||||
"<li>These web pages (help, statistics, settings) are served locally by the app: " &
|
||||
"no data leaves your computer.</li>") &
|
||||
"</ul>"
|
||||
|
||||
b.add "<h2>" & t(lang, "Raccourcis", "Shortcuts") & "</h2><table>" &
|
||||
"<tr><th>" & t(lang, "Action", "Action") & "</th><th>" & t(lang, "Raccourci", "Shortcut") & "</th></tr>" &
|
||||
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td><span class=\"kbd\">Ctrl+Alt+P</span></td></tr>" &
|
||||
"<tr><td>" & t(lang, "Menu contextuel", "Context menu") & "</td><td>" & t(lang, "Clic droit sur l'icône", "Right-click the icon") & "</td></tr>" &
|
||||
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td>" & t(lang, "Double-clic", "Double-click") & "</td></tr>" &
|
||||
"</table>"
|
||||
|
||||
pageShell(lang, t(lang, "Aide", "Help"), b)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page statistiques
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc renderStats*(cfg: Config): string =
|
||||
let lang = resolveLang(cfg)
|
||||
let st = loadStatsFrom(statsFile())
|
||||
let today = st.completedToday()
|
||||
let week = st.completedThisWeek()
|
||||
let streak = st.streak()
|
||||
let total = st.totalPomodoros()
|
||||
let days = st.lastNDays(7)
|
||||
|
||||
var maxv = 1
|
||||
for (_, n) in days:
|
||||
if n > maxv: maxv = n
|
||||
|
||||
var b = ""
|
||||
b.add "<h1>" & t(lang, "Statistiques", "Statistics") & "</h1>"
|
||||
|
||||
b.add "<div class=\"card\"><h2>" & t(lang, "Aujourd'hui", "Today") & "</h2>" &
|
||||
"<p style=\"font-size:2.2rem;margin:.2rem 0;font-weight:700\">" & $today & " <span class=\"muted\">" &
|
||||
t(lang, "pomodoro(s)", "pomodoro(s)") & "</span></p>" &
|
||||
(if cfg.dailyGoal > 0:
|
||||
"<p class=\"muted\">" & t(lang, "Objectif", "Goal") & " : " & $today & " / " & $cfg.dailyGoal & "</p>" &
|
||||
"<div class=\"bar\" style=\"width:100%\"><div style=\"width:" &
|
||||
$(if today >= cfg.dailyGoal: 100 else: today * 100 div cfg.dailyGoal) & "%\"></div></div>"
|
||||
else: "") &
|
||||
"</div>"
|
||||
|
||||
b.add "<div class=\"grid\"><div class=\"card\"><h2>" & t(lang, "Cette semaine", "This week") & "</h2>" &
|
||||
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $week & "</p></div>" &
|
||||
"<div class=\"card\"><h2>" & t(lang, "Série (streak)", "Streak") & "</h2>" &
|
||||
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $streak & " <span class=\"muted\">" &
|
||||
t(lang, "jour(s)", "day(s)") & "</span></p></div></div>"
|
||||
|
||||
b.add "<div class=\"card\"><h2>" & t(lang, "Total", "Total") & "</h2>" &
|
||||
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $total & " <span class=\"muted\">" &
|
||||
t(lang, "pomodoros complétés", "completed pomodoros") & "</span></p></div>"
|
||||
|
||||
b.add "<h2>" & t(lang, "7 derniers jours", "Last 7 days") & "</h2><table>" &
|
||||
"<tr><th>" & t(lang, "Date", "Date") & "</th><th>" & t(lang, "Pomodoros", "Pomodoros") & "</th></tr>"
|
||||
for (date, n) in days:
|
||||
b.add "<tr><td>" & htmlEscape(date) & "</td><td>" & $n & " <span class=\"bar\" style=\"width:120px\"><div style=\"width:" &
|
||||
$(n * 100 div maxv) & "%\"></div></span></td></tr>"
|
||||
b.add "</table>"
|
||||
|
||||
b.add "<p class=\"muted\">" & t(lang,
|
||||
"Un export CSV est disponible depuis le menu de l'icône (Exporter les statistiques).",
|
||||
"A CSV export is available from the icon menu (Export statistics).") & "</p>"
|
||||
|
||||
pageShell(lang, t(lang, "Statistiques", "Statistics"), b)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page réglages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc remindersJson(rs: seq[HealthReminder]): string =
|
||||
## Sérialise les rappels en JSON (compact) pour le champ caché / le script.
|
||||
var items: seq[string]
|
||||
for r in rs:
|
||||
items.add "{\"text\":" & $(%r.text) & ",\"enabled\":" & $(%r.enabled) & "}"
|
||||
"[" & items.join(",") & "]"
|
||||
|
||||
proc renderSettings*(cfg: Config): string =
|
||||
let lang = resolveLang(cfg)
|
||||
|
||||
var profileOpts = ""
|
||||
for p in cfg.profiles:
|
||||
let sel = if p.name == cfg.activeProfile: " selected" else: ""
|
||||
profileOpts.add "<option value=\"" & htmlEscape(p.name) & "\"@@" & sel & ">" &
|
||||
htmlEscape(p.name) & "</option>"
|
||||
|
||||
let langOpts =
|
||||
"<option value=\"auto\"@@" & (if cfg.lang == "auto": " selected" else: "") & ">" &
|
||||
t(lang, "Automatique", "Automatic") & "</option>" &
|
||||
"<option value=\"fr\"@@" & (if cfg.lang == "fr": " selected" else: "") & ">Français</option>" &
|
||||
"<option value=\"en\"@@" & (if cfg.lang == "en": " selected" else: "") & ">English</option>"
|
||||
|
||||
var b = ""
|
||||
b.add "<h1>" & t(lang, "Réglages", "Settings") & "</h1>"
|
||||
b.add "<form method=\"post\" action=\"/settings\" onsubmit=\"syncReminders()\">"
|
||||
|
||||
b.add "<div class=\"grid\"><div>"
|
||||
b.add "<label>" & t(lang, "Travail (min)", "Work (min)") & "</label>" &
|
||||
"<input type=\"number\" name=\"workMin\" min=\"1\" value=\"" & $cfg.workMin & "\">"
|
||||
b.add "<label>" & t(lang, "Pause courte (min)", "Short break (min)") & "</label>" &
|
||||
"<input type=\"number\" name=\"shortBreakMin\" min=\"1\" value=\"" & $cfg.shortBreakMin & "\">"
|
||||
b.add "<label>" & t(lang, "Pause longue (min)", "Long break (min)") & "</label>" &
|
||||
"<input type=\"number\" name=\"longBreakMin\" min=\"1\" value=\"" & $cfg.longBreakMin & "\">"
|
||||
b.add "<label>" & t(lang, "Cycles avant pause longue", "Cycles before long break") & "</label>" &
|
||||
"<input type=\"number\" name=\"cyclesBeforeLongBreak\" min=\"1\" value=\"" & $cfg.cyclesBeforeLongBreak & "\">"
|
||||
b.add "<label>" & t(lang, "Objectif journalier (0 = aucun)", "Daily goal (0 = none)") & "</label>" &
|
||||
"<input type=\"number\" name=\"dailyGoal\" min=\"0\" value=\"" & $cfg.dailyGoal & "\">"
|
||||
b.add "</div><div>"
|
||||
|
||||
b.add "<label>" & t(lang, "Langue", "Language") & "</label>" &
|
||||
"<select name=\"lang\">" & langOpts & "</select>"
|
||||
b.add "<label>" & t(lang, "Profil actif", "Active profile") & "</label>" &
|
||||
"<select name=\"activeProfile\">" & profileOpts & "</select>"
|
||||
|
||||
b.add "<p style=\"margin-top:.9rem\">"
|
||||
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"soundEnabled\"@@" &
|
||||
(if cfg.soundEnabled: " checked" else: "") & "> " & t(lang, "Son en fin de période", "Sound at end of period") & "</label>"
|
||||
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"doNotDisturb\"@@" &
|
||||
(if cfg.doNotDisturb: " checked" else: "") & "> " & t(lang, "Ne pas déranger", "Do not disturb") & "</label>"
|
||||
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"autoStart\"@@" &
|
||||
(if cfg.autoStart: " checked" else: "") & "> " & t(lang, "Démarrage avec Windows", "Start with Windows") & "</label>"
|
||||
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"floatingWindow\"@@" &
|
||||
(if cfg.floatingWindow: " checked" else: "") & "> " & t(lang, "Fenêtre flottante", "Floating window") & "</label>"
|
||||
b.add "</p></div></div>"
|
||||
|
||||
# --- Rappels santé (éditeur client) ---
|
||||
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2>"
|
||||
b.add "<div id=\"reminders\"></div>"
|
||||
b.add "<button type=\"button\" class=\"btn\" onclick=\"addReminder()\" style=\"margin-top:.6rem\">" &
|
||||
t(lang, "Ajouter un rappel", "Add a reminder") & "</button>"
|
||||
b.add "<input type=\"hidden\" name=\"reminders_json\" id=\"reminders_json\">"
|
||||
|
||||
b.add "<p style=\"margin-top:1.4rem\"><button type=\"submit\" class=\"btn\">" &
|
||||
t(lang, "Enregistrer", "Save") & "</button></p>"
|
||||
b.add "</form>"
|
||||
|
||||
b.add "<script>"
|
||||
b.add "var reminders = " & remindersJson(cfg.healthReminders) & ";"
|
||||
b.add """var R = function(fr, en){ return document.documentElement.lang === 'fr' ? fr : en; };
|
||||
function renderReminders(){
|
||||
var el = document.getElementById('reminders');
|
||||
if (reminders.length === 0){ el.innerHTML = '<p class="muted">' + R('Aucun rappel.','No reminders.') + '</p>'; return; }
|
||||
var html = '<table><tr><th></th><th>' + R('Texte','Text') + '</th><th></th><th></th></tr>';
|
||||
for (var i = 0; i < reminders.length; i++){
|
||||
var r = reminders[i];
|
||||
var safe = r.text.replace(/&/g,'&').replace(/</g,'<');
|
||||
html += '<tr><td><input type="checkbox" ' + (r.enabled ? 'checked' : '') + ' onchange="toggleReminder(' + i + ')"></td>' +
|
||||
'<td>' + safe + '</td>' +
|
||||
'<td><button type="button" onclick="editReminder(' + i + ')">' + R('Modifier','Edit') + '</button></td>' +
|
||||
'<td><button type="button" onclick="removeReminder(' + i + ')">' + R('Supprimer','Remove') + '</button></td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
function addReminder(){
|
||||
var t = prompt(R('Nouveau rappel santé :','New health reminder:'));
|
||||
if (t && t.trim().length > 0){ reminders.push({text: t.trim(), enabled: true}); renderReminders(); }
|
||||
}
|
||||
function editReminder(i){
|
||||
var t = prompt(R('Modifier le rappel :','Edit reminder:'), reminders[i].text);
|
||||
if (t && t.trim().length > 0){ reminders[i].text = t.trim(); renderReminders(); }
|
||||
}
|
||||
function removeReminder(i){ reminders.splice(i, 1); renderReminders(); }
|
||||
function toggleReminder(i){ reminders[i].enabled = !reminders[i].enabled; }
|
||||
function syncReminders(){ document.getElementById('reminders_json').value = JSON.stringify(reminders); }
|
||||
renderReminders();
|
||||
"""
|
||||
b.add "</script>"
|
||||
|
||||
pageShell(lang, t(lang, "Réglages", "Settings"), b)
|
||||
|
||||
proc renderSaved(cfg: Config): string =
|
||||
let lang = resolveLang(cfg)
|
||||
var b = "<h1>" & t(lang, "Réglages enregistrés", "Settings saved") & " ✓</h1>" &
|
||||
"<div class=\"card\"><p>" &
|
||||
t(lang, "Votre configuration a été enregistrée et appliquée.",
|
||||
"Your configuration has been saved and applied.") &
|
||||
"</p><p><a class=\"btn\" href=\"/settings\">" & t(lang, "Retour aux réglages", "Back to settings") & "</a> " &
|
||||
"<a class=\"btn\" href=\"/stats\">" & t(lang, "Voir les statistiques", "View statistics") & "</a></p></div>"
|
||||
pageShell(lang, t(lang, "Réglages enregistrés", "Settings saved"), b)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application d'un formulaire de réglages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc parseIntSafe(s: string, dflt: int): int =
|
||||
try: result = parseInt(s)
|
||||
except ValueError: result = dflt
|
||||
|
||||
proc applySettings*(cfg: Config, form: Table[string, string]): Config =
|
||||
## Applique les valeurs d'un formulaire POST à une configuration.
|
||||
result = cfg
|
||||
result.workMin = parseIntSafe(form.getOrDefault("workMin"), cfg.workMin)
|
||||
result.shortBreakMin = parseIntSafe(form.getOrDefault("shortBreakMin"), cfg.shortBreakMin)
|
||||
result.longBreakMin = parseIntSafe(form.getOrDefault("longBreakMin"), cfg.longBreakMin)
|
||||
result.cyclesBeforeLongBreak =
|
||||
parseIntSafe(form.getOrDefault("cyclesBeforeLongBreak"), cfg.cyclesBeforeLongBreak)
|
||||
result.dailyGoal = parseIntSafe(form.getOrDefault("dailyGoal"), cfg.dailyGoal)
|
||||
result.soundEnabled = form.hasKey("soundEnabled")
|
||||
result.doNotDisturb = form.hasKey("doNotDisturb")
|
||||
result.autoStart = form.hasKey("autoStart")
|
||||
result.floatingWindow = form.hasKey("floatingWindow")
|
||||
let lang = form.getOrDefault("lang")
|
||||
if lang in ["auto", "fr", "en"]:
|
||||
result.lang = lang
|
||||
let prof = form.getOrDefault("activeProfile")
|
||||
for p in result.profiles:
|
||||
if p.name == prof:
|
||||
result.activeProfile = prof
|
||||
break
|
||||
let remJson = form.getOrDefault("reminders_json")
|
||||
if remJson.len > 0:
|
||||
try:
|
||||
let n = parseJson(remJson)
|
||||
if n.kind == JArray:
|
||||
var newRem: seq[HealthReminder]
|
||||
for r in n:
|
||||
let text = r.getOrDefault("text").getStr().strip()
|
||||
if text.len > 0:
|
||||
newRem.add HealthReminder(text: text,
|
||||
enabled: r.getOrDefault("enabled").getBool(true))
|
||||
if newRem.len > 0:
|
||||
result.healthReminders = newRem
|
||||
except:
|
||||
discard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleRequest(req: HttpRequest): string {.gcsafe.} =
|
||||
case req.path
|
||||
of "/", "/help":
|
||||
httpResponse(renderHelp(loadConfigFrom(configFile())))
|
||||
of "/stats":
|
||||
httpResponse(renderStats(loadConfigFrom(configFile())))
|
||||
of "/settings":
|
||||
if req.verb == "POST":
|
||||
let cfg = applySettings(loadConfigFrom(configFile()), parseForm(req.body))
|
||||
saveConfigTo(cfg, configFile())
|
||||
markSettingsChanged()
|
||||
httpResponse(renderSaved(cfg))
|
||||
else:
|
||||
httpResponse(renderSettings(loadConfigFrom(configFile())))
|
||||
of "/favicon.ico":
|
||||
"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"
|
||||
else:
|
||||
httpResponse("<h1>404</h1><p>" &
|
||||
t(resolveLang(loadConfigFrom(configFile())), "Page introuvable.", "Page not found.") &
|
||||
"</p>", status = "404 Not Found")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture d'une requête sur la socket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc setRecvTimeout(client: Socket) {.gcsafe.} =
|
||||
## Timeout de lecture de 10 s au niveau socket : un client silencieux ne
|
||||
## bloque pas le thread serveur indéfiniment (recv bloquant simple).
|
||||
when defined(windows):
|
||||
const SoRcvTimeout = 0x1006'i32 ## SO_RCVTIMEO (winsock2)
|
||||
client.getFd().setSockOptInt(SOL_SOCKET, SoRcvTimeout, 10000)
|
||||
|
||||
proc readRequest(client: Socket): HttpRequest =
|
||||
var data = ""
|
||||
var buf = newString(4096)
|
||||
while data.len < MaxRequestSize:
|
||||
var chunk = ""
|
||||
try:
|
||||
chunk = client.recv(4096)
|
||||
except OSError:
|
||||
break
|
||||
if chunk.len == 0:
|
||||
break
|
||||
data.add chunk
|
||||
let headerEnd = data.find("\r\n\r\n")
|
||||
if headerEnd >= 0:
|
||||
let bodyLen = contentLengthFrom(data[0 ..< headerEnd])
|
||||
if data.len >= headerEnd + 4 + bodyLen:
|
||||
break
|
||||
parseRequest(data)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boucle du serveur (thread dédié)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc serverLoop(fd: SocketHandle) {.thread.} =
|
||||
var server: owned(Socket)
|
||||
try:
|
||||
server = newSocket(fd, buffered = false)
|
||||
except:
|
||||
running = false
|
||||
return
|
||||
server.getFd().setBlocking(false)
|
||||
while running:
|
||||
var client: owned(Socket)
|
||||
try:
|
||||
server.accept(client)
|
||||
except OSError:
|
||||
sleep(100) # aucune connexion en attente : on réessaie
|
||||
continue
|
||||
client.getFd().setBlocking(true)
|
||||
setRecvTimeout(client)
|
||||
try:
|
||||
let req = readRequest(client)
|
||||
let resp = handleRequest(req)
|
||||
client.send(resp)
|
||||
except:
|
||||
discard
|
||||
try: client.close()
|
||||
except: discard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Démarrage / arrêt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc startWebServer*(): int =
|
||||
## Démarre le serveur sur 127.0.0.1 (premier port libre à partir de
|
||||
## DefaultPort). Retourne le port choisi, ou 0 en cas d'échec.
|
||||
if gPort != 0:
|
||||
return gPort
|
||||
running = true
|
||||
for attempt in 0 ..< PortAttempts:
|
||||
var srv: Socket
|
||||
try:
|
||||
srv = newSocket()
|
||||
srv.setSockOpt(OptReuseAddr, true)
|
||||
srv.bindAddr(Port(DefaultPort + attempt), "127.0.0.1")
|
||||
srv.listen()
|
||||
except:
|
||||
if srv != nil:
|
||||
try: srv.close()
|
||||
except: discard
|
||||
continue
|
||||
keepSock = srv
|
||||
gPort = DefaultPort + attempt
|
||||
createThread(serverThread, serverLoop, srv.getFd())
|
||||
return gPort
|
||||
running = false
|
||||
return 0
|
||||
|
||||
proc stopWebServer*() =
|
||||
## Arrête le serveur local (attend la fin du thread).
|
||||
if gPort != 0:
|
||||
running = false
|
||||
joinThread(serverThread)
|
||||
gPort = 0
|
||||
keepSock = nil
|
||||
@@ -0,0 +1,134 @@
|
||||
## test_webserver.nim
|
||||
## ==================
|
||||
## Tests du module webserver : parsing HTTP, décodage du formulaire,
|
||||
## application des réglages, rendu des pages et intégration réelle
|
||||
## (serveur local 127.0.0.1 interrogé en HTTP).
|
||||
|
||||
import std/[unittest, httpclient, os, uri, tables, strutils]
|
||||
import ../src/webserver
|
||||
import ../src/config
|
||||
|
||||
proc tmpDir(): string =
|
||||
getAppFilename().parentDir / ".webdata_test"
|
||||
|
||||
suite "http helpers":
|
||||
test "parseRequest parses GET with query":
|
||||
let req = parseRequest("GET /help?lang=fr HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
check req.verb == "GET"
|
||||
check req.path == "/help"
|
||||
check req.body == ""
|
||||
|
||||
test "parseRequest parses POST with body":
|
||||
let req = parseRequest(
|
||||
"POST /settings HTTP/1.1\r\nContent-Length: 10\r\n\r\nworkMin=30")
|
||||
check req.verb == "POST"
|
||||
check req.path == "/settings"
|
||||
check req.body == "workMin=30"
|
||||
|
||||
test "parseRequest decodes percent-encoding":
|
||||
let req = parseRequest("GET /settings%3Fx HTTP/1.1\r\n\r\n")
|
||||
check req.path == "/settings?x"
|
||||
|
||||
test "htmlEscape escapes special characters":
|
||||
check htmlEscape("<a href=\"x\">&'") == "<a href="x">&'"
|
||||
|
||||
test "parseForm decodes urlencoded pairs":
|
||||
let f = parseForm("workMin=30&lang=fr&soundEnabled=on¬e=Hello%20world")
|
||||
check f["workMin"] == "30"
|
||||
check f["lang"] == "fr"
|
||||
check f["soundEnabled"] == "on"
|
||||
check f["note"] == "Hello world"
|
||||
|
||||
suite "applySettings":
|
||||
test "applies a full form":
|
||||
let base = defaultConfig()
|
||||
let form = parseForm("workMin=40&shortBreakMin=8&longBreakMin=20&" &
|
||||
"cyclesBeforeLongBreak=5&dailyGoal=6&lang=en&activeProfile=Sprint&" &
|
||||
"soundEnabled=on&doNotDisturb=on&" &
|
||||
"reminders_json=" & encodeUrl("""[{"text":"Boire de l'eau","enabled":true}]"""))
|
||||
let cfg = applySettings(base, form)
|
||||
check cfg.workMin == 40
|
||||
check cfg.shortBreakMin == 8
|
||||
check cfg.longBreakMin == 20
|
||||
check cfg.cyclesBeforeLongBreak == 5
|
||||
check cfg.dailyGoal == 6
|
||||
check cfg.lang == "en"
|
||||
check cfg.activeProfile == "Sprint"
|
||||
check cfg.soundEnabled
|
||||
check cfg.doNotDisturb
|
||||
check not cfg.autoStart
|
||||
check cfg.healthReminders.len == 1
|
||||
check cfg.healthReminders[0].text == "Boire de l'eau"
|
||||
|
||||
test "invalid values fall back to current ones":
|
||||
let base = defaultConfig()
|
||||
let form = parseForm("workMin=abc&lang=zz&activeProfile=Inexistant")
|
||||
let cfg = applySettings(base, form)
|
||||
check cfg.workMin == base.workMin
|
||||
check cfg.lang == "auto"
|
||||
check cfg.activeProfile == base.activeProfile
|
||||
|
||||
suite "rendering":
|
||||
test "pages contain expected content":
|
||||
var cfg = defaultConfig()
|
||||
cfg.lang = "fr" # langue fixe : le rendu ne dépend pas du système
|
||||
check "NimPulse" in renderHelp(cfg)
|
||||
check "Pomodoro" in renderHelp(cfg)
|
||||
check "Statistiques" in renderStats(cfg)
|
||||
check "Réglages" in renderSettings(cfg)
|
||||
check "name=\"workMin\"" in renderSettings(cfg)
|
||||
check "reminders_json" in renderSettings(cfg)
|
||||
|
||||
suite "integration (local HTTP server)":
|
||||
test "GET routes work and settings POST persists":
|
||||
let dir = tmpDir()
|
||||
removeDir(dir)
|
||||
createDir(dir)
|
||||
# Configuration initiale en français : le rendu est déterministe.
|
||||
var initCfg = defaultConfig()
|
||||
initCfg.lang = "fr"
|
||||
saveConfigTo(initCfg, dir / "config.json")
|
||||
setDataDirOverride(dir)
|
||||
defer: setDataDirOverride("")
|
||||
|
||||
let port = startWebServer()
|
||||
check port > 0
|
||||
defer: stopWebServer()
|
||||
|
||||
let base = "http://127.0.0.1:" & $port
|
||||
let client = newHttpClient()
|
||||
|
||||
let help = client.getContent(base & "/help")
|
||||
check "NimPulse" in help
|
||||
|
||||
let stats = client.getContent(base & "/stats")
|
||||
check "Statistiques" in stats
|
||||
|
||||
let settings = client.getContent(base & "/settings")
|
||||
check "name=\"workMin\"" in settings
|
||||
|
||||
# Sauvegarde via POST (comme le ferait le navigateur).
|
||||
let body = "workMin=33&shortBreakMin=6&longBreakMin=18&cyclesBeforeLongBreak=4&" &
|
||||
"dailyGoal=5&lang=fr&activeProfile=Classique&" &
|
||||
"reminders_json=" & encodeUrl(
|
||||
"""[{"text":"Étire-toi","enabled":true},{"text":"Bois de l'eau","enabled":false}]""")
|
||||
let resp = client.postContent(base & "/settings", body)
|
||||
check "Réglages enregistrés" in resp
|
||||
|
||||
# Le fichier config.json a été écrit dans le répertoire de test.
|
||||
let saved = loadConfigFrom(dir / "config.json")
|
||||
check saved.workMin == 33
|
||||
check saved.dailyGoal == 5
|
||||
check saved.lang == "fr"
|
||||
check saved.healthReminders.len == 2
|
||||
check saved.healthReminders[1].enabled == false
|
||||
|
||||
# Le drapeau de rechargement est posé puis consommé.
|
||||
check consumeSettingsChanged()
|
||||
check not consumeSettingsChanged()
|
||||
|
||||
# La page réglages reflète les nouvelles valeurs.
|
||||
let settings2 = client.getContent(base & "/settings")
|
||||
check "value=\"33\"" in settings2
|
||||
|
||||
removeDir(dir)
|
||||
Reference in New Issue
Block a user