- 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/
153 lines
3.4 KiB
Go
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
|
|
}
|