feat(systemviews): enrich history overview
- commit
- 4f3ae02043482b4483aa16cac716051efeb52a97
- author
- brice · · 2026-04-24 01:56 UTC
- parent
- c23bc35
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +315 -25
M assets/systemviews/systemviews.css +84 -0
| @@ -121,6 +121,90 @@ body { | |||
| 121 | 121 | color: var(--sv-muted, #9aa3b2); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | .sv-stat-grid { | |
| 125 | + | display: grid; | |
| 126 | + | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | |
| 127 | + | gap: 0.75rem; | |
| 128 | + | margin-top: 0.75rem; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | .sv-stat-card { | |
| 132 | + | display: grid; | |
| 133 | + | gap: 0.15rem; | |
| 134 | + | padding: 0.75rem; | |
| 135 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 136 | + | border-radius: 0.65rem; | |
| 137 | + | background: var(--sv-surface-variant, #1d2129); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | .sv-stat-value { | |
| 141 | + | color: var(--sv-accent, #8ab4ff); | |
| 142 | + | font-size: 1.35rem; | |
| 143 | + | font-weight: 700; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | .sv-stat-label { | |
| 147 | + | color: var(--sv-muted, #9aa3b2); | |
| 148 | + | font-size: 12px; | |
| 149 | + | text-transform: uppercase; | |
| 150 | + | letter-spacing: 0.08em; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | .sv-domain-list { | |
| 154 | + | display: flex; | |
| 155 | + | flex-wrap: wrap; | |
| 156 | + | gap: 0.5rem; | |
| 157 | + | margin-top: 0.75rem; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | .sv-domain-chip { | |
| 161 | + | display: inline-grid; | |
| 162 | + | gap: 0.15rem; | |
| 163 | + | padding: 0.45rem 0.65rem; | |
| 164 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 165 | + | border-radius: 999px; | |
| 166 | + | background: var(--sv-surface-variant, #1d2129); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | .sv-domain-meta, | |
| 170 | + | .sv-history-meta, | |
| 171 | + | .sv-history-url { | |
| 172 | + | color: var(--sv-muted, #9aa3b2); | |
| 173 | + | font-size: 12px; | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | .sv-timeline-group + .sv-timeline-group { | |
| 177 | + | margin-top: 1rem; | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | .sv-timeline-group h3 { | |
| 181 | + | margin: 0 0 0.5rem; | |
| 182 | + | color: var(--sv-muted, #9aa3b2); | |
| 183 | + | font-size: 12px; | |
| 184 | + | letter-spacing: 0.08em; | |
| 185 | + | text-transform: uppercase; | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | .sv-history-item { | |
| 189 | + | display: grid; | |
| 190 | + | grid-template-columns: minmax(0, 1fr) auto; | |
| 191 | + | gap: 1rem; | |
| 192 | + | align-items: start; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | .sv-history-main { | |
| 196 | + | min-width: 0; | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | .sv-history-url, | |
| 200 | + | .sv-history-meta { | |
| 201 | + | margin: 0.25rem 0 0; | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | .sv-history-url { | |
| 205 | + | overflow-wrap: anywhere; | |
| 206 | + | } | |
| 207 | + | ||
| 124 | 208 | .sv-error { | |
| 125 | 209 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 126 | 210 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| @@ -121,6 +121,90 @@ body { | |||||
| 121 | color: var(--sv-muted, #9aa3b2); | 121 | color: var(--sv-muted, #9aa3b2); | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | .sv-stat-grid { | |||
| 125 | + | display: grid; | |||
| 126 | + | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | |||
| 127 | + | gap: 0.75rem; | |||
| 128 | + | margin-top: 0.75rem; | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | .sv-stat-card { | |||
| 132 | + | display: grid; | |||
| 133 | + | gap: 0.15rem; | |||
| 134 | + | padding: 0.75rem; | |||
| 135 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 136 | + | border-radius: 0.65rem; | |||
| 137 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | .sv-stat-value { | |||
| 141 | + | color: var(--sv-accent, #8ab4ff); | |||
| 142 | + | font-size: 1.35rem; | |||
| 143 | + | font-weight: 700; | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | .sv-stat-label { | |||
| 147 | + | color: var(--sv-muted, #9aa3b2); | |||
| 148 | + | font-size: 12px; | |||
| 149 | + | text-transform: uppercase; | |||
| 150 | + | letter-spacing: 0.08em; | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | .sv-domain-list { | |||
| 154 | + | display: flex; | |||
| 155 | + | flex-wrap: wrap; | |||
| 156 | + | gap: 0.5rem; | |||
| 157 | + | margin-top: 0.75rem; | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | .sv-domain-chip { | |||
| 161 | + | display: inline-grid; | |||
| 162 | + | gap: 0.15rem; | |||
| 163 | + | padding: 0.45rem 0.65rem; | |||
| 164 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 165 | + | border-radius: 999px; | |||
| 166 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | .sv-domain-meta, | |||
| 170 | + | .sv-history-meta, | |||
| 171 | + | .sv-history-url { | |||
| 172 | + | color: var(--sv-muted, #9aa3b2); | |||
| 173 | + | font-size: 12px; | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | .sv-timeline-group + .sv-timeline-group { | |||
| 177 | + | margin-top: 1rem; | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | .sv-timeline-group h3 { | |||
| 181 | + | margin: 0 0 0.5rem; | |||
| 182 | + | color: var(--sv-muted, #9aa3b2); | |||
| 183 | + | font-size: 12px; | |||
| 184 | + | letter-spacing: 0.08em; | |||
| 185 | + | text-transform: uppercase; | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | .sv-history-item { | |||
| 189 | + | display: grid; | |||
| 190 | + | grid-template-columns: minmax(0, 1fr) auto; | |||
| 191 | + | gap: 1rem; | |||
| 192 | + | align-items: start; | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | .sv-history-main { | |||
| 196 | + | min-width: 0; | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | .sv-history-url, | |||
| 200 | + | .sv-history-meta { | |||
| 201 | + | margin: 0.25rem 0 0; | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | .sv-history-url { | |||
| 205 | + | overflow-wrap: anywhere; | |||
| 206 | + | } | |||
| 207 | + | ||||
| 124 | .sv-error { | 208 | .sv-error { | ||
| 125 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | 209 | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | ||
| 126 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | 210 | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | ||
M internal/infrastructure/cef/popup_opener_bridge_test.go +1 -1
| @@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) { | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | |
| 40 | - | require.Equal(t, actualInternalOrigin, originFromURL("dumb://home")) | |
| 40 | + | require.Equal(t, actualInternalOrigin, originFromURL("dumb://history")) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | |
| @@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | 39 | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | ||
| 40 | - | require.Equal(t, actualInternalOrigin, originFromURL("dumb://home")) | 40 | + | require.Equal(t, actualInternalOrigin, originFromURL("dumb://history")) |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | 43 | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | ||
M internal/ui/systemviews/app.go +29 -11
| @@ -17,16 +17,18 @@ type Dependencies struct { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | type App struct { | |
| 20 | - | deps Dependencies | |
| 21 | - | currentRoute Route | |
| 22 | - | shellTheme shellTheme | |
| 23 | - | historyEntries []*entity.HistoryEntry | |
| 24 | - | favorites []*entity.Favorite | |
| 25 | - | folders []*entity.Folder | |
| 26 | - | tags []*entity.Tag | |
| 27 | - | config *port.SystemviewConfigPayload | |
| 28 | - | keybindings any | |
| 29 | - | renderedHTML string | |
| 20 | + | deps Dependencies | |
| 21 | + | currentRoute Route | |
| 22 | + | shellTheme shellTheme | |
| 23 | + | historyEntries []*entity.HistoryEntry | |
| 24 | + | historyAnalytics *entity.HistoryAnalytics | |
| 25 | + | historyDomainStats []*entity.DomainStat | |
| 26 | + | favorites []*entity.Favorite | |
| 27 | + | folders []*entity.Folder | |
| 28 | + | tags []*entity.Tag | |
| 29 | + | config *port.SystemviewConfigPayload | |
| 30 | + | keybindings any | |
| 31 | + | renderedHTML string | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | const historyTimelineLimit = 25 | |
| @@ -124,14 +126,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 124 | 126 | if err != nil { | |
| 125 | 127 | return err | |
| 126 | 128 | } | |
| 129 | + | analytics, analyticsErr := a.deps.History.Analytics(ctx) | |
| 130 | + | if analyticsErr != nil { | |
| 131 | + | analytics = nil | |
| 132 | + | } | |
| 133 | + | domains, domainsErr := a.deps.History.DomainStats(ctx, 10) | |
| 134 | + | if domainsErr != nil { | |
| 135 | + | domains = nil | |
| 136 | + | } | |
| 127 | 137 | a.favorites = nil | |
| 128 | 138 | a.folders = nil | |
| 129 | 139 | a.tags = nil | |
| 130 | 140 | a.historyEntries = entries | |
| 141 | + | a.historyAnalytics = analytics | |
| 142 | + | a.historyDomainStats = domains | |
| 131 | 143 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 132 | 144 | route: RouteHistory, | |
| 133 | 145 | subtitle: "Recent visits", | |
| 134 | - | body: historyHTML(entries), | |
| 146 | + | body: historyHTML(historyRenderData{ | |
| 147 | + | Entries: entries, | |
| 148 | + | Analytics: analytics, | |
| 149 | + | Domains: domains, | |
| 150 | + | }), | |
| 135 | 151 | }, a.shellTheme) | |
| 136 | 152 | return nil | |
| 137 | 153 | } | |
| @@ -224,6 +240,8 @@ func (a *App) loadShellTheme(ctx context.Context) { | |||
| 224 | 240 | ||
| 225 | 241 | func (a *App) resetRouteState() { | |
| 226 | 242 | a.historyEntries = nil | |
| 243 | + | a.historyAnalytics = nil | |
| 244 | + | a.historyDomainStats = nil | |
| 227 | 245 | a.favorites = nil | |
| 228 | 246 | a.folders = nil | |
| 229 | 247 | a.tags = nil | |
| @@ -17,16 +17,18 @@ type Dependencies struct { | |||||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | type App struct { | 19 | type App struct { | ||
| 20 | - | deps Dependencies | 20 | + | deps Dependencies |
| 21 | - | currentRoute Route | 21 | + | currentRoute Route |
| 22 | - | shellTheme shellTheme | 22 | + | shellTheme shellTheme |
| 23 | - | historyEntries []*entity.HistoryEntry | 23 | + | historyEntries []*entity.HistoryEntry |
| 24 | - | favorites []*entity.Favorite | 24 | + | historyAnalytics *entity.HistoryAnalytics |
| 25 | - | folders []*entity.Folder | 25 | + | historyDomainStats []*entity.DomainStat |
| 26 | - | tags []*entity.Tag | 26 | + | favorites []*entity.Favorite |
| 27 | - | config *port.SystemviewConfigPayload | 27 | + | folders []*entity.Folder |
| 28 | - | keybindings any | 28 | + | tags []*entity.Tag |
| 29 | - | renderedHTML string | 29 | + | config *port.SystemviewConfigPayload |
| 30 | + | keybindings any | |||
| 31 | + | renderedHTML string | |||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | const historyTimelineLimit = 25 | 34 | const historyTimelineLimit = 25 | ||
| @@ -124,14 +126,28 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 124 | if err != nil { | 126 | if err != nil { | ||
| 125 | return err | 127 | return err | ||
| 126 | } | 128 | } | ||
| 129 | + | analytics, analyticsErr := a.deps.History.Analytics(ctx) | |||
| 130 | + | if analyticsErr != nil { | |||
| 131 | + | analytics = nil | |||
| 132 | + | } | |||
| 133 | + | domains, domainsErr := a.deps.History.DomainStats(ctx, 10) | |||
| 134 | + | if domainsErr != nil { | |||
| 135 | + | domains = nil | |||
| 136 | + | } | |||
| 127 | a.favorites = nil | 137 | a.favorites = nil | ||
| 128 | a.folders = nil | 138 | a.folders = nil | ||
| 129 | a.tags = nil | 139 | a.tags = nil | ||
| 130 | a.historyEntries = entries | 140 | a.historyEntries = entries | ||
| 141 | + | a.historyAnalytics = analytics | |||
| 142 | + | a.historyDomainStats = domains | |||
| 131 | a.renderedHTML = renderAppFrame(renderedPage{ | 143 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 132 | route: RouteHistory, | 144 | route: RouteHistory, | ||
| 133 | subtitle: "Recent visits", | 145 | subtitle: "Recent visits", | ||
| 134 | - | body: historyHTML(entries), | 146 | + | body: historyHTML(historyRenderData{ |
| 147 | + | Entries: entries, | |||
| 148 | + | Analytics: analytics, | |||
| 149 | + | Domains: domains, | |||
| 150 | + | }), | |||
| 135 | }, a.shellTheme) | 151 | }, a.shellTheme) | ||
| 136 | return nil | 152 | return nil | ||
| 137 | } | 153 | } | ||
| @@ -224,6 +240,8 @@ func (a *App) loadShellTheme(ctx context.Context) { | |||||
| 224 | 240 | ||||
| 225 | func (a *App) resetRouteState() { | 241 | func (a *App) resetRouteState() { | ||
| 226 | a.historyEntries = nil | 242 | a.historyEntries = nil | ||
| 243 | + | a.historyAnalytics = nil | |||
| 244 | + | a.historyDomainStats = nil | |||
| 227 | a.favorites = nil | 245 | a.favorites = nil | ||
| 228 | a.folders = nil | 246 | a.folders = nil | ||
| 229 | a.tags = nil | 247 | a.tags = nil | ||
M internal/ui/systemviews/app_test.go +3 -3
| @@ -81,7 +81,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 81 | 81 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 82 | 82 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 83 | 83 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | |
| 84 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 84 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 85 | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| @@ -182,7 +182,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||
| 182 | 182 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 183 | 183 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 184 | 184 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | |
| 185 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 185 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 186 | 186 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| @@ -308,7 +308,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 308 | 308 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 309 | 309 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 310 | 310 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | |
| 311 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 311 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 312 | 312 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 313 | 313 | } | |
| 314 | 314 | ||
| @@ -81,7 +81,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 81 | assert.NotContains(t, app.renderedHTML, "<body") | 81 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 82 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 82 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 83 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | 83 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | ||
| 84 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | 84 | + | assert.Contains(t, app.renderedHTML, `sv-section`) |
| 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| @@ -182,7 +182,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 182 | assert.NotContains(t, app.renderedHTML, "<body") | 182 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 183 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 183 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 184 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | 184 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | ||
| 185 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | 185 | + | assert.Contains(t, app.renderedHTML, `sv-section`) |
| 186 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | 186 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| @@ -308,7 +308,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||||
| 308 | assert.NotContains(t, app.renderedHTML, "<body") | 308 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 309 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 309 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 310 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | 310 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | ||
| 311 | - | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | 311 | + | assert.Contains(t, app.renderedHTML, `sv-section`) |
| 312 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | 312 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | ||
| 313 | } | 313 | } | ||
| 314 | 314 | ||||
M internal/ui/systemviews/history_view.go +196 -8
| @@ -2,14 +2,84 @@ package systemviews | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "html" | |
| 6 | + | "net/url" | |
| 5 | 7 | "strings" | |
| 8 | + | "time" | |
| 6 | 9 | ||
| 7 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | - | func historyHTML(entries []*entity.HistoryEntry) string { | |
| 11 | - | items := historyItemsHTML(entries) | |
| 12 | - | return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries")) | |
| 13 | + | type historyRenderData struct { | |
| 14 | + | Entries []*entity.HistoryEntry | |
| 15 | + | Analytics *entity.HistoryAnalytics | |
| 16 | + | Domains []*entity.DomainStat | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func historyHTML(data historyRenderData) string { | |
| 20 | + | sections := []string{ | |
| 21 | + | sectionHTML("sv-history-summary", "History", historySummaryHTML(data)), | |
| 22 | + | sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)), | |
| 23 | + | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)), | |
| 24 | + | } | |
| 25 | + | return strings.Join(sections, "") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func historySummaryHTML(data historyRenderData) string { | |
| 29 | + | entries := int64(countHistoryEntries(data.Entries)) | |
| 30 | + | visits := sumHistoryVisits(data.Entries) | |
| 31 | + | uniqueDays := countUniqueHistoryDays(data.Entries) | |
| 32 | + | ||
| 33 | + | if data.Analytics != nil { | |
| 34 | + | entries = data.Analytics.TotalEntries | |
| 35 | + | visits = data.Analytics.TotalVisits | |
| 36 | + | uniqueDays = data.Analytics.UniqueDays | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`, | |
| 40 | + | historyStatCardHTML("Entries", entries), | |
| 41 | + | historyStatCardHTML("Visits", visits), | |
| 42 | + | historyStatCardHTML("Days", uniqueDays), | |
| 43 | + | ) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func historyStatCardHTML(label string, value int64) string { | |
| 47 | + | return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func historyDomainsHTML(domains []*entity.DomainStat) string { | |
| 51 | + | var chips strings.Builder | |
| 52 | + | for _, domain := range domains { | |
| 53 | + | if domain == nil || strings.TrimSpace(domain.Domain) == "" { | |
| 54 | + | continue | |
| 55 | + | } | |
| 56 | + | chips.WriteString(fmt.Sprintf( | |
| 57 | + | `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`, | |
| 58 | + | html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")), | |
| 59 | + | domain.PageCount, | |
| 60 | + | domain.TotalVisits, | |
| 61 | + | )) | |
| 62 | + | } | |
| 63 | + | if chips.Len() == 0 { | |
| 64 | + | return emptyStateHTML("No domain statistics yet") | |
| 65 | + | } | |
| 66 | + | return `<div class="sv-domain-list">` + chips.String() + `</div>` | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func historyTimelineHTML(entries []*entity.HistoryEntry) string { | |
| 70 | + | groups := groupHistoryEntries(entries) | |
| 71 | + | if len(groups) == 0 { | |
| 72 | + | return emptyStateHTML("No history entries") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | var body strings.Builder | |
| 76 | + | for _, group := range groups { | |
| 77 | + | body.WriteString(`<section class="sv-timeline-group">`) | |
| 78 | + | body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`) | |
| 79 | + | body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries")) | |
| 80 | + | body.WriteString(`</section>`) | |
| 81 | + | } | |
| 82 | + | return body.String() | |
| 13 | 83 | } | |
| 14 | 84 | ||
| 15 | 85 | func historyItemsHTML(entries []*entity.HistoryEntry) string { | |
| @@ -18,16 +88,83 @@ func historyItemsHTML(entries []*entity.HistoryEntry) string { | |||
| 18 | 88 | if entry == nil { | |
| 19 | 89 | continue | |
| 20 | 90 | } | |
| 21 | - | label := entry.Title | |
| 22 | - | if label == "" { | |
| 23 | - | label = entry.URL | |
| 24 | - | } | |
| 25 | - | items.WriteString(listRowHTML(linkHTML(entry.URL, label))) | |
| 91 | + | items.WriteString(listRowHTML(historyItemHTML(entry))) | |
| 26 | 92 | } | |
| 27 | 93 | ||
| 28 | 94 | return items.String() | |
| 29 | 95 | } | |
| 30 | 96 | ||
| 97 | + | func historyItemHTML(entry *entity.HistoryEntry) string { | |
| 98 | + | label := strings.TrimSpace(entry.Title) | |
| 99 | + | if label == "" { | |
| 100 | + | label = displayHistoryDomain(entry.URL) | |
| 101 | + | } | |
| 102 | + | if label == "" { | |
| 103 | + | label = entry.URL | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | meta := []string{displayHistoryDomain(entry.URL)} | |
| 107 | + | if entry.VisitCount > 1 { | |
| 108 | + | meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount)) | |
| 109 | + | } | |
| 110 | + | if !entry.LastVisited.IsZero() { | |
| 111 | + | meta = append(meta, formatHistoryTime(entry.LastVisited)) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`, | |
| 115 | + | linkHTML(entry.URL, label), | |
| 116 | + | html.EscapeString(entry.URL), | |
| 117 | + | html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")), | |
| 118 | + | ) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | type historyTimelineGroup struct { | |
| 122 | + | Date string | |
| 123 | + | Label string | |
| 124 | + | Entries []*entity.HistoryEntry | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | |
| 128 | + | groups := make([]historyTimelineGroup, 0) | |
| 129 | + | indexByDate := map[string]int{} | |
| 130 | + | for _, entry := range entries { | |
| 131 | + | if entry == nil { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | dateKey, label := historyDateKeyAndLabel(entry.LastVisited) | |
| 135 | + | idx, ok := indexByDate[dateKey] | |
| 136 | + | if !ok { | |
| 137 | + | idx = len(groups) | |
| 138 | + | indexByDate[dateKey] = idx | |
| 139 | + | groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label}) | |
| 140 | + | } | |
| 141 | + | groups[idx].Entries = append(groups[idx].Entries, entry) | |
| 142 | + | } | |
| 143 | + | return groups | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func historyDateKeyAndLabel(ts time.Time) (string, string) { | |
| 147 | + | if ts.IsZero() { | |
| 148 | + | return "unknown", "Unknown date" | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | local := ts.Local() | |
| 152 | + | dateKey := local.Format("2006-01-02") | |
| 153 | + | today := time.Now().Local() | |
| 154 | + | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | |
| 155 | + | yesterdayStart := todayStart.AddDate(0, 0, -1) | |
| 156 | + | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | |
| 157 | + | ||
| 158 | + | switch { | |
| 159 | + | case entryStart.Equal(todayStart): | |
| 160 | + | return dateKey, "Today" | |
| 161 | + | case entryStart.Equal(yesterdayStart): | |
| 162 | + | return dateKey, "Yesterday" | |
| 163 | + | default: | |
| 164 | + | return dateKey, local.Format("Mon Jan 2") | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 31 | 168 | func countHistoryEntries(entries []*entity.HistoryEntry) int { | |
| 32 | 169 | count := 0 | |
| 33 | 170 | for _, entry := range entries { | |
| @@ -37,3 +174,54 @@ func countHistoryEntries(entries []*entity.HistoryEntry) int { | |||
| 37 | 174 | } | |
| 38 | 175 | return count | |
| 39 | 176 | } | |
| 177 | + | ||
| 178 | + | func sumHistoryVisits(entries []*entity.HistoryEntry) int64 { | |
| 179 | + | var visits int64 | |
| 180 | + | for _, entry := range entries { | |
| 181 | + | if entry == nil { | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | if entry.VisitCount <= 0 { | |
| 185 | + | visits++ | |
| 186 | + | continue | |
| 187 | + | } | |
| 188 | + | visits += entry.VisitCount | |
| 189 | + | } | |
| 190 | + | return visits | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |
| 194 | + | seen := map[string]struct{}{} | |
| 195 | + | for _, entry := range entries { | |
| 196 | + | if entry == nil || entry.LastVisited.IsZero() { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{} | |
| 200 | + | } | |
| 201 | + | return int64(len(seen)) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func displayHistoryDomain(raw string) string { | |
| 205 | + | parsed, err := url.Parse(raw) | |
| 206 | + | if err != nil || parsed.Hostname() == "" { | |
| 207 | + | return strings.TrimSpace(raw) | |
| 208 | + | } | |
| 209 | + | return strings.TrimPrefix(parsed.Hostname(), "www.") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func formatHistoryTime(ts time.Time) string { | |
| 213 | + | if ts.IsZero() { | |
| 214 | + | return "" | |
| 215 | + | } | |
| 216 | + | return ts.Local().Format("15:04") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func nonEmptyStrings(values []string) []string { | |
| 220 | + | out := values[:0] | |
| 221 | + | for _, value := range values { | |
| 222 | + | if strings.TrimSpace(value) != "" { | |
| 223 | + | out = append(out, value) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | return out | |
| 227 | + | } | |
| @@ -2,14 +2,84 @@ package systemviews | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "html" | |||
| 6 | + | "net/url" | |||
| 5 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 6 | 9 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | - | func historyHTML(entries []*entity.HistoryEntry) string { | 13 | + | type historyRenderData struct { |
| 11 | - | items := historyItemsHTML(entries) | 14 | + | Entries []*entity.HistoryEntry |
| 12 | - | return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries")) | 15 | + | Analytics *entity.HistoryAnalytics |
| 16 | + | Domains []*entity.DomainStat | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func historyHTML(data historyRenderData) string { | |||
| 20 | + | sections := []string{ | |||
| 21 | + | sectionHTML("sv-history-summary", "History", historySummaryHTML(data)), | |||
| 22 | + | sectionHTML("sv-history-domains", "Top domains", historyDomainsHTML(data.Domains)), | |||
| 23 | + | sectionHTML("sv-history-timeline", "Recent visits", historyTimelineHTML(data.Entries)), | |||
| 24 | + | } | |||
| 25 | + | return strings.Join(sections, "") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func historySummaryHTML(data historyRenderData) string { | |||
| 29 | + | entries := int64(countHistoryEntries(data.Entries)) | |||
| 30 | + | visits := sumHistoryVisits(data.Entries) | |||
| 31 | + | uniqueDays := countUniqueHistoryDays(data.Entries) | |||
| 32 | + | ||||
| 33 | + | if data.Analytics != nil { | |||
| 34 | + | entries = data.Analytics.TotalEntries | |||
| 35 | + | visits = data.Analytics.TotalVisits | |||
| 36 | + | uniqueDays = data.Analytics.UniqueDays | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return fmt.Sprintf(`<div class="sv-stat-grid">%s%s%s</div>`, | |||
| 40 | + | historyStatCardHTML("Entries", entries), | |||
| 41 | + | historyStatCardHTML("Visits", visits), | |||
| 42 | + | historyStatCardHTML("Days", uniqueDays), | |||
| 43 | + | ) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func historyStatCardHTML(label string, value int64) string { | |||
| 47 | + | return fmt.Sprintf(`<div class="sv-stat-card"><span class="sv-stat-value">%d</span><span class="sv-stat-label">%s</span></div>`, value, html.EscapeString(label)) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func historyDomainsHTML(domains []*entity.DomainStat) string { | |||
| 51 | + | var chips strings.Builder | |||
| 52 | + | for _, domain := range domains { | |||
| 53 | + | if domain == nil || strings.TrimSpace(domain.Domain) == "" { | |||
| 54 | + | continue | |||
| 55 | + | } | |||
| 56 | + | chips.WriteString(fmt.Sprintf( | |||
| 57 | + | `<span class="sv-domain-chip"><span>%s</span><span class="sv-domain-meta">%d pages · %d visits</span></span>`, | |||
| 58 | + | html.EscapeString(strings.TrimPrefix(domain.Domain, "www.")), | |||
| 59 | + | domain.PageCount, | |||
| 60 | + | domain.TotalVisits, | |||
| 61 | + | )) | |||
| 62 | + | } | |||
| 63 | + | if chips.Len() == 0 { | |||
| 64 | + | return emptyStateHTML("No domain statistics yet") | |||
| 65 | + | } | |||
| 66 | + | return `<div class="sv-domain-list">` + chips.String() + `</div>` | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func historyTimelineHTML(entries []*entity.HistoryEntry) string { | |||
| 70 | + | groups := groupHistoryEntries(entries) | |||
| 71 | + | if len(groups) == 0 { | |||
| 72 | + | return emptyStateHTML("No history entries") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | var body strings.Builder | |||
| 76 | + | for _, group := range groups { | |||
| 77 | + | body.WriteString(`<section class="sv-timeline-group">`) | |||
| 78 | + | body.WriteString(`<h3>` + html.EscapeString(group.Label) + `</h3>`) | |||
| 79 | + | body.WriteString(listHTML(historyItemsHTML(group.Entries), "No history entries")) | |||
| 80 | + | body.WriteString(`</section>`) | |||
| 81 | + | } | |||
| 82 | + | return body.String() | |||
| 13 | } | 83 | } | ||
| 14 | 84 | ||||
| 15 | func historyItemsHTML(entries []*entity.HistoryEntry) string { | 85 | func historyItemsHTML(entries []*entity.HistoryEntry) string { | ||
| @@ -18,16 +88,83 @@ func historyItemsHTML(entries []*entity.HistoryEntry) string { | |||||
| 18 | if entry == nil { | 88 | if entry == nil { | ||
| 19 | continue | 89 | continue | ||
| 20 | } | 90 | } | ||
| 21 | - | label := entry.Title | 91 | + | items.WriteString(listRowHTML(historyItemHTML(entry))) |
| 22 | - | if label == "" { | |||
| 23 | - | label = entry.URL | |||
| 24 | - | } | |||
| 25 | - | items.WriteString(listRowHTML(linkHTML(entry.URL, label))) | |||
| 26 | } | 92 | } | ||
| 27 | 93 | ||||
| 28 | return items.String() | 94 | return items.String() | ||
| 29 | } | 95 | } | ||
| 30 | 96 | ||||
| 97 | + | func historyItemHTML(entry *entity.HistoryEntry) string { | |||
| 98 | + | label := strings.TrimSpace(entry.Title) | |||
| 99 | + | if label == "" { | |||
| 100 | + | label = displayHistoryDomain(entry.URL) | |||
| 101 | + | } | |||
| 102 | + | if label == "" { | |||
| 103 | + | label = entry.URL | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | meta := []string{displayHistoryDomain(entry.URL)} | |||
| 107 | + | if entry.VisitCount > 1 { | |||
| 108 | + | meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount)) | |||
| 109 | + | } | |||
| 110 | + | if !entry.LastVisited.IsZero() { | |||
| 111 | + | meta = append(meta, formatHistoryTime(entry.LastVisited)) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | return fmt.Sprintf(`<article class="sv-history-item"><div class="sv-history-main">%s<p class="sv-history-url">%s</p></div><p class="sv-history-meta">%s</p></article>`, | |||
| 115 | + | linkHTML(entry.URL, label), | |||
| 116 | + | html.EscapeString(entry.URL), | |||
| 117 | + | html.EscapeString(strings.Join(nonEmptyStrings(meta), " · ")), | |||
| 118 | + | ) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | type historyTimelineGroup struct { | |||
| 122 | + | Date string | |||
| 123 | + | Label string | |||
| 124 | + | Entries []*entity.HistoryEntry | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | |||
| 128 | + | groups := make([]historyTimelineGroup, 0) | |||
| 129 | + | indexByDate := map[string]int{} | |||
| 130 | + | for _, entry := range entries { | |||
| 131 | + | if entry == nil { | |||
| 132 | + | continue | |||
| 133 | + | } | |||
| 134 | + | dateKey, label := historyDateKeyAndLabel(entry.LastVisited) | |||
| 135 | + | idx, ok := indexByDate[dateKey] | |||
| 136 | + | if !ok { | |||
| 137 | + | idx = len(groups) | |||
| 138 | + | indexByDate[dateKey] = idx | |||
| 139 | + | groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label}) | |||
| 140 | + | } | |||
| 141 | + | groups[idx].Entries = append(groups[idx].Entries, entry) | |||
| 142 | + | } | |||
| 143 | + | return groups | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func historyDateKeyAndLabel(ts time.Time) (string, string) { | |||
| 147 | + | if ts.IsZero() { | |||
| 148 | + | return "unknown", "Unknown date" | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | local := ts.Local() | |||
| 152 | + | dateKey := local.Format("2006-01-02") | |||
| 153 | + | today := time.Now().Local() | |||
| 154 | + | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | |||
| 155 | + | yesterdayStart := todayStart.AddDate(0, 0, -1) | |||
| 156 | + | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | |||
| 157 | + | ||||
| 158 | + | switch { | |||
| 159 | + | case entryStart.Equal(todayStart): | |||
| 160 | + | return dateKey, "Today" | |||
| 161 | + | case entryStart.Equal(yesterdayStart): | |||
| 162 | + | return dateKey, "Yesterday" | |||
| 163 | + | default: | |||
| 164 | + | return dateKey, local.Format("Mon Jan 2") | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 31 | func countHistoryEntries(entries []*entity.HistoryEntry) int { | 168 | func countHistoryEntries(entries []*entity.HistoryEntry) int { | ||
| 32 | count := 0 | 169 | count := 0 | ||
| 33 | for _, entry := range entries { | 170 | for _, entry := range entries { | ||
| @@ -37,3 +174,54 @@ func countHistoryEntries(entries []*entity.HistoryEntry) int { | |||||
| 37 | } | 174 | } | ||
| 38 | return count | 175 | return count | ||
| 39 | } | 176 | } | ||
| 177 | + | ||||
| 178 | + | func sumHistoryVisits(entries []*entity.HistoryEntry) int64 { | |||
| 179 | + | var visits int64 | |||
| 180 | + | for _, entry := range entries { | |||
| 181 | + | if entry == nil { | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | if entry.VisitCount <= 0 { | |||
| 185 | + | visits++ | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | visits += entry.VisitCount | |||
| 189 | + | } | |||
| 190 | + | return visits | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||
| 194 | + | seen := map[string]struct{}{} | |||
| 195 | + | for _, entry := range entries { | |||
| 196 | + | if entry == nil || entry.LastVisited.IsZero() { | |||
| 197 | + | continue | |||
| 198 | + | } | |||
| 199 | + | seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{} | |||
| 200 | + | } | |||
| 201 | + | return int64(len(seen)) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func displayHistoryDomain(raw string) string { | |||
| 205 | + | parsed, err := url.Parse(raw) | |||
| 206 | + | if err != nil || parsed.Hostname() == "" { | |||
| 207 | + | return strings.TrimSpace(raw) | |||
| 208 | + | } | |||
| 209 | + | return strings.TrimPrefix(parsed.Hostname(), "www.") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func formatHistoryTime(ts time.Time) string { | |||
| 213 | + | if ts.IsZero() { | |||
| 214 | + | return "" | |||
| 215 | + | } | |||
| 216 | + | return ts.Local().Format("15:04") | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func nonEmptyStrings(values []string) []string { | |||
| 220 | + | out := values[:0] | |||
| 221 | + | for _, value := range values { | |||
| 222 | + | if strings.TrimSpace(value) != "" { | |||
| 223 | + | out = append(out, value) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | return out | |||
| 227 | + | } | |||
M internal/ui/systemviews/href_test.go +2 -2
| @@ -11,10 +11,10 @@ import ( | |||
| 11 | 11 | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | |
| 12 | 12 | t.Parallel() | |
| 13 | 13 | ||
| 14 | - | html := historyHTML([]*entity.HistoryEntry{ | |
| 14 | + | html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{ | |
| 15 | 15 | {URL: "javascript:alert(1)", Title: "Bad"}, | |
| 16 | 16 | {URL: "https://example.com", Title: "Good"}, | |
| 17 | - | }) | |
| 17 | + | }}) | |
| 18 | 18 | ||
| 19 | 19 | require.NotContains(t, html, `href="javascript:alert(1)"`) | |
| 20 | 20 | assert.Contains(t, html, `href="#"`) | |
| @@ -11,10 +11,10 @@ import ( | |||||
| 11 | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | 11 | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | ||
| 12 | t.Parallel() | 12 | t.Parallel() | ||
| 13 | 13 | ||||
| 14 | - | html := historyHTML([]*entity.HistoryEntry{ | 14 | + | html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{ |
| 15 | {URL: "javascript:alert(1)", Title: "Bad"}, | 15 | {URL: "javascript:alert(1)", Title: "Bad"}, | ||
| 16 | {URL: "https://example.com", Title: "Good"}, | 16 | {URL: "https://example.com", Title: "Good"}, | ||
| 17 | - | }) | 17 | + | }}) |
| 18 | 18 | ||||
| 19 | require.NotContains(t, html, `href="javascript:alert(1)"`) | 19 | require.NotContains(t, html, `href="javascript:alert(1)"`) | ||
| 20 | assert.Contains(t, html, `href="#"`) | 20 | assert.Contains(t, html, `href="#"`) | ||