File: timefilter.go

package info (click to toggle)
gdu 5.34.4-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,288 kB
  • sloc: makefile: 145
file content (250 lines) | stat: -rw-r--r-- 7,062 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
package timefilter

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// TimeBound represents a parsed time filter value that can be either an instant or a date-only value
type TimeBound struct {
	instant  *time.Time // absolute instant (UTC)
	dateOnly *time.Time // at local midnight; only YYYY-MM-DD will set this
}

// IsEmpty returns true if the TimeBound has no filter criteria
func (tb TimeBound) IsEmpty() bool {
	return tb.instant == nil && tb.dateOnly == nil
}

// TimeFilter represents multiple time filtering criteria
type TimeFilter struct {
	since []*TimeBound
	until []*TimeBound
}

// NewTimeFilter creates a new TimeFilter with the given parameters
func NewTimeFilter(since, until, maxAge, minAge string, now time.Time, loc *time.Location) (*TimeFilter, error) {
	tf := &TimeFilter{}

	// Parse since
	if since != "" {
		sinceBound, err := parseTimeValue(since, loc)
		if err != nil {
			return nil, fmt.Errorf("invalid --since value: %w", err)
		}
		if !sinceBound.IsEmpty() {
			tf.since = append(tf.since, &sinceBound)
		}
	}

	// Parse until
	if until != "" {
		untilBound, err := parseTimeValue(until, loc)
		if err != nil {
			return nil, fmt.Errorf("invalid --until value: %w", err)
		}
		if !untilBound.IsEmpty() {
			tf.until = append(tf.until, &untilBound)
		}
	}

	// Parse max-age (convert to since)
	if maxAge != "" {
		duration, err := parseDuration(maxAge)
		if err != nil {
			return nil, fmt.Errorf("invalid --max-age value: %w", err)
		}
		sinceTime := now.Add(-duration).UTC()
		tf.since = append(tf.since, &TimeBound{instant: &sinceTime})
	}

	// Parse min-age (convert to until)
	if minAge != "" {
		duration, err := parseDuration(minAge)
		if err != nil {
			return nil, fmt.Errorf("invalid --min-age value: %w", err)
		}
		untilTime := now.Add(-duration).UTC()
		tf.until = append(tf.until, &TimeBound{instant: &untilTime})
	}

	return tf, nil
}

// IncludeByTimeFilter determines if a file should be included based on the complete time filter
func (tf *TimeFilter) IncludeByTimeFilter(mtime time.Time, loc *time.Location) bool {
	// Check since bound
	for _, since := range tf.since {
		if !includeByTimeBound(mtime, *since, loc, false) {
			return false
		}
	}

	// Check until bound
	for _, until := range tf.until {
		if !includeByTimeBound(mtime, *until, loc, true) {
			return false
		}
	}

	return true
}

// IsEmpty returns true if the TimeFilter has no filter criteria
func (tf *TimeFilter) IsEmpty() bool {
	return tf.since == nil && tf.until == nil
}

// FormatForDisplay returns a formatted string showing the active time filters
// This shows what the program actually parsed and is acting on
func (tf *TimeFilter) FormatForDisplay(loc *time.Location) string {
	if tf.IsEmpty() {
		return ""
	}

	var parts []string

	for _, since := range tf.since {
		if since.instant != nil {
			parts = append(parts, "since="+since.instant.In(loc).Format(time.RFC3339))
		} else if since.dateOnly != nil {
			parts = append(parts, "since="+since.dateOnly.Format("2006-01-02")+" (date-only)")
		}
	}

	for _, until := range tf.until {
		if until.instant != nil {
			parts = append(parts, "until=", until.instant.In(loc).Format(time.RFC3339))
		} else if until.dateOnly != nil {
			parts = append(parts, "until=", until.dateOnly.Format("2006-01-02")+" (date-only)")
		}
	}

	if len(parts) == 0 {
		return ""
	}

	return " Filtered by: time=mtime; " + strings.Join(parts, "; ")
}

// includeByTimeBound determines if a file should be included based on its mtime and the time bound
func includeByTimeBound(mtime time.Time, tb TimeBound, loc *time.Location, isUntil bool) bool {
	if tb.instant == nil && tb.dateOnly == nil {
		return true // no filter applied
	}

	if tb.instant != nil {
		if isUntil {
			return !mtime.After(*tb.instant) // inclusive (<=)
		}
		return !mtime.Before(*tb.instant) // inclusive (>=)
	}

	if tb.dateOnly != nil {
		// For date-only comparisons, adjust the bound to cover the whole day.
		boundDate := tb.dateOnly.In(loc)

		if isUntil {
			// For `until`, we want to include the entire day.
			// So the upper bound is the beginning of the *next* day.
			upperBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
			return mtime.Before(upperBound)
		}

		// For `since`, we want to include the entire day.
		// So the lower bound is the beginning of that day.
		lowerBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc)
		return !mtime.Before(lowerBound) // inclusive (>=)
	}

	return true
}

// parseDuration parses a duration string with support for extended units
// Supports: s, m, h, d (=24h), w (=7d), mo (=30d), y (=365d)
// Examples: "90m", "2h30m", "7d", "6w", "1y2mo"
func parseDuration(input string) (time.Duration, error) {
	if input == "" {
		return 0, fmt.Errorf("empty duration")
	}

	// Remove whitespace and convert to lowercase
	input = strings.ToLower(strings.ReplaceAll(input, " ", ""))

	// Regex to match number+unit pairs (mo must come before m to avoid greedy matching)
	re := regexp.MustCompile(`(\d+)(mo|s|m|h|d|w|y)`)
	matches := re.FindAllStringSubmatch(input, -1)

	if len(matches) == 0 {
		return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input)
	}

	// Check if the entire input was consumed by matches
	consumed := ""
	for _, match := range matches {
		consumed += match[0]
	}
	if consumed != input {
		return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input)
	}

	var total time.Duration
	for _, match := range matches {
		value, err := strconv.Atoi(match[1])
		if err != nil {
			return 0, fmt.Errorf("invalid number in duration: %s", match[1])
		}

		unit := match[2]
		var duration time.Duration

		switch unit {
		case "s":
			duration = time.Duration(value) * time.Second
		case "m":
			duration = time.Duration(value) * time.Minute
		case "h":
			duration = time.Duration(value) * time.Hour
		case "d":
			duration = time.Duration(value) * 24 * time.Hour
		case "w":
			duration = time.Duration(value) * 7 * 24 * time.Hour
		case "mo":
			duration = time.Duration(value) * 30 * 24 * time.Hour
		case "y":
			duration = time.Duration(value) * 365 * 24 * time.Hour
		default:
			return 0, fmt.Errorf("unsupported duration unit: %s", unit)
		}

		total += duration
	}

	return total, nil
}

// parseTimeValue parses a time value into either a timestamp instant or a date-only value
func parseTimeValue(arg string, loc *time.Location) (TimeBound, error) {
	if arg == "" {
		return TimeBound{}, nil
	}

	// 1) Try RFC3339 instant
	if t, err := time.Parse(time.RFC3339, arg); err == nil {
		u := t.UTC()
		return TimeBound{instant: &u}, nil
	}

	// 2) Try strict YYYY-MM-DD
	if len(arg) == 10 {
		if d, err := time.ParseInLocation("2006-01-02", arg, loc); err == nil {
			// dateOnly uses local date; we will compare date parts only
			return TimeBound{dateOnly: &d}, nil
		}
	}

	return TimeBound{}, fmt.Errorf("invalid time value %q. Use RFC3339 timestamp or YYYY-MM-DD", arg)
}