Files
summercms/modules/diary/daily.go
Jakub Zych e903cc32be feat(diary): component loggers through a redacting console and daily stack
- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig
- Proxy component handler safe in package vars; styled console handler in bonfire's palette
- Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure
- Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler
- bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile)
- Generated main runs diary.Bootstrap first and diary.Setup after compass.Load
- Ignore /storage/ and /examples/hello/storage/
2026-10-21 11:41:48 +02:00

153 lines
3.4 KiB
Go

package diary
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const dateLayout = "2006-01-02"
// dailyWriter writes to one file per local date: path storage/logs/summer.log
// gives storage/logs/summer-2026-10-21.log. Files open lazily on the first
// write (no empty files), are created 0o640 in directories created 0o750,
// and on every open the files of the same base older than the retention are
// deleted. If a file cannot be opened the writer reports once through warn
// and is disabled: later writes succeed without writing anything.
type dailyWriter struct {
mu sync.Mutex
dir string
base string
ext string
days int
now func() time.Time
warn func(path string, err error)
file *os.File
date string
disabled bool
}
func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter {
dir, name := filepath.Split(path)
ext := filepath.Ext(name)
if dir == "" {
dir = "."
}
return &dailyWriter{
dir: filepath.Clean(dir),
base: strings.TrimSuffix(name, ext),
ext: ext,
days: days,
now: now,
warn: warn,
}
}
func (d *dailyWriter) fileName(date string) string {
return filepath.Join(d.dir, d.base+"-"+date+d.ext)
}
// Write appends p to the current day's file in one write call.
func (d *dailyWriter) Write(p []byte) (int, error) {
d.mu.Lock()
if d.disabled {
d.mu.Unlock()
return len(p), nil
}
date := d.now().Local().Format(dateLayout)
if d.file == nil || date != d.date {
if err := d.open(date); err != nil {
d.disabled = true
warn := d.warn
name := d.fileName(date)
d.mu.Unlock()
if warn != nil {
warn(name, err)
}
return len(p), nil
}
}
n, err := d.file.Write(p)
d.mu.Unlock()
return n, err
}
func (d *dailyWriter) open(date string) error {
if d.file != nil {
_ = d.file.Close()
d.file = nil
}
if err := os.MkdirAll(d.dir, 0o750); err != nil {
return err
}
name := d.fileName(date)
_, statErr := os.Stat(name)
f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return err
}
if errors.Is(statErr, fs.ErrNotExist) {
// The process umask may strip bits; new log files are 0o640 exactly.
_ = f.Chmod(0o640)
}
d.file, d.date = f, date
d.prune(date)
return nil
}
// prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the
// retention window: with days N the files of today and the N-1 days before
// it are kept. Other files in the directory are never touched.
func (d *dailyWriter) prune(today string) {
if d.days <= 0 {
return
}
now, err := time.ParseInLocation(dateLayout, today, time.Local)
if err != nil {
return
}
cutoff := now.AddDate(0, 0, -d.days)
entries, err := os.ReadDir(d.dir)
if err != nil {
return
}
prefix := d.base + "-"
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
rest, ok := strings.CutPrefix(name, prefix)
if !ok {
continue
}
stamp, ok := strings.CutSuffix(rest, d.ext)
if !ok || len(stamp) != len(dateLayout) {
continue
}
day, err := time.ParseInLocation(dateLayout, stamp, time.Local)
if err != nil {
continue
}
if !day.After(cutoff) {
_ = os.Remove(filepath.Join(d.dir, name))
}
}
}
// Close closes the open file. A later write opens it again.
func (d *dailyWriter) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.file == nil {
return nil
}
err := d.file.Close()
d.file, d.date = nil, ""
return err
}