1291 lines
36 KiB
Go
1291 lines
36 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"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
|
|
}
|
|
|
|
type joinLeaveEventSnapshot struct {
|
|
At time.Time
|
|
Kind string
|
|
Name string
|
|
Count int
|
|
}
|
|
|
|
type historyExportRequest struct {
|
|
Day time.Time
|
|
Selected map[string]bool
|
|
Rows []historyDetailRow
|
|
ExportDir string
|
|
IncludeTime bool
|
|
IncludeWorld bool
|
|
IncludeJoinLeave bool
|
|
CustomEnabled bool
|
|
Custom string
|
|
RegexText string
|
|
Query historyQuery
|
|
}
|
|
|
|
type historyExportResult struct {
|
|
Path string
|
|
Err error
|
|
Day time.Time
|
|
SelectedCount int
|
|
}
|
|
|
|
type historyRefreshResult struct {
|
|
Rows []guiWorldVisitRow
|
|
CalendarCells []historyCalendarCell
|
|
DetailRows []historyDetailRow
|
|
DetailHeightPx int32
|
|
SelectedDate time.Time
|
|
Month time.Time
|
|
SelectedKeys map[string]bool
|
|
Status string
|
|
Regex string
|
|
FromDate string
|
|
ToDate string
|
|
Err error
|
|
Elapsed time.Duration
|
|
}
|
|
|
|
var historyEventsCacheMu sync.Mutex
|
|
var historyEventsCache struct {
|
|
jsonMod time.Time
|
|
jsonSize int64
|
|
logMod time.Time
|
|
logSize int64
|
|
items []historyEventRecord
|
|
}
|
|
|
|
func ensureHistoryState(app *guiApp) {
|
|
if app == nil {
|
|
return
|
|
}
|
|
if app.historySelectedKeys == nil {
|
|
app.historySelectedKeys = map[string]bool{}
|
|
}
|
|
if app.historySelectedDate.IsZero() {
|
|
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 historyEventsFromSnapshot() []historyEventRecord {
|
|
jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json")
|
|
logPath := filepath.Join(runtimeDir(), "join_leave.log")
|
|
jsonInfo, jsonErr := os.Stat(jsonPath)
|
|
logInfo, logErr := os.Stat(logPath)
|
|
if jsonErr == nil || logErr == nil {
|
|
historyEventsCacheMu.Lock()
|
|
if historyEventsCache.items != nil &&
|
|
((jsonErr == nil && historyEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && historyEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) &&
|
|
((logErr == nil && historyEventsCache.logMod.Equal(logInfo.ModTime()) && historyEventsCache.logSize == logInfo.Size()) || logErr != nil) {
|
|
out := append([]historyEventRecord(nil), historyEventsCache.items...)
|
|
historyEventsCacheMu.Unlock()
|
|
return out
|
|
}
|
|
historyEventsCacheMu.Unlock()
|
|
}
|
|
if b, err := os.ReadFile(jsonPath); err == nil && len(b) > 0 {
|
|
if events := decodeHistoryEventsSnapshot(b); len(events) > 0 {
|
|
if jsonErr == nil {
|
|
historyEventsCacheMu.Lock()
|
|
historyEventsCache.jsonMod = jsonInfo.ModTime()
|
|
historyEventsCache.jsonSize = jsonInfo.Size()
|
|
historyEventsCache.logMod = time.Time{}
|
|
historyEventsCache.logSize = 0
|
|
historyEventsCache.items = append([]historyEventRecord(nil), events...)
|
|
historyEventsCacheMu.Unlock()
|
|
}
|
|
return events
|
|
}
|
|
}
|
|
b, err := os.ReadFile(logPath)
|
|
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,
|
|
})
|
|
}
|
|
if logErr == nil {
|
|
historyEventsCacheMu.Lock()
|
|
historyEventsCache.jsonMod = time.Time{}
|
|
historyEventsCache.jsonSize = 0
|
|
historyEventsCache.logMod = logInfo.ModTime()
|
|
historyEventsCache.logSize = logInfo.Size()
|
|
historyEventsCache.items = append([]historyEventRecord(nil), out...)
|
|
historyEventsCacheMu.Unlock()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeHistoryEventsSnapshot(b []byte) []historyEventRecord {
|
|
type snapshot struct {
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Events []struct {
|
|
At time.Time `json:"at"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
} `json:"events"`
|
|
}
|
|
var snap snapshot
|
|
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
|
|
out := make([]historyEventRecord, 0, len(snap.Events))
|
|
for _, ev := range snap.Events {
|
|
out = append(out, historyEventRecord{
|
|
At: ev.At,
|
|
Kind: ev.Kind,
|
|
Name: ev.Name,
|
|
Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name),
|
|
})
|
|
}
|
|
return dedupeHistoryEventRecords(out)
|
|
}
|
|
var flat []struct {
|
|
At time.Time `json:"at"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 {
|
|
out := make([]historyEventRecord, 0, len(flat))
|
|
for _, ev := range flat {
|
|
out = append(out, historyEventRecord{
|
|
At: ev.At,
|
|
Kind: ev.Kind,
|
|
Name: ev.Name,
|
|
Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name),
|
|
})
|
|
}
|
|
return dedupeHistoryEventRecords(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dedupeHistoryEventRecords(events []historyEventRecord) []historyEventRecord {
|
|
if len(events) <= 1 {
|
|
return events
|
|
}
|
|
seen := make(map[string]struct{}, len(events))
|
|
out := make([]historyEventRecord, 0, len(events))
|
|
for _, ev := range events {
|
|
key := ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + strings.TrimSpace(ev.Name)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow {
|
|
q := historyQueryFromApp(app)
|
|
if !historyInQueryRange(day, q) {
|
|
return nil
|
|
}
|
|
events := historyEventsFromSnapshot()
|
|
visits := historyVisitSource()
|
|
return historyVisitsForDayFromData(app, day, visits, events, q)
|
|
}
|
|
|
|
func historyVisitsForDayFromData(app *guiApp, day time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyDetailRow {
|
|
dayStart, dayEnd := historyDayBounds(day)
|
|
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())
|
|
events := historyEventsFromSnapshot()
|
|
visits := historyVisitSource()
|
|
return historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q)
|
|
}
|
|
|
|
func historyCalendarCellsForMonthFromData(app *guiApp, monthStart time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyCalendarCell {
|
|
counts := map[string]int{}
|
|
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
|
|
if q.Pattern == nil {
|
|
monthEnd := monthStart.AddDate(0, 1, 0)
|
|
for _, visit := range visits {
|
|
visitStart := visit.Start
|
|
if visitStart.IsZero() {
|
|
continue
|
|
}
|
|
visitEnd := visit.End
|
|
if visitEnd.IsZero() {
|
|
visitEnd = time.Now()
|
|
}
|
|
if !visitStart.Before(monthEnd) || !visitEnd.After(monthStart) {
|
|
continue
|
|
}
|
|
dayCursor := time.Date(visitStart.Year(), visitStart.Month(), visitStart.Day(), 0, 0, 0, 0, monthStart.Location())
|
|
if dayCursor.Before(monthStart) {
|
|
dayCursor = monthStart
|
|
}
|
|
for dayCursor.Before(monthEnd) {
|
|
dayStart, dayEnd := historyDayBounds(dayCursor)
|
|
if visitStart.Before(dayEnd) && visitEnd.After(dayStart) {
|
|
counts[dayCursor.Format("2006-01-02")]++
|
|
}
|
|
dayCursor = dayCursor.AddDate(0, 0, 1)
|
|
}
|
|
}
|
|
} else {
|
|
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(historyVisitsForDayFromData(app, date, visits, events, q))
|
|
}
|
|
}
|
|
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) {
|
|
req := historyExportRequest{Day: day, Query: historyQueryFromApp(app)}
|
|
if app != nil {
|
|
req.Selected = cloneHistorySelection(selected)
|
|
req.Rows = cloneHistoryDetailRows(app.historyDetailRows)
|
|
req.ExportDir = normalizeHistoryExportDir(app.historyExportDir)
|
|
req.IncludeTime = app.historyExportIncludeTime
|
|
req.IncludeWorld = app.historyExportIncludeWorld
|
|
req.IncludeJoinLeave = app.historyExportIncludeJoinLeave
|
|
req.CustomEnabled = app.historyExportCustomEnabled
|
|
req.Custom = strings.TrimSpace(app.historyExportCustom)
|
|
req.RegexText = strings.TrimSpace(app.historyRegex)
|
|
}
|
|
return exportHistoryDayFromRequest(req)
|
|
}
|
|
|
|
func exportHistoryDayFromRequest(req historyExportRequest) (string, error) {
|
|
rows := append([]historyDetailRow(nil), req.Rows...)
|
|
if len(rows) == 0 {
|
|
rows = historyVisitsForDayFromData(nil, req.Day, historyVisitSource(), historyEventsFromSnapshot(), req.Query)
|
|
}
|
|
if len(req.Selected) > 0 {
|
|
filtered := rows[:0]
|
|
for _, row := range rows {
|
|
if req.Selected[row.Key] {
|
|
filtered = append(filtered, row)
|
|
}
|
|
}
|
|
rows = append([]historyDetailRow(nil), filtered...)
|
|
}
|
|
customPattern := (*regexp.Regexp)(nil)
|
|
if req.CustomEnabled {
|
|
if s := strings.TrimSpace(req.Custom); s != "" {
|
|
if re, err := regexp.Compile(s); err == nil {
|
|
customPattern = re
|
|
}
|
|
}
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Date: ")
|
|
b.WriteString(req.Day.Format("2006-01-02"))
|
|
b.WriteString("\n")
|
|
if len(req.Selected) > 0 {
|
|
b.WriteString("Selection: ")
|
|
b.WriteString(fmt.Sprintf("%d rows", len(req.Selected)))
|
|
b.WriteString("\n")
|
|
}
|
|
if req.Query.Pattern != nil {
|
|
b.WriteString("Filter: ")
|
|
if strings.TrimSpace(req.RegexText) != "" {
|
|
b.WriteString(strings.TrimSpace(req.RegexText))
|
|
} else {
|
|
b.WriteString(req.Query.Pattern.String())
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
if req.CustomEnabled && customPattern != nil {
|
|
b.WriteString("Custom: ")
|
|
b.WriteString(strings.TrimSpace(req.Custom))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
if len(rows) == 0 {
|
|
if len(req.Selected) > 0 {
|
|
b.WriteString("(no selected visits)\n")
|
|
} else {
|
|
b.WriteString("(no visits)\n")
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
rowLines := append([]string(nil), row.Lines...)
|
|
if customPattern != nil {
|
|
filtered := rowLines[:0]
|
|
for _, line := range rowLines {
|
|
if customPattern.MatchString(line) || customPattern.MatchString(row.WorldLabel) || customPattern.MatchString(strings.Join(row.Users, " ")) {
|
|
filtered = append(filtered, line)
|
|
}
|
|
}
|
|
rowLines = append([]string(nil), filtered...)
|
|
}
|
|
if req.IncludeWorld {
|
|
b.WriteString("\nWorld: ")
|
|
b.WriteString(strings.TrimSpace(row.WorldLabel))
|
|
b.WriteString("\n")
|
|
}
|
|
if req.IncludeTime {
|
|
b.WriteString("Time: ")
|
|
b.WriteString(historyTimeRangeLabel(row.Start, row.End, row.Current))
|
|
b.WriteString("\n")
|
|
}
|
|
if len(row.Users) > 0 {
|
|
b.WriteString("Users: ")
|
|
b.WriteString(strings.Join(row.Users, ", "))
|
|
b.WriteString("\n")
|
|
}
|
|
if req.IncludeJoinLeave {
|
|
if len(rowLines) == 0 {
|
|
b.WriteString("(no matches)\n")
|
|
}
|
|
for _, line := range rowLines {
|
|
b.WriteString(line)
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
exportDir := runtimeDir()
|
|
if strings.TrimSpace(req.ExportDir) != "" {
|
|
exportDir = normalizeHistoryExportDir(req.ExportDir)
|
|
if !filepath.IsAbs(exportDir) {
|
|
exportDir = filepath.Join(runtimeDir(), exportDir)
|
|
}
|
|
}
|
|
path := filepath.Join(exportDir, "visit_"+req.Day.Format("20060102")+".log")
|
|
if err := os.MkdirAll(exportDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func cloneHistorySelection(selected map[string]bool) map[string]bool {
|
|
if len(selected) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(selected))
|
|
for key, ok := range selected {
|
|
if ok {
|
|
out[key] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneHistoryDetailRows(rows []historyDetailRow) []historyDetailRow {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]historyDetailRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
cloned := row
|
|
if len(row.Users) > 0 {
|
|
cloned.Users = append([]string(nil), row.Users...)
|
|
}
|
|
if len(row.Lines) > 0 {
|
|
cloned.Lines = append([]string(nil), row.Lines...)
|
|
}
|
|
out = append(out, cloned)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func refreshHistoryCache(app *guiApp, force bool) bool {
|
|
if app == nil {
|
|
return false
|
|
}
|
|
ensureHistoryState(app)
|
|
if !force && app.historyCacheLoaded && !app.historyReloadPending {
|
|
return false
|
|
}
|
|
events := historyEventsFromSnapshot()
|
|
visits := historyVisitSource()
|
|
q := historyQueryFromApp(app)
|
|
app.historyRows = historyRows()
|
|
if app.historyRows == nil {
|
|
app.historyRows = []guiWorldVisitRow{}
|
|
}
|
|
monthStart := app.historyMonth
|
|
if monthStart.IsZero() {
|
|
monthStart = time.Now()
|
|
}
|
|
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
|
app.historyCalendarCells = historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q)
|
|
if app.historyCalendarCells == nil {
|
|
app.historyCalendarCells = []historyCalendarCell{}
|
|
}
|
|
app.historyDetailRows = historyVisitsForDayFromData(app, app.historySelectedDate, visits, events, q)
|
|
if app.historyDetailRows == nil {
|
|
app.historyDetailRows = []historyDetailRow{}
|
|
}
|
|
height := int32(132)
|
|
for _, row := range app.historyDetailRows {
|
|
height += historyDetailRowHeight(row) + 6
|
|
}
|
|
if height < 120 {
|
|
height = 120
|
|
}
|
|
app.historyDetailContentHeightPx = height
|
|
app.historyReloadPending = false
|
|
app.historyCacheLoaded = true
|
|
app.historyStatus = "reloaded: " + time.Now().Format("15:04:05")
|
|
return true
|
|
}
|
|
|
|
func startHistoryRefresh(app *guiApp, force bool) bool {
|
|
if app == nil || app.historyRefreshRunning {
|
|
return false
|
|
}
|
|
if !force && app.historyCacheLoaded && !app.historyReloadPending {
|
|
return false
|
|
}
|
|
ensureHistoryState(app)
|
|
if app.historyRefreshResultCh == nil {
|
|
app.historyRefreshResultCh = make(chan historyRefreshResult, 1)
|
|
}
|
|
work := *app
|
|
work.historySelectedKeys = cloneHistorySelection(app.historySelectedKeys)
|
|
work.historyRefreshResultCh = nil
|
|
work.historyExportResultCh = nil
|
|
app.historyRefreshRunning = true
|
|
app.historyReloadPending = true
|
|
if strings.TrimSpace(app.historyStatus) == "" {
|
|
app.historyStatus = "reloading..."
|
|
}
|
|
hwnd := app.hwnd
|
|
go func(ch chan<- historyRefreshResult, work guiApp, hwnd uintptr) {
|
|
start := time.Now()
|
|
result := historyRefreshResult{
|
|
SelectedDate: work.historySelectedDate,
|
|
Month: work.historyMonth,
|
|
SelectedKeys: cloneHistorySelection(work.historySelectedKeys),
|
|
Regex: work.historyRegex,
|
|
FromDate: work.historyFromDate,
|
|
ToDate: work.historyToDate,
|
|
}
|
|
defer func() {
|
|
result.Elapsed = time.Since(start)
|
|
if r := recover(); r != nil {
|
|
result.Err = fmt.Errorf("refresh panic: %v", r)
|
|
}
|
|
select {
|
|
case ch <- result:
|
|
default:
|
|
}
|
|
if postMessageProc != nil && hwnd != 0 {
|
|
postMessageProc.Call(hwnd, uintptr(wmHistoryRefreshDone), 0, 0)
|
|
}
|
|
}()
|
|
refreshHistoryCache(&work, true)
|
|
result.Rows = append([]guiWorldVisitRow(nil), work.historyRows...)
|
|
result.CalendarCells = append([]historyCalendarCell(nil), work.historyCalendarCells...)
|
|
result.DetailRows = cloneHistoryDetailRows(work.historyDetailRows)
|
|
result.DetailHeightPx = work.historyDetailContentHeightPx
|
|
result.SelectedDate = work.historySelectedDate
|
|
result.Month = work.historyMonth
|
|
result.SelectedKeys = cloneHistorySelection(work.historySelectedKeys)
|
|
result.Status = work.historyStatus
|
|
}(app.historyRefreshResultCh, work, hwnd)
|
|
guiLog("history refresh start")
|
|
return true
|
|
}
|
|
|
|
func drainHistoryRefreshResult(app *guiApp) bool {
|
|
if app == nil || app.historyRefreshResultCh == nil || !app.historyRefreshRunning {
|
|
return false
|
|
}
|
|
handled := false
|
|
for {
|
|
select {
|
|
case res := <-app.historyRefreshResultCh:
|
|
handled = true
|
|
app.historyRefreshRunning = false
|
|
if res.Err != nil {
|
|
app.historyStatus = "reload failed: " + res.Err.Error()
|
|
app.historyReloadPending = true
|
|
guiLog(fmt.Sprintf("history refresh failed elapsed=%s err=%v", res.Elapsed, res.Err))
|
|
invalidateHistoryPanes(app)
|
|
continue
|
|
}
|
|
stale := !sameDay(app.historySelectedDate, res.SelectedDate) ||
|
|
!sameHistoryMonth(app.historyMonth, res.Month) ||
|
|
app.historyRegex != res.Regex ||
|
|
app.historyFromDate != res.FromDate ||
|
|
app.historyToDate != res.ToDate
|
|
if stale {
|
|
app.historyReloadPending = true
|
|
guiLog("history refresh stale; scheduling another refresh")
|
|
invalidateHistoryPanes(app)
|
|
continue
|
|
}
|
|
app.historyRows = res.Rows
|
|
app.historyCalendarCells = res.CalendarCells
|
|
app.historyDetailRows = res.DetailRows
|
|
app.historyDetailContentHeightPx = res.DetailHeightPx
|
|
app.historySelectedDate = res.SelectedDate
|
|
app.historyMonth = res.Month
|
|
app.historySelectedKeys = res.SelectedKeys
|
|
if app.historySelectedKeys == nil {
|
|
app.historySelectedKeys = map[string]bool{}
|
|
}
|
|
app.historyReloadPending = false
|
|
app.historyCacheLoaded = true
|
|
if strings.TrimSpace(res.Status) != "" {
|
|
app.historyStatus = res.Status
|
|
} else {
|
|
app.historyStatus = "reloaded: " + time.Now().Format("15:04:05")
|
|
}
|
|
guiLog(fmt.Sprintf("history refresh ok elapsed=%s rows=%d details=%d", res.Elapsed, len(res.Rows), len(res.DetailRows)))
|
|
invalidateHistoryPanes(app)
|
|
default:
|
|
return handled
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameHistoryMonth(a, b time.Time) bool {
|
|
if a.IsZero() || b.IsZero() {
|
|
return a.IsZero() && b.IsZero()
|
|
}
|
|
return a.Year() == b.Year() && a.Month() == b.Month()
|
|
}
|
|
|
|
func requestHistoryRefresh(app *guiApp, reason string) {
|
|
if app == nil {
|
|
return
|
|
}
|
|
app.historyReloadPending = true
|
|
if strings.TrimSpace(reason) != "" {
|
|
app.historyStatus = reason
|
|
}
|
|
}
|
|
|
|
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(102)
|
|
for i, wd := range weekdays {
|
|
x := startX + int32(i)*cellW
|
|
drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText)
|
|
}
|
|
|
|
cells := app.historyCalendarCells
|
|
if len(cells) == 0 {
|
|
drawPaneText(hdc, 24, 118, "履歴カレンダーを読み込み中", app.joinBoldHFont, clrMutedText)
|
|
drawPaneText(hdc, 24, 146, "しばらくしても出ない場合は再読み込みを押してください", app.hFont, clrMutedText)
|
|
}
|
|
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[i].Rect = rect
|
|
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
|
|
}
|
|
start := time.Now()
|
|
defer func() {
|
|
if d := time.Since(start); d > 80*time.Millisecond {
|
|
guiLog(fmt.Sprintf("paintHistoryDetailPane slow=%s rows=%d scroll=%d", d, len(app.historyDetailRows), app.historyScrollPos))
|
|
}
|
|
}()
|
|
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 := app.historyDetailRows
|
|
|
|
title := app.historySelectedDate.Format("2006年01月02日")
|
|
drawPaneText(hdc, 18, 52, title, app.joinBoldHFont, clrAccentGreen)
|
|
summary := fmt.Sprintf("%d件", len(rows))
|
|
drawPaneText(hdc, 18, 78, summary, app.hFont, clrMutedText)
|
|
if strings.TrimSpace(app.historyRegex) != "" {
|
|
drawPaneText(hdc, 96, 78, "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, 78, rangeLabel, app.hFont, clrMutedText)
|
|
}
|
|
|
|
app.historyReloadRect = winRect{Left: rc.Right - 256, Top: 10, Right: rc.Right - 140, Bottom: 38}
|
|
drawSettingsBox(hdc, app.historyReloadRect, 0x0038281a, clrPaneBorder)
|
|
drawCenteredPaneText(hdc, app.historyReloadRect, "Reload", app.hFont, clrText)
|
|
app.historyExportRect = winRect{Left: rc.Right - 132, Top: 10, Right: rc.Right - 16, Bottom: 38}
|
|
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(126 - app.historyScrollPos)
|
|
for i, row := range rows {
|
|
rowHeight := historyDetailRowHeight(row)
|
|
row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4}
|
|
app.historyDetailRows[i].Rect = row.Rect
|
|
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, "入室 "+historyEntryTimeLabel(row.Start, false, row.Current), app.hFont, clrMutedText)
|
|
drawPaneText(hdc, row.Rect.Left+146, y+6, "退室 "+historyEntryTimeLabel(row.End, true, row.Current), app.hFont, clrMutedText)
|
|
drawPaneText(hdc, row.Rect.Right-110, 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
|
|
}
|
|
if app.historyDetailContentHeightPx > 0 {
|
|
return app.historyDetailContentHeightPx
|
|
}
|
|
rows := app.historyDetailRows
|
|
height := int32(132)
|
|
for _, row := range rows {
|
|
height += historyDetailRowHeight(row) + 6
|
|
}
|
|
if height < 120 {
|
|
height = 120
|
|
}
|
|
app.historyDetailContentHeightPx = height
|
|
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 historyTimeRangeLabel(start, end time.Time, current bool) string {
|
|
if start.IsZero() {
|
|
return ""
|
|
}
|
|
if end.IsZero() {
|
|
end = time.Now()
|
|
}
|
|
if end.Before(start) {
|
|
end = start
|
|
}
|
|
startLabel := start.Format("15:04")
|
|
endLabel := end.Format("15:04")
|
|
if !sameDay(start, end) {
|
|
startLabel = start.Format("01/02 15:04")
|
|
if !current && isMidnight(end) {
|
|
endLabel = "23:59"
|
|
} else {
|
|
endLabel = end.Format("01/02 15:04")
|
|
}
|
|
} else if !current && isMidnight(end) {
|
|
endLabel = "23:59"
|
|
}
|
|
if current {
|
|
return startLabel + " - now"
|
|
}
|
|
return startLabel + " - " + endLabel
|
|
}
|
|
|
|
func isMidnight(t time.Time) bool {
|
|
return t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0
|
|
}
|
|
|
|
func historyEntryTimeLabel(t time.Time, isEnd bool, current bool) string {
|
|
if t.IsZero() {
|
|
if current {
|
|
return "now"
|
|
}
|
|
return ""
|
|
}
|
|
if current {
|
|
return "now"
|
|
}
|
|
if isEnd && isMidnight(t) {
|
|
return "23:59"
|
|
}
|
|
return t.Format("15:04")
|
|
}
|
|
|
|
func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, getWindowText, 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.historyReloadRect) {
|
|
requestHistoryRefresh(app, "reloading...")
|
|
invalidateHistoryPanes(app)
|
|
return true
|
|
}
|
|
if pointInRect(x, y, app.historyExportRect) {
|
|
guiLog(fmt.Sprintf("history export click day=%s status=%q", app.historySelectedDate.Format("2006-01-02"), app.historyStatus))
|
|
startHistoryExport(app, getWindowText, setWindowText)
|
|
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
|
|
requestHistoryRefresh(app, "reloading...")
|
|
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)
|
|
}
|
|
}
|
|
}
|