1package systemviews
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "time"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10 browserurl "github.com/bnema/dumber/internal/domain/url"
11)
12
13const historyURLDisplayMaxRunes = 180
14
15type historyRenderData struct {
16 Entries []*entity.HistoryEntry
17 Stats *entity.HistoryStats
18 Analytics *entity.HistoryAnalytics
19 Domains []*entity.DomainStat
20 Query string
21 DomainFilter string
22 Offset int
23 Limit int
24 WindowBefore time.Time
25 WindowAfter time.Time
26 HasMore bool
27 AppendSkipFirstDate string
28 Notice string
29 Error string
30 Loading bool
31}
32
33type historyTimelineGroup struct {
34 Date string
35 Label string
36 Entries []*entity.HistoryEntry
37}
38
39type historyCleanupItem struct {
40 Label string
41 RangeID string
42 Confirm string
43 Notice string
44}
45
46func historyHTML(data historyRenderData) string {
47 return mustRenderComponent(HistoryView(data))
48}
49
50func historyTimelineAppendHTML(data historyRenderData) string {
51 return mustRenderComponent(HistoryTimelineAppend(data))
52}
53
54func historyDocumentTitle(data historyRenderData) string {
55 return "History " + historyTitleDetail(data)
56}
57
58func historyTitleDetail(data historyRenderData) string {
59 if data.Loading {
60 return "- Loading"
61 }
62 entries, visits, days := historySummaryValues(data)
63 return fmt.Sprintf(
64 "- %s, %s, %s",
65 historyCountLabel(entries, "entry", "entries"),
66 historyCountLabel(visits, "visit", "visits"),
67 historyCountLabel(days, "day", "days"),
68 )
69}
70
71func historyCountLabel(count int64, singular, plural string) string {
72 label := plural
73 if count == 1 {
74 label = singular
75 }
76 return fmt.Sprintf("%d %s", count, label)
77}
78
79var historyCleanupRanges = []historyCleanupItem{
80 {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
81 {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
82 {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
83 {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
84 {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
85}
86
87func historyCleanupItems() []historyCleanupItem {
88 return historyCleanupRanges
89}
90
91func historyLimit(data historyRenderData) int {
92 if data.Limit <= 0 {
93 return 0
94 }
95 return data.Limit
96}
97
98func historyIsPaginated(data historyRenderData) bool {
99 return historyLimit(data) > 0
100}
101
102func disableHistoryNext(data historyRenderData) bool {
103 return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
104}
105
106func showHistoryFilters(data historyRenderData) bool {
107 return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
108}
109
110func historyShowingLabel(data historyRenderData) string {
111 if data.Loading {
112 return "Loading history…"
113 }
114 count := countHistoryEntries(data.Entries)
115 if strings.TrimSpace(data.Query) != "" {
116 return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count))
117 }
118 if strings.TrimSpace(data.DomainFilter) != "" {
119 return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count))
120 }
121 return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count))
122}
123
124func pluralSuffix(count int) string {
125 if count == 1 {
126 return ""
127 }
128 return "s"
129}
130
131func historyLoadMoreCursor(data historyRenderData) string {
132 if data.WindowAfter.IsZero() {
133 return ""
134 }
135 return data.WindowAfter.Format(time.RFC3339Nano)
136}
137
138func showHistoryLoadMore(data historyRenderData) bool {
139 return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140}
141
142func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
143 if data.Stats != nil {
144 return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays
145 }
146 if data.Analytics != nil {
147 return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays
148 }
149 entries = int64(countHistoryEntries(data.Entries))
150 visits = sumHistoryVisits(data.Entries)
151 uniqueDays = countUniqueHistoryDays(data.Entries)
152
153 return entries, visits, uniqueDays
154}
155
156func historyTimelineEmptyMessage(data historyRenderData) string {
157 if data.Loading {
158 return "Loading history…"
159 }
160 if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
161 return "No history entries match the current filters"
162 }
163 return "No history entries"
164}
165
166func lastHistoryDateKey(entries []*entity.HistoryEntry) string {
167 for i := len(entries) - 1; i >= 0; i-- {
168 if entries[i] == nil {
169 continue
170 }
171 dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local())
172 return dateKey
173 }
174 return ""
175}
176
177func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
178 groups := make([]historyTimelineGroup, 0)
179 indexByDate := map[string]int{}
180 now := time.Now().Local()
181 for _, entry := range entries {
182 if entry == nil {
183 continue
184 }
185 dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
186 idx, ok := indexByDate[dateKey]
187 if !ok {
188 idx = len(groups)
189 indexByDate[dateKey] = idx
190 groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
191 }
192 groups[idx].Entries = append(groups[idx].Entries, entry)
193 }
194 return groups
195}
196
197func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
198 if ts.IsZero() {
199 return "unknown", "Unknown date"
200 }
201
202 local := ts.Local()
203 dateKey := local.Format("2006-01-02")
204 today := now.Local()
205 todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
206 yesterdayStart := todayStart.AddDate(0, 0, -1)
207 entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
208
209 switch {
210 case entryStart.Equal(todayStart):
211 return dateKey, "Today"
212 case entryStart.Equal(yesterdayStart):
213 return dateKey, "Yesterday"
214 default:
215 return dateKey, local.Format("Mon Jan 2")
216 }
217}
218
219func countHistoryEntries(entries []*entity.HistoryEntry) int {
220 count := 0
221 for _, entry := range entries {
222 if entry != nil {
223 count++
224 }
225 }
226 return count
227}
228
229func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
230 var visits int64
231 for _, entry := range entries {
232 if entry == nil {
233 continue
234 }
235 // Each HistoryEntry represents at least one visit, even when VisitCount
236 // is unset or non-positive in older persisted rows.
237 if entry.VisitCount <= 0 {
238 visits++
239 continue
240 }
241 visits += entry.VisitCount
242 }
243 return visits
244}
245
246func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
247 seen := map[string]struct{}{}
248 for _, entry := range entries {
249 if entry == nil || entry.LastVisited.IsZero() {
250 continue
251 }
252 seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
253 }
254 return int64(len(seen))
255}
256
257func displayHistoryDomain(raw string) string {
258 return browserurl.DisplayDomain(raw)
259}
260
261func historyDomainActionKey(domain *entity.DomainStat) string {
262 if domain == nil {
263 return ""
264 }
265 return browserurl.CanonicalDomain(domain.Domain)
266}
267
268func historyDomainDisplayLabel(domain *entity.DomainStat) string {
269 if domain == nil {
270 return ""
271 }
272 return displayHistoryDomain(domain.Domain)
273}
274
275func formatHistoryTime(ts time.Time) string {
276 if ts.IsZero() {
277 return ""
278 }
279 return ts.Local().Format("15:04")
280}
281
282func historyItemLabel(entry *entity.HistoryEntry) string {
283 if entry == nil {
284 return ""
285 }
286 label := strings.TrimSpace(entry.Title)
287 if label == "" {
288 label = displayHistoryDomain(entry.URL)
289 }
290 if label == "" {
291 label = entry.URL
292 }
293 return label
294}
295
296func historyItemURL(entry *entity.HistoryEntry) string {
297 if entry == nil {
298 return ""
299 }
300 return truncateHistoryURL(entry.URL)
301}
302
303func historyItemFaviconURL(entry *entity.HistoryEntry) string {
304 if entry == nil {
305 return ""
306 }
307 domain := browserurl.CanonicalDomain(entry.URL)
308 if domain == "" {
309 return ""
310 }
311 query := url.Values{}
312 query.Set("domain", domain)
313 query.Set("size", "32")
314 return "/api/favicon?" + query.Encode()
315}
316
317func historyFaviconClass(faviconURL string) string {
318 if strings.TrimSpace(faviconURL) == "" {
319 return "sv-history-favicon"
320 }
321 return "sv-history-favicon sv-history-favicon-has-image"
322}
323
324func historyFaviconFallback(entry *entity.HistoryEntry) string {
325 label := ""
326 if entry != nil {
327 label = displayHistoryDomain(entry.URL)
328 }
329 if label == "" {
330 return "•"
331 }
332 for _, r := range label {
333 return strings.ToUpper(string(r))
334 }
335 return "•"
336}
337
338func truncateHistoryURL(raw string) string {
339 raw = strings.TrimSpace(raw)
340 if raw == "" {
341 return ""
342 }
343 runes := []rune(raw)
344 if len(runes) <= historyURLDisplayMaxRunes {
345 return raw
346 }
347 return string(runes[:historyURLDisplayMaxRunes-1]) + "…"
348}
349
350func historyItemMeta(entry *entity.HistoryEntry) string {
351 if entry == nil {
352 return ""
353 }
354 meta := []string{displayHistoryDomain(entry.URL)}
355 if entry.VisitCount > 1 {
356 meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
357 }
358 if !entry.LastVisited.IsZero() {
359 meta = append(meta, formatHistoryTime(entry.LastVisited))
360 }
361 return strings.Join(nonEmptyStrings(meta), " · ")
362}
363
364func nonEmptyStrings(values []string) []string {
365 out := make([]string, 0, len(values))
366 for _, value := range values {
367 if strings.TrimSpace(value) != "" {
368 out = append(out, value)
369 }
370 }
371 return out
372}