Add history calendar export view

This commit is contained in:
every_holiday
2026-06-30 11:03:17 +09:00
parent ae426244e0
commit 3ff33e40e3
4 changed files with 3506 additions and 255 deletions

View File

@@ -0,0 +1,779 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"time"
"unsafe"
)
type historyCalendarCell struct {
Date time.Time
Rect winRect
Count int
Selected bool
InRange bool
}
type historyDetailRow struct {
Key string
Rect winRect
WorldLabel string
Start time.Time
End time.Time
Users []string
Lines []string
Selected bool
Current bool
}
type historyEventRecord struct {
At time.Time
Kind string
Name string
Raw string
}
type historyVisitRecord struct {
Key string
WorldLabel string
WorldID string
InstanceID string
Start time.Time
End time.Time
Current bool
}
type historyQuery struct {
Pattern *regexp.Regexp
From time.Time
To time.Time
HasFrom bool
HasTo bool
}
func ensureHistoryState(app *guiApp) {
if app == nil {
return
}
if app.historySelectedKeys == nil {
app.historySelectedKeys = map[string]bool{}
}
if app.historySelectedDate.IsZero() {
if latest := latestHistoryDate(); !latest.IsZero() {
app.historySelectedDate = latest
} else {
app.historySelectedDate = time.Now()
}
}
if app.historyMonth.IsZero() {
app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location())
}
app.historySelectedDate = clampHistoryDate(app.historySelectedDate, historyQueryFromApp(app))
app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location())
}
func historyQueryFromApp(app *guiApp) historyQuery {
q := historyQuery{}
if app == nil {
return q
}
if s := strings.TrimSpace(app.historyRegex); s != "" {
if re, err := regexp.Compile(s); err == nil {
q.Pattern = re
}
}
if s := strings.TrimSpace(app.historyFromDate); s != "" {
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
q.From = t
q.HasFrom = true
}
}
if s := strings.TrimSpace(app.historyToDate); s != "" {
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
q.To = t.AddDate(0, 0, 1)
q.HasTo = true
}
}
return q
}
func historyInQueryRange(day time.Time, q historyQuery) bool {
start, end := historyDayBounds(day)
if q.HasFrom && end.Before(q.From) {
return false
}
if q.HasTo && !start.Before(q.To) {
return false
}
return true
}
func historyDayBounds(day time.Time) (time.Time, time.Time) {
loc := day.Location()
if loc == nil {
loc = time.Local
}
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
return start, start.AddDate(0, 0, 1)
}
func clampHistoryDate(day time.Time, q historyQuery) time.Time {
if day.IsZero() {
return day
}
start, end := historyDayBounds(day)
if q.HasFrom && start.Before(q.From) {
return q.From
}
if q.HasTo && !start.Before(q.To) {
return q.To.AddDate(0, 0, -1)
}
if q.HasFrom && q.HasTo {
if end.Before(q.From) {
return q.From
}
}
return day
}
func latestHistoryDate() time.Time {
visits := historyVisitSource()
var latest time.Time
for _, v := range visits {
at := v.End
if at.IsZero() {
at = v.Start
}
if at.After(latest) {
latest = at
}
}
if latest.IsZero() {
if world, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(world) != "" && !since.IsZero() {
return since
}
}
return latest
}
func historyVisitSource() []historyVisitRecord {
records := make([]historyVisitRecord, 0, len(readWorldHistory())+1)
for _, visit := range readWorldHistory() {
records = append(records, historyVisitRecord{
Key: historyVisitKey(visit.WorldLabel, visit.StartedAt, visit.EndedAt, visit.WorldID, visit.InstanceID, false),
WorldLabel: visit.WorldLabel,
WorldID: visit.WorldID,
InstanceID: visit.InstanceID,
Start: visit.StartedAt,
End: visit.EndedAt,
})
}
if label, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(label) != "" && !since.IsZero() {
records = append(records, historyVisitRecord{
Key: historyVisitKey(label, since, time.Time{}, "", "", true),
WorldLabel: label,
Start: since,
Current: true,
})
}
sort.SliceStable(records, func(i, j int) bool {
ti := records[i].End
if ti.IsZero() {
ti = records[i].Start
}
tj := records[j].End
if tj.IsZero() {
tj = records[j].Start
}
if ti.Equal(tj) {
return strings.ToLower(records[i].WorldLabel) < strings.ToLower(records[j].WorldLabel)
}
return ti.After(tj)
})
return records
}
func historyVisitKey(label string, start, end time.Time, worldID, instanceID string, current bool) string {
key := strings.TrimSpace(label)
if worldID != "" {
key = worldID
if instanceID != "" {
key += ":" + instanceID
}
}
if !start.IsZero() {
key += "|" + start.UTC().Format(time.RFC3339Nano)
}
if !end.IsZero() {
key += "|" + end.UTC().Format(time.RFC3339Nano)
}
if current {
key += "|current"
}
return key
}
func historyEventsFromLog() []historyEventRecord {
p := filepath.Join(runtimeDir(), "join_leave.log")
b, err := os.ReadFile(p)
if err != nil || len(b) == 0 {
return nil
}
out := make([]historyEventRecord, 0, 128)
var currentAt time.Time
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
if strings.Contains(line, "VRC JOIN/LEAVE") {
if at, ok := parseRuntimeTime(line); ok {
currentAt = at
}
continue
}
m := joinLeaveBodyPattern.FindStringSubmatch(line)
if len(m) != 4 {
continue
}
out = append(out, historyEventRecord{
At: currentAt,
Kind: m[1],
Name: strings.TrimSpace(m[2]),
Raw: line,
})
}
return out
}
func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow {
q := historyQueryFromApp(app)
if !historyInQueryRange(day, q) {
return nil
}
dayStart, dayEnd := historyDayBounds(day)
events := historyEventsFromLog()
visits := historyVisitSource()
rows := make([]historyDetailRow, 0, len(visits))
for _, visit := range visits {
visitStart := visit.Start
if visitStart.IsZero() {
continue
}
visitEnd := visit.End
if visitEnd.IsZero() {
visitEnd = time.Now()
}
if !visitStart.Before(dayEnd) || !visitEnd.After(dayStart) {
continue
}
segmentStart := visitStart
if segmentStart.Before(dayStart) {
segmentStart = dayStart
}
segmentEnd := visitEnd
if segmentEnd.After(dayEnd) {
segmentEnd = dayEnd
}
lines, users, matched := historyVisitLinesForSegment(visit.WorldLabel, segmentStart, segmentEnd, events, q)
if q.Pattern != nil && !matched {
continue
}
rows = append(rows, historyDetailRow{
Key: visit.Key,
WorldLabel: visit.WorldLabel,
Start: segmentStart,
End: segmentEnd,
Users: users,
Lines: lines,
Current: visit.Current,
})
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Start.Equal(rows[j].Start) {
return strings.ToLower(rows[i].WorldLabel) < strings.ToLower(rows[j].WorldLabel)
}
return rows[i].Start.Before(rows[j].Start)
})
return rows
}
func historyVisitLinesForSegment(worldLabel string, start, end time.Time, events []historyEventRecord, q historyQuery) ([]string, []string, bool) {
if end.Before(start) {
end = start
}
lines := make([]string, 0, 16)
users := make([]string, 0, 8)
seenUsers := map[string]struct{}{}
matched := false
if q.Pattern != nil && q.Pattern.MatchString(worldLabel) {
matched = true
}
for _, ev := range events {
if ev.At.IsZero() || ev.At.Before(start) || !ev.At.Before(end) {
continue
}
raw := fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name)
if q.Pattern != nil {
if !(q.Pattern.MatchString(worldLabel) || q.Pattern.MatchString(ev.Name) || q.Pattern.MatchString(raw)) {
continue
}
}
matched = true
lines = append(lines, raw)
if _, ok := seenUsers[ev.Name]; !ok {
seenUsers[ev.Name] = struct{}{}
users = append(users, ev.Name)
}
}
if q.Pattern == nil {
matched = true
}
sort.Strings(users)
return lines, users, matched
}
func historyCalendarCellsForMonth(app *guiApp) []historyCalendarCell {
ensureHistoryState(app)
q := historyQueryFromApp(app)
monthStart := app.historyMonth
if monthStart.IsZero() {
monthStart = time.Now()
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
counts := map[string]int{}
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
for day := 1; day <= daysInMonth; day++ {
date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location())
counts[date.Format("2006-01-02")] = len(historyVisitsForDay(app, date))
}
firstWeekday := int(monthStart.Weekday())
out := make([]historyCalendarCell, 0, 42)
for i := 0; i < firstWeekday; i++ {
out = append(out, historyCalendarCell{})
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location())
cell := historyCalendarCell{
Date: date,
Count: counts[date.Format("2006-01-02")],
InRange: historyInQueryRange(date, q),
}
if sameDay(date, app.historySelectedDate) {
cell.Selected = true
}
out = append(out, cell)
}
for len(out)%7 != 0 {
out = append(out, historyCalendarCell{})
}
return out
}
func sameDay(a, b time.Time) bool {
if a.IsZero() || b.IsZero() {
return false
}
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) {
rows := historyVisitsForDay(app, day)
if len(rows) == 0 {
return "", nil
}
if len(selected) > 0 {
filtered := rows[:0]
for _, row := range rows {
if selected[row.Key] {
filtered = append(filtered, row)
}
}
rows = append([]historyDetailRow(nil), filtered...)
}
if len(rows) == 0 {
return "", nil
}
var b strings.Builder
b.WriteString("Date: ")
b.WriteString(day.Format("2006-01-02"))
b.WriteString("\n")
if q := historyQueryFromApp(app); q.Pattern != nil {
b.WriteString("Filter: ")
b.WriteString(strings.TrimSpace(app.historyRegex))
b.WriteString("\n")
}
b.WriteString("\n")
for _, row := range rows {
b.WriteString("\nWorld: ")
b.WriteString(strings.TrimSpace(row.WorldLabel))
b.WriteString("\n")
b.WriteString("Time: ")
b.WriteString(row.Start.Format("15:04"))
b.WriteString(" - ")
b.WriteString(row.End.Format("15:04"))
b.WriteString("\n")
if len(row.Users) > 0 {
b.WriteString("Users: ")
b.WriteString(strings.Join(row.Users, ", "))
b.WriteString("\n")
}
if len(row.Lines) == 0 {
b.WriteString("(no matches)\n")
continue
}
for _, line := range row.Lines {
b.WriteString(line)
b.WriteString("\n")
}
}
path := filepath.Join(runtimeDir(), "visit_"+day.Format("20060102")+".log")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return "", err
}
return path, nil
}
func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
ensureHistoryState(app)
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBg)
drawPaneText(hdc, 18, 18, "履歴カレンダー", app.joinBoldHFont, clrAccentGreen)
monthLabel := app.historyMonth.Format("2006年01月")
drawPaneText(hdc, 18, 42, monthLabel, app.hFont, clrText)
app.historyPrevMonthRect = winRect{Left: rc.Right - 108, Top: 14, Right: rc.Right - 58, Bottom: 42}
app.historyNextMonthRect = winRect{Left: rc.Right - 54, Top: 14, Right: rc.Right - 14, Bottom: 42}
drawSettingsBox(hdc, app.historyPrevMonthRect, 0x0038281a, clrPaneBorder)
drawSettingsBox(hdc, app.historyNextMonthRect, 0x0038281a, clrPaneBorder)
drawCenteredPaneText(hdc, app.historyPrevMonthRect, "<", app.hFont, clrText)
drawCenteredPaneText(hdc, app.historyNextMonthRect, ">", app.hFont, clrText)
weekdays := []string{"日", "月", "火", "水", "木", "金", "土"}
cellW := (rc.Right - 24) / 7
if cellW < 48 {
cellW = 48
}
cellH := int32(56)
startX := int32(12)
startY := int32(80)
for i, wd := range weekdays {
x := startX + int32(i)*cellW
drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText)
}
cells := historyCalendarCellsForMonth(app)
app.historyCalendarCells = app.historyCalendarCells[:0]
for i, cell := range cells {
if cell.Date.IsZero() {
continue
}
col := i % 7
row := i / 7
rect := winRect{
Left: startX + int32(col)*cellW,
Top: startY + int32(row)*cellH,
Right: startX + int32(col+1)*cellW - 4,
Bottom: startY + int32(row+1)*cellH - 4,
}
cell.Rect = rect
app.historyCalendarCells = append(app.historyCalendarCells, cell)
fill := uint32(0x00261a11)
border := uint32(clrPaneBorder)
textColor := uint32(clrText)
if !cell.InRange {
fill = 0x001b140d
textColor = uint32(clrMutedText)
}
if cell.Count > 0 {
fill = 0x00321f12
border = clrAccentGreen
}
if cell.Selected {
fill = 0x003d2a14
border = clrAccentBlue
}
drawSettingsBox(hdc, rect, fill, border)
drawPaneText(hdc, rect.Left+6, rect.Top+6, fmt.Sprintf("%d", cell.Date.Day()), app.hFont, textColor)
if cell.Count > 0 {
drawPaneText(hdc, rect.Right-24, rect.Bottom-18, fmt.Sprintf("%d", cell.Count), app.hFont, clrAccentGreen)
}
}
if app.historyStatus != "" {
drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText)
}
return 0
}
func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr {
if app == nil {
return 0
}
ensureHistoryState(app)
hdc, done := beginPanePaint(hwnd)
if hdc == 0 {
return 0
}
defer done()
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
fillSolid(hdc, rc, clrPaneBgAlt)
rows := historyVisitsForDay(app, app.historySelectedDate)
app.historyDetailRows = app.historyDetailRows[:0]
title := app.historySelectedDate.Format("2006年01月02日")
drawPaneText(hdc, 18, 18, title, app.joinBoldHFont, clrAccentGreen)
summary := fmt.Sprintf("%d件", len(rows))
drawPaneText(hdc, 18, 44, summary, app.hFont, clrMutedText)
if strings.TrimSpace(app.historyRegex) != "" {
drawPaneText(hdc, 96, 44, "grep: "+app.historyRegex, app.hFont, clrMutedText)
}
if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" {
rangeLabel := strings.TrimSpace(app.historyFromDate)
if rangeLabel == "" {
rangeLabel = "..."
}
rangeLabel += " - "
if to := strings.TrimSpace(app.historyToDate); to != "" {
rangeLabel += to
} else {
rangeLabel += "..."
}
drawPaneText(hdc, 260, 44, rangeLabel, app.hFont, clrMutedText)
}
app.historyExportRect = winRect{Left: rc.Right - 132, Top: 14, Right: rc.Right - 16, Bottom: 42}
drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen)
drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText)
if app.historyStatus != "" {
drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText)
}
pageHeight := rc.Bottom - rc.Top
contentHeight := historyDetailContentHeight(hwnd, app)
maxPos := scrollMax(contentHeight, pageHeight)
if app.historyScrollPos > maxPos {
app.historyScrollPos = maxPos
}
applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight)
y := int32(82 - app.historyScrollPos)
for _, row := range rows {
rowHeight := historyDetailRowHeight(row)
row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4}
app.historyDetailRows = append(app.historyDetailRows, row)
if row.Rect.Bottom < 72 {
y += rowHeight
continue
}
if row.Rect.Top > rc.Bottom-40 {
break
}
selected := app.historySelectedKeys != nil && app.historySelectedKeys[row.Key]
fill := uint32(0x00271b12)
border := uint32(clrPaneBorder)
if selected {
fill = 0x00331f11
border = clrAccentBlue
} else if row.Current {
border = clrAccentGreen
}
drawSettingsBox(hdc, row.Rect, fill, border)
drawPaneText(hdc, row.Rect.Left+12, y+6, row.Start.Format("15:04")+" - "+row.End.Format("15:04"), app.hFont, clrMutedText)
drawPaneText(hdc, row.Rect.Left+122, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText)
label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont)
if label == "" {
label = "(unknown)"
}
drawPaneText(hdc, row.Rect.Left+12, y+24, label, app.joinBoldHFont, clrText)
if len(row.Users) > 0 {
users := "Users: " + strings.Join(row.Users, ", ")
drawPaneText(hdc, row.Rect.Left+12, y+44, ellipsizeTextToWidth(hdc, users, row.Rect.Right-row.Rect.Left-24, app.hFont), app.hFont, clrMutedText)
}
lineY := y + 62
showLines := row.Lines
if len(showLines) > 3 {
showLines = append([]string(nil), showLines[:3]...)
showLines = append(showLines, fmt.Sprintf("... +%d more", len(row.Lines)-3))
}
for _, line := range showLines {
drawPaneText(hdc, row.Rect.Left+18, lineY, ellipsizeTextToWidth(hdc, line, row.Rect.Right-row.Rect.Left-40, app.hFont), app.hFont, clrText)
lineY += 18
}
y += rowHeight
}
return 0
}
func historyDetailContentHeight(hwnd uintptr, app *guiApp) int32 {
if app == nil || getClientRectProc == nil {
return 0
}
var rc winRect
getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc)))
rows := historyVisitsForDay(app, app.historySelectedDate)
height := int32(90)
for _, row := range rows {
height += historyDetailRowHeight(row) + 6
}
if height < 120 {
height = 120
}
return height
}
func historyDetailRowHeight(row historyDetailRow) int32 {
lines := len(row.Lines)
if lines > 3 {
lines = 4
}
height := int32(72 + lines*18)
if len(row.Users) > 0 {
height += 18
}
if height < 84 {
height = 84
}
if height > 160 {
height = 160
}
return height
}
func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, setWindowText *syscall.LazyProc) bool {
if app == nil {
return false
}
ensureHistoryState(app)
switch hwnd {
case app.leftHwnd:
if pointInRect(x, y, app.historyPrevMonthRect) {
historyShiftMonth(app, -1)
return true
}
if pointInRect(x, y, app.historyNextMonthRect) {
historyShiftMonth(app, 1)
return true
}
for _, cell := range app.historyCalendarCells {
if cell.Date.IsZero() || !pointInRect(x, y, cell.Rect) {
continue
}
historySelectDate(app, cell.Date)
return true
}
case app.rightHwnd:
if pointInRect(x, y, app.historyExportRect) {
path, err := exportHistoryDay(app, app.historySelectedDate, app.historySelectedKeys)
if err != nil {
app.historyStatus = "export failed: " + err.Error()
} else if path != "" {
app.historyStatus = "exported: " + filepath.Base(path)
} else {
app.historyStatus = "exported: nothing selected"
}
invalidateHistoryPanes(app)
return true
}
for _, row := range app.historyDetailRows {
if row.Key == "" || !pointInRect(x, y, row.Rect) {
continue
}
if app.historySelectedKeys == nil {
app.historySelectedKeys = map[string]bool{}
}
if app.historySelectedKeys[row.Key] {
delete(app.historySelectedKeys, row.Key)
} else {
app.historySelectedKeys[row.Key] = true
}
invalidateHistoryPanes(app)
return true
}
}
return false
}
func historyShiftMonth(app *guiApp, delta int) {
if app == nil || delta == 0 {
return
}
ensureHistoryState(app)
base := app.historyMonth
if base.IsZero() {
base = app.historySelectedDate
}
if base.IsZero() {
base = time.Now()
}
nextMonth := time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).AddDate(0, delta, 0)
if nextMonth.IsZero() {
return
}
day := app.historySelectedDate.Day()
lastDay := nextMonth.AddDate(0, 1, -1).Day()
if day > lastDay {
day = lastDay
}
nextDate := time.Date(nextMonth.Year(), nextMonth.Month(), day, 0, 0, 0, 0, nextMonth.Location())
historySelectDate(app, nextDate)
invalidateHistoryPanes(app)
}
func historySelectDate(app *guiApp, day time.Time) {
if app == nil || day.IsZero() {
return
}
ensureHistoryState(app)
q := historyQueryFromApp(app)
day = clampHistoryDate(day, q)
if day.IsZero() {
return
}
app.historySelectedDate = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location())
app.historyMonth = time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location())
app.historySelectedKeys = map[string]bool{}
app.historyStatus = ""
app.historyScrollPos = 0
invalidateHistoryPanes(app)
}
func invalidateHistoryPanes(app *guiApp) {
if invalidateRectProc == nil {
return
}
if app != nil {
if app.leftHwnd != 0 {
invalidateRectProc.Call(app.leftHwnd, 0, 1)
}
if app.rightHwnd != 0 {
invalidateRectProc.Call(app.rightHwnd, 0, 1)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"vrc_osc_go/internal/common" "vrc_osc_go/internal/common"
) )
@@ -32,14 +33,20 @@ type VrcLogConfig struct {
} }
type GUIConfig struct { type GUIConfig struct {
TopMost bool TopMost bool
FontSize int FontSize int
AutoUnmuteOnSelfLeave bool
RefreshIntervalValue int
RefreshIntervalUnit string
HistoryRegex string
HistoryFromDate string
HistoryToDate string
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
cfg := &Config{ cfg := &Config{
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001}, OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
GUI: GUIConfig{FontSize: 18}, GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"},
} }
if path == "" { if path == "" {
path = filepath.Join(common.RootDir(), "config", "config.toml") path = filepath.Join(common.RootDir(), "config", "config.toml")
@@ -114,9 +121,29 @@ func Load(path string) (*Config, error) {
if n, err := strconv.Atoi(val); err == nil && n > 0 { if n, err := strconv.Atoi(val); err == nil && n > 0 {
cfg.GUI.FontSize = n cfg.GUI.FontSize = n
} }
case "auto_unmute_on_self_leave":
cfg.GUI.AutoUnmuteOnSelfLeave = parseBool(val, cfg.GUI.AutoUnmuteOnSelfLeave)
case "refresh_interval_value":
if n, err := strconv.Atoi(val); err == nil && n > 0 {
cfg.GUI.RefreshIntervalValue = n
}
case "refresh_interval_unit":
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(val)
case "history_regex":
cfg.GUI.HistoryRegex = val
case "history_from":
cfg.GUI.HistoryFromDate = normalizeHistoryDate(val)
case "history_to":
cfg.GUI.HistoryToDate = normalizeHistoryDate(val)
} }
} }
} }
if cfg.GUI.RefreshIntervalValue <= 0 {
cfg.GUI.RefreshIntervalValue = 2
}
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit)
cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate)
cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate)
if cfg.VrcLog.GuestFile != "" { if cfg.VrcLog.GuestFile != "" {
if !filepath.IsAbs(cfg.VrcLog.GuestFile) { if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile) cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
@@ -222,13 +249,56 @@ func upsertGUISection(existing string, gui GUIConfig) string {
} }
func appendGUISection(out *[]string, gui GUIConfig) { func appendGUISection(out *[]string, gui GUIConfig) {
gui.RefreshIntervalUnit = normalizeRefreshIntervalUnit(gui.RefreshIntervalUnit)
if gui.RefreshIntervalValue <= 0 {
gui.RefreshIntervalValue = 2
}
*out = append(*out, *out = append(*out,
"[gui]", "[gui]",
fmt.Sprintf("top_most = %t", gui.TopMost), fmt.Sprintf("top_most = %t", gui.TopMost),
fmt.Sprintf("font_size = %d", gui.FontSize), fmt.Sprintf("font_size = %d", gui.FontSize),
fmt.Sprintf("auto_unmute_on_self_leave = %t", gui.AutoUnmuteOnSelfLeave),
fmt.Sprintf("refresh_interval_value = %d", gui.RefreshIntervalValue),
fmt.Sprintf("refresh_interval_unit = %q", gui.RefreshIntervalUnit),
fmt.Sprintf("history_regex = %q", gui.HistoryRegex),
fmt.Sprintf("history_from = %q", gui.HistoryFromDate),
fmt.Sprintf("history_to = %q", gui.HistoryToDate),
) )
} }
func normalizeRefreshIntervalUnit(unit string) string {
switch strings.ToLower(strings.TrimSpace(unit)) {
case "min", "minute", "minutes":
return "min"
default:
return "sec"
}
}
func (g GUIConfig) RefreshInterval() time.Duration {
value := g.RefreshIntervalValue
if value <= 0 {
value = 2
}
switch normalizeRefreshIntervalUnit(g.RefreshIntervalUnit) {
case "min":
return time.Duration(value) * time.Minute
default:
return time.Duration(value) * time.Second
}
}
func normalizeHistoryDate(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if _, err := time.Parse("2006-01-02", value); err != nil {
return ""
}
return value
}
func loadLines(path string) ([]string, error) { func loadLines(path string) ([]string, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@@ -35,14 +35,14 @@ func TestLoadOSCValues(t *testing.T) {
func TestLoadGUIValues(t *testing.T) { func TestLoadGUIValues(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.toml") path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
cfg, err := Load(path) cfg, err := Load(path)
if err != nil { if err != nil {
t.Fatalf("Load returned error: %v", err) t.Fatalf("Load returned error: %v", err)
} }
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 { if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
t.Fatalf("unexpected gui values: %+v", cfg.GUI) t.Fatalf("unexpected gui values: %+v", cfg.GUI)
} }
} }
@@ -54,14 +54,14 @@ func TestSaveGUIUpdatesSection(t *testing.T) {
if err := os.WriteFile(path, initial, 0o600); err != nil { if err := os.WriteFile(path, initial, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26}); err != nil { if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31"}); err != nil {
t.Fatalf("SaveGUI returned error: %v", err) t.Fatalf("SaveGUI returned error: %v", err)
} }
cfg, err := Load(path) cfg, err := Load(path)
if err != nil { if err != nil {
t.Fatalf("Load returned error: %v", err) t.Fatalf("Load returned error: %v", err)
} }
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 { if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
t.Fatalf("unexpected saved gui values: %+v", cfg.GUI) t.Fatalf("unexpected saved gui values: %+v", cfg.GUI)
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)