Le binaire ne parlait que français alors que le menu suit EL_LANG : un opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message humain passe par T(), dont la clé EST le texte français, et un catalogue anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses motifs ne connaissant que cette langue. Un test refuse un message sans traduction, une traduction orpheline et un verbe de format qui diverge. --- EN --- The binary spoke French only while the menu follows EL_LANG: an English reader got a status he could not read. Every human message goes through T(), whose key IS the French text, and an English catalogue translates it; the language comes from --lang, then EL_LANG, French remaining the default. The menu asks for French for what it READS BACK, its patterns knowing that language alone. A test refuses a message without translation, an orphan translation, and a format verb that drifts. Assisted-by: Claude Opus 5
287 lines
8.1 KiB
Go
287 lines
8.1 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Le cache ne diminue jamais de lui-même — c'est assumé, et la place se
|
|
// surveille à la main. Encore faut-il pouvoir la surveiller : savoir CE QUI
|
|
// occupe, et depuis QUAND, avant de décider ce qui peut partir.
|
|
//
|
|
// L'âge retenu est la date du dernier USAGE et non celle du stockage. Un
|
|
// paquet servi tous les jours depuis un an n'est pas vieux : le supprimer
|
|
// obligerait à le retélécharger le lendemain. C'est ce qu'un opérateur veut
|
|
// dire par « nettoyer ce qui ne sert plus », et c'est pour cela que le service
|
|
// remet la date d'un objet à chaque fois qu'il le sert.
|
|
|
|
// Tranche est un groupe d'objets d'un même âge.
|
|
type Tranche struct {
|
|
// Debut est le premier instant de la tranche ; Libelle la nomme.
|
|
Debut time.Time
|
|
Libelle string
|
|
Objets int
|
|
Octets int64
|
|
}
|
|
|
|
// Granularite dit comment les tranches sont découpées.
|
|
type Granularite string
|
|
|
|
const (
|
|
ParJour Granularite = "jour"
|
|
ParSemaine Granularite = "semaine"
|
|
ParMois Granularite = "mois"
|
|
)
|
|
|
|
// LireGranularite tolère l'anglais comme le français : le CLI est bilingue
|
|
// partout ailleurs dans ce dépôt.
|
|
func LireGranularite(s string) (Granularite, error) {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "jour", "day", "j", "d":
|
|
return ParJour, nil
|
|
case "semaine", "week", "s", "w":
|
|
return ParSemaine, nil
|
|
case "mois", "month", "m":
|
|
return ParMois, nil
|
|
}
|
|
return "", fmt.Errorf(T("granularité inconnue %q : jour, semaine ou mois"), s)
|
|
}
|
|
|
|
// debutDeTranche ramène un instant au premier de sa tranche.
|
|
//
|
|
// La semaine commence le LUNDI : c'est la convention ISO, et celle que
|
|
// « %V » et les outils du système emploient. Ramener au dimanche donnerait des
|
|
// tranches qui ne correspondent à aucun autre relevé de la machine.
|
|
func debutDeTranche(t time.Time, g Granularite) time.Time {
|
|
t = t.Local()
|
|
jour := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
switch g {
|
|
case ParSemaine:
|
|
recul := (int(jour.Weekday()) + 6) % 7
|
|
return jour.AddDate(0, 0, -recul)
|
|
case ParMois:
|
|
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
|
}
|
|
return jour
|
|
}
|
|
|
|
func libelleDeTranche(t time.Time, g Granularite) string {
|
|
switch g {
|
|
case ParSemaine:
|
|
an, sem := t.ISOWeek()
|
|
return fmt.Sprintf("%d-S%02d", an, sem)
|
|
case ParMois:
|
|
return t.Format("2006-01")
|
|
}
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
// TranchesStore groupe les objets du magasin par âge, du plus récent au plus
|
|
// ancien.
|
|
func (s *Store) Tranches(g Granularite) []Tranche {
|
|
par := map[time.Time]*Tranche{}
|
|
filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil {
|
|
return nil
|
|
}
|
|
if horsCasier(s.Dir, p, info) {
|
|
return filepath.SkipDir
|
|
}
|
|
if info.IsDir() || filepath.Ext(p) != ".body" {
|
|
return nil
|
|
}
|
|
d := debutDeTranche(info.ModTime(), g)
|
|
tr, ok := par[d]
|
|
if !ok {
|
|
tr = &Tranche{Debut: d, Libelle: libelleDeTranche(d, g)}
|
|
par[d] = tr
|
|
}
|
|
tr.Objets++
|
|
tr.Octets += info.Size()
|
|
return nil
|
|
})
|
|
return trier(par)
|
|
}
|
|
|
|
// TranchesMiroirs fait de même pour les dépôts git.
|
|
//
|
|
// Un dépôt entier par tranche, pas ses fichiers : ce qui s'efface est un
|
|
// dépôt, et le compter au fichier donnerait un relevé qu'on ne peut pas
|
|
// suivre d'un geste.
|
|
func (g *GitMirror) Tranches(gran Granularite) []Tranche {
|
|
if g == nil || g.Dir == "" {
|
|
return nil
|
|
}
|
|
par := map[time.Time]*Tranche{}
|
|
for _, d := range g.Depots() {
|
|
debut := debutDeTranche(d.Maj, gran)
|
|
tr, ok := par[debut]
|
|
if !ok {
|
|
tr = &Tranche{Debut: debut, Libelle: libelleDeTranche(debut, gran)}
|
|
par[debut] = tr
|
|
}
|
|
tr.Objets++
|
|
tr.Octets += d.Octets
|
|
}
|
|
return trier(par)
|
|
}
|
|
|
|
func trier(par map[time.Time]*Tranche) []Tranche {
|
|
out := make([]Tranche, 0, len(par))
|
|
for _, tr := range par {
|
|
out = append(out, *tr)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Debut.After(out[j].Debut)
|
|
})
|
|
return out
|
|
}
|
|
|
|
// Purger efface les objets dont le dernier usage précède `avant`.
|
|
//
|
|
// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie
|
|
// présente, et la lecture échouerait au moment de servir, c'est-à-dire au pire
|
|
// moment. Rend (objets effacés, octets rendus).
|
|
func (s *Store) Purger(avant time.Time) (int, int64, error) {
|
|
n, octets := 0, int64(0)
|
|
err := filepath.Walk(s.Dir, func(
|
|
p string, info os.FileInfo, err error,
|
|
) error {
|
|
if err != nil || info == nil {
|
|
return nil
|
|
}
|
|
if horsCasier(s.Dir, p, info) {
|
|
return filepath.SkipDir
|
|
}
|
|
if info.IsDir() || filepath.Ext(p) != ".body" {
|
|
return nil
|
|
}
|
|
if !info.ModTime().Before(avant) {
|
|
return nil
|
|
}
|
|
taille := info.Size()
|
|
if e := os.Remove(p); e != nil {
|
|
return nil
|
|
}
|
|
os.Remove(strings.TrimSuffix(p, ".body") + ".meta")
|
|
n++
|
|
octets += taille
|
|
return nil
|
|
})
|
|
return n, octets, err
|
|
}
|
|
|
|
// PurgerMiroirs efface les dépôts qui n'ont pas été rafraîchis depuis
|
|
// `avant`. Rend (dépôts effacés, octets rendus).
|
|
func (g *GitMirror) PurgerMiroirs(avant time.Time) (int, int64, error) {
|
|
if g == nil || g.Dir == "" {
|
|
return 0, 0, nil
|
|
}
|
|
n, octets := 0, int64(0)
|
|
for _, d := range g.Depots() {
|
|
if !d.Maj.Before(avant) {
|
|
continue
|
|
}
|
|
if err := g.Retirer(d.Chemin); err != nil {
|
|
return n, octets, err
|
|
}
|
|
n++
|
|
octets += d.Octets
|
|
}
|
|
return n, octets, nil
|
|
}
|
|
|
|
// LireDuree accepte les suffixes de Go et, en plus, « j » pour un jour.
|
|
//
|
|
// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie
|
|
// raisonne en jours, pas en sept cent vingt heures.
|
|
func LireDuree(s string) (time.Duration, error) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return 0, fmt.Errorf("%s", T("durée vide"))
|
|
}
|
|
if n, unite := s[:len(s)-1], s[len(s)-1]; unite == 'j' || unite == 'd' {
|
|
var jours float64
|
|
if _, err := fmt.Sscanf(n, "%g", &jours); err != nil || jours < 0 {
|
|
return 0, fmt.Errorf(T("durée illisible %q"), s)
|
|
}
|
|
return time.Duration(jours * 24 * float64(time.Hour)), nil
|
|
}
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil || d < 0 {
|
|
return 0, fmt.Errorf(
|
|
T("durée illisible %q : essayer 30j, 12h, 90m"), s)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// printAge écrit le relevé par âge : les objets, puis les dépôts.
|
|
//
|
|
// Les deux SÉPARÉMENT : ils ne s'effacent pas de la même façon — un objet est
|
|
// un fichier, un dépôt est un arbre — et un total commun cacherait qu'un seul
|
|
// dépôt pèse plus que mille objets.
|
|
func printAge(store *Store, miroir *GitMirror, gran Granularite) {
|
|
fmt.Printf(T("Âge du dernier usage, par %s.\n"), T(string(gran)))
|
|
fmt.Printf(
|
|
"%s", T("\nUn objet servi voit sa date remise à jour : « vieux » veut donc "+
|
|
"dire\n« n'a plus servi », et non « est entré il y a "+
|
|
"longtemps ».\n"))
|
|
|
|
ecrire := func(titre string, tranches []Tranche, quoi string) {
|
|
fmt.Printf("\n%s\n", titre)
|
|
if len(tranches) == 0 {
|
|
fmt.Println(T(" rien"))
|
|
return
|
|
}
|
|
var n int
|
|
var octets int64
|
|
for _, tr := range tranches {
|
|
fmt.Printf(" %-12s %6d %-8s %10s\n",
|
|
tr.Libelle, tr.Objets, quoi, HumanBytes(tr.Octets))
|
|
n += tr.Objets
|
|
octets += tr.Octets
|
|
}
|
|
fmt.Printf(" %-12s %6d %-8s %10s\n", "total", n, quoi,
|
|
HumanBytes(octets))
|
|
}
|
|
ecrire(T("Objets du cache :"), store.Tranches(gran), T("objets"))
|
|
if miroir.Actif() {
|
|
ecrire(T("Dépôts en miroir :"), miroir.Tranches(gran), T("dépôts"))
|
|
}
|
|
}
|
|
|
|
// printPurgeABlanc dit ce qu'une purge emporterait, sans rien effacer.
|
|
//
|
|
// Une purge ne se rattrape pas — les octets sont rendus, il faut les
|
|
// retélécharger — et c'est le genre de geste qu'on veut relire avant.
|
|
func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) {
|
|
var n int
|
|
var octets int64
|
|
for _, tr := range store.Tranches(ParJour) {
|
|
if tr.Debut.Before(avant) {
|
|
n += tr.Objets
|
|
octets += tr.Octets
|
|
}
|
|
}
|
|
fmt.Printf(T("[à blanc] objets qui partiraient : %d, %s\n"),
|
|
n, HumanBytes(octets))
|
|
|
|
var nd int
|
|
var octd int64
|
|
for _, d := range miroir.Depots() {
|
|
if d.Maj.Before(avant) {
|
|
nd++
|
|
octd += d.Octets
|
|
}
|
|
}
|
|
fmt.Printf(T("[à blanc] dépôts qui partiraient : %d, %s\n"),
|
|
nd, HumanBytes(octd))
|
|
fmt.Println(T("[à blanc] rien n'a été effacé."))
|
|
}
|