fix(systemviews): refine history lazy loading
- commit
- ff220dd440157b14b1fe4037265aa29df937be9c
- author
- brice · · 2026-04-25 15:11 UTC
- parent
- a4e770b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
47 files · +4455 -725
M assets/systemviews/systemviews.css +188 -5
| @@ -203,6 +203,43 @@ body { | |||||
| 203 | color: var(--sv-muted, #9aa3b2); | 203 | color: var(--sv-muted, #9aa3b2); | ||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | + | .sv-loading-panel, | |||
| 207 | + | .sv-loading-card { | |||
| 208 | + | display: inline-flex; | |||
| 209 | + | align-items: center; | |||
| 210 | + | gap: 0.55rem; | |||
| 211 | + | color: var(--sv-muted, #9aa3b2); | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | .sv-loading-panel { | |||
| 215 | + | min-height: 4rem; | |||
| 216 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 217 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 218 | + | padding: 0.85rem; | |||
| 219 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | .sv-spinner { | |||
| 223 | + | width: 1rem; | |||
| 224 | + | height: 1rem; | |||
| 225 | + | border: 2px solid color-mix(in srgb, var(--sv-muted, #9aa3b2) 35%, transparent); | |||
| 226 | + | border-top-color: var(--sv-accent, #8ab4ff); | |||
| 227 | + | border-radius: 999px; | |||
| 228 | + | animation: sv-spin 0.75s linear infinite; | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | .sv-history-load-more { | |||
| 232 | + | display: flex; | |||
| 233 | + | justify-content: center; | |||
| 234 | + | padding: 1rem 0 0.25rem; | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | @keyframes sv-spin { | |||
| 238 | + | to { | |||
| 239 | + | transform: rotate(360deg); | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 206 | .sv-stat-grid { | 243 | .sv-stat-grid { | ||
| 207 | display: grid; | 244 | display: grid; | ||
| 208 | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | 245 | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | ||
| @@ -240,14 +277,23 @@ body { | |||||
| 240 | } | 277 | } | ||
| 241 | 278 | ||||
| 242 | .sv-domain-chip { | 279 | .sv-domain-chip { | ||
| 243 | - | display: inline-grid; | 280 | + | display: grid; |
| 244 | - | gap: 0.15rem; | 281 | + | grid-template-columns: minmax(0, 1fr) auto; |
| 245 | - | padding: 0.45rem 0.65rem; | 282 | + | gap: 0.5rem; |
| 283 | + | align-items: start; | |||
| 284 | + | min-width: 10.5rem; | |||
| 285 | + | padding: 0.55rem 0.65rem; | |||
| 246 | border: 1px solid var(--sv-border, #2a313d); | 286 | border: 1px solid var(--sv-border, #2a313d); | ||
| 247 | border-radius: var(--sv-radius-chip, 0.45rem); | 287 | border-radius: var(--sv-radius-chip, 0.45rem); | ||
| 248 | background: var(--sv-surface-variant, #1d2129); | 288 | background: var(--sv-surface-variant, #1d2129); | ||
| 249 | } | 289 | } | ||
| 250 | 290 | ||||
| 291 | + | .sv-domain-chip-main { | |||
| 292 | + | display: grid; | |||
| 293 | + | gap: 0.15rem; | |||
| 294 | + | min-width: 0; | |||
| 295 | + | } | |||
| 296 | + | ||||
| 251 | .sv-domain-meta, | 297 | .sv-domain-meta, | ||
| 252 | .sv-history-meta, | 298 | .sv-history-meta, | ||
| 253 | .sv-history-url { | 299 | .sv-history-url { | ||
| @@ -274,6 +320,46 @@ body { | |||||
| 274 | align-items: start; | 320 | align-items: start; | ||
| 275 | } | 321 | } | ||
| 276 | 322 | ||||
| 323 | + | .sv-history-content { | |||
| 324 | + | display: grid; | |||
| 325 | + | grid-template-columns: 24px minmax(0, 1fr); | |||
| 326 | + | gap: 0.75rem; | |||
| 327 | + | align-items: start; | |||
| 328 | + | min-width: 0; | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | .sv-history-favicon { | |||
| 332 | + | position: relative; | |||
| 333 | + | display: inline-grid; | |||
| 334 | + | place-items: center; | |||
| 335 | + | width: 24px; | |||
| 336 | + | height: 24px; | |||
| 337 | + | overflow: hidden; | |||
| 338 | + | color: var(--sv-muted, #9aa3b2); | |||
| 339 | + | font-size: 0.75rem; | |||
| 340 | + | font-weight: 800; | |||
| 341 | + | line-height: 1; | |||
| 342 | + | text-transform: uppercase; | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | .sv-history-favicon img { | |||
| 346 | + | position: absolute; | |||
| 347 | + | inset: 0; | |||
| 348 | + | z-index: 1; | |||
| 349 | + | width: 24px; | |||
| 350 | + | height: 24px; | |||
| 351 | + | object-fit: contain; | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | .sv-history-favicon-fallback { | |||
| 355 | + | position: relative; | |||
| 356 | + | z-index: 0; | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | .sv-history-favicon-has-image .sv-history-favicon-fallback { | |||
| 360 | + | visibility: hidden; | |||
| 361 | + | } | |||
| 362 | + | ||||
| 277 | .sv-history-main { | 363 | .sv-history-main { | ||
| 278 | min-width: 0; | 364 | min-width: 0; | ||
| 279 | } | 365 | } | ||
| @@ -284,7 +370,17 @@ body { | |||||
| 284 | } | 370 | } | ||
| 285 | 371 | ||||
| 286 | .sv-history-url { | 372 | .sv-history-url { | ||
| 373 | + | display: -webkit-box; | |||
| 374 | + | max-width: 100%; | |||
| 375 | + | overflow: hidden; | |||
| 287 | overflow-wrap: anywhere; | 376 | overflow-wrap: anywhere; | ||
| 377 | + | -webkit-box-orient: vertical; | |||
| 378 | + | -webkit-line-clamp: 2; | |||
| 379 | + | line-clamp: 2; | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | .sv-app.sv-dark .sv-history-meta { | |||
| 383 | + | color: var(--sv-text, #e8ebf0); | |||
| 288 | } | 384 | } | ||
| 289 | 385 | ||||
| 290 | .sv-error { | 386 | .sv-error { | ||
| @@ -397,6 +493,7 @@ body { | |||||
| 397 | display: inline-flex; | 493 | display: inline-flex; | ||
| 398 | align-items: center; | 494 | align-items: center; | ||
| 399 | justify-content: center; | 495 | justify-content: center; | ||
| 496 | + | gap: 0.4rem; | |||
| 400 | min-height: 2rem; | 497 | min-height: 2rem; | ||
| 401 | border: 1px solid var(--sv-accent, #8ab4ff); | 498 | border: 1px solid var(--sv-accent, #8ab4ff); | ||
| 402 | border-radius: var(--sv-radius-control, 0.35rem); | 499 | border-radius: var(--sv-radius-control, 0.35rem); | ||
| @@ -430,9 +527,33 @@ body { | |||||
| 430 | opacity: 0.7; | 527 | opacity: 0.7; | ||
| 431 | } | 528 | } | ||
| 432 | 529 | ||||
| 530 | + | .sv-button-icon-only { | |||
| 531 | + | width: 2rem; | |||
| 532 | + | min-width: 2rem; | |||
| 533 | + | min-height: 2rem; | |||
| 534 | + | padding: 0.35rem; | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | .sv-danger-link { | |||
| 538 | + | border: 0; | |||
| 539 | + | padding: 0.15rem 0.2rem; | |||
| 540 | + | background: transparent; | |||
| 541 | + | color: var(--sv-danger-text, #fecaca); | |||
| 542 | + | cursor: pointer; | |||
| 543 | + | font: inherit; | |||
| 544 | + | font-weight: 650; | |||
| 545 | + | text-decoration: none; | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | .sv-danger-link:hover { | |||
| 549 | + | color: var(--sv-danger, #ef4444); | |||
| 550 | + | text-decoration: underline; | |||
| 551 | + | } | |||
| 552 | + | ||||
| 433 | .sv-button:focus-visible, | 553 | .sv-button:focus-visible, | ||
| 434 | .sv-icon-button:focus-visible, | 554 | .sv-icon-button:focus-visible, | ||
| 435 | .sv-domain-filter:focus-visible, | 555 | .sv-domain-filter:focus-visible, | ||
| 556 | + | .sv-danger-link:focus-visible, | |||
| 436 | .sv-filter-chip button:focus-visible, | 557 | .sv-filter-chip button:focus-visible, | ||
| 437 | .sv-link:focus-visible, | 558 | .sv-link:focus-visible, | ||
| 438 | .sv-search-label input:focus-visible, | 559 | .sv-search-label input:focus-visible, | ||
| @@ -482,11 +603,35 @@ body { | |||||
| 482 | display: inline-flex; | 603 | display: inline-flex; | ||
| 483 | align-items: center; | 604 | align-items: center; | ||
| 484 | justify-content: center; | 605 | justify-content: center; | ||
| 485 | - | width: 1.4rem; | 606 | + | width: 1.75rem; |
| 486 | - | height: 1.4rem; | 607 | + | height: 1.75rem; |
| 487 | border-radius: 999px; | 608 | border-radius: 999px; | ||
| 488 | } | 609 | } | ||
| 489 | 610 | ||||
| 611 | + | .sv-icon { | |||
| 612 | + | flex: 0 0 auto; | |||
| 613 | + | width: 1rem; | |||
| 614 | + | height: 1rem; | |||
| 615 | + | stroke-width: 2.15; | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | .sv-icon-button .sv-icon { | |||
| 619 | + | width: 0.95rem; | |||
| 620 | + | height: 0.95rem; | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | .sv-sr-only { | |||
| 624 | + | position: absolute; | |||
| 625 | + | width: 1px; | |||
| 626 | + | height: 1px; | |||
| 627 | + | padding: 0; | |||
| 628 | + | margin: -1px; | |||
| 629 | + | overflow: hidden; | |||
| 630 | + | clip: rect(0, 0, 0, 0); | |||
| 631 | + | white-space: nowrap; | |||
| 632 | + | border: 0; | |||
| 633 | + | } | |||
| 634 | + | ||||
| 490 | .sv-danger { | 635 | .sv-danger { | ||
| 491 | color: var(--sv-danger-text, #fecaca); | 636 | color: var(--sv-danger-text, #fecaca); | ||
| 492 | } | 637 | } | ||
| @@ -497,6 +642,44 @@ body { | |||||
| 497 | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | 642 | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | ||
| 498 | } | 643 | } | ||
| 499 | 644 | ||||
| 645 | + | .sv-confirm-backdrop { | |||
| 646 | + | position: fixed; | |||
| 647 | + | inset: 0; | |||
| 648 | + | z-index: 1000; | |||
| 649 | + | display: grid; | |||
| 650 | + | place-items: center; | |||
| 651 | + | padding: 1rem; | |||
| 652 | + | background: rgba(0, 0, 0, 0.55); | |||
| 653 | + | backdrop-filter: blur(2px); | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | .sv-confirm-dialog { | |||
| 657 | + | width: min(26rem, 100%); | |||
| 658 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 48%, var(--sv-border, #2a313d)); | |||
| 659 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 660 | + | padding: 1rem; | |||
| 661 | + | background: var(--sv-surface, #171a21); | |||
| 662 | + | color: var(--sv-text, #e8ebf0); | |||
| 663 | + | box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35); | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | .sv-confirm-dialog h3 { | |||
| 667 | + | margin: 0; | |||
| 668 | + | font-size: 1rem; | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | .sv-confirm-dialog p { | |||
| 672 | + | margin: 0.65rem 0 0; | |||
| 673 | + | color: var(--sv-muted, #9aa3b2); | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | .sv-confirm-actions { | |||
| 677 | + | display: flex; | |||
| 678 | + | justify-content: flex-end; | |||
| 679 | + | gap: 0.5rem; | |||
| 680 | + | margin-top: 1rem; | |||
| 681 | + | } | |||
| 682 | + | ||||
| 500 | .sv-history-item.sv-focused { | 683 | .sv-history-item.sv-focused { | ||
| 501 | outline: 2px solid var(--sv-accent, #8ab4ff); | 684 | outline: 2px solid var(--sv-accent, #8ab4ff); | ||
| 502 | outline-offset: 0.35rem; | 685 | outline-offset: 0.35rem; | ||
M cmd/systemviews/bootstrap_test.go +11 -1
| @@ -3,6 +3,7 @@ package main | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | "time" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/dto" | 8 | "github.com/bnema/dumber/internal/application/dto" | ||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -48,7 +49,7 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||||
| 48 | app := newBridgeApp(dom, "dumb://history", bridge) | 49 | app := newBridgeApp(dom, "dumb://history", bridge) | ||
| 49 | 50 | ||||
| 50 | require.NoError(t, app.Run()) | 51 | require.NoError(t, app.Run()) | ||
| 51 | - | assert.True(t, bridge.calledHistory) | 52 | + | require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond) |
| 52 | assert.True(t, bridge.calledConfig) | 53 | assert.True(t, bridge.calledConfig) | ||
| 53 | assert.False(t, bridge.calledKeybindings) | 54 | assert.False(t, bridge.calledKeybindings) | ||
| 54 | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | 55 | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | ||
| @@ -123,6 +124,11 @@ func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) | |||||
| 123 | return f.historyEntries, nil | 124 | return f.historyEntries, nil | ||
| 124 | } | 125 | } | ||
| 125 | 126 | ||||
| 127 | + | func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | |||
| 128 | + | f.calledHistory = true | |||
| 129 | + | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 126 | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | 132 | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | ||
| 127 | return nil, nil | 133 | return nil, nil | ||
| 128 | } | 134 | } | ||
| @@ -131,6 +137,10 @@ func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil | |||||
| 131 | 137 | ||||
| 132 | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | 138 | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | ||
| 133 | 139 | ||||
| 140 | + | func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) { | |||
| 141 | + | return nil, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 134 | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | 144 | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | ||
| 135 | return nil, nil | 145 | return nil, nil | ||
| 136 | } | 146 | } | ||
M internal/application/port/favicon_service.go +7 -0
| @@ -9,6 +9,7 @@ type FaviconService interface { | |||||
| 9 | DiskPathPNG(domain string) string | 9 | DiskPathPNG(domain string) string | ||
| 10 | HasPNGOnDisk(domain string) bool | 10 | HasPNGOnDisk(domain string) bool | ||
| 11 | HasPNGSizedOnDisk(domain string, size int) bool | 11 | HasPNGSizedOnDisk(domain string, size int) bool | ||
| 12 | + | DiskPathPNGSized(domain string, size int) string | |||
| 12 | EnsureSizedPNG(ctx context.Context, domain string, size int) error | 13 | EnsureSizedPNG(ctx context.Context, domain string, size int) error | ||
| 13 | EnsureCacheDir() error | 14 | EnsureCacheDir() error | ||
| 14 | // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given | 15 | // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given | ||
| @@ -17,3 +18,9 @@ type FaviconService interface { | |||||
| 17 | EnsureDiskCache(ctx context.Context, domain string) | 18 | EnsureDiskCache(ctx context.Context, domain string) | ||
| 18 | Close() | 19 | Close() | ||
| 19 | } | 20 | } | ||
| 21 | + | ||||
| 22 | + | // SystemviewFaviconServiceSetter is an optional engine capability that lets the | |||
| 23 | + | // UI expose cached favicons to internal systemview pages without remote image loads. | |||
| 24 | + | type SystemviewFaviconServiceSetter interface { | |||
| 25 | + | SetSystemviewFaviconService(service FaviconService) | |||
| 26 | + | } | |||
M internal/application/port/homepage.go +2 -0
| @@ -22,11 +22,13 @@ type HistorySearchOutput struct { | |||||
| 22 | type HomepageHistory interface { | 22 | type HomepageHistory interface { | ||
| 23 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 23 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 24 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 24 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 25 | + | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |||
| 25 | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | 26 | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | ||
| 26 | Delete(ctx context.Context, id int64) error | 27 | Delete(ctx context.Context, id int64) error | ||
| 27 | ClearRange(ctx context.Context, rangeID string) error | 28 | ClearRange(ctx context.Context, rangeID string) error | ||
| 28 | ClearAll(ctx context.Context) error | 29 | ClearAll(ctx context.Context) error | ||
| 29 | ClearOlderThan(ctx context.Context, before time.Time) error | 30 | ClearOlderThan(ctx context.Context, before time.Time) error | ||
| 31 | + | GetStats(ctx context.Context) (*entity.HistoryStats, error) | |||
| 30 | GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) | 32 | GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) | ||
| 31 | GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | 33 | GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | ||
| 32 | DeleteByDomain(ctx context.Context, domain string) error | 34 | DeleteByDomain(ctx context.Context, domain string) error | ||
M internal/application/port/mocks/mocks.go +193 -0
| @@ -5052,6 +5052,63 @@ func (_c *MockFaviconService_DiskPathPNG_Call) RunAndReturn(run func(domain stri | |||||
| 5052 | return _c | 5052 | return _c | ||
| 5053 | } | 5053 | } | ||
| 5054 | 5054 | ||||
| 5055 | + | // DiskPathPNGSized provides a mock function for the type MockFaviconService | |||
| 5056 | + | func (_mock *MockFaviconService) DiskPathPNGSized(domain string, size int) string { | |||
| 5057 | + | ret := _mock.Called(domain, size) | |||
| 5058 | + | ||||
| 5059 | + | if len(ret) == 0 { | |||
| 5060 | + | panic("no return value specified for DiskPathPNGSized") | |||
| 5061 | + | } | |||
| 5062 | + | ||||
| 5063 | + | var r0 string | |||
| 5064 | + | if returnFunc, ok := ret.Get(0).(func(string, int) string); ok { | |||
| 5065 | + | r0 = returnFunc(domain, size) | |||
| 5066 | + | } else { | |||
| 5067 | + | r0 = ret.Get(0).(string) | |||
| 5068 | + | } | |||
| 5069 | + | return r0 | |||
| 5070 | + | } | |||
| 5071 | + | ||||
| 5072 | + | // MockFaviconService_DiskPathPNGSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiskPathPNGSized' | |||
| 5073 | + | type MockFaviconService_DiskPathPNGSized_Call struct { | |||
| 5074 | + | *mock.Call | |||
| 5075 | + | } | |||
| 5076 | + | ||||
| 5077 | + | // DiskPathPNGSized is a helper method to define mock.On call | |||
| 5078 | + | // - domain string | |||
| 5079 | + | // - size int | |||
| 5080 | + | func (_e *MockFaviconService_Expecter) DiskPathPNGSized(domain interface{}, size interface{}) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5081 | + | return &MockFaviconService_DiskPathPNGSized_Call{Call: _e.mock.On("DiskPathPNGSized", domain, size)} | |||
| 5082 | + | } | |||
| 5083 | + | ||||
| 5084 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Run(run func(domain string, size int)) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 5086 | + | var arg0 string | |||
| 5087 | + | if args[0] != nil { | |||
| 5088 | + | arg0 = args[0].(string) | |||
| 5089 | + | } | |||
| 5090 | + | var arg1 int | |||
| 5091 | + | if args[1] != nil { | |||
| 5092 | + | arg1 = args[1].(int) | |||
| 5093 | + | } | |||
| 5094 | + | run( | |||
| 5095 | + | arg0, | |||
| 5096 | + | arg1, | |||
| 5097 | + | ) | |||
| 5098 | + | }) | |||
| 5099 | + | return _c | |||
| 5100 | + | } | |||
| 5101 | + | ||||
| 5102 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Return(s string) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5103 | + | _c.Call.Return(s) | |||
| 5104 | + | return _c | |||
| 5105 | + | } | |||
| 5106 | + | ||||
| 5107 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) RunAndReturn(run func(domain string, size int) string) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5108 | + | _c.Call.Return(run) | |||
| 5109 | + | return _c | |||
| 5110 | + | } | |||
| 5111 | + | ||||
| 5055 | // EnsureCacheDir provides a mock function for the type MockFaviconService | 5112 | // EnsureCacheDir provides a mock function for the type MockFaviconService | ||
| 5056 | func (_mock *MockFaviconService) EnsureCacheDir() error { | 5113 | func (_mock *MockFaviconService) EnsureCacheDir() error { | ||
| 5057 | ret := _mock.Called() | 5114 | ret := _mock.Called() | ||
| @@ -7002,6 +7059,142 @@ func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx | |||||
| 7002 | return _c | 7059 | return _c | ||
| 7003 | } | 7060 | } | ||
| 7004 | 7061 | ||||
| 7062 | + | // GetRecentWindow provides a mock function for the type MockHomepageHistory | |||
| 7063 | + | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 7064 | + | ret := _mock.Called(ctx, before, domain) | |||
| 7065 | + | ||||
| 7066 | + | if len(ret) == 0 { | |||
| 7067 | + | panic("no return value specified for GetRecentWindow") | |||
| 7068 | + | } | |||
| 7069 | + | ||||
| 7070 | + | var r0 *entity.HistoryWindow | |||
| 7071 | + | var r1 error | |||
| 7072 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok { | |||
| 7073 | + | return returnFunc(ctx, before, domain) | |||
| 7074 | + | } | |||
| 7075 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok { | |||
| 7076 | + | r0 = returnFunc(ctx, before, domain) | |||
| 7077 | + | } else { | |||
| 7078 | + | if ret.Get(0) != nil { | |||
| 7079 | + | r0 = ret.Get(0).(*entity.HistoryWindow) | |||
| 7080 | + | } | |||
| 7081 | + | } | |||
| 7082 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok { | |||
| 7083 | + | r1 = returnFunc(ctx, before, domain) | |||
| 7084 | + | } else { | |||
| 7085 | + | r1 = ret.Error(1) | |||
| 7086 | + | } | |||
| 7087 | + | return r0, r1 | |||
| 7088 | + | } | |||
| 7089 | + | ||||
| 7090 | + | // MockHomepageHistory_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |||
| 7091 | + | type MockHomepageHistory_GetRecentWindow_Call struct { | |||
| 7092 | + | *mock.Call | |||
| 7093 | + | } | |||
| 7094 | + | ||||
| 7095 | + | // GetRecentWindow is a helper method to define mock.On call | |||
| 7096 | + | // - ctx context.Context | |||
| 7097 | + | // - before time.Time | |||
| 7098 | + | // - domain string | |||
| 7099 | + | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx interface{}, before interface{}, domain interface{}) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7100 | + | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)} | |||
| 7101 | + | } | |||
| 7102 | + | ||||
| 7103 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7104 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7105 | + | var arg0 context.Context | |||
| 7106 | + | if args[0] != nil { | |||
| 7107 | + | arg0 = args[0].(context.Context) | |||
| 7108 | + | } | |||
| 7109 | + | var arg1 time.Time | |||
| 7110 | + | if args[1] != nil { | |||
| 7111 | + | arg1 = args[1].(time.Time) | |||
| 7112 | + | } | |||
| 7113 | + | var arg2 string | |||
| 7114 | + | if args[2] != nil { | |||
| 7115 | + | arg2 = args[2].(string) | |||
| 7116 | + | } | |||
| 7117 | + | run( | |||
| 7118 | + | arg0, | |||
| 7119 | + | arg1, | |||
| 7120 | + | arg2, | |||
| 7121 | + | ) | |||
| 7122 | + | }) | |||
| 7123 | + | return _c | |||
| 7124 | + | } | |||
| 7125 | + | ||||
| 7126 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity.HistoryWindow, err error) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7127 | + | _c.Call.Return(historyWindow, err) | |||
| 7128 | + | return _c | |||
| 7129 | + | } | |||
| 7130 | + | ||||
| 7131 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7132 | + | _c.Call.Return(run) | |||
| 7133 | + | return _c | |||
| 7134 | + | } | |||
| 7135 | + | ||||
| 7136 | + | // GetStats provides a mock function for the type MockHomepageHistory | |||
| 7137 | + | func (_mock *MockHomepageHistory) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 7138 | + | ret := _mock.Called(ctx) | |||
| 7139 | + | ||||
| 7140 | + | if len(ret) == 0 { | |||
| 7141 | + | panic("no return value specified for GetStats") | |||
| 7142 | + | } | |||
| 7143 | + | ||||
| 7144 | + | var r0 *entity.HistoryStats | |||
| 7145 | + | var r1 error | |||
| 7146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.HistoryStats, error)); ok { | |||
| 7147 | + | return returnFunc(ctx) | |||
| 7148 | + | } | |||
| 7149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.HistoryStats); ok { | |||
| 7150 | + | r0 = returnFunc(ctx) | |||
| 7151 | + | } else { | |||
| 7152 | + | if ret.Get(0) != nil { | |||
| 7153 | + | r0 = ret.Get(0).(*entity.HistoryStats) | |||
| 7154 | + | } | |||
| 7155 | + | } | |||
| 7156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 7157 | + | r1 = returnFunc(ctx) | |||
| 7158 | + | } else { | |||
| 7159 | + | r1 = ret.Error(1) | |||
| 7160 | + | } | |||
| 7161 | + | return r0, r1 | |||
| 7162 | + | } | |||
| 7163 | + | ||||
| 7164 | + | // MockHomepageHistory_GetStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStats' | |||
| 7165 | + | type MockHomepageHistory_GetStats_Call struct { | |||
| 7166 | + | *mock.Call | |||
| 7167 | + | } | |||
| 7168 | + | ||||
| 7169 | + | // GetStats is a helper method to define mock.On call | |||
| 7170 | + | // - ctx context.Context | |||
| 7171 | + | func (_e *MockHomepageHistory_Expecter) GetStats(ctx interface{}) *MockHomepageHistory_GetStats_Call { | |||
| 7172 | + | return &MockHomepageHistory_GetStats_Call{Call: _e.mock.On("GetStats", ctx)} | |||
| 7173 | + | } | |||
| 7174 | + | ||||
| 7175 | + | func (_c *MockHomepageHistory_GetStats_Call) Run(run func(ctx context.Context)) *MockHomepageHistory_GetStats_Call { | |||
| 7176 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7177 | + | var arg0 context.Context | |||
| 7178 | + | if args[0] != nil { | |||
| 7179 | + | arg0 = args[0].(context.Context) | |||
| 7180 | + | } | |||
| 7181 | + | run( | |||
| 7182 | + | arg0, | |||
| 7183 | + | ) | |||
| 7184 | + | }) | |||
| 7185 | + | return _c | |||
| 7186 | + | } | |||
| 7187 | + | ||||
| 7188 | + | func (_c *MockHomepageHistory_GetStats_Call) Return(historyStats *entity.HistoryStats, err error) *MockHomepageHistory_GetStats_Call { | |||
| 7189 | + | _c.Call.Return(historyStats, err) | |||
| 7190 | + | return _c | |||
| 7191 | + | } | |||
| 7192 | + | ||||
| 7193 | + | func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.Context) (*entity.HistoryStats, error)) *MockHomepageHistory_GetStats_Call { | |||
| 7194 | + | _c.Call.Return(run) | |||
| 7195 | + | return _c | |||
| 7196 | + | } | |||
| 7197 | + | ||||
| 7005 | // Search provides a mock function for the type MockHomepageHistory | 7198 | // Search provides a mock function for the type MockHomepageHistory | ||
| 7006 | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | 7199 | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | ||
| 7007 | ret := _mock.Called(ctx, input) | 7200 | ret := _mock.Called(ctx, input) | ||
M internal/application/port/systemviews.go +3 -0
| @@ -2,6 +2,7 @@ package port | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/dto" | 7 | "github.com/bnema/dumber/internal/application/dto" | ||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -11,9 +12,11 @@ import ( | |||||
| 11 | type SystemviewHistoryService interface { | 12 | type SystemviewHistoryService interface { | ||
| 12 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 13 | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 13 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 14 | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 15 | + | TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |||
| 14 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | 16 | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | ||
| 15 | DeleteEntry(ctx context.Context, id int64) error | 17 | DeleteEntry(ctx context.Context, id int64) error | ||
| 16 | DeleteRange(ctx context.Context, rangeID string) error | 18 | DeleteRange(ctx context.Context, rangeID string) error | ||
| 19 | + | Stats(ctx context.Context) (*entity.HistoryStats, error) | |||
| 17 | Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) | 20 | Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) | ||
| 18 | DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | 21 | DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | ||
| 19 | DeleteDomain(ctx context.Context, domain string) error | 22 | DeleteDomain(ctx context.Context, domain string) error | ||
M internal/application/usecase/search_history.go +55 -5
| @@ -16,6 +16,8 @@ import ( | |||||
| 16 | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | 16 | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | ||
| 17 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | 17 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | ||
| 18 | 18 | ||||
| 19 | + | const historyWindowDuration = 24 * time.Hour | |||
| 20 | + | ||||
| 19 | // SearchHistoryUseCase handles history search and retrieval operations. | 21 | // SearchHistoryUseCase handles history search and retrieval operations. | ||
| 20 | type SearchHistoryUseCase struct { | 22 | type SearchHistoryUseCase struct { | ||
| 21 | historyRepo repository.HistoryRepository | 23 | historyRepo repository.HistoryRepository | ||
| @@ -62,10 +64,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||||
| 62 | return &SearchOutput{Matches: matches}, nil | 64 | return &SearchOutput{Matches: matches}, nil | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | - | // GetRecent retrieves recent history entries with pagination. | 67 | + | // GetRecent retrieves recent history entries. A zero limit means all entries; |
| 68 | + | // negative limits retain the historical default page size. | |||
| 66 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 69 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 67 | - | if limit <= 0 { | 70 | + | if limit < 0 { |
| 68 | - | limit = 50 // Default limit | 71 | + | limit = 50 // Default limit for invalid negative values. |
| 69 | } | 72 | } | ||
| 70 | 73 | ||||
| 71 | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | 74 | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | ||
| @@ -76,9 +79,10 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||||
| 76 | return entries, nil | 79 | return entries, nil | ||
| 77 | } | 80 | } | ||
| 78 | 81 | ||||
| 79 | - | // GetRecentByDomain retrieves recent history entries for a canonical domain with pagination. | 82 | + | // GetRecentByDomain retrieves recent history entries for a canonical domain. A |
| 83 | + | // zero limit means all matching entries; negative limits use the default page size. | |||
| 80 | func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | 84 | func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 81 | - | if limit <= 0 { | 85 | + | if limit < 0 { |
| 82 | limit = 50 | 86 | limit = 50 | ||
| 83 | } | 87 | } | ||
| 84 | domain, err := canonicalHistoryDomain(domain) | 88 | domain, err := canonicalHistoryDomain(domain) | ||
| @@ -103,6 +107,43 @@ func canonicalHistoryDomain(domain string) (string, error) { | |||||
| 103 | return domain, nil | 107 | return domain, nil | ||
| 104 | } | 108 | } | ||
| 105 | 109 | ||||
| 110 | + | // GetRecentWindow retrieves the 24-hour history window ending before the given cursor. | |||
| 111 | + | func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 112 | + | if before.IsZero() { | |||
| 113 | + | before = time.Now() | |||
| 114 | + | } | |||
| 115 | + | before = before.UTC() | |||
| 116 | + | after := before.Add(-historyWindowDuration) | |||
| 117 | + | ||||
| 118 | + | var ( | |||
| 119 | + | entries []*entity.HistoryEntry | |||
| 120 | + | hasMore bool | |||
| 121 | + | err error | |||
| 122 | + | ) | |||
| 123 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 124 | + | if domain != "" { | |||
| 125 | + | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after) | |||
| 126 | + | if err == nil { | |||
| 127 | + | hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after) | |||
| 128 | + | } | |||
| 129 | + | } else { | |||
| 130 | + | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after) | |||
| 131 | + | if err == nil { | |||
| 132 | + | hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | if err != nil { | |||
| 136 | + | return nil, fmt.Errorf("failed to get recent history window: %w", err) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return &entity.HistoryWindow{ | |||
| 140 | + | Entries: entries, | |||
| 141 | + | Before: before, | |||
| 142 | + | After: after, | |||
| 143 | + | HasMore: hasMore, | |||
| 144 | + | }, nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 106 | // GetRecentSince retrieves history entries visited within the last N days. | 147 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 107 | // If days is 0, returns all history entries. | 148 | // If days is 0, returns all history entries. | ||
| 108 | // If days is negative, defaults to 30 days. | 149 | // If days is negative, defaults to 30 days. | ||
| @@ -258,6 +299,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ( | |||||
| 258 | return uc.historyRepo.GetDomainStats(ctx, limit) | 299 | return uc.historyRepo.GetDomainStats(ctx, limit) | ||
| 259 | } | 300 | } | ||
| 260 | 301 | ||||
| 302 | + | // GetStats retrieves lightweight aggregate history statistics. | |||
| 303 | + | func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 304 | + | stats, err := uc.historyRepo.GetStats(ctx) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, fmt.Errorf("failed to get history stats: %w", err) | |||
| 307 | + | } | |||
| 308 | + | return stats, nil | |||
| 309 | + | } | |||
| 310 | + | ||||
| 261 | // GetAnalytics retrieves aggregated history analytics for the homepage. | 311 | // GetAnalytics retrieves aggregated history analytics for the homepage. | ||
| 262 | func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | 312 | func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | ||
| 263 | log := logging.FromContext(ctx) | 313 | log := logging.FromContext(ctx) | ||
M internal/application/usecase/search_history_test.go +25 -0
| @@ -13,6 +13,31 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||
| 17 | + | ctx := testContext() | |||
| 18 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 19 | + | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | |||
| 20 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once() | |||
| 21 | + | ||||
| 22 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 23 | + | result, err := uc.GetRecent(ctx, 0, 0) | |||
| 24 | + | ||||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.Len(t, result, 1) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | |||
| 30 | + | ctx := testContext() | |||
| 31 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 32 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once() | |||
| 33 | + | ||||
| 34 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 35 | + | result, err := uc.GetRecent(ctx, -1, 0) | |||
| 36 | + | ||||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.Empty(t, result) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 16 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | 41 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | ||
| 17 | ctx := testContext() | 42 | ctx := testContext() | ||
| 18 | 43 | ||||
A internal/domain/entity/history_window.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // HistoryWindow contains one fixed-duration slice of browsing history. | |||
| 6 | + | type HistoryWindow struct { | |||
| 7 | + | Entries []*HistoryEntry `json:"entries"` | |||
| 8 | + | Before time.Time `json:"before"` | |||
| 9 | + | After time.Time `json:"after"` | |||
| 10 | + | HasMore bool `json:"has_more"` | |||
| 11 | + | } | |||
M internal/domain/repository/history.go +14 -2
| @@ -18,12 +18,24 @@ type HistoryRepository interface { | |||||
| 18 | // Search performs a fuzzy search on history entries. | 18 | // Search performs a fuzzy search on history entries. | ||
| 19 | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | 19 | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | ||
| 20 | 20 | ||||
| 21 | - | // GetRecent retrieves recent history entries with pagination. | 21 | + | // GetRecent retrieves recent history entries with pagination. A zero limit means all entries. |
| 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 23 | 23 | ||||
| 24 | - | // GetRecentByDomain retrieves recent history entries for a domain with pagination. | 24 | + | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. |
| 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | 25 | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 26 | 26 | ||||
| 27 | + | // GetRecentWindow retrieves entries visited in [after, before), sorted by recency. | |||
| 28 | + | GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) | |||
| 29 | + | ||||
| 30 | + | // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency. | |||
| 31 | + | GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) | |||
| 32 | + | ||||
| 33 | + | // HasEntriesBefore reports whether any history entry was visited before the given time. | |||
| 34 | + | HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) | |||
| 35 | + | ||||
| 36 | + | // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time. | |||
| 37 | + | HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) | |||
| 38 | + | ||||
| 27 | // GetRecentSince retrieves history entries visited within the last N days. | 39 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 28 | // days must be > 0. | 40 | // days must be > 0. | ||
| 29 | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | 41 | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | ||
M internal/domain/repository/mocks/mocks.go +292 -0
| @@ -2866,6 +2866,160 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c | |||||
| 2866 | return _c | 2866 | return _c | ||
| 2867 | } | 2867 | } | ||
| 2868 | 2868 | ||||
| 2869 | + | // GetRecentWindow provides a mock function for the type MockHistoryRepository | |||
| 2870 | + | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 2871 | + | ret := _mock.Called(ctx, before, after) | |||
| 2872 | + | ||||
| 2873 | + | if len(ret) == 0 { | |||
| 2874 | + | panic("no return value specified for GetRecentWindow") | |||
| 2875 | + | } | |||
| 2876 | + | ||||
| 2877 | + | var r0 []*entity.HistoryEntry | |||
| 2878 | + | var r1 error | |||
| 2879 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |||
| 2880 | + | return returnFunc(ctx, before, after) | |||
| 2881 | + | } | |||
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok { | |||
| 2883 | + | r0 = returnFunc(ctx, before, after) | |||
| 2884 | + | } else { | |||
| 2885 | + | if ret.Get(0) != nil { | |||
| 2886 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2887 | + | } | |||
| 2888 | + | } | |||
| 2889 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok { | |||
| 2890 | + | r1 = returnFunc(ctx, before, after) | |||
| 2891 | + | } else { | |||
| 2892 | + | r1 = ret.Error(1) | |||
| 2893 | + | } | |||
| 2894 | + | return r0, r1 | |||
| 2895 | + | } | |||
| 2896 | + | ||||
| 2897 | + | // MockHistoryRepository_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |||
| 2898 | + | type MockHistoryRepository_GetRecentWindow_Call struct { | |||
| 2899 | + | *mock.Call | |||
| 2900 | + | } | |||
| 2901 | + | ||||
| 2902 | + | // GetRecentWindow is a helper method to define mock.On call | |||
| 2903 | + | // - ctx context.Context | |||
| 2904 | + | // - before time.Time | |||
| 2905 | + | // - after time.Time | |||
| 2906 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2907 | + | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)} | |||
| 2908 | + | } | |||
| 2909 | + | ||||
| 2910 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2912 | + | var arg0 context.Context | |||
| 2913 | + | if args[0] != nil { | |||
| 2914 | + | arg0 = args[0].(context.Context) | |||
| 2915 | + | } | |||
| 2916 | + | var arg1 time.Time | |||
| 2917 | + | if args[1] != nil { | |||
| 2918 | + | arg1 = args[1].(time.Time) | |||
| 2919 | + | } | |||
| 2920 | + | var arg2 time.Time | |||
| 2921 | + | if args[2] != nil { | |||
| 2922 | + | arg2 = args[2].(time.Time) | |||
| 2923 | + | } | |||
| 2924 | + | run( | |||
| 2925 | + | arg0, | |||
| 2926 | + | arg1, | |||
| 2927 | + | arg2, | |||
| 2928 | + | ) | |||
| 2929 | + | }) | |||
| 2930 | + | return _c | |||
| 2931 | + | } | |||
| 2932 | + | ||||
| 2933 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2934 | + | _c.Call.Return(historyEntrys, err) | |||
| 2935 | + | return _c | |||
| 2936 | + | } | |||
| 2937 | + | ||||
| 2938 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2939 | + | _c.Call.Return(run) | |||
| 2940 | + | return _c | |||
| 2941 | + | } | |||
| 2942 | + | ||||
| 2943 | + | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | |||
| 2944 | + | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 2945 | + | ret := _mock.Called(ctx, domain, before, after) | |||
| 2946 | + | ||||
| 2947 | + | if len(ret) == 0 { | |||
| 2948 | + | panic("no return value specified for GetRecentWindowByDomain") | |||
| 2949 | + | } | |||
| 2950 | + | ||||
| 2951 | + | var r0 []*entity.HistoryEntry | |||
| 2952 | + | var r1 error | |||
| 2953 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |||
| 2954 | + | return returnFunc(ctx, domain, before, after) | |||
| 2955 | + | } | |||
| 2956 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok { | |||
| 2957 | + | r0 = returnFunc(ctx, domain, before, after) | |||
| 2958 | + | } else { | |||
| 2959 | + | if ret.Get(0) != nil { | |||
| 2960 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2961 | + | } | |||
| 2962 | + | } | |||
| 2963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok { | |||
| 2964 | + | r1 = returnFunc(ctx, domain, before, after) | |||
| 2965 | + | } else { | |||
| 2966 | + | r1 = ret.Error(1) | |||
| 2967 | + | } | |||
| 2968 | + | return r0, r1 | |||
| 2969 | + | } | |||
| 2970 | + | ||||
| 2971 | + | // MockHistoryRepository_GetRecentWindowByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindowByDomain' | |||
| 2972 | + | type MockHistoryRepository_GetRecentWindowByDomain_Call struct { | |||
| 2973 | + | *mock.Call | |||
| 2974 | + | } | |||
| 2975 | + | ||||
| 2976 | + | // GetRecentWindowByDomain is a helper method to define mock.On call | |||
| 2977 | + | // - ctx context.Context | |||
| 2978 | + | // - domain string | |||
| 2979 | + | // - before time.Time | |||
| 2980 | + | // - after time.Time | |||
| 2981 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx interface{}, domain interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 2982 | + | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)} | |||
| 2983 | + | } | |||
| 2984 | + | ||||
| 2985 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 2986 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2987 | + | var arg0 context.Context | |||
| 2988 | + | if args[0] != nil { | |||
| 2989 | + | arg0 = args[0].(context.Context) | |||
| 2990 | + | } | |||
| 2991 | + | var arg1 string | |||
| 2992 | + | if args[1] != nil { | |||
| 2993 | + | arg1 = args[1].(string) | |||
| 2994 | + | } | |||
| 2995 | + | var arg2 time.Time | |||
| 2996 | + | if args[2] != nil { | |||
| 2997 | + | arg2 = args[2].(time.Time) | |||
| 2998 | + | } | |||
| 2999 | + | var arg3 time.Time | |||
| 3000 | + | if args[3] != nil { | |||
| 3001 | + | arg3 = args[3].(time.Time) | |||
| 3002 | + | } | |||
| 3003 | + | run( | |||
| 3004 | + | arg0, | |||
| 3005 | + | arg1, | |||
| 3006 | + | arg2, | |||
| 3007 | + | arg3, | |||
| 3008 | + | ) | |||
| 3009 | + | }) | |||
| 3010 | + | return _c | |||
| 3011 | + | } | |||
| 3012 | + | ||||
| 3013 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 3014 | + | _c.Call.Return(historyEntrys, err) | |||
| 3015 | + | return _c | |||
| 3016 | + | } | |||
| 3017 | + | ||||
| 3018 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 3019 | + | _c.Call.Return(run) | |||
| 3020 | + | return _c | |||
| 3021 | + | } | |||
| 3022 | + | ||||
| 2869 | // GetStats provides a mock function for the type MockHistoryRepository | 3023 | // GetStats provides a mock function for the type MockHistoryRepository | ||
| 2870 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | 3024 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | ||
| 2871 | ret := _mock.Called(ctx) | 3025 | ret := _mock.Called(ctx) | ||
| @@ -2928,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context | |||||
| 2928 | return _c | 3082 | return _c | ||
| 2929 | } | 3083 | } | ||
| 2930 | 3084 | ||||
| 3085 | + | // HasEntriesBefore provides a mock function for the type MockHistoryRepository | |||
| 3086 | + | func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 3087 | + | ret := _mock.Called(ctx, before) | |||
| 3088 | + | ||||
| 3089 | + | if len(ret) == 0 { | |||
| 3090 | + | panic("no return value specified for HasEntriesBefore") | |||
| 3091 | + | } | |||
| 3092 | + | ||||
| 3093 | + | var r0 bool | |||
| 3094 | + | var r1 error | |||
| 3095 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok { | |||
| 3096 | + | return returnFunc(ctx, before) | |||
| 3097 | + | } | |||
| 3098 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok { | |||
| 3099 | + | r0 = returnFunc(ctx, before) | |||
| 3100 | + | } else { | |||
| 3101 | + | r0 = ret.Get(0).(bool) | |||
| 3102 | + | } | |||
| 3103 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |||
| 3104 | + | r1 = returnFunc(ctx, before) | |||
| 3105 | + | } else { | |||
| 3106 | + | r1 = ret.Error(1) | |||
| 3107 | + | } | |||
| 3108 | + | return r0, r1 | |||
| 3109 | + | } | |||
| 3110 | + | ||||
| 3111 | + | // MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore' | |||
| 3112 | + | type MockHistoryRepository_HasEntriesBefore_Call struct { | |||
| 3113 | + | *mock.Call | |||
| 3114 | + | } | |||
| 3115 | + | ||||
| 3116 | + | // HasEntriesBefore is a helper method to define mock.On call | |||
| 3117 | + | // - ctx context.Context | |||
| 3118 | + | // - before time.Time | |||
| 3119 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx interface{}, before interface{}) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3120 | + | return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)} | |||
| 3121 | + | } | |||
| 3122 | + | ||||
| 3123 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3124 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3125 | + | var arg0 context.Context | |||
| 3126 | + | if args[0] != nil { | |||
| 3127 | + | arg0 = args[0].(context.Context) | |||
| 3128 | + | } | |||
| 3129 | + | var arg1 time.Time | |||
| 3130 | + | if args[1] != nil { | |||
| 3131 | + | arg1 = args[1].(time.Time) | |||
| 3132 | + | } | |||
| 3133 | + | run( | |||
| 3134 | + | arg0, | |||
| 3135 | + | arg1, | |||
| 3136 | + | ) | |||
| 3137 | + | }) | |||
| 3138 | + | return _c | |||
| 3139 | + | } | |||
| 3140 | + | ||||
| 3141 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3142 | + | _c.Call.Return(b, err) | |||
| 3143 | + | return _c | |||
| 3144 | + | } | |||
| 3145 | + | ||||
| 3146 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3147 | + | _c.Call.Return(run) | |||
| 3148 | + | return _c | |||
| 3149 | + | } | |||
| 3150 | + | ||||
| 3151 | + | // HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository | |||
| 3152 | + | func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 3153 | + | ret := _mock.Called(ctx, domain, before) | |||
| 3154 | + | ||||
| 3155 | + | if len(ret) == 0 { | |||
| 3156 | + | panic("no return value specified for HasEntriesByDomainBefore") | |||
| 3157 | + | } | |||
| 3158 | + | ||||
| 3159 | + | var r0 bool | |||
| 3160 | + | var r1 error | |||
| 3161 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok { | |||
| 3162 | + | return returnFunc(ctx, domain, before) | |||
| 3163 | + | } | |||
| 3164 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok { | |||
| 3165 | + | r0 = returnFunc(ctx, domain, before) | |||
| 3166 | + | } else { | |||
| 3167 | + | r0 = ret.Get(0).(bool) | |||
| 3168 | + | } | |||
| 3169 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { | |||
| 3170 | + | r1 = returnFunc(ctx, domain, before) | |||
| 3171 | + | } else { | |||
| 3172 | + | r1 = ret.Error(1) | |||
| 3173 | + | } | |||
| 3174 | + | return r0, r1 | |||
| 3175 | + | } | |||
| 3176 | + | ||||
| 3177 | + | // MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore' | |||
| 3178 | + | type MockHistoryRepository_HasEntriesByDomainBefore_Call struct { | |||
| 3179 | + | *mock.Call | |||
| 3180 | + | } | |||
| 3181 | + | ||||
| 3182 | + | // HasEntriesByDomainBefore is a helper method to define mock.On call | |||
| 3183 | + | // - ctx context.Context | |||
| 3184 | + | // - domain string | |||
| 3185 | + | // - before time.Time | |||
| 3186 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx interface{}, domain interface{}, before interface{}) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3187 | + | return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)} | |||
| 3188 | + | } | |||
| 3189 | + | ||||
| 3190 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3191 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3192 | + | var arg0 context.Context | |||
| 3193 | + | if args[0] != nil { | |||
| 3194 | + | arg0 = args[0].(context.Context) | |||
| 3195 | + | } | |||
| 3196 | + | var arg1 string | |||
| 3197 | + | if args[1] != nil { | |||
| 3198 | + | arg1 = args[1].(string) | |||
| 3199 | + | } | |||
| 3200 | + | var arg2 time.Time | |||
| 3201 | + | if args[2] != nil { | |||
| 3202 | + | arg2 = args[2].(time.Time) | |||
| 3203 | + | } | |||
| 3204 | + | run( | |||
| 3205 | + | arg0, | |||
| 3206 | + | arg1, | |||
| 3207 | + | arg2, | |||
| 3208 | + | ) | |||
| 3209 | + | }) | |||
| 3210 | + | return _c | |||
| 3211 | + | } | |||
| 3212 | + | ||||
| 3213 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3214 | + | _c.Call.Return(b, err) | |||
| 3215 | + | return _c | |||
| 3216 | + | } | |||
| 3217 | + | ||||
| 3218 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3219 | + | _c.Call.Return(run) | |||
| 3220 | + | return _c | |||
| 3221 | + | } | |||
| 3222 | + | ||||
| 2931 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | 3223 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | ||
| 2932 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | 3224 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | ||
| 2933 | ret := _mock.Called(ctx, url) | 3225 | ret := _mock.Called(ctx, url) | ||
A internal/infrastructure/cef/favicon_systemviews.go +15 -0
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |||
| 6 | + | ||||
| 7 | + | // SetSystemviewFaviconService exposes the local favicon cache to internal | |||
| 8 | + | // systemview pages. The scheme handler only serves cached PNGs; it does not | |||
| 9 | + | // fetch remote favicons on behalf of History rendering. | |||
| 10 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |||
| 11 | + | if e == nil || e.schemeHandler == nil { | |||
| 12 | + | return | |||
| 13 | + | } | |||
| 14 | + | e.schemeHandler.setFaviconService(service) | |||
| 15 | + | } | |||
M internal/infrastructure/cef/scheme_handler.go +251 -4
| @@ -13,7 +13,9 @@ import ( | |||||
| 13 | "net" | 13 | "net" | ||
| 14 | "net/http" | 14 | "net/http" | ||
| 15 | "net/url" | 15 | "net/url" | ||
| 16 | + | "os" | |||
| 16 | "path" | 17 | "path" | ||
| 18 | + | "strconv" | |||
| 17 | "strings" | 19 | "strings" | ||
| 18 | "sync" | 20 | "sync" | ||
| 19 | "time" | 21 | "time" | ||
| @@ -23,6 +25,7 @@ import ( | |||||
| 23 | purecef "github.com/bnema/purego-cef/cef" | 25 | purecef "github.com/bnema/purego-cef/cef" | ||
| 24 | 26 | ||||
| 25 | "github.com/bnema/dumber/internal/application/port" | 27 | "github.com/bnema/dumber/internal/application/port" | ||
| 28 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 26 | "github.com/bnema/dumber/internal/infrastructure/netguard" | 29 | "github.com/bnema/dumber/internal/infrastructure/netguard" | ||
| 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 30 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 28 | "github.com/bnema/dumber/internal/logging" | 31 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -57,6 +60,7 @@ type dumbSchemeHandler struct { | |||||
| 57 | ctx context.Context | 60 | ctx context.Context | ||
| 58 | messageRouter *MessageRouter | 61 | messageRouter *MessageRouter | ||
| 59 | transcoder port.MediaTranscoder | 62 | transcoder port.MediaTranscoder | ||
| 63 | + | faviconService port.FaviconService | |||
| 60 | assets embed.FS | 64 | assets embed.FS | ||
| 61 | assetsSet bool | 65 | assetsSet bool | ||
| 62 | assetDir string | 66 | assetDir string | ||
| @@ -123,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||||
| 123 | h.logger.Debug().Msg("assets filesystem configured") | 127 | h.logger.Debug().Msg("assets filesystem configured") | ||
| 124 | } | 128 | } | ||
| 125 | 129 | ||||
| 130 | + | func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) { | |||
| 131 | + | h.mu.Lock() | |||
| 132 | + | defer h.mu.Unlock() | |||
| 133 | + | h.faviconService = service | |||
| 134 | + | } | |||
| 135 | + | ||||
| 126 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | 136 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | ||
| 127 | // for requests to either dumb:// or the internal https origin. | 137 | // for requests to either dumb:// or the internal https origin. | ||
| 128 | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | 138 | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | ||
| @@ -171,6 +181,12 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s | |||||
| 171 | // handleAPI routes API requests to the message router or built-in handlers. | 181 | // handleAPI routes API requests to the message router or built-in handlers. | ||
| 172 | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | 182 | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | ||
| 173 | if strings.EqualFold(method, http.MethodOptions) { | 183 | if strings.EqualFold(method, http.MethodOptions) { | ||
| 184 | + | if path == "/api/config" || path == "/api/config/default" { | |||
| 185 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |||
| 186 | + | return denied | |||
| 187 | + | } | |||
| 188 | + | return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |||
| 189 | + | } | |||
| 174 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | 190 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | ||
| 175 | } | 191 | } | ||
| 176 | if isAPIGetMethod(method) { | 192 | if isAPIGetMethod(method) { | ||
| @@ -197,17 +213,19 @@ func isAPIPostMethod(method string) bool { | |||||
| 197 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 213 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | ||
| 198 | switch path { | 214 | switch path { | ||
| 199 | case "/api/config": | 215 | case "/api/config": | ||
| 200 | - | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | 216 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { |
| 201 | return denied, true | 217 | return denied, true | ||
| 202 | } | 218 | } | ||
| 203 | return h.handleConfigAPI(h.currentConfigPayload), true | 219 | return h.handleConfigAPI(h.currentConfigPayload), true | ||
| 204 | case "/api/config/default": | 220 | case "/api/config/default": | ||
| 205 | - | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | 221 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { |
| 206 | return denied, true | 222 | return denied, true | ||
| 207 | } | 223 | } | ||
| 208 | return h.handleConfigAPI(h.defaultConfigPayload), true | 224 | return h.handleConfigAPI(h.defaultConfigPayload), true | ||
| 209 | case "/api/transcode": | 225 | case "/api/transcode": | ||
| 210 | return h.handleTranscodeAPI(request), true | 226 | return h.handleTranscodeAPI(request), true | ||
| 227 | + | case "/api/favicon": | |||
| 228 | + | return h.handleFaviconAPI(request), true | |||
| 211 | default: | 229 | default: | ||
| 212 | return nil, false | 230 | return nil, false | ||
| 213 | } | 231 | } | ||
| @@ -259,6 +277,103 @@ func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) pu | |||||
| 259 | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | 277 | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | ||
| 260 | } | 278 | } | ||
| 261 | 279 | ||||
| 280 | + | func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler { | |||
| 281 | + | if request == nil { | |||
| 282 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 283 | + | } | |||
| 284 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |||
| 285 | + | if origin != "" { | |||
| 286 | + | if isTrustedSystemviewURL(origin) { | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 290 | + | } | |||
| 291 | + | referrer := strings.TrimSpace(request.GetReferrerURL()) | |||
| 292 | + | if referrer == "" { | |||
| 293 | + | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |||
| 294 | + | } | |||
| 295 | + | if !isTrustedSystemviewURL(referrer) { | |||
| 296 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 297 | + | } | |||
| 298 | + | return nil | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | const systemviewFaviconSize = 32 | |||
| 302 | + | ||||
| 303 | + | func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler { | |||
| 304 | + | if denied := h.rejectUntrustedFaviconRequester(request); denied != nil { | |||
| 305 | + | return denied | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | h.mu.RLock() | |||
| 309 | + | service := h.faviconService | |||
| 310 | + | h.mu.RUnlock() | |||
| 311 | + | if service == nil { | |||
| 312 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"}) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | parsed, err := url.Parse(request.GetURL()) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |||
| 318 | + | } | |||
| 319 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |||
| 320 | + | if domain == "" { | |||
| 321 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"}) | |||
| 322 | + | } | |||
| 323 | + | size := systemviewFaviconSize | |||
| 324 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |||
| 325 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |||
| 326 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |||
| 327 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"}) | |||
| 328 | + | } | |||
| 329 | + | size = parsedSize | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | return newFaviconResourceHandler(service, domain, size) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler { | |||
| 336 | + | if request == nil { | |||
| 337 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 338 | + | } | |||
| 339 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |||
| 340 | + | if origin != "" { | |||
| 341 | + | if isTrustedSystemviewURL(origin) { | |||
| 342 | + | return nil | |||
| 343 | + | } | |||
| 344 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 345 | + | } | |||
| 346 | + | referrer := strings.TrimSpace(request.GetReferrerURL()) | |||
| 347 | + | if referrer == "" { | |||
| 348 | + | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |||
| 349 | + | } | |||
| 350 | + | if !isTrustedSystemviewURL(referrer) { | |||
| 351 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 352 | + | } | |||
| 353 | + | return nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func isTrustedSystemviewURL(raw string) bool { | |||
| 357 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |||
| 358 | + | if err != nil || parsed.Scheme == "" { | |||
| 359 | + | return false | |||
| 360 | + | } | |||
| 361 | + | if strings.EqualFold(parsed.Scheme, actualInternalScheme) { | |||
| 362 | + | return strings.EqualFold(parsed.Host, actualInternalHost) | |||
| 363 | + | } | |||
| 364 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 365 | + | return false | |||
| 366 | + | } | |||
| 367 | + | host := parsed.Host | |||
| 368 | + | if host == "" { | |||
| 369 | + | host = parsed.Opaque | |||
| 370 | + | } | |||
| 371 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |||
| 372 | + | host = host[:idx] | |||
| 373 | + | } | |||
| 374 | + | return isInternalPageHost(host) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 262 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | 377 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 263 | if h.transcoder == nil { | 378 | if h.transcoder == nil { | ||
| 264 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | 379 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | ||
| @@ -357,9 +472,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||||
| 357 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | 472 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | ||
| 358 | data, err := resolveConfigPayload(build) | 473 | data, err := resolveConfigPayload(build) | ||
| 359 | if err != nil { | 474 | if err != nil { | ||
| 360 | - | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | 475 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) |
| 361 | } | 476 | } | ||
| 362 | - | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data) | 477 | + | return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data) |
| 363 | } | 478 | } | ||
| 364 | 479 | ||||
| 365 | // handleClipboardSet receives copied text from JS copy/cut events and writes | 480 | // handleClipboardSet receives copied text from JS copy/cut events and writes | ||
| @@ -789,6 +904,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str | |||||
| 789 | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | 904 | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | ||
| 790 | } | 905 | } | ||
| 791 | 906 | ||||
| 907 | + | func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |||
| 908 | + | return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"}) | |||
| 909 | + | } | |||
| 910 | + | ||||
| 792 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | 911 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | ||
| 793 | escaped := html.EscapeString(msg) | 912 | escaped := html.EscapeString(msg) | ||
| 794 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | 913 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | ||
| @@ -804,6 +923,134 @@ func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef | |||||
| 804 | return h.newAPIRawResourceHandler(status, "application/json", data) | 923 | return h.newAPIRawResourceHandler(status, "application/json", data) | ||
| 805 | } | 924 | } | ||
| 806 | 925 | ||||
| 926 | + | func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler { | |||
| 927 | + | data, err := json.Marshal(v) | |||
| 928 | + | if err != nil { | |||
| 929 | + | fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"}) | |||
| 930 | + | return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback) | |||
| 931 | + | } | |||
| 932 | + | return h.newPrivateAPIRawResourceHandler(status, "application/json", data) | |||
| 933 | + | } | |||
| 934 | + | ||||
| 935 | + | func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler { | |||
| 936 | + | return cefNewResourceHandler(&faviconResourceHandler{ | |||
| 937 | + | service: service, | |||
| 938 | + | domain: domain, | |||
| 939 | + | size: size, | |||
| 940 | + | done: make(chan struct{}), | |||
| 941 | + | headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 942 | + | }) | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | type faviconResourceHandler struct { | |||
| 946 | + | service port.FaviconService | |||
| 947 | + | domain string | |||
| 948 | + | size int | |||
| 949 | + | headers map[string]string | |||
| 950 | + | once sync.Once | |||
| 951 | + | done chan struct{} | |||
| 952 | + | data []byte | |||
| 953 | + | contentType string | |||
| 954 | + | statusCode int | |||
| 955 | + | offset int | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | func (rh *faviconResourceHandler) load() { | |||
| 959 | + | defer close(rh.done) | |||
| 960 | + | rh.statusCode = http.StatusNotFound | |||
| 961 | + | rh.contentType = "application/json" | |||
| 962 | + | rh.data = []byte(`{"error":"favicon not cached"}`) | |||
| 963 | + | if rh.service == nil || !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |||
| 964 | + | return | |||
| 965 | + | } | |||
| 966 | + | path := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |||
| 967 | + | if path == "" { | |||
| 968 | + | return | |||
| 969 | + | } | |||
| 970 | + | data, err := os.ReadFile(path) | |||
| 971 | + | if err != nil || len(data) == 0 { | |||
| 972 | + | return | |||
| 973 | + | } | |||
| 974 | + | rh.statusCode = http.StatusOK | |||
| 975 | + | rh.contentType = "image/png" | |||
| 976 | + | rh.data = data | |||
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | |||
| 980 | + | if handleRequest != nil { | |||
| 981 | + | *handleRequest = 0 | |||
| 982 | + | } | |||
| 983 | + | rh.once.Do(func() { | |||
| 984 | + | go func() { | |||
| 985 | + | rh.load() | |||
| 986 | + | if callback != nil { | |||
| 987 | + | callback.Cont() | |||
| 988 | + | } | |||
| 989 | + | }() | |||
| 990 | + | }) | |||
| 991 | + | return 1 | |||
| 992 | + | } | |||
| 993 | + | ||||
| 994 | + | func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 { | |||
| 995 | + | rh.once.Do(func() { | |||
| 996 | + | go func() { | |||
| 997 | + | rh.load() | |||
| 998 | + | if callback != nil { | |||
| 999 | + | callback.Cont() | |||
| 1000 | + | } | |||
| 1001 | + | }() | |||
| 1002 | + | }) | |||
| 1003 | + | return 1 | |||
| 1004 | + | } | |||
| 1005 | + | ||||
| 1006 | + | func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) { | |||
| 1007 | + | <-rh.done | |||
| 1008 | + | response.SetStatus(int32(rh.statusCode)) | |||
| 1009 | + | if text := http.StatusText(rh.statusCode); text != "" { | |||
| 1010 | + | response.SetStatusText(text) | |||
| 1011 | + | } | |||
| 1012 | + | mimeType, charset := splitMimeCharset(rh.contentType) | |||
| 1013 | + | response.SetMimeType(mimeType) | |||
| 1014 | + | if charset != "" { | |||
| 1015 | + | response.SetCharset(charset) | |||
| 1016 | + | } | |||
| 1017 | + | for name, value := range rh.headers { | |||
| 1018 | + | response.SetHeaderByName(name, value, 1) | |||
| 1019 | + | } | |||
| 1020 | + | if responseLength != nil { | |||
| 1021 | + | *responseLength = int64(len(rh.data)) | |||
| 1022 | + | } | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |||
| 1026 | + | return 0 | |||
| 1027 | + | } | |||
| 1028 | + | ||||
| 1029 | + | func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 { | |||
| 1030 | + | <-rh.done | |||
| 1031 | + | if rh.offset >= len(rh.data) { | |||
| 1032 | + | return 0 | |||
| 1033 | + | } | |||
| 1034 | + | remaining := len(rh.data) - rh.offset | |||
| 1035 | + | toRead := int(bytesToRead) | |||
| 1036 | + | if toRead > remaining { | |||
| 1037 | + | toRead = remaining | |||
| 1038 | + | } | |||
| 1039 | + | dst := unsafe.Slice((*byte)(dataOut), toRead) | |||
| 1040 | + | copy(dst, rh.data[rh.offset:rh.offset+toRead]) | |||
| 1041 | + | rh.offset += toRead | |||
| 1042 | + | if bytesRead != nil { | |||
| 1043 | + | *bytesRead = int32(toRead) | |||
| 1044 | + | } | |||
| 1045 | + | return 1 | |||
| 1046 | + | } | |||
| 1047 | + | ||||
| 1048 | + | func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |||
| 1049 | + | return 0 | |||
| 1050 | + | } | |||
| 1051 | + | ||||
| 1052 | + | func (rh *faviconResourceHandler) Cancel() {} | |||
| 1053 | + | ||||
| 807 | // Open handles the request immediately (synchronous). | 1054 | // Open handles the request immediately (synchronous). | ||
| 808 | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | 1055 | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | ||
| 809 | // Set handleRequest = true (1) to indicate we handle it immediately. | 1056 | // Set handleRequest = true (1) to indicate we handle it immediately. | ||
M internal/infrastructure/cef/scheme_handler_test.go +187 -0
| @@ -26,6 +26,31 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |||||
| 26 | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | 26 | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | ||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | + | func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) { | |||
| 30 | + | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type panicFaviconService struct{} | |||
| 34 | + | ||||
| 35 | + | func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) { | |||
| 36 | + | panic("unexpected favicon lookup") | |||
| 37 | + | } | |||
| 38 | + | func (panicFaviconService) Get(context.Context, string) ([]byte, error) { | |||
| 39 | + | panic("unexpected favicon lookup") | |||
| 40 | + | } | |||
| 41 | + | func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") } | |||
| 42 | + | func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") } | |||
| 43 | + | func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") } | |||
| 44 | + | func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") } | |||
| 45 | + | func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error { | |||
| 46 | + | panic("unexpected favicon lookup") | |||
| 47 | + | } | |||
| 48 | + | func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") } | |||
| 49 | + | func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |||
| 50 | + | panic("unexpected favicon lookup") | |||
| 51 | + | } | |||
| 52 | + | func (panicFaviconService) Close() {} | |||
| 53 | + | ||||
| 29 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | 54 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | ||
| 30 | data, err := resolveConfigPayload(func() ([]byte, error) { | 55 | data, err := resolveConfigPayload(func() ([]byte, error) { | ||
| 31 | return []byte(`{"engine_type":"cef"}`), nil | 56 | return []byte(`{"engine_type":"cef"}`), nil | ||
| @@ -96,6 +121,168 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||||
| 96 | require.Positive(t, responseLength) | 121 | require.Positive(t, responseLength) | ||
| 97 | } | 122 | } | ||
| 98 | 123 | ||||
| 124 | + | func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testing.T) { | |||
| 125 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 126 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 127 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 128 | + | ||||
| 129 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | ||||
| 132 | + | trustedOrigin := cefmocks.NewMockRequest(t) | |||
| 133 | + | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 134 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedOrigin)) | |||
| 135 | + | ||||
| 136 | + | trustedReferrer := cefmocks.NewMockRequest(t) | |||
| 137 | + | trustedReferrer.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 138 | + | trustedReferrer.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |||
| 139 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedReferrer)) | |||
| 140 | + | ||||
| 141 | + | emptyContext := cefmocks.NewMockRequest(t) | |||
| 142 | + | emptyContext.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 143 | + | emptyContext.EXPECT().GetReferrerURL().Return("").Once() | |||
| 144 | + | emptyContext.EXPECT().GetHeaderByName("Referer").Return("").Once() | |||
| 145 | + | denied := h.rejectUntrustedConfigRequester(emptyContext) | |||
| 146 | + | require.NotNil(t, denied) | |||
| 147 | + | ||||
| 148 | + | response := cefmocks.NewMockResponse(t) | |||
| 149 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 150 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 151 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 152 | + | expectPrivateAPIResponseHeaders(response) | |||
| 153 | + | var responseLength int64 | |||
| 154 | + | denied.GetResponseHeaders(response, &responseLength, 0) | |||
| 155 | + | require.Positive(t, responseLength) | |||
| 156 | + | ||||
| 157 | + | untrustedOrigin := cefmocks.NewMockRequest(t) | |||
| 158 | + | untrustedOrigin.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 159 | + | require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin)) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 163 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 164 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 165 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 166 | + | ||||
| 167 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | ||||
| 170 | + | response := cefmocks.NewMockResponse(t) | |||
| 171 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 172 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 173 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 174 | + | expectPrivateAPIResponseHeaders(response) | |||
| 175 | + | ||||
| 176 | + | handler := h.handleConfigAPI(h.currentConfigPayload) | |||
| 177 | + | var responseLength int64 | |||
| 178 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 179 | + | require.Positive(t, responseLength) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 183 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 184 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 185 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 186 | + | ||||
| 187 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | ||||
| 190 | + | request := cefmocks.NewMockRequest(t) | |||
| 191 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 192 | + | ||||
| 193 | + | response := cefmocks.NewMockResponse(t) | |||
| 194 | + | response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once() | |||
| 195 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once() | |||
| 196 | + | response.EXPECT().SetMimeType("text/plain").Once() | |||
| 197 | + | response.EXPECT().SetCharset("utf-8").Once() | |||
| 198 | + | expectPrivateAPIResponseHeaders(response) | |||
| 199 | + | ||||
| 200 | + | handler := h.handleAPI(nil, http.MethodOptions, "/api/config", request) | |||
| 201 | + | var responseLength int64 | |||
| 202 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 203 | + | require.Zero(t, responseLength) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 207 | + | t.Parallel() | |||
| 208 | + | ||||
| 209 | + | for _, raw := range []string{ | |||
| 210 | + | "https://dumber.invalid/history", | |||
| 211 | + | "dumb://history", | |||
| 212 | + | "dumb:history", | |||
| 213 | + | } { | |||
| 214 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |||
| 215 | + | } | |||
| 216 | + | for _, raw := range []string{ | |||
| 217 | + | "", | |||
| 218 | + | "https://evil.example/history", | |||
| 219 | + | "dumb://api/favicon", | |||
| 220 | + | "dumb://evil/history", | |||
| 221 | + | } { | |||
| 222 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | |||
| 227 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 228 | + | require.NoError(t, err) | |||
| 229 | + | ||||
| 230 | + | request := cefmocks.NewMockRequest(t) | |||
| 231 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 232 | + | ||||
| 233 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(request)) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedReferrer(t *testing.T) { | |||
| 237 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 238 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 239 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 240 | + | ||||
| 241 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | ||||
| 244 | + | request := cefmocks.NewMockRequest(t) | |||
| 245 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 246 | + | ||||
| 247 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(request)) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(t *testing.T) { | |||
| 251 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 252 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 253 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 254 | + | ||||
| 255 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | ||||
| 258 | + | trusted := cefmocks.NewMockRequest(t) | |||
| 259 | + | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 260 | + | trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |||
| 261 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(trusted)) | |||
| 262 | + | ||||
| 263 | + | untrusted := cefmocks.NewMockRequest(t) | |||
| 264 | + | untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 265 | + | untrusted.EXPECT().GetReferrerURL().Return("").Once() | |||
| 266 | + | untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once() | |||
| 267 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(untrusted)) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) { | |||
| 271 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 272 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 273 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 274 | + | ||||
| 275 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 276 | + | require.NoError(t, err) | |||
| 277 | + | h.setFaviconService(panicFaviconService{}) | |||
| 278 | + | ||||
| 279 | + | request := cefmocks.NewMockRequest(t) | |||
| 280 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 281 | + | request.EXPECT().GetURL().Return(actualInternalOrigin + "/api/favicon?domain=example.com&size=32").Once() | |||
| 282 | + | ||||
| 283 | + | require.NotNil(t, h.handleFaviconAPI(request)) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 99 | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | 286 | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | ||
| 100 | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | 287 | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | ||
| 101 | require.NoError(t, err) | 288 | require.NoError(t, err) | ||
M internal/infrastructure/handlers/homepage/history.go +72 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -35,6 +36,12 @@ type timelineByDomainRequest struct { | |||||
| 35 | Offset int `json:"offset"` | 36 | Offset int `json:"offset"` | ||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 39 | + | type timelineWindowRequest struct { | |||
| 40 | + | RequestID string `json:"requestId"` | |||
| 41 | + | Before string `json:"before"` | |||
| 42 | + | Domain string `json:"domain"` | |||
| 43 | + | } | |||
| 44 | + | ||||
| 38 | // HandleTimeline handles history_timeline messages. | 45 | // HandleTimeline handles history_timeline messages. | ||
| 39 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | 46 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | ||
| 40 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 47 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
| @@ -50,6 +57,9 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||||
| 50 | Int("limit", req.Limit). | 57 | Int("limit", req.Limit). | ||
| 51 | Int("offset", req.Offset). | 58 | Int("offset", req.Offset). | ||
| 52 | Msg("handling history_timeline") | 59 | Msg("handling history_timeline") | ||
| 60 | + | if req.Limit <= 0 { | |||
| 61 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading")), nil | |||
| 62 | + | } | |||
| 53 | 63 | ||||
| 54 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | 64 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | ||
| 55 | if err != nil { | 65 | if err != nil { | ||
| @@ -81,6 +91,9 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||||
| 81 | Int("limit", req.Limit). | 91 | Int("limit", req.Limit). | ||
| 82 | Int("offset", req.Offset). | 92 | Int("offset", req.Offset). | ||
| 83 | Msg("handling history_timeline_by_domain") | 93 | Msg("handling history_timeline_by_domain") | ||
| 94 | + | if req.Limit <= 0 { | |||
| 95 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading")), nil | |||
| 96 | + | } | |||
| 84 | 97 | ||||
| 85 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | 98 | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | ||
| 86 | if err != nil { | 99 | if err != nil { | ||
| @@ -91,6 +104,42 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||||
| 91 | }) | 104 | }) | ||
| 92 | } | 105 | } | ||
| 93 | 106 | ||||
| 107 | + | // HandleTimelineWindow handles history_timeline_window messages. | |||
| 108 | + | func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||
| 109 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 110 | + | log := logging.FromContext(ctx) | |||
| 111 | + | ||||
| 112 | + | var req timelineWindowRequest | |||
| 113 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 114 | + | return NewErrorResponse("", err), nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | domain := strings.TrimSpace(req.Domain) | |||
| 118 | + | ||||
| 119 | + | var before time.Time | |||
| 120 | + | if strings.TrimSpace(req.Before) != "" { | |||
| 121 | + | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | |||
| 124 | + | } | |||
| 125 | + | before = parsed | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | log.Debug(). | |||
| 129 | + | Str("request_id", req.RequestID). | |||
| 130 | + | Str("domain", domain). | |||
| 131 | + | Time("before", before). | |||
| 132 | + | Msg("handling history_timeline_window") | |||
| 133 | + | ||||
| 134 | + | window, err := h.historyUC.GetRecentWindow(ctx, before, domain) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return NewSuccessResponse(req.RequestID, window), nil | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 94 | // searchRequest is the payload for history_search_fts messages. | 143 | // searchRequest is the payload for history_search_fts messages. | ||
| 95 | type searchRequest struct { | 144 | type searchRequest struct { | ||
| 96 | RequestID string `json:"requestId"` | 145 | RequestID string `json:"requestId"` | ||
| @@ -209,6 +258,29 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | |||||
| 209 | }) | 258 | }) | ||
| 210 | } | 259 | } | ||
| 211 | 260 | ||||
| 261 | + | // HandleStats handles history_stats messages. | |||
| 262 | + | func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler { | |||
| 263 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 264 | + | log := logging.FromContext(ctx) | |||
| 265 | + | ||||
| 266 | + | var req struct { | |||
| 267 | + | RequestID string `json:"requestId"` | |||
| 268 | + | } | |||
| 269 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 270 | + | return NewErrorResponse("", err), nil | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats") | |||
| 274 | + | ||||
| 275 | + | stats, err := h.historyUC.GetStats(ctx) | |||
| 276 | + | if err != nil { | |||
| 277 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | return NewSuccessResponse(req.RequestID, stats), nil | |||
| 281 | + | }) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 212 | // HandleAnalytics handles history_analytics messages. | 284 | // HandleAnalytics handles history_analytics messages. | ||
| 213 | func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler { | 285 | func (h *HistoryHandlers) HandleAnalytics() port.WebUIMessageHandler { | ||
| 214 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 286 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
M internal/infrastructure/handlers/homepage/registry.go +2 -0
| @@ -30,10 +30,12 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | ||
| 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | ||
| 32 | handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain() | 32 | handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain() | ||
| 33 | + | handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow() | |||
| 33 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | 34 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | ||
| 34 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | 35 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | ||
| 35 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | 36 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | ||
| 36 | handlers["history_clear_all"] = historyHandlers.HandleClearAll() | 37 | handlers["history_clear_all"] = historyHandlers.HandleClearAll() | ||
| 38 | + | handlers["history_stats"] = historyHandlers.HandleStats() | |||
| 37 | handlers["history_analytics"] = historyHandlers.HandleAnalytics() | 39 | handlers["history_analytics"] = historyHandlers.HandleAnalytics() | ||
| 38 | handlers["history_domain_stats"] = historyHandlers.HandleDomainStats() | 40 | handlers["history_domain_stats"] = historyHandlers.HandleDomainStats() | ||
| 39 | handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain() | 41 | handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain() | ||
M internal/infrastructure/persistence/sqlite/history_repo.go +66 -0
| @@ -483,6 +483,13 @@ func isExpectedFTSError(err error) bool { | |||||
| 483 | } | 483 | } | ||
| 484 | 484 | ||||
| 485 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 485 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 486 | + | if limit <= 0 { | |||
| 487 | + | rows, err := r.queries.GetAllRecentHistory(ctx) | |||
| 488 | + | if err != nil { | |||
| 489 | + | return nil, err | |||
| 490 | + | } | |||
| 491 | + | return historyEntriesFromRows(rows), nil | |||
| 492 | + | } | |||
| 486 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | 493 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | ||
| 487 | Limit: int64(limit), | 494 | Limit: int64(limit), | ||
| 488 | Offset: int64(offset), | 495 | Offset: int64(offset), | ||
| @@ -499,6 +506,13 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||||
| 499 | if domain == "" { | 506 | if domain == "" { | ||
| 500 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | 507 | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | ||
| 501 | } | 508 | } | ||
| 509 | + | if limit <= 0 { | |||
| 510 | + | rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) | |||
| 511 | + | if err != nil { | |||
| 512 | + | return nil, err | |||
| 513 | + | } | |||
| 514 | + | return historyEntriesFromRows(rows), nil | |||
| 515 | + | } | |||
| 502 | rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{ | 516 | rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{ | ||
| 503 | Domain: sql.NullString{String: domain, Valid: true}, | 517 | Domain: sql.NullString{String: domain, Valid: true}, | ||
| 504 | Limit: int64(limit), | 518 | Limit: int64(limit), | ||
| @@ -510,6 +524,58 @@ func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limi | |||||
| 510 | return historyEntriesFromRows(rows), nil | 524 | return historyEntriesFromRows(rows), nil | ||
| 511 | } | 525 | } | ||
| 512 | 526 | ||||
| 527 | + | func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 528 | + | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | |||
| 529 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 530 | + | After: sql.NullTime{Time: after, Valid: true}, | |||
| 531 | + | }) | |||
| 532 | + | if err != nil { | |||
| 533 | + | return nil, err | |||
| 534 | + | } | |||
| 535 | + | return historyEntriesFromRows(rows), nil | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 539 | + | rawDomain := domain | |||
| 540 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 541 | + | if domain == "" { | |||
| 542 | + | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 543 | + | } | |||
| 544 | + | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | |||
| 545 | + | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 546 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 547 | + | After: sql.NullTime{Time: after, Valid: true}, | |||
| 548 | + | }) | |||
| 549 | + | if err != nil { | |||
| 550 | + | return nil, err | |||
| 551 | + | } | |||
| 552 | + | return historyEntriesFromRows(rows), nil | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 556 | + | value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true}) | |||
| 557 | + | if err != nil { | |||
| 558 | + | return false, err | |||
| 559 | + | } | |||
| 560 | + | return value != 0, nil | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 564 | + | rawDomain := domain | |||
| 565 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 566 | + | if domain == "" { | |||
| 567 | + | return false, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 568 | + | } | |||
| 569 | + | value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{ | |||
| 570 | + | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 571 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 572 | + | }) | |||
| 573 | + | if err != nil { | |||
| 574 | + | return false, err | |||
| 575 | + | } | |||
| 576 | + | return value != 0, nil | |||
| 577 | + | } | |||
| 578 | + | ||||
| 513 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 579 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| 514 | if days <= 0 { | 580 | if days <= 0 { | ||
| 515 | return nil, fmt.Errorf("days must be positive, got %d", days) | 581 | return nil, fmt.Errorf("days must be positive, got %d", days) | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +5 -1
| @@ -191,6 +191,10 @@ func TestHistoryRepository_CRUD(t *testing.T) { | |||||
| 191 | require.NoError(t, err) | 191 | require.NoError(t, err) | ||
| 192 | require.Len(t, recent, 1) | 192 | require.Len(t, recent, 1) | ||
| 193 | 193 | ||||
| 194 | + | allRecent, err := repo.GetRecent(ctx, 0, 0) | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | require.Len(t, allRecent, 1) | |||
| 197 | + | ||||
| 194 | // Delete | 198 | // Delete | ||
| 195 | require.NoError(t, repo.Delete(ctx, found.ID)) | 199 | require.NoError(t, repo.Delete(ctx, found.ID)) | ||
| 196 | 200 | ||||
| @@ -862,7 +866,7 @@ func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing. | |||||
| 862 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | 866 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | ||
| 863 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | 867 | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | ||
| 864 | 868 | ||||
| 865 | - | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0) | 869 | + | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0) |
| 866 | require.NoError(t, err) | 870 | require.NoError(t, err) | ||
| 867 | require.Len(t, exampleEntries, 2) | 871 | require.Len(t, exampleEntries, 2) | ||
| 868 | for _, entry := range exampleEntries { | 872 | for _, entry := range exampleEntries { | ||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +28 -0
| @@ -156,6 +156,34 @@ func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain st | |||||
| 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | 156 | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | ||
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | + | func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 160 | + | if err := r.init(ctx); err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | return r.repo.GetRecentWindow(ctx, before, after) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (r *LazyHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 167 | + | if err := r.init(ctx); err != nil { | |||
| 168 | + | return nil, err | |||
| 169 | + | } | |||
| 170 | + | return r.repo.GetRecentWindowByDomain(ctx, domain, before, after) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 174 | + | if err := r.init(ctx); err != nil { | |||
| 175 | + | return false, err | |||
| 176 | + | } | |||
| 177 | + | return r.repo.HasEntriesBefore(ctx, before) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 181 | + | if err := r.init(ctx); err != nil { | |||
| 182 | + | return false, err | |||
| 183 | + | } | |||
| 184 | + | return r.repo.HasEntriesByDomainBefore(ctx, domain, before) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 159 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 187 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| 160 | if err := r.init(ctx); err != nil { | 188 | if err := r.init(ctx); err != nil { | ||
| 161 | return nil, err | 189 | return nil, err | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +21 -0
| @@ -7,6 +7,27 @@ WHERE domain = @domain | |||||
| 7 | ORDER BY last_visited DESC | 7 | ORDER BY last_visited DESC | ||
| 8 | LIMIT @limit OFFSET @offset; | 8 | LIMIT @limit OFFSET @offset; | ||
| 9 | 9 | ||||
| 10 | + | -- name: GetAllRecentHistoryByDomain :many | |||
| 11 | + | SELECT * FROM history | |||
| 12 | + | WHERE domain = @domain | |||
| 13 | + | ORDER BY last_visited DESC; | |||
| 14 | + | ||||
| 15 | + | -- name: GetRecentHistoryWindow :many | |||
| 16 | + | SELECT * FROM history | |||
| 17 | + | WHERE last_visited < @before AND last_visited >= @after | |||
| 18 | + | ORDER BY last_visited DESC; | |||
| 19 | + | ||||
| 20 | + | -- name: GetRecentHistoryWindowByDomain :many | |||
| 21 | + | SELECT * FROM history | |||
| 22 | + | WHERE domain = @domain AND last_visited < @before AND last_visited >= @after | |||
| 23 | + | ORDER BY last_visited DESC; | |||
| 24 | + | ||||
| 25 | + | -- name: HasHistoryBefore :one | |||
| 26 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1); | |||
| 27 | + | ||||
| 28 | + | -- name: HasHistoryByDomainBefore :one | |||
| 29 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1); | |||
| 30 | + | ||||
| 10 | -- name: GetHistoryByURL :one | 31 | -- name: GetHistoryByURL :one | ||
| 11 | SELECT * FROM history WHERE url = ? LIMIT 1; | 32 | SELECT * FROM history WHERE url = ? LIMIT 1; | ||
| 12 | 33 | ||||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +152 -0
| @@ -144,6 +144,44 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||||
| 144 | return items, nil | 144 | return items, nil | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | + | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | |||
| 148 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 149 | + | WHERE domain = ?1 | |||
| 150 | + | ORDER BY last_visited DESC | |||
| 151 | + | ` | |||
| 152 | + | ||||
| 153 | + | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | |||
| 154 | + | rows, err := q.db.QueryContext(ctx, GetAllRecentHistoryByDomain, domain) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return nil, err | |||
| 157 | + | } | |||
| 158 | + | defer rows.Close() | |||
| 159 | + | items := []History{} | |||
| 160 | + | for rows.Next() { | |||
| 161 | + | var i History | |||
| 162 | + | if err := rows.Scan( | |||
| 163 | + | &i.ID, | |||
| 164 | + | &i.Url, | |||
| 165 | + | &i.Title, | |||
| 166 | + | &i.FaviconUrl, | |||
| 167 | + | &i.VisitCount, | |||
| 168 | + | &i.LastVisited, | |||
| 169 | + | &i.CreatedAt, | |||
| 170 | + | &i.Domain, | |||
| 171 | + | ); err != nil { | |||
| 172 | + | return nil, err | |||
| 173 | + | } | |||
| 174 | + | items = append(items, i) | |||
| 175 | + | } | |||
| 176 | + | if err := rows.Close(); err != nil { | |||
| 177 | + | return nil, err | |||
| 178 | + | } | |||
| 179 | + | if err := rows.Err(); err != nil { | |||
| 180 | + | return nil, err | |||
| 181 | + | } | |||
| 182 | + | return items, nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 147 | const GetDailyVisitCount = `-- name: GetDailyVisitCount :many | 185 | const GetDailyVisitCount = `-- name: GetDailyVisitCount :many | ||
| 148 | SELECT | 186 | SELECT | ||
| 149 | date(last_visited) as day, | 187 | date(last_visited) as day, | ||
| @@ -472,6 +510,120 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 472 | return items, nil | 510 | return items, nil | ||
| 473 | } | 511 | } | ||
| 474 | 512 | ||||
| 513 | + | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | |||
| 514 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 515 | + | WHERE last_visited < ?1 AND last_visited >= ?2 | |||
| 516 | + | ORDER BY last_visited DESC | |||
| 517 | + | ` | |||
| 518 | + | ||||
| 519 | + | type GetRecentHistoryWindowParams struct { | |||
| 520 | + | Before sql.NullTime `json:"before"` | |||
| 521 | + | After sql.NullTime `json:"after"` | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | |||
| 525 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After) | |||
| 526 | + | if err != nil { | |||
| 527 | + | return nil, err | |||
| 528 | + | } | |||
| 529 | + | defer rows.Close() | |||
| 530 | + | items := []History{} | |||
| 531 | + | for rows.Next() { | |||
| 532 | + | var i History | |||
| 533 | + | if err := rows.Scan( | |||
| 534 | + | &i.ID, | |||
| 535 | + | &i.Url, | |||
| 536 | + | &i.Title, | |||
| 537 | + | &i.FaviconUrl, | |||
| 538 | + | &i.VisitCount, | |||
| 539 | + | &i.LastVisited, | |||
| 540 | + | &i.CreatedAt, | |||
| 541 | + | &i.Domain, | |||
| 542 | + | ); err != nil { | |||
| 543 | + | return nil, err | |||
| 544 | + | } | |||
| 545 | + | items = append(items, i) | |||
| 546 | + | } | |||
| 547 | + | if err := rows.Close(); err != nil { | |||
| 548 | + | return nil, err | |||
| 549 | + | } | |||
| 550 | + | if err := rows.Err(); err != nil { | |||
| 551 | + | return nil, err | |||
| 552 | + | } | |||
| 553 | + | return items, nil | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | |||
| 557 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 558 | + | WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3 | |||
| 559 | + | ORDER BY last_visited DESC | |||
| 560 | + | ` | |||
| 561 | + | ||||
| 562 | + | type GetRecentHistoryWindowByDomainParams struct { | |||
| 563 | + | Domain sql.NullString `json:"domain"` | |||
| 564 | + | Before sql.NullTime `json:"before"` | |||
| 565 | + | After sql.NullTime `json:"after"` | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | |||
| 569 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After) | |||
| 570 | + | if err != nil { | |||
| 571 | + | return nil, err | |||
| 572 | + | } | |||
| 573 | + | defer rows.Close() | |||
| 574 | + | items := []History{} | |||
| 575 | + | for rows.Next() { | |||
| 576 | + | var i History | |||
| 577 | + | if err := rows.Scan( | |||
| 578 | + | &i.ID, | |||
| 579 | + | &i.Url, | |||
| 580 | + | &i.Title, | |||
| 581 | + | &i.FaviconUrl, | |||
| 582 | + | &i.VisitCount, | |||
| 583 | + | &i.LastVisited, | |||
| 584 | + | &i.CreatedAt, | |||
| 585 | + | &i.Domain, | |||
| 586 | + | ); err != nil { | |||
| 587 | + | return nil, err | |||
| 588 | + | } | |||
| 589 | + | items = append(items, i) | |||
| 590 | + | } | |||
| 591 | + | if err := rows.Close(); err != nil { | |||
| 592 | + | return nil, err | |||
| 593 | + | } | |||
| 594 | + | if err := rows.Err(); err != nil { | |||
| 595 | + | return nil, err | |||
| 596 | + | } | |||
| 597 | + | return items, nil | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | const HasHistoryBefore = `-- name: HasHistoryBefore :one | |||
| 601 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1) | |||
| 602 | + | ` | |||
| 603 | + | ||||
| 604 | + | func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) { | |||
| 605 | + | row := q.db.QueryRowContext(ctx, HasHistoryBefore, before) | |||
| 606 | + | var column_1 int64 | |||
| 607 | + | err := row.Scan(&column_1) | |||
| 608 | + | return column_1, err | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one | |||
| 612 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1) | |||
| 613 | + | ` | |||
| 614 | + | ||||
| 615 | + | type HasHistoryByDomainBeforeParams struct { | |||
| 616 | + | Domain sql.NullString `json:"domain"` | |||
| 617 | + | Before sql.NullTime `json:"before"` | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) { | |||
| 621 | + | row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before) | |||
| 622 | + | var column_1 int64 | |||
| 623 | + | err := row.Scan(&column_1) | |||
| 624 | + | return column_1, err | |||
| 625 | + | } | |||
| 626 | + | ||||
| 475 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | 627 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | ||
| 476 | UPDATE history | 628 | UPDATE history | ||
| 477 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | 629 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +5 -0
| @@ -37,6 +37,7 @@ type Querier interface { | |||||
| 37 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | 37 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | ||
| 38 | GetAllMostVisited(ctx context.Context) ([]History, error) | 38 | GetAllMostVisited(ctx context.Context) ([]History, error) | ||
| 39 | GetAllRecentHistory(ctx context.Context) ([]History, error) | 39 | GetAllRecentHistory(ctx context.Context) ([]History, error) | ||
| 40 | + | GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) | |||
| 40 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | 41 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | ||
| 41 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | 42 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | ||
| 42 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | 43 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | ||
| @@ -57,6 +58,8 @@ type Querier interface { | |||||
| 57 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | 58 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | ||
| 58 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | 59 | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | ||
| 59 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | 60 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 61 | + | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | |||
| 62 | + | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | |||
| 60 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 63 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| 61 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | 64 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | ||
| 62 | GetSessionByID(ctx context.Context, id string) (Session, error) | 65 | GetSessionByID(ctx context.Context, id string) (Session, error) | ||
| @@ -67,6 +70,8 @@ type Querier interface { | |||||
| 67 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | 70 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | ||
| 68 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | 71 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | ||
| 69 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | 72 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | ||
| 73 | + | HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) | |||
| 74 | + | HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) | |||
| 70 | IncrementVisitCount(ctx context.Context, url string) error | 75 | IncrementVisitCount(ctx context.Context, url string) error | ||
| 71 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | 76 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | ||
| 72 | InsertSession(ctx context.Context, arg InsertSessionParams) error | 77 | InsertSession(ctx context.Context, arg InsertSessionParams) error | ||
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +1 -1
| @@ -24,7 +24,7 @@ func NewBrowserClient() *Client { | |||||
| 24 | 24 | ||||
| 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | 25 | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | ||
| 26 | switch msgType { | 26 | switch msgType { | ||
| 27 | - | case "history_timeline", "history_timeline_by_domain", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | 27 | + | case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics", |
| 28 | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | 28 | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | ||
| 29 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | 29 | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | ||
| 30 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | 30 | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | ||
M internal/infrastructure/systemviewsbridge/client.go +18 -0
| @@ -108,6 +108,18 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||||
| 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | 108 | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | ||
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | + | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 112 | + | beforeCursor := "" | |||
| 113 | + | if !before.IsZero() { | |||
| 114 | + | beforeCursor = before.Format(time.RFC3339Nano) | |||
| 115 | + | } | |||
| 116 | + | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | |||
| 117 | + | RequestID string `json:"requestId"` | |||
| 118 | + | Before string `json:"before,omitempty"` | |||
| 119 | + | Domain string `json:"domain,omitempty"` | |||
| 120 | + | }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)}) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 111 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | 123 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | ||
| 112 | if limit < 0 { | 124 | if limit < 0 { | ||
| 113 | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | 125 | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | ||
| @@ -185,6 +197,12 @@ func (c *Client) ResetAllKeybindings(ctx context.Context) error { | |||||
| 185 | return err | 197 | return err | ||
| 186 | } | 198 | } | ||
| 187 | 199 | ||||
| 200 | + | func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 201 | + | return request[*entity.HistoryStats](c, ctx, "history_stats", struct { | |||
| 202 | + | RequestID string `json:"requestId"` | |||
| 203 | + | }{RequestID: nextRequestID()}) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 188 | func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | 206 | func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | ||
| 189 | return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct { | 207 | return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct { | ||
| 190 | RequestID string `json:"requestId"` | 208 | RequestID string `json:"requestId"` | ||
M internal/infrastructure/systemviewsbridge/client_test.go +27 -0
| @@ -127,6 +127,33 @@ func TestClientTimelineDecodesEntries(t *testing.T) { | |||||
| 127 | } | 127 | } | ||
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | + | func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||
| 131 | + | t.Parallel() | |||
| 132 | + | ||||
| 133 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)} | |||
| 134 | + | client := NewClient(native, nil) | |||
| 135 | + | ||||
| 136 | + | _, err := client.TimelineWindow(context.Background(), time.Time{}, "") | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatalf("TimelineWindow() error = %v", err) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | var msg port.WebUIMessage | |||
| 142 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 143 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 144 | + | } | |||
| 145 | + | if msg.Type != "history_timeline_window" { | |||
| 146 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window") | |||
| 147 | + | } | |||
| 148 | + | var payload map[string]any | |||
| 149 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 150 | + | t.Fatalf("unmarshal payload: %v", err) | |||
| 151 | + | } | |||
| 152 | + | if _, ok := payload["before"]; ok { | |||
| 153 | + | t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 130 | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | 157 | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | ||
| 131 | t.Parallel() | 158 | t.Parallel() | ||
| 132 | 159 | ||||
A internal/infrastructure/webkit/favicon_systemviews.go +14 -0
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |||
| 6 | + | ||||
| 7 | + | // SetSystemviewFaviconService exposes cached favicons to systemview pages via | |||
| 8 | + | // dumb://api/favicon without triggering remote favicon fetches during History rendering. | |||
| 9 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |||
| 10 | + | if e == nil || e.schemeHandler == nil { | |||
| 11 | + | return | |||
| 12 | + | } | |||
| 13 | + | e.schemeHandler.SetFaviconService(service) | |||
| 14 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +162 -26
| @@ -9,11 +9,15 @@ import ( | |||||
| 9 | "io/fs" | 9 | "io/fs" | ||
| 10 | "net/http" | 10 | "net/http" | ||
| 11 | "net/url" | 11 | "net/url" | ||
| 12 | + | "os" | |||
| 12 | "path" | 13 | "path" | ||
| 14 | + | "strconv" | |||
| 13 | "strings" | 15 | "strings" | ||
| 14 | "sync" | 16 | "sync" | ||
| 15 | 17 | ||||
| 16 | "github.com/andybalholm/brotli" | 18 | "github.com/andybalholm/brotli" | ||
| 19 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 20 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 17 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 21 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 18 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 19 | "github.com/bnema/puregotk/v4/gio" | 23 | "github.com/bnema/puregotk/v4/gio" | ||
| @@ -37,19 +41,22 @@ const ( | |||||
| 37 | 41 | ||||
| 38 | // SchemeRequest represents a request to a custom URI scheme. | 42 | // SchemeRequest represents a request to a custom URI scheme. | ||
| 39 | type SchemeRequest struct { | 43 | type SchemeRequest struct { | ||
| 40 | - | inner *webkit.URISchemeRequest | 44 | + | inner *webkit.URISchemeRequest |
| 41 | - | URI string | 45 | + | URI string |
| 42 | - | Path string | 46 | + | Path string |
| 43 | - | Method string | 47 | + | Method string |
| 44 | - | Scheme string | 48 | + | Scheme string |
| 49 | + | Origin string | |||
| 50 | + | Referer string | |||
| 45 | } | 51 | } | ||
| 46 | 52 | ||||
| 47 | // SchemeResponse represents a response to a scheme request. | 53 | // SchemeResponse represents a response to a scheme request. | ||
| 48 | type SchemeResponse struct { | 54 | type SchemeResponse struct { | ||
| 49 | - | Data []byte | 55 | + | Data []byte |
| 50 | - | ContentType string | 56 | + | ContentType string |
| 51 | - | StatusCode int | 57 | + | StatusCode int |
| 52 | - | Headers map[string]string | 58 | + | Headers map[string]string |
| 59 | + | SuppressDefaultHeaders bool | |||
| 53 | } | 60 | } | ||
| 54 | 61 | ||||
| 55 | // PageHandler generates content for a specific page path. | 62 | // PageHandler generates content for a specific page path. | ||
| @@ -68,6 +75,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 68 | type DumbSchemeHandler struct { | 75 | type DumbSchemeHandler struct { | ||
| 69 | handlers map[string]PageHandler | 76 | handlers map[string]PageHandler | ||
| 70 | assets embed.FS | 77 | assets embed.FS | ||
| 78 | + | faviconService port.FaviconService | |||
| 71 | assetDir string // default subdirectory within embed.FS (e.g., "systemviews") | 79 | assetDir string // default subdirectory within embed.FS (e.g., "systemviews") | ||
| 72 | logger zerolog.Logger | 80 | logger zerolog.Logger | ||
| 73 | mu sync.RWMutex | 81 | mu sync.RWMutex | ||
| @@ -108,6 +116,12 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun | |||||
| 108 | h.logger.Debug().Msg("config payload builders configured") | 116 | h.logger.Debug().Msg("config payload builders configured") | ||
| 109 | } | 117 | } | ||
| 110 | 118 | ||||
| 119 | + | func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) { | |||
| 120 | + | h.mu.Lock() | |||
| 121 | + | defer h.mu.Unlock() | |||
| 122 | + | h.faviconService = service | |||
| 123 | + | } | |||
| 124 | + | ||||
| 111 | // registerDefaults sets up default page handlers. | 125 | // registerDefaults sets up default page handlers. | ||
| 112 | func (h *DumbSchemeHandler) registerDefaults() { | 126 | func (h *DumbSchemeHandler) registerDefaults() { | ||
| 113 | // Error page (static fallback) | 127 | // Error page (static fallback) | ||
| @@ -137,6 +151,9 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 137 | if req.Method != "" && req.Method != httpGET { | 151 | if req.Method != "" && req.Method != httpGET { | ||
| 138 | return nil | 152 | return nil | ||
| 139 | } | 153 | } | ||
| 154 | + | if !isTrustedSystemviewAPIRequest(req) { | |||
| 155 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 156 | + | } | |||
| 140 | 157 | ||||
| 141 | h.mu.RLock() | 158 | h.mu.RLock() | ||
| 142 | build := h.currentConfigPayload | 159 | build := h.currentConfigPayload | ||
| @@ -149,12 +166,22 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 149 | if req.Method != "" && req.Method != httpGET { | 166 | if req.Method != "" && req.Method != httpGET { | ||
| 150 | return nil | 167 | return nil | ||
| 151 | } | 168 | } | ||
| 169 | + | if !isTrustedSystemviewAPIRequest(req) { | |||
| 170 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 171 | + | } | |||
| 152 | 172 | ||||
| 153 | h.mu.RLock() | 173 | h.mu.RLock() | ||
| 154 | build := h.defaultConfigPayload | 174 | build := h.defaultConfigPayload | ||
| 155 | h.mu.RUnlock() | 175 | h.mu.RUnlock() | ||
| 156 | return buildConfigResponse(build) | 176 | return buildConfigResponse(build) | ||
| 157 | })) | 177 | })) | ||
| 178 | + | ||||
| 179 | + | h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 180 | + | if req.Method != "" && req.Method != httpGET { | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | return h.handleFaviconAPI(req) | |||
| 184 | + | })) | |||
| 158 | } | 185 | } | ||
| 159 | 186 | ||||
| 160 | func crashOriginalURI(requestURI string) string { | 187 | func crashOriginalURI(requestURI string) string { | ||
| @@ -176,28 +203,129 @@ func buildCrashPageHTML(originalURI string) string { | |||||
| 176 | return webutil.BuildCrashPageHTML(originalURI) | 203 | return webutil.BuildCrashPageHTML(originalURI) | ||
| 177 | } | 204 | } | ||
| 178 | 205 | ||||
| 206 | + | const systemviewFaviconSize = 32 | |||
| 207 | + | ||||
| 208 | + | func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse { | |||
| 209 | + | if !isTrustedSystemviewFaviconRequest(req) { | |||
| 210 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 211 | + | } | |||
| 212 | + | h.mu.RLock() | |||
| 213 | + | service := h.faviconService | |||
| 214 | + | h.mu.RUnlock() | |||
| 215 | + | if service == nil { | |||
| 216 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable") | |||
| 217 | + | } | |||
| 218 | + | if req == nil { | |||
| 219 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request") | |||
| 220 | + | } | |||
| 221 | + | parsed, err := url.Parse(req.URI) | |||
| 222 | + | if err != nil { | |||
| 223 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL") | |||
| 224 | + | } | |||
| 225 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |||
| 226 | + | if domain == "" { | |||
| 227 | + | return privateJSONErrorResponse(http.StatusBadRequest, "missing domain") | |||
| 228 | + | } | |||
| 229 | + | size := systemviewFaviconSize | |||
| 230 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |||
| 231 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |||
| 232 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |||
| 233 | + | return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size") | |||
| 234 | + | } | |||
| 235 | + | size = parsedSize | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | if !service.HasPNGSizedOnDisk(domain, size) { | |||
| 239 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 240 | + | } | |||
| 241 | + | path := service.DiskPathPNGSized(domain, size) | |||
| 242 | + | if path == "" { | |||
| 243 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 244 | + | } | |||
| 245 | + | data, err := os.ReadFile(path) | |||
| 246 | + | if err != nil || len(data) == 0 { | |||
| 247 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 248 | + | } | |||
| 249 | + | return &SchemeResponse{ | |||
| 250 | + | Data: data, | |||
| 251 | + | ContentType: "image/png", | |||
| 252 | + | StatusCode: http.StatusOK, | |||
| 253 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 254 | + | SuppressDefaultHeaders: true, | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool { | |||
| 259 | + | return isTrustedSystemviewAPIRequest(req) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool { | |||
| 263 | + | if req == nil { | |||
| 264 | + | return false | |||
| 265 | + | } | |||
| 266 | + | origin := strings.TrimSpace(req.Origin) | |||
| 267 | + | if origin != "" { | |||
| 268 | + | return isTrustedSystemviewURL(origin) | |||
| 269 | + | } | |||
| 270 | + | return isTrustedSystemviewURL(req.Referer) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func isTrustedSystemviewURL(raw string) bool { | |||
| 274 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |||
| 275 | + | if err != nil || parsed.Scheme == "" { | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | host := parsed.Host | |||
| 282 | + | if host == "" { | |||
| 283 | + | host = parsed.Opaque | |||
| 284 | + | } | |||
| 285 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |||
| 286 | + | host = host[:idx] | |||
| 287 | + | } | |||
| 288 | + | switch host { | |||
| 289 | + | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: | |||
| 290 | + | return true | |||
| 291 | + | default: | |||
| 292 | + | return false | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func jsonErrorResponse(status int, message string) *SchemeResponse { | |||
| 297 | + | return &SchemeResponse{ | |||
| 298 | + | Data: []byte(fmt.Sprintf(`{"error":%q}`, message)), | |||
| 299 | + | ContentType: "application/json", | |||
| 300 | + | StatusCode: status, | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func privateJSONErrorResponse(status int, message string) *SchemeResponse { | |||
| 305 | + | resp := jsonErrorResponse(status, message) | |||
| 306 | + | resp.Headers = map[string]string{"Cache-Control": "no-store"} | |||
| 307 | + | resp.SuppressDefaultHeaders = true | |||
| 308 | + | return resp | |||
| 309 | + | } | |||
| 310 | + | ||||
| 179 | func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | 311 | func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | ||
| 180 | if build == nil { | 312 | if build == nil { | ||
| 181 | - | return &SchemeResponse{ | 313 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured") |
| 182 | - | Data: []byte(`{"error": "config payload builder not configured"}`), | 314 | + | return resp |
| 183 | - | ContentType: "application/json", | |||
| 184 | - | StatusCode: http.StatusInternalServerError, | |||
| 185 | - | } | |||
| 186 | } | 315 | } | ||
| 187 | 316 | ||||
| 188 | data, err := build() | 317 | data, err := build() | ||
| 189 | if err != nil { | 318 | if err != nil { | ||
| 190 | - | return &SchemeResponse{ | 319 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error()) |
| 191 | - | Data: []byte(fmt.Sprintf(`{"error": %q}`, err)), | 320 | + | return resp |
| 192 | - | ContentType: "application/json", | |||
| 193 | - | StatusCode: http.StatusInternalServerError, | |||
| 194 | - | } | |||
| 195 | } | 321 | } | ||
| 196 | 322 | ||||
| 197 | return &SchemeResponse{ | 323 | return &SchemeResponse{ | ||
| 198 | - | Data: data, | 324 | + | Data: data, |
| 199 | - | ContentType: "application/json", | 325 | + | ContentType: "application/json", |
| 200 | - | StatusCode: http.StatusOK, | 326 | + | StatusCode: http.StatusOK, |
| 327 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 328 | + | SuppressDefaultHeaders: true, | |||
| 201 | } | 329 | } | ||
| 202 | } | 330 | } | ||
| 203 | 331 | ||||
| @@ -217,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 217 | } | 345 | } | ||
| 218 | 346 | ||||
| 219 | uri := req.GetUri() | 347 | uri := req.GetUri() | ||
| 348 | + | requestHeaders := req.GetHttpHeaders() | |||
| 220 | schemeReq := &SchemeRequest{ | 349 | schemeReq := &SchemeRequest{ | ||
| 221 | inner: req, | 350 | inner: req, | ||
| 222 | URI: uri, | 351 | URI: uri, | ||
| @@ -224,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 224 | Method: req.GetHttpMethod(), | 353 | Method: req.GetHttpMethod(), | ||
| 225 | Scheme: req.GetScheme(), | 354 | Scheme: req.GetScheme(), | ||
| 226 | } | 355 | } | ||
| 356 | + | if requestHeaders != nil { | |||
| 357 | + | schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin")) | |||
| 358 | + | schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer")) | |||
| 359 | + | } | |||
| 227 | 360 | ||||
| 228 | h.logger.Debug(). | 361 | h.logger.Debug(). | ||
| 229 | Str("uri", schemeReq.URI). | 362 | Str("uri", schemeReq.URI). | ||
| @@ -412,7 +545,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 412 | 545 | ||||
| 413 | func shouldAddCORSHeaders(path string) bool { | 546 | func shouldAddCORSHeaders(path string) bool { | ||
| 414 | path = strings.TrimSpace(path) | 547 | path = strings.TrimSpace(path) | ||
| 415 | - | return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm") | 548 | + | return strings.HasSuffix(path, ".wasm") |
| 416 | } | 549 | } | ||
| 417 | 550 | ||||
| 418 | func responseHeadersForPath(path, contentType string) map[string]string { | 551 | func responseHeadersForPath(path, contentType string) map[string]string { | ||
| @@ -463,9 +596,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 463 | schemeResp.SetContentType(contentType) | 596 | schemeResp.SetContentType(contentType) | ||
| 464 | schemeResp.SetStatus(uint(response.StatusCode), nil) | 597 | schemeResp.SetStatus(uint(response.StatusCode), nil) | ||
| 465 | 598 | ||||
| 466 | - | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | 599 | + | // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset. |
| 467 | - | // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset. | 600 | + | // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers. |
| 468 | - | headers := responseHeadersForPath(req.GetPath(), contentType) | 601 | + | var headers map[string]string |
| 602 | + | if !response.SuppressDefaultHeaders { | |||
| 603 | + | headers = responseHeadersForPath(req.GetPath(), contentType) | |||
| 604 | + | } | |||
| 469 | for name, value := range response.Headers { | 605 | for name, value := range response.Headers { | ||
| 470 | if headers == nil { | 606 | if headers == nil { | ||
| 471 | headers = map[string]string{} | 607 | headers = map[string]string{} | ||
M internal/infrastructure/webkit/scheme_handler_test.go +67 -9
| @@ -50,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | |||||
| 50 | assert.Contains(t, body, "</script><script>alert(1)</script>") | 50 | assert.Contains(t, body, "</script><script>alert(1)</script>") | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 54 | + | t.Parallel() | |||
| 55 | + | ||||
| 56 | + | for _, raw := range []string{ | |||
| 57 | + | "dumb://history", | |||
| 58 | + | "dumb://favorites", | |||
| 59 | + | "dumb:config", | |||
| 60 | + | } { | |||
| 61 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |||
| 62 | + | } | |||
| 63 | + | for _, raw := range []string{ | |||
| 64 | + | "", | |||
| 65 | + | "https://dumber.invalid/history", | |||
| 66 | + | "dumb://api/favicon", | |||
| 67 | + | "dumb://evil/history", | |||
| 68 | + | } { | |||
| 69 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) { | |||
| 74 | + | t.Parallel() | |||
| 75 | + | ||||
| 76 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"})) | |||
| 77 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""})) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) { | |||
| 81 | + | t.Parallel() | |||
| 82 | + | ||||
| 83 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"})) | |||
| 84 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"})) | |||
| 85 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"})) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 53 | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | 88 | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | ||
| 54 | handler := NewDumbSchemeHandler(context.Background()) | 89 | handler := NewDumbSchemeHandler(context.Background()) | ||
| 55 | require.NotNil(t, handler) | 90 | require.NotNil(t, handler) | ||
| @@ -162,10 +197,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||||
| 162 | require.NotNil(t, defaultHandler) | 197 | require.NotNil(t, defaultHandler) | ||
| 163 | 198 | ||||
| 164 | currentResp := currentHandler.Handle(&SchemeRequest{ | 199 | currentResp := currentHandler.Handle(&SchemeRequest{ | ||
| 165 | - | URI: "dumb://config/api/config", | 200 | + | URI: "dumb://config/api/config", |
| 166 | - | Path: "/api/config", | 201 | + | Path: "/api/config", |
| 167 | - | Method: http.MethodGet, | 202 | + | Method: http.MethodGet, |
| 168 | - | Scheme: "dumb", | 203 | + | Scheme: "dumb", |
| 204 | + | Referer: "dumb://config", | |||
| 169 | }) | 205 | }) | ||
| 170 | require.NotNil(t, currentResp) | 206 | require.NotNil(t, currentResp) | ||
| 171 | assert.Equal(t, http.StatusOK, currentResp.StatusCode) | 207 | assert.Equal(t, http.StatusOK, currentResp.StatusCode) | ||
| @@ -173,10 +209,11 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||||
| 173 | assert.Equal(t, []byte(`{"current":true}`), currentResp.Data) | 209 | assert.Equal(t, []byte(`{"current":true}`), currentResp.Data) | ||
| 174 | 210 | ||||
| 175 | defaultResp := defaultHandler.Handle(&SchemeRequest{ | 211 | defaultResp := defaultHandler.Handle(&SchemeRequest{ | ||
| 176 | - | URI: "dumb://config/api/config/default", | 212 | + | URI: "dumb://config/api/config/default", |
| 177 | - | Path: "/api/config/default", | 213 | + | Path: "/api/config/default", |
| 178 | - | Method: http.MethodGet, | 214 | + | Method: http.MethodGet, |
| 179 | - | Scheme: "dumb", | 215 | + | Scheme: "dumb", |
| 216 | + | Referer: "dumb://config", | |||
| 180 | }) | 217 | }) | ||
| 181 | require.NotNil(t, defaultResp) | 218 | require.NotNil(t, defaultResp) | ||
| 182 | assert.Equal(t, http.StatusOK, defaultResp.StatusCode) | 219 | assert.Equal(t, http.StatusOK, defaultResp.StatusCode) | ||
| @@ -184,6 +221,27 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||||
| 184 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | 221 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | ||
| 185 | } | 222 | } | ||
| 186 | 223 | ||||
| 224 | + | func TestConfigHandlersRejectUntrustedRequests(t *testing.T) { | |||
| 225 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 226 | + | h.SetConfigPayloadBuilders( | |||
| 227 | + | func() ([]byte, error) { return []byte(`{"current":true}`), nil }, | |||
| 228 | + | func() ([]byte, error) { return []byte(`{"default":true}`), nil }, | |||
| 229 | + | ) | |||
| 230 | + | ||||
| 231 | + | h.mu.RLock() | |||
| 232 | + | currentHandler := h.handlers["/api/config"] | |||
| 233 | + | defaultHandler := h.handlers["/api/config/default"] | |||
| 234 | + | h.mu.RUnlock() | |||
| 235 | + | ||||
| 236 | + | for _, handler := range []PageHandler{currentHandler, defaultHandler} { | |||
| 237 | + | resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"}) | |||
| 238 | + | require.NotNil(t, resp) | |||
| 239 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 240 | + | assert.True(t, resp.SuppressDefaultHeaders) | |||
| 241 | + | assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 187 | func TestShouldAddCORSHeaders(t *testing.T) { | 245 | func TestShouldAddCORSHeaders(t *testing.T) { | ||
| 188 | t.Parallel() | 246 | t.Parallel() | ||
| 189 | 247 | ||||
| @@ -192,7 +250,7 @@ func TestShouldAddCORSHeaders(t *testing.T) { | |||||
| 192 | path string | 250 | path string | ||
| 193 | want bool | 251 | want bool | ||
| 194 | }{ | 252 | }{ | ||
| 195 | - | {name: "api route", path: "/api/config", want: true}, | 253 | + | {name: "private api route", path: "/api/config", want: false}, |
| 196 | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | 254 | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | ||
| 197 | {name: "html shell", path: "/", want: false}, | 255 | {name: "html shell", path: "/", want: false}, | ||
| 198 | {name: "js asset", path: "/wasm_exec.js", want: false}, | 256 | {name: "js asset", path: "/wasm_exec.js", want: false}, | ||
M internal/ui/app.go +4 -0
| @@ -204,6 +204,10 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 204 | glib.IdleAdd(&cb, 0) | 204 | glib.IdleAdd(&cb, 0) | ||
| 205 | }) | 205 | }) | ||
| 206 | } | 206 | } | ||
| 207 | + | if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok { | |||
| 208 | + | faviconSetter.SetSystemviewFaviconService(deps.FaviconService) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 207 | // Register message handlers through the engine. | 211 | // Register message handlers through the engine. | ||
| 208 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 212 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | ||
| 209 | HistoryUC: deps.HistoryUC, | 213 | HistoryUC: deps.HistoryUC, | ||
M internal/ui/systemviews/app.go +138 -22
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/application/dto" | 11 | "github.com/bnema/dumber/internal/application/dto" | ||
| 11 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -29,11 +30,15 @@ type App struct { | |||||
| 29 | currentRoute Route | 30 | currentRoute Route | ||
| 30 | shellTheme shellTheme | 31 | shellTheme shellTheme | ||
| 31 | historyEntries []*entity.HistoryEntry | 32 | historyEntries []*entity.HistoryEntry | ||
| 33 | + | historyStats *entity.HistoryStats | |||
| 32 | historyAnalytics *entity.HistoryAnalytics | 34 | historyAnalytics *entity.HistoryAnalytics | ||
| 33 | historyDomainStats []*entity.DomainStat | 35 | historyDomainStats []*entity.DomainStat | ||
| 34 | historyQuery string | 36 | historyQuery string | ||
| 35 | historyDomainFilter string | 37 | historyDomainFilter string | ||
| 36 | historyOffset int | 38 | historyOffset int | ||
| 39 | + | historyWindowBefore time.Time | |||
| 40 | + | historyWindowAfter time.Time | |||
| 41 | + | historyHasMore bool | |||
| 37 | historyNotice string | 42 | historyNotice string | ||
| 38 | historyError string | 43 | historyError string | ||
| 39 | favorites []*entity.Favorite | 44 | favorites []*entity.Favorite | ||
| @@ -56,7 +61,10 @@ type App struct { | |||||
| 56 | actionWG sync.WaitGroup | 61 | actionWG sync.WaitGroup | ||
| 57 | } | 62 | } | ||
| 58 | 63 | ||||
| 59 | - | const historyTimelineLimit = 25 | 64 | + | const ( |
| 65 | + | historyTimelineLimit = 0 | |||
| 66 | + | historySearchLimit = 100 | |||
| 67 | + | ) | |||
| 60 | 68 | ||||
| 61 | func (a *App) lockAction() { | 69 | func (a *App) lockAction() { | ||
| 62 | // Lock order: actionMu is never acquired while App.mu is held. | 70 | // Lock order: actionMu is never acquired while App.mu is held. | ||
| @@ -96,7 +104,10 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||||
| 96 | if a.deps.DOM == nil { | 104 | if a.deps.DOM == nil { | ||
| 97 | return errors.New("DOM not configured") | 105 | return errors.New("DOM not configured") | ||
| 98 | } | 106 | } | ||
| 99 | - | if err := a.LoadInitial(ctx); err != nil { | 107 | + | asyncInitial := a.shouldLoadInitialRouteAsync() |
| 108 | + | if asyncInitial { | |||
| 109 | + | a.LoadInitialShell(ctx) | |||
| 110 | + | } else if err := a.LoadInitial(ctx); err != nil { | |||
| 100 | return err | 111 | return err | ||
| 101 | } | 112 | } | ||
| 102 | 113 | ||||
| @@ -104,11 +115,39 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||||
| 104 | return err | 115 | return err | ||
| 105 | } | 116 | } | ||
| 106 | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | 117 | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | ||
| 107 | - | return a.bindDOMActions(ctx, binder) | 118 | + | if err := a.bindDOMActions(ctx, binder); err != nil { |
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | if asyncInitial { | |||
| 123 | + | a.refreshCurrentRouteAsync(ctx) | |||
| 108 | } | 124 | } | ||
| 109 | return nil | 125 | return nil | ||
| 110 | } | 126 | } | ||
| 111 | 127 | ||||
| 128 | + | func (a *App) shouldLoadInitialRouteAsync() bool { | |||
| 129 | + | return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |||
| 133 | + | if a == nil { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | go func() { | |||
| 137 | + | a.lockState() | |||
| 138 | + | defer a.unlockState() | |||
| 139 | + | if err := a.loadCurrentRoute(ctx); err != nil { | |||
| 140 | + | a.renderRouteError(err) | |||
| 141 | + | } | |||
| 142 | + | html := a.renderedHTML | |||
| 143 | + | go func() { | |||
| 144 | + | if err := a.mountHTML(html); err != nil { | |||
| 145 | + | logActionMountError(ctx, err, nil) | |||
| 146 | + | } | |||
| 147 | + | }() | |||
| 148 | + | }() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 112 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | 151 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | ||
| 113 | var ( | 152 | var ( | ||
| 114 | created bool | 153 | created bool | ||
| @@ -329,6 +368,28 @@ func (a *App) releaseDOMBindings() { | |||||
| 329 | } | 368 | } | ||
| 330 | } | 369 | } | ||
| 331 | 370 | ||||
| 371 | + | func (a *App) LoadInitialShell(ctx context.Context) { | |||
| 372 | + | if a == nil { | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | a.loadShellTheme(ctx) | |||
| 376 | + | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | |||
| 377 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 378 | + | } | |||
| 379 | + | switch a.currentRoute { | |||
| 380 | + | case RouteHistory: | |||
| 381 | + | a.loadHistoryLoadingRoute() | |||
| 382 | + | default: | |||
| 383 | + | a.resetRouteState() | |||
| 384 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 385 | + | route: a.currentRoute, | |||
| 386 | + | title: routeDocumentTitle(a.currentRoute), | |||
| 387 | + | subtitle: routeSubtitle(a.currentRoute), | |||
| 388 | + | body: placeholderHTML(a.currentRoute), | |||
| 389 | + | }, a.shellTheme) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | ||||
| 332 | func (a *App) LoadInitial(ctx context.Context) error { | 393 | func (a *App) LoadInitial(ctx context.Context) error { | ||
| 333 | if a == nil { | 394 | if a == nil { | ||
| 334 | return errors.New("app is nil") | 395 | return errors.New("app is nil") | ||
| @@ -338,14 +399,20 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 338 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 399 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 339 | } | 400 | } | ||
| 340 | 401 | ||||
| 341 | - | var err error | 402 | + | if err := a.loadCurrentRoute(ctx); err != nil { |
| 403 | + | a.renderRouteError(err) | |||
| 404 | + | } | |||
| 405 | + | return nil | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func (a *App) loadCurrentRoute(ctx context.Context) error { | |||
| 342 | switch a.currentRoute { | 409 | switch a.currentRoute { | ||
| 343 | case RouteHistory: | 410 | case RouteHistory: | ||
| 344 | - | err = a.loadHistoryRoute(ctx) | 411 | + | return a.loadHistoryRoute(ctx) |
| 345 | case RouteFavorites: | 412 | case RouteFavorites: | ||
| 346 | - | err = a.loadFavoritesRoute(ctx) | 413 | + | return a.loadFavoritesRoute(ctx) |
| 347 | case RouteConfig: | 414 | case RouteConfig: | ||
| 348 | - | err = a.loadConfigRoute(ctx) | 415 | + | return a.loadConfigRoute(ctx) |
| 349 | default: | 416 | default: | ||
| 350 | a.resetRouteState() | 417 | a.resetRouteState() | ||
| 351 | a.renderedHTML = renderAppFrame(renderedPage{ | 418 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| @@ -356,10 +423,6 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 356 | }, a.shellTheme) | 423 | }, a.shellTheme) | ||
| 357 | return nil | 424 | return nil | ||
| 358 | } | 425 | } | ||
| 359 | - | if err != nil { | |||
| 360 | - | a.renderRouteError(err) | |||
| 361 | - | } | |||
| 362 | - | return nil | |||
| 363 | } | 426 | } | ||
| 364 | 427 | ||||
| 365 | func (a *App) renderRouteError(err error) { | 428 | func (a *App) renderRouteError(err error) { | ||
| @@ -402,6 +465,36 @@ func routeSubtitle(route Route) string { | |||||
| 402 | } | 465 | } | ||
| 403 | } | 466 | } | ||
| 404 | 467 | ||||
| 468 | + | func (a *App) loadHistoryLoadingRoute() { | |||
| 469 | + | a.favorites = nil | |||
| 470 | + | a.folders = nil | |||
| 471 | + | a.tags = nil | |||
| 472 | + | a.historyEntries = nil | |||
| 473 | + | a.historyStats = nil | |||
| 474 | + | a.historyAnalytics = nil | |||
| 475 | + | a.historyDomainStats = nil | |||
| 476 | + | a.historyNotice = "" | |||
| 477 | + | a.historyError = "" | |||
| 478 | + | data := historyRenderData{ | |||
| 479 | + | Query: a.historyQuery, | |||
| 480 | + | DomainFilter: a.historyDomainFilter, | |||
| 481 | + | Offset: a.historyOffset, | |||
| 482 | + | Limit: historyTimelineLimit, | |||
| 483 | + | WindowBefore: a.historyWindowBefore, | |||
| 484 | + | WindowAfter: a.historyWindowAfter, | |||
| 485 | + | HasMore: a.historyHasMore, | |||
| 486 | + | Notice: a.historyNotice, | |||
| 487 | + | Error: a.historyError, | |||
| 488 | + | Loading: true, | |||
| 489 | + | } | |||
| 490 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 491 | + | route: RouteHistory, | |||
| 492 | + | title: "History — Loading", | |||
| 493 | + | subtitle: "Recent visits", | |||
| 494 | + | body: historyHTML(data), | |||
| 495 | + | }, a.shellTheme) | |||
| 496 | + | } | |||
| 497 | + | ||||
| 405 | func (a *App) loadHistoryRoute(ctx context.Context) error { | 498 | func (a *App) loadHistoryRoute(ctx context.Context) error { | ||
| 406 | if a.deps.History == nil { | 499 | if a.deps.History == nil { | ||
| 407 | a.resetRouteState() | 500 | a.resetRouteState() | ||
| @@ -414,13 +507,13 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 414 | return nil | 507 | return nil | ||
| 415 | } | 508 | } | ||
| 416 | 509 | ||||
| 417 | - | entries, err := a.loadHistoryEntries(ctx) | 510 | + | window, entries, err := a.loadHistoryWindow(ctx) |
| 418 | if err != nil { | 511 | if err != nil { | ||
| 419 | return err | 512 | return err | ||
| 420 | } | 513 | } | ||
| 421 | - | analytics, analyticsErr := a.deps.History.Analytics(ctx) | 514 | + | stats, err := a.deps.History.Stats(ctx) |
| 422 | - | if analyticsErr != nil { | 515 | + | if err != nil { |
| 423 | - | analytics = nil | 516 | + | return fmt.Errorf("failed to load history stats: %w", err) |
| 424 | } | 517 | } | ||
| 425 | domains, domainsErr := a.deps.History.DomainStats(ctx, 10) | 518 | domains, domainsErr := a.deps.History.DomainStats(ctx, 10) | ||
| 426 | if domainsErr != nil { | 519 | if domainsErr != nil { | ||
| @@ -430,16 +523,30 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 430 | a.folders = nil | 523 | a.folders = nil | ||
| 431 | a.tags = nil | 524 | a.tags = nil | ||
| 432 | a.historyEntries = entries | 525 | a.historyEntries = entries | ||
| 433 | - | a.historyAnalytics = analytics | 526 | + | a.historyStats = stats |
| 527 | + | a.historyAnalytics = nil | |||
| 434 | a.historyDomainStats = domains | 528 | a.historyDomainStats = domains | ||
| 529 | + | if window != nil { | |||
| 530 | + | a.historyWindowBefore = window.Before | |||
| 531 | + | a.historyWindowAfter = window.After | |||
| 532 | + | a.historyHasMore = window.HasMore | |||
| 533 | + | } else { | |||
| 534 | + | a.historyWindowBefore = time.Time{} | |||
| 535 | + | a.historyWindowAfter = time.Time{} | |||
| 536 | + | a.historyHasMore = false | |||
| 537 | + | } | |||
| 435 | data := historyRenderData{ | 538 | data := historyRenderData{ | ||
| 436 | Entries: entries, | 539 | Entries: entries, | ||
| 437 | - | Analytics: analytics, | 540 | + | Stats: stats, |
| 541 | + | Analytics: nil, | |||
| 438 | Domains: domains, | 542 | Domains: domains, | ||
| 439 | Query: a.historyQuery, | 543 | Query: a.historyQuery, | ||
| 440 | DomainFilter: a.historyDomainFilter, | 544 | DomainFilter: a.historyDomainFilter, | ||
| 441 | Offset: a.historyOffset, | 545 | Offset: a.historyOffset, | ||
| 442 | Limit: historyTimelineLimit, | 546 | Limit: historyTimelineLimit, | ||
| 547 | + | WindowBefore: a.historyWindowBefore, | |||
| 548 | + | WindowAfter: a.historyWindowAfter, | |||
| 549 | + | HasMore: a.historyHasMore, | |||
| 443 | Notice: a.historyNotice, | 550 | Notice: a.historyNotice, | ||
| 444 | Error: a.historyError, | 551 | Error: a.historyError, | ||
| 445 | } | 552 | } | ||
| @@ -452,16 +559,21 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 452 | return nil | 559 | return nil | ||
| 453 | } | 560 | } | ||
| 454 | 561 | ||||
| 455 | - | func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, error) { | 562 | + | func (a *App) loadHistoryWindow(ctx context.Context) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { |
| 456 | query := strings.TrimSpace(a.historyQuery) | 563 | query := strings.TrimSpace(a.historyQuery) | ||
| 457 | domain := strings.TrimSpace(a.historyDomainFilter) | 564 | domain := strings.TrimSpace(a.historyDomainFilter) | ||
| 458 | if query != "" { | 565 | if query != "" { | ||
| 459 | - | return a.deps.History.Search(ctx, query, historyTimelineLimit) | 566 | + | entries, err := a.deps.History.Search(ctx, query, historySearchLimit) |
| 567 | + | return nil, entries, err | |||
| 568 | + | } | |||
| 569 | + | window, err := a.deps.History.TimelineWindow(ctx, a.historyWindowBefore, domain) | |||
| 570 | + | if err != nil { | |||
| 571 | + | return nil, nil, err | |||
| 460 | } | 572 | } | ||
| 461 | - | if domain != "" { | 573 | + | if window == nil { |
| 462 | - | return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset) | 574 | + | return nil, nil, nil |
| 463 | } | 575 | } | ||
| 464 | - | return a.deps.History.Timeline(ctx, historyTimelineLimit, a.historyOffset) | 576 | + | return window, window.Entries, nil |
| 465 | } | 577 | } | ||
| 466 | 578 | ||||
| 467 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | 579 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | ||
| @@ -571,11 +683,15 @@ func (a *App) loadShellTheme(ctx context.Context) { | |||||
| 571 | 683 | ||||
| 572 | func (a *App) resetRouteState() { | 684 | func (a *App) resetRouteState() { | ||
| 573 | a.historyEntries = nil | 685 | a.historyEntries = nil | ||
| 686 | + | a.historyStats = nil | |||
| 574 | a.historyAnalytics = nil | 687 | a.historyAnalytics = nil | ||
| 575 | a.historyDomainStats = nil | 688 | a.historyDomainStats = nil | ||
| 576 | a.historyQuery = "" | 689 | a.historyQuery = "" | ||
| 577 | a.historyDomainFilter = "" | 690 | a.historyDomainFilter = "" | ||
| 578 | a.historyOffset = 0 | 691 | a.historyOffset = 0 | ||
| 692 | + | a.historyWindowBefore = time.Time{} | |||
| 693 | + | a.historyWindowAfter = time.Time{} | |||
| 694 | + | a.historyHasMore = false | |||
| 579 | a.historyNotice = "" | 695 | a.historyNotice = "" | ||
| 580 | a.historyError = "" | 696 | a.historyError = "" | ||
| 581 | a.favorites = nil | 697 | a.favorites = nil | ||
M internal/ui/systemviews/app_test.go +206 -9
| @@ -3,7 +3,9 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "sync" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/internal/application/dto" | 10 | "github.com/bnema/dumber/internal/application/dto" | ||
| 9 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -78,7 +80,8 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 78 | assert.Contains(t, app.renderedHTML, "Example") | 80 | assert.Contains(t, app.renderedHTML, "Example") | ||
| 79 | assert.Contains(t, app.renderedHTML, "https://example.com") | 81 | assert.Contains(t, app.renderedHTML, "https://example.com") | ||
| 80 | assert.True(t, history.called) | 82 | assert.True(t, history.called) | ||
| 81 | - | assert.Equal(t, 25, history.limit) | 83 | + | assert.False(t, history.analyticsCalled) |
| 84 | + | assert.Equal(t, 0, history.limit) | |||
| 82 | assert.Equal(t, 0, history.offset) | 85 | assert.Equal(t, 0, history.offset) | ||
| 83 | 86 | ||||
| 84 | // Shell frame present, no full document wrapper. | 87 | // Shell frame present, no full document wrapper. | ||
| @@ -92,6 +95,129 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 92 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | 95 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | ||
| 93 | } | 96 | } | ||
| 94 | 97 | ||||
| 98 | + | func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | history := &fakeHistoryService{ | |||
| 102 | + | entries: []*entity.HistoryEntry{ | |||
| 103 | + | {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2}, | |||
| 104 | + | {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3}, | |||
| 105 | + | }, | |||
| 106 | + | stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121}, | |||
| 107 | + | } | |||
| 108 | + | app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"}) | |||
| 109 | + | ||||
| 110 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 111 | + | assert.True(t, history.statsCalled) | |||
| 112 | + | assert.Contains(t, app.renderedHTML, "Loaded 2 items") | |||
| 113 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History — 8904 entries"`) | |||
| 114 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">8904</span><span class="sv-stat-label">Entries</span>`) | |||
| 115 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">40947</span><span class="sv-stat-label">Visits</span>`) | |||
| 116 | + | assert.Contains(t, app.renderedHTML, `<span class="sv-stat-value">121</span><span class="sv-stat-label">Days</span>`) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) { | |||
| 120 | + | t.Parallel() | |||
| 121 | + | ||||
| 122 | + | dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}} | |||
| 123 | + | history := &fakeHistoryService{ | |||
| 124 | + | entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}}, | |||
| 125 | + | timelineStarted: make(chan struct{}), | |||
| 126 | + | releaseTimeline: make(chan struct{}), | |||
| 127 | + | } | |||
| 128 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 129 | + | ||||
| 130 | + | require.NoError(t, app.RunWithContext(context.Background())) | |||
| 131 | + | loadingHTML := receiveMount(t, dom.mounts) | |||
| 132 | + | assert.Contains(t, loadingHTML, "Loading history") | |||
| 133 | + | assert.NotContains(t, loadingHTML, "Loaded entry") | |||
| 134 | + | ||||
| 135 | + | select { | |||
| 136 | + | case <-history.timelineStarted: | |||
| 137 | + | case <-time.After(time.Second): | |||
| 138 | + | t.Fatal("history timeline was not started asynchronously") | |||
| 139 | + | } | |||
| 140 | + | close(history.releaseTimeline) | |||
| 141 | + | ||||
| 142 | + | hydratedHTML := receiveMount(t, dom.mounts) | |||
| 143 | + | assert.Contains(t, hydratedHTML, "Loaded entry") | |||
| 144 | + | assert.Contains(t, hydratedHTML, "Loaded 1 item") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||
| 148 | + | t.Parallel() | |||
| 149 | + | ||||
| 150 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 151 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |||
| 152 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |||
| 153 | + | Before: cursor, | |||
| 154 | + | After: cursor.Add(-24 * time.Hour), | |||
| 155 | + | HasMore: true, | |||
| 156 | + | }} | |||
| 157 | + | dom := &fakeActionDOM{} | |||
| 158 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 159 | + | app.currentRoute = RouteHistory | |||
| 160 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |||
| 161 | + | app.historyWindowAfter = cursor | |||
| 162 | + | app.historyHasMore = true | |||
| 163 | + | ||||
| 164 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 165 | + | Action: historyActionLoadMore, | |||
| 166 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |||
| 167 | + | })) | |||
| 168 | + | assert.Equal(t, cursor, history.windowBefore) | |||
| 169 | + | assert.Len(t, app.historyEntries, 2) | |||
| 170 | + | assert.Contains(t, dom.appendedHTML, "Older entry") | |||
| 171 | + | assert.Contains(t, dom.appendedHTML, `data-sv-action="history.loadMore"`) | |||
| 172 | + | assert.Empty(t, dom.html, "load-more should append a fragment instead of remounting the whole page") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||
| 176 | + | t.Parallel() | |||
| 177 | + | ||||
| 178 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 179 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |||
| 180 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |||
| 181 | + | Before: cursor, | |||
| 182 | + | After: cursor.Add(-24 * time.Hour), | |||
| 183 | + | HasMore: true, | |||
| 184 | + | }} | |||
| 185 | + | dom := &fakeDOM{} | |||
| 186 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 187 | + | app.currentRoute = RouteHistory | |||
| 188 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |||
| 189 | + | app.historyWindowAfter = cursor | |||
| 190 | + | app.historyHasMore = true | |||
| 191 | + | ||||
| 192 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 193 | + | Action: historyActionLoadMore, | |||
| 194 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |||
| 195 | + | })) | |||
| 196 | + | assert.Equal(t, cursor, history.windowBefore) | |||
| 197 | + | assert.Len(t, app.historyEntries, 2) | |||
| 198 | + | assert.Contains(t, dom.html, "Older entry") | |||
| 199 | + | assert.Contains(t, dom.html, `data-sv-action="history.loadMore"`) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |||
| 203 | + | t.Parallel() | |||
| 204 | + | ||||
| 205 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 206 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}} | |||
| 207 | + | dom := &fakeActionDOM{} | |||
| 208 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 209 | + | app.currentRoute = RouteHistory | |||
| 210 | + | app.historyWindowAfter = cursor | |||
| 211 | + | app.historyHasMore = true | |||
| 212 | + | ||||
| 213 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 214 | + | Action: historyActionLoadMore, | |||
| 215 | + | Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)}, | |||
| 216 | + | })) | |||
| 217 | + | assert.False(t, history.called) | |||
| 218 | + | assert.Empty(t, dom.appendedHTML) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 95 | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | 221 | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | ||
| 96 | t.Parallel() | 222 | t.Parallel() | ||
| 97 | 223 | ||||
| @@ -161,8 +287,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||||
| 161 | Action: historyActionFilterDomain, | 287 | Action: historyActionFilterDomain, | ||
| 162 | Data: map[string]string{"domain": "example.com"}, | 288 | Data: map[string]string{"domain": "example.com"}, | ||
| 163 | })) | 289 | })) | ||
| 164 | - | assert.True(t, history.domainCalled) | 290 | + | assert.True(t, history.called) |
| 165 | - | assert.Equal(t, "example.com", history.domain) | 291 | + | assert.Equal(t, "example.com", history.windowDomain) |
| 166 | assert.Empty(t, app.historyQuery) | 292 | assert.Empty(t, app.historyQuery) | ||
| 167 | 293 | ||||
| 168 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 294 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -720,16 +846,39 @@ func testDefaultConfigPayload() dto.SystemviewConfigPayload { | |||||
| 720 | 846 | ||||
| 721 | // Handwritten fake to capture DOM mounts for stateful render assertions. | 847 | // Handwritten fake to capture DOM mounts for stateful render assertions. | ||
| 722 | type fakeDOM struct { | 848 | type fakeDOM struct { | ||
| 723 | - | mounted bool | 849 | + | mu sync.Mutex |
| 724 | - | html string | 850 | + | mounted bool |
| 851 | + | html string | |||
| 852 | + | appendedHTML string | |||
| 853 | + | mounts chan string | |||
| 725 | } | 854 | } | ||
| 726 | 855 | ||||
| 727 | func (d *fakeDOM) Mount(markup string) error { | 856 | func (d *fakeDOM) Mount(markup string) error { | ||
| 857 | + | d.mu.Lock() | |||
| 728 | d.mounted = true | 858 | d.mounted = true | ||
| 729 | d.html = markup | 859 | d.html = markup | ||
| 860 | + | mounts := d.mounts | |||
| 861 | + | d.mu.Unlock() | |||
| 862 | + | if mounts != nil { | |||
| 863 | + | select { | |||
| 864 | + | case mounts <- markup: | |||
| 865 | + | default: | |||
| 866 | + | } | |||
| 867 | + | } | |||
| 730 | return nil | 868 | return nil | ||
| 731 | } | 869 | } | ||
| 732 | 870 | ||||
| 871 | + | func receiveMount(t *testing.T, mounts <-chan string) string { | |||
| 872 | + | t.Helper() | |||
| 873 | + | select { | |||
| 874 | + | case html := <-mounts: | |||
| 875 | + | return html | |||
| 876 | + | case <-time.After(time.Second): | |||
| 877 | + | t.Fatal("timed out waiting for DOM mount") | |||
| 878 | + | return "" | |||
| 879 | + | } | |||
| 880 | + | } | |||
| 881 | + | ||||
| 733 | type fakeActionDOM struct { | 882 | type fakeActionDOM struct { | ||
| 734 | fakeDOM | 883 | fakeDOM | ||
| 735 | handler DOMActionHandler | 884 | handler DOMActionHandler | ||
| @@ -741,6 +890,13 @@ func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error { | |||||
| 741 | return nil | 890 | return nil | ||
| 742 | } | 891 | } | ||
| 743 | 892 | ||||
| 893 | + | func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error { | |||
| 894 | + | d.mu.Lock() | |||
| 895 | + | d.appendedHTML += markup | |||
| 896 | + | d.mu.Unlock() | |||
| 897 | + | return nil | |||
| 898 | + | } | |||
| 899 | + | ||||
| 744 | func (d *fakeActionDOM) Release() { | 900 | func (d *fakeActionDOM) Release() { | ||
| 745 | d.released = true | 901 | d.released = true | ||
| 746 | } | 902 | } | ||
| @@ -769,16 +925,32 @@ type fakeHistoryService struct { | |||||
| 769 | searchLimit int | 925 | searchLimit int | ||
| 770 | searchEntries []*entity.HistoryEntry | 926 | searchEntries []*entity.HistoryEntry | ||
| 771 | 927 | ||||
| 772 | - | deletedEntryID int64 | 928 | + | deletedEntryID int64 |
| 773 | - | deletedRangeID string | 929 | + | deletedRangeID string |
| 774 | - | deletedDomain string | 930 | + | deletedDomain string |
| 775 | - | domainStats []*entity.DomainStat | 931 | + | domainStats []*entity.DomainStat |
| 932 | + | stats *entity.HistoryStats | |||
| 933 | + | statsCalled bool | |||
| 934 | + | window *entity.HistoryWindow | |||
| 935 | + | windowBefore time.Time | |||
| 936 | + | windowDomain string | |||
| 937 | + | analyticsCalled bool | |||
| 938 | + | ||||
| 939 | + | timelineStarted chan struct{} | |||
| 940 | + | releaseTimeline chan struct{} | |||
| 941 | + | startOnce sync.Once | |||
| 776 | } | 942 | } | ||
| 777 | 943 | ||||
| 778 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 944 | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 779 | s.called = true | 945 | s.called = true | ||
| 780 | s.limit = limit | 946 | s.limit = limit | ||
| 781 | s.offset = offset | 947 | s.offset = offset | ||
| 948 | + | if s.timelineStarted != nil { | |||
| 949 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |||
| 950 | + | } | |||
| 951 | + | if s.releaseTimeline != nil { | |||
| 952 | + | <-s.releaseTimeline | |||
| 953 | + | } | |||
| 782 | return s.entries, s.err | 954 | return s.entries, s.err | ||
| 783 | } | 955 | } | ||
| 784 | 956 | ||||
| @@ -790,6 +962,25 @@ func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, | |||||
| 790 | return s.entries, s.err | 962 | return s.entries, s.err | ||
| 791 | } | 963 | } | ||
| 792 | 964 | ||||
| 965 | + | func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 966 | + | s.called = true | |||
| 967 | + | s.windowBefore = before | |||
| 968 | + | s.windowDomain = domain | |||
| 969 | + | if s.timelineStarted != nil { | |||
| 970 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |||
| 971 | + | } | |||
| 972 | + | if s.releaseTimeline != nil { | |||
| 973 | + | <-s.releaseTimeline | |||
| 974 | + | } | |||
| 975 | + | if s.err != nil { | |||
| 976 | + | return nil, s.err | |||
| 977 | + | } | |||
| 978 | + | if s.window != nil { | |||
| 979 | + | return s.window, nil | |||
| 980 | + | } | |||
| 981 | + | return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |||
| 982 | + | } | |||
| 983 | + | ||||
| 793 | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | 984 | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | ||
| 794 | s.searchCalled = true | 985 | s.searchCalled = true | ||
| 795 | s.query = query | 986 | s.query = query | ||
| @@ -810,7 +1001,13 @@ func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) erro | |||||
| 810 | return nil | 1001 | return nil | ||
| 811 | } | 1002 | } | ||
| 812 | 1003 | ||||
| 1004 | + | func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) { | |||
| 1005 | + | s.statsCalled = true | |||
| 1006 | + | return s.stats, nil | |||
| 1007 | + | } | |||
| 1008 | + | ||||
| 813 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | 1009 | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | ||
| 1010 | + | s.analyticsCalled = true | |||
| 814 | return nil, nil | 1011 | return nil, nil | ||
| 815 | } | 1012 | } | ||
| 816 | 1013 | ||||
M internal/ui/systemviews/components.templ +14 -0
| @@ -32,6 +32,20 @@ templ EmptyState(message string) { | |||||
| 32 | <div class="sv-empty"><p>{ message }</p></div> | 32 | <div class="sv-empty"><p>{ message }</p></div> | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | + | templ LoadingPanel(message string) { | |||
| 36 | + | <div class="sv-loading-panel" role="status" aria-live="polite"> | |||
| 37 | + | <span class="sv-spinner" aria-hidden="true"></span> | |||
| 38 | + | <span>{ message }</span> | |||
| 39 | + | </div> | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | templ LoadingCard(label string) { | |||
| 43 | + | <div class="sv-stat-card sv-loading-card" role="status" aria-live="polite"> | |||
| 44 | + | <span class="sv-spinner" aria-hidden="true"></span> | |||
| 45 | + | <span class="sv-stat-label">{ label }</span> | |||
| 46 | + | </div> | |||
| 47 | + | } | |||
| 48 | + | ||||
| 35 | templ Alert(kind string, message string) { | 49 | templ Alert(kind string, message string) { | ||
| 36 | if message != "" { | 50 | if message != "" { | ||
| 37 | <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div> | 51 | <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div> | ||
M internal/ui/systemviews/components_templ.go +162 -78 generated file: collapsed
M internal/ui/systemviews/config.templ +10 -10
| @@ -41,8 +41,8 @@ templ AppearanceForm(config dto.SystemviewConfigPayload) { | |||||
| 41 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | 41 | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | ||
| 42 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | 42 | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | ||
| 43 | <div class="sv-button-row sv-form-actions"> | 43 | <div class="sv-button-row sv-form-actions"> | ||
| 44 | - | <button class="sv-button" type="submit">Save appearance</button> | 44 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save appearance")</button> |
| 45 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button> | 45 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset appearance")</button> |
| 46 | </div> | 46 | </div> | ||
| 47 | </form> | 47 | </form> | ||
| 48 | } | 48 | } | ||
| @@ -63,7 +63,7 @@ templ PaletteFields(title string, prefix string, palette dto.ColorPalette) { | |||||
| 63 | templ SearchSettings(config dto.SystemviewConfigPayload) { | 63 | templ SearchSettings(config dto.SystemviewConfigPayload) { | ||
| 64 | <form class="sv-card-form" data-sv-action="config.search.save"> | 64 | <form class="sv-card-form" data-sv-action="config.search.save"> | ||
| 65 | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | 65 | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | ||
| 66 | - | <button class="sv-button" type="submit">Save search default</button> | 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save search default")</button> |
| 67 | </form> | 67 | </form> | ||
| 68 | <div class="sv-group"> | 68 | <div class="sv-group"> | ||
| 69 | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | 69 | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | ||
| @@ -72,7 +72,7 @@ templ SearchSettings(config dto.SystemviewConfigPayload) { | |||||
| 72 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | 72 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | ||
| 73 | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> | 73 | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> | ||
| 74 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | 74 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | ||
| 75 | - | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | 75 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconPlus, "Add shortcut")</button> |
| 76 | </form> | 76 | </form> | ||
| 77 | {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }} | 77 | {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }} | ||
| 78 | if len(shortcuts) == 0 { | 78 | if len(shortcuts) == 0 { | ||
| @@ -94,8 +94,8 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||||
| 94 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | 94 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | ||
| 95 | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> | 95 | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> | ||
| 96 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | 96 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | ||
| 97 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 97 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> |
| 98 | - | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | 98 | + | <button class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?" aria-label={ "Delete search shortcut " + shortcut.Key }>@IconOnly(iconTrash, "Delete search shortcut " + shortcut.Key)</button> |
| 99 | </form> | 99 | </form> | ||
| 100 | </li> | 100 | </li> | ||
| 101 | } | 101 | } | ||
| @@ -115,7 +115,7 @@ templ PerformanceSettings(config dto.SystemviewConfigPayload) { | |||||
| 115 | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label> | 115 | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label> | ||
| 116 | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label> | 116 | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label> | ||
| 117 | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label> | 117 | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label> | ||
| 118 | - | <button class="sv-button" type="submit">Save performance</button> | 118 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button> |
| 119 | </form> | 119 | </form> | ||
| 120 | <div class="sv-stat-grid"> | 120 | <div class="sv-stat-grid"> | ||
| 121 | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | 121 | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | ||
| @@ -138,7 +138,7 @@ templ ConfigKeybindings(keybindings port.KeybindingsConfig) { | |||||
| 138 | @EmptyState("No keybindings configured") | 138 | @EmptyState("No keybindings configured") | ||
| 139 | } else { | 139 | } else { | ||
| 140 | <div class="sv-button-row"> | 140 | <div class="sv-button-row"> | ||
| 141 | - | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button> | 141 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset all keybindings")</button> |
| 142 | </div> | 142 | </div> | ||
| 143 | for _, group := range groups { | 143 | for _, group := range groups { | ||
| 144 | @ConfigKeybindingGroup(group) | 144 | @ConfigKeybindingGroup(group) | ||
| @@ -189,8 +189,8 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding | |||||
| 189 | <input type="hidden" name="mode" value={ group.Mode }/> | 189 | <input type="hidden" name="mode" value={ group.Mode }/> | ||
| 190 | <input type="hidden" name="action" value={ binding.Action }/> | 190 | <input type="hidden" name="action" value={ binding.Action }/> | ||
| 191 | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> | 191 | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> | ||
| 192 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 192 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> |
| 193 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | 193 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>@ButtonIconLabel(iconRotateCCW, "Reset")</button> |
| 194 | </form> | 194 | </form> | ||
| 195 | </div> | 195 | </div> | ||
| 196 | </li> | 196 | </li> | ||
M internal/ui/systemviews/config_templ.go +242 -149 generated file: collapsed
M internal/ui/systemviews/dom.go +6 -0
| @@ -24,6 +24,12 @@ type DOMActionBinder interface { | |||||
| 24 | BindActions(handler DOMActionHandler) error | 24 | BindActions(handler DOMActionHandler) error | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | + | // DOMHistoryTimelineAppender is implemented by DOM adapters that can append | |||
| 28 | + | // older history windows without reparsing and remounting the whole page. | |||
| 29 | + | type DOMHistoryTimelineAppender interface { | |||
| 30 | + | AppendHistoryTimeline(markup string) error | |||
| 31 | + | } | |||
| 32 | + | ||||
| 27 | func placeholderHTML(route Route) string { | 33 | func placeholderHTML(route Route) string { | ||
| 28 | name := html.EscapeString(string(route)) | 34 | name := html.EscapeString(string(route)) | ||
| 29 | if name == "" || route == RouteUnknown { | 35 | if name == "" || route == RouteUnknown { | ||
M internal/ui/systemviews/dom_js.go +283 -18
| @@ -10,9 +10,13 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | type browserDOM struct { | 12 | type browserDOM struct { | ||
| 13 | - | document js.Value | 13 | + | document js.Value |
| 14 | - | target js.Value | 14 | + | target js.Value |
| 15 | - | bindings []jsEventBinding | 15 | + | bindings []jsEventBinding |
| 16 | + | historyObserver js.Value | |||
| 17 | + | historyObserverCallback js.Func | |||
| 18 | + | activeConfirmCleanup func() | |||
| 19 | + | activeConfirmID uint64 | |||
| 16 | } | 20 | } | ||
| 17 | 21 | ||||
| 18 | type jsEventBinding struct { | 22 | type jsEventBinding struct { | ||
| @@ -38,9 +42,64 @@ func (d *browserDOM) Mount(markup string) error { | |||||
| 38 | d.target.Set("innerHTML", markup) | 42 | d.target.Set("innerHTML", markup) | ||
| 39 | d.updateDocumentTitle() | 43 | d.updateDocumentTitle() | ||
| 40 | d.scheduleAlertDismissal() | 44 | d.scheduleAlertDismissal() | ||
| 45 | + | d.setupHistoryInfiniteScroll() | |||
| 41 | return nil | 46 | return nil | ||
| 42 | } | 47 | } | ||
| 43 | 48 | ||||
| 49 | + | func (d *browserDOM) AppendHistoryTimeline(markup string) error { | |||
| 50 | + | if d == nil || !d.target.Truthy() { | |||
| 51 | + | return fmt.Errorf("DOM mount target #app not found") | |||
| 52 | + | } | |||
| 53 | + | timeline := d.target.Call("querySelector", "[data-sv-history-timeline]") | |||
| 54 | + | if !timeline.Truthy() { | |||
| 55 | + | return fmt.Errorf("history timeline target not found") | |||
| 56 | + | } | |||
| 57 | + | if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() { | |||
| 58 | + | old.Call("remove") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | container := d.document.Call("createElement", "div") | |||
| 62 | + | container.Set("innerHTML", markup) | |||
| 63 | + | d.mergeLeadingHistoryGroup(timeline, container) | |||
| 64 | + | for container.Get("firstChild").Truthy() { | |||
| 65 | + | timeline.Call("appendChild", container.Get("firstChild")) | |||
| 66 | + | } | |||
| 67 | + | d.setupHistoryInfiniteScroll() | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) { | |||
| 72 | + | if !timeline.Truthy() || !container.Truthy() { | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | first := container.Get("firstElementChild") | |||
| 76 | + | if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String()) | |||
| 80 | + | if date == "" { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | groups := timeline.Call("querySelectorAll", "[data-sv-history-date]") | |||
| 84 | + | if !groups.Truthy() || groups.Get("length").Int() == 0 { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | lastGroup := groups.Index(groups.Get("length").Int() - 1) | |||
| 88 | + | if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | list := lastGroup.Call("querySelector", "ul") | |||
| 92 | + | if !list.Truthy() { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | for first.Get("firstChild").Truthy() { | |||
| 96 | + | list.Call("appendChild", first.Get("firstChild")) | |||
| 97 | + | } | |||
| 98 | + | if first.Get("remove").Truthy() { | |||
| 99 | + | first.Call("remove") | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 44 | func (d *browserDOM) updateDocumentTitle() { | 103 | func (d *browserDOM) updateDocumentTitle() { | ||
| 45 | if d == nil || !d.document.Truthy() || !d.target.Truthy() { | 104 | if d == nil || !d.document.Truthy() || !d.target.Truthy() { | ||
| 46 | return | 105 | return | ||
| @@ -129,11 +188,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 129 | if action == "" { | 188 | if action == "" { | ||
| 130 | return nil | 189 | return nil | ||
| 131 | } | 190 | } | ||
| 132 | - | if !confirmAction(element) { | |||
| 133 | - | return nil | |||
| 134 | - | } | |||
| 135 | event.Call("preventDefault") | 191 | event.Call("preventDefault") | ||
| 136 | - | handler(DOMAction{Action: action, Data: collectActionData(element)}) | 192 | + | data := collectActionData(element) |
| 193 | + | d.confirmAction(element, func() { | |||
| 194 | + | handler(DOMAction{Action: action, Data: data}) | |||
| 195 | + | }) | |||
| 137 | return nil | 196 | return nil | ||
| 138 | }) | 197 | }) | ||
| 139 | d.addEventBinding(d.target, "click", clickHandler) | 198 | d.addEventBinding(d.target, "click", clickHandler) | ||
| @@ -151,15 +210,14 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 151 | if action == "" { | 210 | if action == "" { | ||
| 152 | return nil | 211 | return nil | ||
| 153 | } | 212 | } | ||
| 154 | - | if !confirmAction(form) { | |||
| 155 | - | return nil | |||
| 156 | - | } | |||
| 157 | event.Call("preventDefault") | 213 | event.Call("preventDefault") | ||
| 158 | data := collectActionData(form) | 214 | data := collectActionData(form) | ||
| 159 | for key, value := range collectFormData(form) { | 215 | for key, value := range collectFormData(form) { | ||
| 160 | data[key] = value | 216 | data[key] = value | ||
| 161 | } | 217 | } | ||
| 162 | - | handler(DOMAction{Action: action, Data: data}) | 218 | + | d.confirmAction(form, func() { |
| 219 | + | handler(DOMAction{Action: action, Data: data}) | |||
| 220 | + | }) | |||
| 163 | return nil | 221 | return nil | ||
| 164 | }) | 222 | }) | ||
| 165 | d.addEventBinding(d.target, "submit", submitHandler) | 223 | d.addEventBinding(d.target, "submit", submitHandler) | ||
| @@ -206,10 +264,70 @@ func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) | |||||
| 206 | d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn}) | 264 | d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn}) | ||
| 207 | } | 265 | } | ||
| 208 | 266 | ||||
| 267 | + | func (d *browserDOM) setupHistoryInfiniteScroll() { | |||
| 268 | + | if d == nil || !d.target.Truthy() { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | d.disconnectHistoryObserver() | |||
| 272 | + | observerCtor := js.Global().Get("IntersectionObserver") | |||
| 273 | + | if !observerCtor.Truthy() { | |||
| 274 | + | return | |||
| 275 | + | } | |||
| 276 | + | button := d.target.Call("querySelector", "[data-sv-history-load-more]") | |||
| 277 | + | if !button.Truthy() { | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 281 | + | if len(args) == 0 || !button.Truthy() { | |||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | entries := args[0] | |||
| 285 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |||
| 286 | + | entry := entries.Index(i) | |||
| 287 | + | if !entry.Truthy() || !entry.Get("isIntersecting").Bool() { | |||
| 288 | + | continue | |||
| 289 | + | } | |||
| 290 | + | if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" { | |||
| 291 | + | return nil | |||
| 292 | + | } | |||
| 293 | + | // A disabled button does not dispatch click events. Dispatch first, then | |||
| 294 | + | // mark the control busy so the observer cannot enqueue duplicate loads | |||
| 295 | + | // while the WASM action worker fetches the next history window. | |||
| 296 | + | button.Call("click") | |||
| 297 | + | button.Get("dataset").Set("svLoading", "true") | |||
| 298 | + | button.Set("disabled", true) | |||
| 299 | + | button.Set("textContent", "Loading older visits…") | |||
| 300 | + | return nil | |||
| 301 | + | } | |||
| 302 | + | return nil | |||
| 303 | + | }) | |||
| 304 | + | d.historyObserverCallback = callback | |||
| 305 | + | options := js.Global().Get("Object").New() | |||
| 306 | + | options.Set("rootMargin", "400px 0px") | |||
| 307 | + | d.historyObserver = observerCtor.New(callback, options) | |||
| 308 | + | d.historyObserver.Call("observe", button) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func (d *browserDOM) disconnectHistoryObserver() { | |||
| 312 | + | if d == nil { | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | if d.historyObserver.Truthy() { | |||
| 316 | + | d.historyObserver.Call("disconnect") | |||
| 317 | + | d.historyObserver = js.Value{} | |||
| 318 | + | } | |||
| 319 | + | if d.historyObserverCallback.Truthy() { | |||
| 320 | + | d.historyObserverCallback.Release() | |||
| 321 | + | d.historyObserverCallback = js.Func{} | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 209 | func (d *browserDOM) Release() { | 325 | func (d *browserDOM) Release() { | ||
| 210 | if d == nil { | 326 | if d == nil { | ||
| 211 | return | 327 | return | ||
| 212 | } | 328 | } | ||
| 329 | + | d.disconnectHistoryObserver() | |||
| 330 | + | d.cleanupActiveConfirmDialog() | |||
| 213 | for _, binding := range d.bindings { | 331 | for _, binding := range d.bindings { | ||
| 214 | if binding.target.Truthy() { | 332 | if binding.target.Truthy() { | ||
| 215 | binding.target.Call("removeEventListener", binding.event, binding.fn) | 333 | binding.target.Call("removeEventListener", binding.event, binding.fn) | ||
| @@ -277,16 +395,163 @@ func collectFormData(form js.Value) map[string]string { | |||||
| 277 | return data | 395 | return data | ||
| 278 | } | 396 | } | ||
| 279 | 397 | ||||
| 280 | - | func confirmAction(element js.Value) bool { | 398 | + | func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) { |
| 281 | - | message := element.Call("getAttribute", "data-sv-confirm").String() | 399 | + | if onConfirm == nil { |
| 400 | + | return | |||
| 401 | + | } | |||
| 402 | + | confirmAttr := element.Call("getAttribute", "data-sv-confirm") | |||
| 403 | + | if confirmAttr.Type() != js.TypeString { | |||
| 404 | + | onConfirm() | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | message := strings.TrimSpace(confirmAttr.String()) | |||
| 282 | if message == "" { | 408 | if message == "" { | ||
| 283 | - | return true | 409 | + | onConfirm() |
| 410 | + | return | |||
| 284 | } | 411 | } | ||
| 285 | - | confirm := js.Global().Get("window").Get("confirm") | 412 | + | if !d.showConfirmDialog(message, onConfirm) { |
| 286 | - | if !confirm.Truthy() { | 413 | + | onConfirm() |
| 287 | - | return true | 414 | + | } |
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func (d *browserDOM) cleanupActiveConfirmDialog() { | |||
| 418 | + | if d == nil || d.activeConfirmCleanup == nil { | |||
| 419 | + | return | |||
| 420 | + | } | |||
| 421 | + | cleanup := d.activeConfirmCleanup | |||
| 422 | + | d.activeConfirmCleanup = nil | |||
| 423 | + | cleanup() | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool { | |||
| 427 | + | if d == nil || !d.document.Truthy() { | |||
| 428 | + | return false | |||
| 429 | + | } | |||
| 430 | + | body := d.document.Get("body") | |||
| 431 | + | if !body.Truthy() { | |||
| 432 | + | return false | |||
| 288 | } | 433 | } | ||
| 289 | - | return confirm.Invoke(message).Bool() | 434 | + | // Always close an existing modal through its cleanup path so key handlers and |
| 435 | + | // js.Func values are released before the replacement is mounted. | |||
| 436 | + | d.cleanupActiveConfirmDialog() | |||
| 437 | + | ||||
| 438 | + | overlay := d.document.Call("createElement", "div") | |||
| 439 | + | overlay.Get("classList").Call("add", "sv-confirm-backdrop") | |||
| 440 | + | overlay.Set("role", "presentation") | |||
| 441 | + | ||||
| 442 | + | dialog := d.document.Call("createElement", "div") | |||
| 443 | + | dialog.Get("classList").Call("add", "sv-confirm-dialog") | |||
| 444 | + | dialog.Set("role", "dialog") | |||
| 445 | + | dialog.Call("setAttribute", "aria-modal", "true") | |||
| 446 | + | dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title") | |||
| 447 | + | dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message") | |||
| 448 | + | overlay.Call("appendChild", dialog) | |||
| 449 | + | ||||
| 450 | + | title := d.document.Call("createElement", "h3") | |||
| 451 | + | title.Set("id", "sv-confirm-title") | |||
| 452 | + | title.Set("textContent", "Confirm action") | |||
| 453 | + | dialog.Call("appendChild", title) | |||
| 454 | + | ||||
| 455 | + | text := d.document.Call("createElement", "p") | |||
| 456 | + | text.Set("id", "sv-confirm-message") | |||
| 457 | + | text.Set("textContent", message) | |||
| 458 | + | dialog.Call("appendChild", text) | |||
| 459 | + | ||||
| 460 | + | actions := d.document.Call("createElement", "div") | |||
| 461 | + | actions.Get("classList").Call("add", "sv-confirm-actions") | |||
| 462 | + | dialog.Call("appendChild", actions) | |||
| 463 | + | ||||
| 464 | + | cancelButton := d.document.Call("createElement", "button") | |||
| 465 | + | cancelButton.Set("type", "button") | |||
| 466 | + | cancelButton.Set("className", "sv-button sv-button-secondary") | |||
| 467 | + | cancelButton.Set("textContent", "Cancel") | |||
| 468 | + | actions.Call("appendChild", cancelButton) | |||
| 469 | + | ||||
| 470 | + | confirmButton := d.document.Call("createElement", "button") | |||
| 471 | + | confirmButton.Set("type", "button") | |||
| 472 | + | confirmButton.Set("className", "sv-button sv-button-danger") | |||
| 473 | + | confirmButton.Set("textContent", "Confirm") | |||
| 474 | + | actions.Call("appendChild", confirmButton) | |||
| 475 | + | ||||
| 476 | + | d.activeConfirmID++ | |||
| 477 | + | confirmID := d.activeConfirmID | |||
| 478 | + | ||||
| 479 | + | var cleanup func() | |||
| 480 | + | var cancelFn js.Func | |||
| 481 | + | var confirmFn js.Func | |||
| 482 | + | var keyFn js.Func | |||
| 483 | + | var backdropFn js.Func | |||
| 484 | + | cleaned := false | |||
| 485 | + | confirmed := false | |||
| 486 | + | cleanup = func() { | |||
| 487 | + | if cleaned { | |||
| 488 | + | return | |||
| 489 | + | } | |||
| 490 | + | cleaned = true | |||
| 491 | + | if d.activeConfirmID == confirmID { | |||
| 492 | + | d.activeConfirmCleanup = nil | |||
| 493 | + | } | |||
| 494 | + | if overlay.Truthy() { | |||
| 495 | + | overlay.Call("remove") | |||
| 496 | + | } | |||
| 497 | + | if d.document.Truthy() && keyFn.Value.Truthy() { | |||
| 498 | + | d.document.Call("removeEventListener", "keydown", keyFn) | |||
| 499 | + | } | |||
| 500 | + | cancelFn.Release() | |||
| 501 | + | confirmFn.Release() | |||
| 502 | + | keyFn.Release() | |||
| 503 | + | backdropFn.Release() | |||
| 504 | + | } | |||
| 505 | + | runConfirm := func() { | |||
| 506 | + | if confirmed { | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | confirmed = true | |||
| 510 | + | cleanup() | |||
| 511 | + | onConfirm() | |||
| 512 | + | } | |||
| 513 | + | cancelFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 514 | + | cleanup() | |||
| 515 | + | return nil | |||
| 516 | + | }) | |||
| 517 | + | confirmFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 518 | + | runConfirm() | |||
| 519 | + | return nil | |||
| 520 | + | }) | |||
| 521 | + | keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 522 | + | event := firstJSArg(args) | |||
| 523 | + | if !event.Truthy() { | |||
| 524 | + | return nil | |||
| 525 | + | } | |||
| 526 | + | switch strings.ToLower(event.Get("key").String()) { | |||
| 527 | + | case "escape": | |||
| 528 | + | event.Call("preventDefault") | |||
| 529 | + | event.Call("stopPropagation") | |||
| 530 | + | cleanup() | |||
| 531 | + | case "enter": | |||
| 532 | + | event.Call("preventDefault") | |||
| 533 | + | event.Call("stopPropagation") | |||
| 534 | + | runConfirm() | |||
| 535 | + | } | |||
| 536 | + | return nil | |||
| 537 | + | }) | |||
| 538 | + | backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 539 | + | event := firstJSArg(args) | |||
| 540 | + | if event.Truthy() && event.Get("target").Equal(overlay) { | |||
| 541 | + | cleanup() | |||
| 542 | + | } | |||
| 543 | + | return nil | |||
| 544 | + | }) | |||
| 545 | + | ||||
| 546 | + | cancelButton.Call("addEventListener", "click", cancelFn) | |||
| 547 | + | confirmButton.Call("addEventListener", "click", confirmFn) | |||
| 548 | + | document := d.document | |||
| 549 | + | document.Call("addEventListener", "keydown", keyFn) | |||
| 550 | + | overlay.Call("addEventListener", "click", backdropFn) | |||
| 551 | + | d.activeConfirmCleanup = cleanup | |||
| 552 | + | body.Call("appendChild", overlay) | |||
| 553 | + | confirmButton.Call("focus") | |||
| 554 | + | return true | |||
| 290 | } | 555 | } | ||
| 291 | 556 | ||||
| 292 | func (d *browserDOM) handleKeyboard(event js.Value) { | 557 | func (d *browserDOM) handleKeyboard(event js.Value) { | ||
M internal/ui/systemviews/favorites.templ +10 -10
| @@ -63,7 +63,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | ||
| 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | ||
| 65 | @FavoriteCreateTagPicker(data.Tags) | 65 | @FavoriteCreateTagPicker(data.Tags) | ||
| 66 | - | <button class="sv-button" type="submit">Add favorite</button> | 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button> |
| 67 | </form> | 67 | </form> | ||
| 68 | <div class="sv-filter-panel"> | 68 | <div class="sv-filter-panel"> | ||
| 69 | <div class="sv-button-row"> | 69 | <div class="sv-button-row"> | ||
| @@ -120,8 +120,8 @@ templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []* | |||||
| 120 | } | 120 | } | ||
| 121 | </div> | 121 | </div> | ||
| 122 | <div class="sv-history-row-actions"> | 122 | <div class="sv-history-row-actions"> | ||
| 123 | - | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>Open</a> | 123 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> |
| 124 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?">Delete</button> | 124 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button> |
| 125 | </div> | 125 | </div> | ||
| 126 | </div> | 126 | </div> | ||
| 127 | @FavoriteEditForm(favorite, folders) | 127 | @FavoriteEditForm(favorite, folders) | ||
| @@ -137,7 +137,7 @@ templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) { | |||||
| 137 | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | 137 | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | ||
| 138 | @FolderSelect("folder_id", folders, favorite.FolderID, "Folder") | 138 | @FolderSelect("folder_id", folders, favorite.FolderID, "Folder") | ||
| 139 | @ShortcutSelect(favorite.ShortcutKey) | 139 | @ShortcutSelect(favorite.ShortcutKey) | ||
| 140 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 140 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> |
| 141 | </form> | 141 | </form> | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| @@ -160,7 +160,7 @@ templ FolderCreateForm() { | |||||
| 160 | <form class="sv-inline-form" data-sv-action="folder.create"> | 160 | <form class="sv-inline-form" data-sv-action="folder.create"> | ||
| 161 | <label><span>Name</span><input name="name" type="text" required/></label> | 161 | <label><span>Name</span><input name="name" type="text" required/></label> | ||
| 162 | <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label> | 162 | <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label> | ||
| 163 | - | <button class="sv-button sv-button-secondary" type="submit">Create folder</button> | 163 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button> |
| 164 | </form> | 164 | </form> | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| @@ -170,8 +170,8 @@ templ FolderItem(folder *entity.Folder) { | |||||
| 170 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/> | 170 | <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/> | ||
| 171 | <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label> | 171 | <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label> | ||
| 172 | <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label> | 172 | <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label> | ||
| 173 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 173 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> |
| 174 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it.">Delete</button> | 174 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button> |
| 175 | </form> | 175 | </form> | ||
| 176 | </li> | 176 | </li> | ||
| 177 | } | 177 | } | ||
| @@ -180,7 +180,7 @@ templ TagCreateForm() { | |||||
| 180 | <form class="sv-inline-form" data-sv-action="tag.create"> | 180 | <form class="sv-inline-form" data-sv-action="tag.create"> | ||
| 181 | <label><span>Name</span><input name="name" type="text" required/></label> | 181 | <label><span>Name</span><input name="name" type="text" required/></label> | ||
| 182 | <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label> | 182 | <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label> | ||
| 183 | - | <button class="sv-button sv-button-secondary" type="submit">Create tag</button> | 183 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconTag, "Create tag")</button> |
| 184 | </form> | 184 | </form> | ||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| @@ -191,8 +191,8 @@ templ TagItem(tag *entity.Tag) { | |||||
| 191 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> | 191 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> | ||
| 192 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | 192 | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | ||
| 193 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | 193 | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | ||
| 194 | - | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 194 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> |
| 195 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?">Delete</button> | 195 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?" aria-label="Delete this tag">@IconOnly(iconTrash, "Delete this tag")</button> |
| 196 | </form> | 196 | </form> | ||
| 197 | </li> | 197 | </li> | ||
| 198 | } | 198 | } | ||
M internal/ui/systemviews/favorites_templ.go +163 -83 generated file: collapsed
M internal/ui/systemviews/history.templ +107 -36
| @@ -2,6 +2,7 @@ package systemviews | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "strings" | |||
| 5 | "github.com/bnema/dumber/internal/domain/entity" | 6 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| @@ -28,8 +29,8 @@ templ HistoryControls(data historyRenderData) { | |||||
| 28 | <span>Search</span> | 29 | <span>Search</span> | ||
| 29 | <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/> | 30 | <input data-sv-history-search name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off"/> | ||
| 30 | </label> | 31 | </label> | ||
| 31 | - | <button class="sv-button" type="submit">Search</button> | 32 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSearch, "Search")</button> |
| 32 | - | <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">Clear</button> | 33 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">@ButtonIconLabel(iconX, "Clear")</button> |
| 33 | </form> | 34 | </form> | ||
| 34 | if showHistoryFilters(data) { | 35 | if showHistoryFilters(data) { | ||
| 35 | <div class="sv-active-filters"> | 36 | <div class="sv-active-filters"> | ||
| @@ -37,20 +38,18 @@ templ HistoryControls(data historyRenderData) { | |||||
| 37 | <span class="sv-filter-chip">Query: { data.Query }</span> | 38 | <span class="sv-filter-chip">Query: { data.Query }</span> | ||
| 38 | } | 39 | } | ||
| 39 | if data.DomainFilter != "" { | 40 | if data.DomainFilter != "" { | ||
| 40 | - | <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">×</button></span> | 41 | + | <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">@IconOnly(iconX, "Clear domain filter")</button></span> |
| 41 | } | 42 | } | ||
| 42 | </div> | 43 | </div> | ||
| 43 | } | 44 | } | ||
| 44 | <div class="sv-history-actions-panel"> | 45 | <div class="sv-history-actions-panel"> | ||
| 45 | <div class="sv-button-row" aria-label="History pagination"> | 46 | <div class="sv-button-row" aria-label="History pagination"> | ||
| 46 | - | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", previousHistoryOffset(data)) } disabled?={ disableHistoryPrev(data) }>Previous</button> | 47 | + | <span class="sv-meta">{ historyShowingLabel(data) }</span> |
| 47 | - | <span class="sv-meta">Showing { fmt.Sprintf("%d", countHistoryEntries(data.Entries)) } item{ pluralSuffix(countHistoryEntries(data.Entries)) }{ historyOffsetLabel(data.Offset, data.Query) }</span> | |||
| 48 | - | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.page" data-offset={ fmt.Sprintf("%d", nextHistoryOffset(data)) } disabled?={ disableHistoryNext(data) }>Next</button> | |||
| 49 | </div> | 48 | </div> | ||
| 50 | <div class="sv-button-row" aria-label="History cleanup"> | 49 | <div class="sv-button-row" aria-label="History cleanup"> | ||
| 51 | <span class="sv-meta">Clean up</span> | 50 | <span class="sv-meta">Clean up</span> | ||
| 52 | for _, item := range historyCleanupItems() { | 51 | for _, item := range historyCleanupItems() { | ||
| 53 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button> | 52 | + | <button type="button" class="sv-danger-link" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button> |
| 54 | } | 53 | } | ||
| 55 | </div> | 54 | </div> | ||
| 56 | </div> | 55 | </div> | ||
| @@ -58,20 +57,31 @@ templ HistoryControls(data historyRenderData) { | |||||
| 58 | } | 57 | } | ||
| 59 | 58 | ||||
| 60 | templ HistorySummary(data historyRenderData) { | 59 | templ HistorySummary(data historyRenderData) { | ||
| 61 | - | {{ entries, visits, days := historySummaryValues(data) }} | 60 | + | if historySummaryLoading(data) { |
| 62 | - | <div class="sv-stat-grid"> | 61 | + | <div class="sv-stat-grid"> |
| 63 | - | @HistoryStatCard("Entries", entries) | 62 | + | @LoadingCard("Entries") |
| 64 | - | @HistoryStatCard("Visits", visits) | 63 | + | @LoadingCard("Visits") |
| 65 | - | @HistoryStatCard("Days", days) | 64 | + | @LoadingCard("Days") |
| 66 | - | </div> | 65 | + | </div> |
| 66 | + | } else { | |||
| 67 | + | {{ entries, visits, days := historySummaryValues(data) }} | |||
| 68 | + | <div class="sv-stat-grid"> | |||
| 69 | + | @HistoryStatCard("Entries", entries) | |||
| 70 | + | @HistoryStatCard("Visits", visits) | |||
| 71 | + | @HistoryStatCard("Days", days) | |||
| 72 | + | </div> | |||
| 73 | + | } | |||
| 67 | } | 74 | } | ||
| 68 | 75 | ||||
| 76 | + | ||||
| 69 | templ HistoryStatCard(label string, value int64) { | 77 | templ HistoryStatCard(label string, value int64) { | ||
| 70 | <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div> | 78 | <div class="sv-stat-card"><span class="sv-stat-value">{ fmt.Sprintf("%d", value) }</span><span class="sv-stat-label">{ label }</span></div> | ||
| 71 | } | 79 | } | ||
| 72 | 80 | ||||
| 73 | templ HistoryDomains(data historyRenderData) { | 81 | templ HistoryDomains(data historyRenderData) { | ||
| 74 | - | if len(data.Domains) == 0 { | 82 | + | if data.Loading { |
| 83 | + | @LoadingPanel("Loading top domains…") | |||
| 84 | + | } else if len(data.Domains) == 0 { | |||
| 75 | @EmptyState("No domain statistics yet") | 85 | @EmptyState("No domain statistics yet") | ||
| 76 | } else { | 86 | } else { | ||
| 77 | <div class="sv-domain-list"> | 87 | <div class="sv-domain-list"> | ||
| @@ -80,9 +90,11 @@ templ HistoryDomains(data historyRenderData) { | |||||
| 80 | {{ domainKey := historyDomainActionKey(domain) }} | 90 | {{ domainKey := historyDomainActionKey(domain) }} | ||
| 81 | {{ displayDomain := historyDomainDisplayLabel(domain) }} | 91 | {{ displayDomain := historyDomainDisplayLabel(domain) }} | ||
| 82 | <span class="sv-domain-chip"> | 92 | <span class="sv-domain-chip"> | ||
| 83 | - | <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button> | 93 | + | <span class="sv-domain-chip-main"> |
| 84 | - | <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span> | 94 | + | <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button> |
| 85 | - | <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button> | 95 | + | <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span> |
| 96 | + | </span> | |||
| 97 | + | <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>@IconOnly(iconTrash, "Delete history for " + displayDomain)</button> | |||
| 86 | </span> | 98 | </span> | ||
| 87 | } | 99 | } | ||
| 88 | } | 100 | } | ||
| @@ -91,36 +103,95 @@ templ HistoryDomains(data historyRenderData) { | |||||
| 91 | } | 103 | } | ||
| 92 | 104 | ||||
| 93 | templ HistoryTimeline(data historyRenderData) { | 105 | templ HistoryTimeline(data historyRenderData) { | ||
| 94 | - | {{ groups := groupHistoryEntries(data.Entries) }} | 106 | + | if data.Loading { |
| 95 | - | if len(groups) == 0 { | 107 | + | @LoadingPanel("Loading recent visits…") |
| 96 | - | @EmptyState(historyTimelineEmptyMessage(data)) | |||
| 97 | } else { | 108 | } else { | ||
| 98 | - | for _, group := range groups { | 109 | + | <div class="sv-history-timeline-list" data-sv-history-timeline> |
| 99 | - | <section class="sv-timeline-group"> | 110 | + | {{ groups := groupHistoryEntries(data.Entries) }} |
| 100 | - | <h3>{ group.Label }</h3> | 111 | + | if len(groups) == 0 { |
| 101 | - | <ul class="sv-list"> | 112 | + | @EmptyState(historyTimelineEmptyMessage(data)) |
| 102 | - | for _, entry := range group.Entries { | 113 | + | } else { |
| 103 | - | if entry != nil { | 114 | + | @HistoryTimelineGroups(data.Entries, "") |
| 104 | - | @HistoryItem(entry) | 115 | + | } |
| 105 | - | } | 116 | + | @HistoryLoadMore(data) |
| 117 | + | </div> | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | templ HistoryTimelineAppend(data historyRenderData) { | |||
| 122 | + | @HistoryTimelineAppendGroups(data.Entries, data.AppendSkipFirstDate) | |||
| 123 | + | @HistoryLoadMore(data) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | templ HistoryTimelineGroups(entries []*entity.HistoryEntry, skipFirstDate string) { | |||
| 127 | + | {{ groups := groupHistoryEntries(entries) }} | |||
| 128 | + | for _, group := range groups { | |||
| 129 | + | @HistoryTimelineGroup(group, true) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | templ HistoryTimelineAppendGroups(entries []*entity.HistoryEntry, mergeFirstDate string) { | |||
| 134 | + | {{ groups := groupHistoryEntries(entries) }} | |||
| 135 | + | for index, group := range groups { | |||
| 136 | + | if index == 0 && group.Date == mergeFirstDate { | |||
| 137 | + | <div data-sv-history-merge-date={ group.Date }> | |||
| 138 | + | for _, entry := range group.Entries { | |||
| 139 | + | if entry != nil { | |||
| 140 | + | @HistoryItem(entry) | |||
| 106 | } | 141 | } | ||
| 107 | - | </ul> | 142 | + | } |
| 108 | - | </section> | 143 | + | </div> |
| 144 | + | } else { | |||
| 145 | + | @HistoryTimelineGroup(group, true) | |||
| 109 | } | 146 | } | ||
| 110 | } | 147 | } | ||
| 111 | } | 148 | } | ||
| 112 | 149 | ||||
| 150 | + | templ HistoryTimelineGroup(group historyTimelineGroup, showHeading bool) { | |||
| 151 | + | <section class="sv-timeline-group" data-sv-history-date={ group.Date }> | |||
| 152 | + | if showHeading { | |||
| 153 | + | <h3>{ group.Label }</h3> | |||
| 154 | + | } | |||
| 155 | + | <ul class="sv-list"> | |||
| 156 | + | for _, entry := range group.Entries { | |||
| 157 | + | if entry != nil { | |||
| 158 | + | @HistoryItem(entry) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | </ul> | |||
| 162 | + | </section> | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | templ HistoryLoadMore(data historyRenderData) { | |||
| 166 | + | <div class="sv-history-load-more" data-sv-history-load-more-container> | |||
| 167 | + | if showHistoryLoadMore(data) { | |||
| 168 | + | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.loadMore" data-before={ historyLoadMoreCursor(data) } data-sv-history-load-more>Load older visits</button> | |||
| 169 | + | } else if strings.TrimSpace(data.Query) == "" && countHistoryEntries(data.Entries) > 0 { | |||
| 170 | + | <p class="sv-meta">End of history</p> | |||
| 171 | + | } | |||
| 172 | + | </div> | |||
| 173 | + | } | |||
| 174 | + | ||||
| 113 | templ HistoryItem(entry *entity.HistoryEntry) { | 175 | templ HistoryItem(entry *entity.HistoryEntry) { | ||
| 114 | <li class="sv-list-row"> | 176 | <li class="sv-list-row"> | ||
| 115 | <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }> | 177 | <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }> | ||
| 116 | - | <div class="sv-history-main"> | 178 | + | <div class="sv-history-content"> |
| 117 | - | <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a> | 179 | + | {{ faviconURL := historyItemFaviconURL(entry) }} |
| 118 | - | <p class="sv-history-url">{ entry.URL }</p> | 180 | + | <span class={ historyFaviconClass(faviconURL) } aria-hidden="true"> |
| 119 | - | <p class="sv-history-meta">{ historyItemMeta(entry) }</p> | 181 | + | <span class="sv-history-favicon-fallback">{ historyFaviconFallback(entry) }</span> |
| 182 | + | if faviconURL != "" { | |||
| 183 | + | <img src={ templ.SafeURL(faviconURL) } alt="" width="24" height="24" loading="lazy" decoding="async" referrerpolicy="same-origin" onerror="this.parentElement.classList.remove('sv-history-favicon-has-image'); this.remove()"/> | |||
| 184 | + | } | |||
| 185 | + | </span> | |||
| 186 | + | <div class="sv-history-main"> | |||
| 187 | + | <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a> | |||
| 188 | + | <p class="sv-history-url" title={ entry.URL }>{ historyItemURL(entry) }</p> | |||
| 189 | + | <p class="sv-history-meta">{ historyItemMeta(entry) }</p> | |||
| 190 | + | </div> | |||
| 120 | </div> | 191 | </div> | ||
| 121 | <div class="sv-history-row-actions"> | 192 | <div class="sv-history-row-actions"> | ||
| 122 | - | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>Open</a> | 193 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> |
| 123 | - | <button type="button" class="sv-button sv-button-secondary sv-button-danger" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" disabled?={ entry.ID <= 0 }>Delete</button> | 194 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" aria-label="Delete this history entry" disabled?={ entry.ID <= 0 }>@IconOnly(iconTrash, "Delete this history entry")</button> |
| 124 | </div> | 195 | </div> | ||
| 125 | </article> | 196 | </article> | ||
| 126 | </li> | 197 | </li> | ||
M internal/ui/systemviews/history_actions.go +108 -9
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | const ( | 11 | const ( | ||
| @@ -13,6 +14,7 @@ const ( | |||||
| 13 | historyActionFilterDomain = "history.filterDomain" | 14 | historyActionFilterDomain = "history.filterDomain" | ||
| 14 | historyActionClearDomain = "history.clearDomain" | 15 | historyActionClearDomain = "history.clearDomain" | ||
| 15 | historyActionPage = "history.page" | 16 | historyActionPage = "history.page" | ||
| 17 | + | historyActionLoadMore = "history.loadMore" | |||
| 16 | historyActionDeleteEntry = "history.deleteEntry" | 18 | historyActionDeleteEntry = "history.deleteEntry" | ||
| 17 | historyActionDeleteRange = "history.deleteRange" | 19 | historyActionDeleteRange = "history.deleteRange" | ||
| 18 | historyActionDeleteDomain = "history.deleteDomain" | 20 | historyActionDeleteDomain = "history.deleteDomain" | ||
| @@ -26,12 +28,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 26 | return fmt.Errorf("app is nil") | 28 | return fmt.Errorf("app is nil") | ||
| 27 | } | 29 | } | ||
| 28 | a.lockState() | 30 | a.lockState() | ||
| 29 | - | defer a.unlockState() | |||
| 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | 31 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | ||
| 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 32 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 32 | } | 33 | } | ||
| 34 | + | route := a.currentRoute | |||
| 35 | + | if route == RouteHistory && event.Action == historyActionLoadMore { | |||
| 36 | + | a.unlockState() | |||
| 37 | + | return a.handleHistoryLoadMore(ctx, event) | |||
| 38 | + | } | |||
| 39 | + | defer a.unlockState() | |||
| 33 | 40 | ||||
| 34 | - | switch a.currentRoute { | 41 | + | switch route { |
| 35 | case RouteHistory: | 42 | case RouteHistory: | ||
| 36 | a.historyError = "" | 43 | a.historyError = "" | ||
| 37 | if err := a.handleHistoryAction(ctx, event); err != nil { | 44 | if err := a.handleHistoryAction(ctx, event); err != nil { | ||
| @@ -84,12 +91,12 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 84 | case historyActionSearch: | 91 | case historyActionSearch: | ||
| 85 | a.historyQuery = strings.TrimSpace(data["query"]) | 92 | a.historyQuery = strings.TrimSpace(data["query"]) | ||
| 86 | a.historyDomainFilter = "" | 93 | a.historyDomainFilter = "" | ||
| 87 | - | a.historyOffset = 0 | 94 | + | a.resetHistoryWindowState() |
| 88 | a.historyNotice = "" | 95 | a.historyNotice = "" | ||
| 89 | case historyActionClear: | 96 | case historyActionClear: | ||
| 90 | a.historyQuery = "" | 97 | a.historyQuery = "" | ||
| 91 | a.historyDomainFilter = "" | 98 | a.historyDomainFilter = "" | ||
| 92 | - | a.historyOffset = 0 | 99 | + | a.resetHistoryWindowState() |
| 93 | a.historyNotice = "" | 100 | a.historyNotice = "" | ||
| 94 | case historyActionFilterDomain: | 101 | case historyActionFilterDomain: | ||
| 95 | domain := strings.TrimSpace(data["domain"]) | 102 | domain := strings.TrimSpace(data["domain"]) | ||
| @@ -98,17 +105,18 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 98 | } | 105 | } | ||
| 99 | a.historyDomainFilter = domain | 106 | a.historyDomainFilter = domain | ||
| 100 | a.historyQuery = "" | 107 | a.historyQuery = "" | ||
| 101 | - | a.historyOffset = 0 | 108 | + | a.resetHistoryWindowState() |
| 102 | a.historyNotice = "" | 109 | a.historyNotice = "" | ||
| 103 | case historyActionClearDomain: | 110 | case historyActionClearDomain: | ||
| 104 | a.historyDomainFilter = "" | 111 | a.historyDomainFilter = "" | ||
| 105 | - | a.historyOffset = 0 | 112 | + | a.resetHistoryWindowState() |
| 106 | a.historyNotice = "" | 113 | a.historyNotice = "" | ||
| 107 | case historyActionPage: | 114 | case historyActionPage: | ||
| 108 | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | 115 | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | ||
| 109 | if err != nil || offset < 0 { | 116 | if err != nil || offset < 0 { | ||
| 110 | return fmt.Errorf("invalid history offset") | 117 | return fmt.Errorf("invalid history offset") | ||
| 111 | } | 118 | } | ||
| 119 | + | a.resetHistoryWindowState() | |||
| 112 | a.historyOffset = offset | 120 | a.historyOffset = offset | ||
| 113 | a.historyNotice = "" | 121 | a.historyNotice = "" | ||
| 114 | case historyActionDeleteEntry: | 122 | case historyActionDeleteEntry: | ||
| @@ -119,7 +127,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 119 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | 127 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | ||
| 120 | return err | 128 | return err | ||
| 121 | } | 129 | } | ||
| 122 | - | a.historyOffset = 0 | 130 | + | a.resetHistoryWindowState() |
| 123 | a.historyNotice = "Deleted history entry" | 131 | a.historyNotice = "Deleted history entry" | ||
| 124 | case historyActionDeleteRange: | 132 | case historyActionDeleteRange: | ||
| 125 | rangeID := strings.TrimSpace(data["range"]) | 133 | rangeID := strings.TrimSpace(data["range"]) | ||
| @@ -132,7 +140,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 132 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | 140 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | ||
| 133 | return err | 141 | return err | ||
| 134 | } | 142 | } | ||
| 135 | - | a.historyOffset = 0 | 143 | + | a.resetHistoryWindowState() |
| 136 | a.historyNotice = historyRangeNotice(rangeID) | 144 | a.historyNotice = historyRangeNotice(rangeID) | ||
| 137 | case historyActionDeleteDomain: | 145 | case historyActionDeleteDomain: | ||
| 138 | domain := strings.TrimSpace(data["domain"]) | 146 | domain := strings.TrimSpace(data["domain"]) | ||
| @@ -145,7 +153,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 145 | if strings.EqualFold(a.historyDomainFilter, domain) { | 153 | if strings.EqualFold(a.historyDomainFilter, domain) { | ||
| 146 | a.historyDomainFilter = "" | 154 | a.historyDomainFilter = "" | ||
| 147 | } | 155 | } | ||
| 148 | - | a.historyOffset = 0 | 156 | + | a.resetHistoryWindowState() |
| 149 | a.historyNotice = "Deleted history for " + domain | 157 | a.historyNotice = "Deleted history for " + domain | ||
| 150 | default: | 158 | default: | ||
| 151 | return fmt.Errorf("unknown history action: %q", event.Action) | 159 | return fmt.Errorf("unknown history action: %q", event.Action) | ||
| @@ -153,6 +161,97 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 153 | return nil | 161 | return nil | ||
| 154 | } | 162 | } | ||
| 155 | 163 | ||||
| 164 | + | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | |||
| 165 | + | a.lockState() | |||
| 166 | + | if a.deps.History == nil { | |||
| 167 | + | a.unlockState() | |||
| 168 | + | return fmt.Errorf("history service not configured") | |||
| 169 | + | } | |||
| 170 | + | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |||
| 171 | + | a.unlockState() | |||
| 172 | + | return nil | |||
| 173 | + | } | |||
| 174 | + | before := a.historyWindowAfter | |||
| 175 | + | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | |||
| 176 | + | parsed, err := time.Parse(time.RFC3339Nano, requested) | |||
| 177 | + | if err != nil { | |||
| 178 | + | a.unlockState() | |||
| 179 | + | return fmt.Errorf("invalid history cursor") | |||
| 180 | + | } | |||
| 181 | + | if !before.IsZero() && !parsed.Equal(before) { | |||
| 182 | + | a.unlockState() | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | before = parsed | |||
| 186 | + | } | |||
| 187 | + | if before.IsZero() { | |||
| 188 | + | before = time.Now() | |||
| 189 | + | } | |||
| 190 | + | domain := a.historyDomainFilter | |||
| 191 | + | history := a.deps.History | |||
| 192 | + | dom := a.deps.DOM | |||
| 193 | + | a.unlockState() | |||
| 194 | + | ||||
| 195 | + | window, err := history.TimelineWindow(ctx, before, domain) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | if window == nil { | |||
| 200 | + | a.lockState() | |||
| 201 | + | a.historyHasMore = false | |||
| 202 | + | a.unlockState() | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | a.lockState() | |||
| 207 | + | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | |||
| 208 | + | a.historyEntries = append(a.historyEntries, window.Entries...) | |||
| 209 | + | a.historyWindowBefore = window.Before | |||
| 210 | + | a.historyWindowAfter = window.After | |||
| 211 | + | a.historyHasMore = window.HasMore | |||
| 212 | + | data := historyRenderData{ | |||
| 213 | + | Entries: window.Entries, | |||
| 214 | + | Stats: a.historyStats, | |||
| 215 | + | Domains: a.historyDomainStats, | |||
| 216 | + | Query: a.historyQuery, | |||
| 217 | + | DomainFilter: a.historyDomainFilter, | |||
| 218 | + | Offset: a.historyOffset, | |||
| 219 | + | Limit: historyTimelineLimit, | |||
| 220 | + | WindowBefore: a.historyWindowBefore, | |||
| 221 | + | WindowAfter: a.historyWindowAfter, | |||
| 222 | + | HasMore: a.historyHasMore, | |||
| 223 | + | AppendSkipFirstDate: appendSkipFirstDate, | |||
| 224 | + | Notice: a.historyNotice, | |||
| 225 | + | Error: a.historyError, | |||
| 226 | + | } | |||
| 227 | + | fragment := historyTimelineAppendHTML(data) | |||
| 228 | + | var fallbackHTML string | |||
| 229 | + | if _, ok := dom.(DOMHistoryTimelineAppender); !ok { | |||
| 230 | + | fullData := data | |||
| 231 | + | fullData.Entries = a.historyEntries | |||
| 232 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 233 | + | route: RouteHistory, | |||
| 234 | + | title: historyDocumentTitle(fullData), | |||
| 235 | + | subtitle: "Recent visits", | |||
| 236 | + | body: historyHTML(fullData), | |||
| 237 | + | }, a.shellTheme) | |||
| 238 | + | fallbackHTML = a.renderedHTML | |||
| 239 | + | } | |||
| 240 | + | a.unlockState() | |||
| 241 | + | ||||
| 242 | + | if appender, ok := dom.(DOMHistoryTimelineAppender); ok { | |||
| 243 | + | return appender.AppendHistoryTimeline(fragment) | |||
| 244 | + | } | |||
| 245 | + | return a.mountHTML(fallbackHTML) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (a *App) resetHistoryWindowState() { | |||
| 249 | + | a.historyOffset = 0 | |||
| 250 | + | a.historyWindowBefore = time.Time{} | |||
| 251 | + | a.historyWindowAfter = time.Time{} | |||
| 252 | + | a.historyHasMore = false | |||
| 253 | + | } | |||
| 254 | + | ||||
| 156 | func (a *App) mountRenderedHTML() error { | 255 | func (a *App) mountRenderedHTML() error { | ||
| 157 | return a.mountHTML(a.renderedHTML) | 256 | return a.mountHTML(a.renderedHTML) | ||
| 158 | } | 257 | } | ||
M internal/ui/systemviews/history_templ.go +564 -228 generated file: collapsed
M internal/ui/systemviews/history_view.go +140 -19
| @@ -2,6 +2,7 @@ package systemviews | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "net/url" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -9,16 +10,24 @@ import ( | |||||
| 9 | browserurl "github.com/bnema/dumber/internal/domain/url" | 10 | browserurl "github.com/bnema/dumber/internal/domain/url" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 13 | + | const historyURLDisplayMaxRunes = 180 | |||
| 14 | + | ||||
| 12 | type historyRenderData struct { | 15 | type historyRenderData struct { | ||
| 13 | - | Entries []*entity.HistoryEntry | 16 | + | Entries []*entity.HistoryEntry |
| 14 | - | Analytics *entity.HistoryAnalytics | 17 | + | Stats *entity.HistoryStats |
| 15 | - | Domains []*entity.DomainStat | 18 | + | Analytics *entity.HistoryAnalytics |
| 16 | - | Query string | 19 | + | Domains []*entity.DomainStat |
| 17 | - | DomainFilter string | 20 | + | Query string |
| 18 | - | Offset int | 21 | + | DomainFilter string |
| 19 | - | Limit int | 22 | + | Offset int |
| 20 | - | Notice string | 23 | + | Limit int |
| 21 | - | Error string | 24 | + | WindowBefore time.Time |
| 25 | + | WindowAfter time.Time | |||
| 26 | + | HasMore bool | |||
| 27 | + | AppendSkipFirstDate string | |||
| 28 | + | Notice string | |||
| 29 | + | Error string | |||
| 30 | + | Loading bool | |||
| 22 | } | 31 | } | ||
| 23 | 32 | ||||
| 24 | type historyTimelineGroup struct { | 33 | type historyTimelineGroup struct { | ||
| @@ -38,7 +47,14 @@ func historyHTML(data historyRenderData) string { | |||||
| 38 | return mustRenderComponent(HistoryView(data)) | 47 | return mustRenderComponent(HistoryView(data)) | ||
| 39 | } | 48 | } | ||
| 40 | 49 | ||||
| 50 | + | func historyTimelineAppendHTML(data historyRenderData) string { | |||
| 51 | + | return mustRenderComponent(HistoryTimelineAppend(data)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 41 | func historyDocumentTitle(data historyRenderData) string { | 54 | func historyDocumentTitle(data historyRenderData) string { | ||
| 55 | + | if data.Loading { | |||
| 56 | + | return "History — Loading" | |||
| 57 | + | } | |||
| 42 | if query := strings.TrimSpace(data.Query); query != "" { | 58 | if query := strings.TrimSpace(data.Query); query != "" { | ||
| 43 | return "History — search: " + truncateTitle(query, 48) | 59 | return "History — search: " + truncateTitle(query, 48) | ||
| 44 | } | 60 | } | ||
| @@ -51,7 +67,9 @@ func historyDocumentTitle(data historyRenderData) string { | |||||
| 51 | } | 67 | } | ||
| 52 | 68 | ||||
| 53 | entries := int64(countHistoryEntries(data.Entries)) | 69 | entries := int64(countHistoryEntries(data.Entries)) | ||
| 54 | - | if data.Analytics != nil { | 70 | + | if data.Stats != nil { |
| 71 | + | entries = data.Stats.TotalEntries | |||
| 72 | + | } else if data.Analytics != nil { | |||
| 55 | entries = data.Analytics.TotalEntries | 73 | entries = data.Analytics.TotalEntries | ||
| 56 | } | 74 | } | ||
| 57 | if entries == 1 { | 75 | if entries == 1 { | ||
| @@ -82,12 +100,19 @@ func historyCleanupItems() []historyCleanupItem { | |||||
| 82 | 100 | ||||
| 83 | func historyLimit(data historyRenderData) int { | 101 | func historyLimit(data historyRenderData) int { | ||
| 84 | if data.Limit <= 0 { | 102 | if data.Limit <= 0 { | ||
| 85 | - | return historyTimelineLimit | 103 | + | return 0 |
| 86 | } | 104 | } | ||
| 87 | return data.Limit | 105 | return data.Limit | ||
| 88 | } | 106 | } | ||
| 89 | 107 | ||||
| 108 | + | func historyIsPaginated(data historyRenderData) bool { | |||
| 109 | + | return historyLimit(data) > 0 | |||
| 110 | + | } | |||
| 111 | + | ||||
| 90 | func previousHistoryOffset(data historyRenderData) int { | 112 | func previousHistoryOffset(data historyRenderData) int { | ||
| 113 | + | if !historyIsPaginated(data) { | |||
| 114 | + | return 0 | |||
| 115 | + | } | |||
| 91 | offset := data.Offset - historyLimit(data) | 116 | offset := data.Offset - historyLimit(data) | ||
| 92 | if offset < 0 { | 117 | if offset < 0 { | ||
| 93 | return 0 | 118 | return 0 | ||
| @@ -96,29 +121,36 @@ func previousHistoryOffset(data historyRenderData) int { | |||||
| 96 | } | 121 | } | ||
| 97 | 122 | ||||
| 98 | func nextHistoryOffset(data historyRenderData) int { | 123 | func nextHistoryOffset(data historyRenderData) int { | ||
| 124 | + | if !historyIsPaginated(data) { | |||
| 125 | + | return 0 | |||
| 126 | + | } | |||
| 99 | return max(data.Offset, 0) + historyLimit(data) | 127 | return max(data.Offset, 0) + historyLimit(data) | ||
| 100 | } | 128 | } | ||
| 101 | 129 | ||||
| 102 | func disableHistoryPrev(data historyRenderData) bool { | 130 | func disableHistoryPrev(data historyRenderData) bool { | ||
| 103 | - | return data.Offset <= 0 || strings.TrimSpace(data.Query) != "" | 131 | + | return !historyIsPaginated(data) || data.Offset <= 0 || strings.TrimSpace(data.Query) != "" |
| 104 | } | 132 | } | ||
| 105 | 133 | ||||
| 106 | func disableHistoryNext(data historyRenderData) bool { | 134 | func disableHistoryNext(data historyRenderData) bool { | ||
| 107 | - | return strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | 135 | + | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) |
| 108 | } | 136 | } | ||
| 109 | 137 | ||||
| 110 | func showHistoryFilters(data historyRenderData) bool { | 138 | func showHistoryFilters(data historyRenderData) bool { | ||
| 111 | return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" | 139 | return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" | ||
| 112 | } | 140 | } | ||
| 113 | 141 | ||||
| 114 | - | func historyOffsetLabel(offset int, query string) string { | 142 | + | func historyShowingLabel(data historyRenderData) string { |
| 115 | - | if strings.TrimSpace(query) != "" { | 143 | + | if data.Loading { |
| 116 | - | return " matching search" | 144 | + | return "Loading history…" |
| 117 | } | 145 | } | ||
| 118 | - | if offset <= 0 { | 146 | + | count := countHistoryEntries(data.Entries) |
| 119 | - | return "" | 147 | + | if strings.TrimSpace(data.Query) != "" { |
| 148 | + | return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count)) | |||
| 149 | + | } | |||
| 150 | + | if strings.TrimSpace(data.DomainFilter) != "" { | |||
| 151 | + | return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count)) | |||
| 120 | } | 152 | } | ||
| 121 | - | return fmt.Sprintf(" from offset %d", offset) | 153 | + | return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count)) |
| 122 | } | 154 | } | ||
| 123 | 155 | ||||
| 124 | func pluralSuffix(count int) string { | 156 | func pluralSuffix(count int) string { | ||
| @@ -128,7 +160,28 @@ func pluralSuffix(count int) string { | |||||
| 128 | return "s" | 160 | return "s" | ||
| 129 | } | 161 | } | ||
| 130 | 162 | ||||
| 163 | + | func historyLoadMoreCursor(data historyRenderData) string { | |||
| 164 | + | if data.WindowAfter.IsZero() { | |||
| 165 | + | return "" | |||
| 166 | + | } | |||
| 167 | + | return data.WindowAfter.Format(time.RFC3339Nano) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func showHistoryLoadMore(data historyRenderData) bool { | |||
| 171 | + | return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func historySummaryLoading(data historyRenderData) bool { | |||
| 175 | + | return data.Loading | |||
| 176 | + | } | |||
| 177 | + | ||||
| 131 | func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) { | 178 | func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) { | ||
| 179 | + | if data.Stats != nil { | |||
| 180 | + | return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays | |||
| 181 | + | } | |||
| 182 | + | if data.Analytics != nil { | |||
| 183 | + | return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays | |||
| 184 | + | } | |||
| 132 | entries = int64(countHistoryEntries(data.Entries)) | 185 | entries = int64(countHistoryEntries(data.Entries)) | ||
| 133 | visits = sumHistoryVisits(data.Entries) | 186 | visits = sumHistoryVisits(data.Entries) | ||
| 134 | uniqueDays = countUniqueHistoryDays(data.Entries) | 187 | uniqueDays = countUniqueHistoryDays(data.Entries) | ||
| @@ -137,12 +190,26 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i | |||||
| 137 | } | 190 | } | ||
| 138 | 191 | ||||
| 139 | func historyTimelineEmptyMessage(data historyRenderData) string { | 192 | func historyTimelineEmptyMessage(data historyRenderData) string { | ||
| 193 | + | if data.Loading { | |||
| 194 | + | return "Loading history…" | |||
| 195 | + | } | |||
| 140 | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | 196 | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | ||
| 141 | return "No history entries match the current filters" | 197 | return "No history entries match the current filters" | ||
| 142 | } | 198 | } | ||
| 143 | return "No history entries" | 199 | return "No history entries" | ||
| 144 | } | 200 | } | ||
| 145 | 201 | ||||
| 202 | + | func lastHistoryDateKey(entries []*entity.HistoryEntry) string { | |||
| 203 | + | for i := len(entries) - 1; i >= 0; i-- { | |||
| 204 | + | if entries[i] == nil { | |||
| 205 | + | continue | |||
| 206 | + | } | |||
| 207 | + | dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local()) | |||
| 208 | + | return dateKey | |||
| 209 | + | } | |||
| 210 | + | return "" | |||
| 211 | + | } | |||
| 212 | + | ||||
| 146 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | 213 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | ||
| 147 | groups := make([]historyTimelineGroup, 0) | 214 | groups := make([]historyTimelineGroup, 0) | ||
| 148 | indexByDate := map[string]int{} | 215 | indexByDate := map[string]int{} | ||
| @@ -262,6 +329,60 @@ func historyItemLabel(entry *entity.HistoryEntry) string { | |||||
| 262 | return label | 329 | return label | ||
| 263 | } | 330 | } | ||
| 264 | 331 | ||||
| 332 | + | func historyItemURL(entry *entity.HistoryEntry) string { | |||
| 333 | + | if entry == nil { | |||
| 334 | + | return "" | |||
| 335 | + | } | |||
| 336 | + | return truncateHistoryURL(entry.URL) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func historyItemFaviconURL(entry *entity.HistoryEntry) string { | |||
| 340 | + | if entry == nil { | |||
| 341 | + | return "" | |||
| 342 | + | } | |||
| 343 | + | domain := browserurl.CanonicalDomain(entry.URL) | |||
| 344 | + | if domain == "" { | |||
| 345 | + | return "" | |||
| 346 | + | } | |||
| 347 | + | query := url.Values{} | |||
| 348 | + | query.Set("domain", domain) | |||
| 349 | + | query.Set("size", "32") | |||
| 350 | + | return "/api/favicon?" + query.Encode() | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func historyFaviconClass(faviconURL string) string { | |||
| 354 | + | if strings.TrimSpace(faviconURL) == "" { | |||
| 355 | + | return "sv-history-favicon" | |||
| 356 | + | } | |||
| 357 | + | return "sv-history-favicon sv-history-favicon-has-image" | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func historyFaviconFallback(entry *entity.HistoryEntry) string { | |||
| 361 | + | label := "" | |||
| 362 | + | if entry != nil { | |||
| 363 | + | label = displayHistoryDomain(entry.URL) | |||
| 364 | + | } | |||
| 365 | + | if label == "" { | |||
| 366 | + | return "•" | |||
| 367 | + | } | |||
| 368 | + | for _, r := range label { | |||
| 369 | + | return strings.ToUpper(string(r)) | |||
| 370 | + | } | |||
| 371 | + | return "•" | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func truncateHistoryURL(raw string) string { | |||
| 375 | + | raw = strings.TrimSpace(raw) | |||
| 376 | + | if raw == "" { | |||
| 377 | + | return "" | |||
| 378 | + | } | |||
| 379 | + | runes := []rune(raw) | |||
| 380 | + | if len(runes) <= historyURLDisplayMaxRunes { | |||
| 381 | + | return raw | |||
| 382 | + | } | |||
| 383 | + | return string(runes[:historyURLDisplayMaxRunes-1]) + "…" | |||
| 384 | + | } | |||
| 385 | + | ||||
| 265 | func historyItemMeta(entry *entity.HistoryEntry) string { | 386 | func historyItemMeta(entry *entity.HistoryEntry) string { | ||
| 266 | if entry == nil { | 387 | if entry == nil { | ||
| 267 | return "" | 388 | return "" | ||
M internal/ui/systemviews/history_view_test.go +89 -0
| @@ -1,9 +1,11 @@ | |||||
| 1 | package systemviews | 1 | package systemviews | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| @@ -24,3 +26,90 @@ func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) { | |||||
| 24 | assert.Equal(t, "Yesterday", yesterdayLabel) | 26 | assert.Equal(t, "Yesterday", yesterdayLabel) | ||
| 25 | assert.Equal(t, "Mon Apr 20", olderLabel) | 27 | assert.Equal(t, "Mon Apr 20", olderLabel) | ||
| 26 | } | 28 | } | ||
| 29 | + | ||||
| 30 | + | func TestHistorySummaryValuesUsesAllProvidedEntries(t *testing.T) { | |||
| 31 | + | t.Parallel() | |||
| 32 | + | ||||
| 33 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 34 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{ | |||
| 35 | + | {VisitCount: 2, LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |||
| 36 | + | {VisitCount: 3, LastVisited: time.Date(2026, 4, 24, 8, 0, 0, 0, loc)}, | |||
| 37 | + | }} | |||
| 38 | + | ||||
| 39 | + | entries, visits, days := historySummaryValues(data) | |||
| 40 | + | ||||
| 41 | + | assert.Equal(t, int64(2), entries) | |||
| 42 | + | assert.Equal(t, int64(5), visits) | |||
| 43 | + | assert.Equal(t, int64(2), days) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHistoryShowingLabelDescribesUnpaginatedDefault(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | ||||
| 49 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{{}, {}}, Limit: 0} | |||
| 50 | + | ||||
| 51 | + | assert.Equal(t, "Loaded 2 items", historyShowingLabel(data)) | |||
| 52 | + | assert.True(t, disableHistoryNext(data)) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestHistoryTimelineAppendMergesDuplicateFirstDayGroup(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | ||||
| 58 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 59 | + | data := historyRenderData{ | |||
| 60 | + | Entries: []*entity.HistoryEntry{ | |||
| 61 | + | {URL: "https://same.example", Title: "Same day", LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |||
| 62 | + | {URL: "https://older.example", Title: "Older day", LastVisited: time.Date(2026, 4, 22, 8, 0, 0, 0, loc)}, | |||
| 63 | + | }, | |||
| 64 | + | AppendSkipFirstDate: "2026-04-23", | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | html := historyTimelineAppendHTML(data) | |||
| 68 | + | ||||
| 69 | + | assert.NotContains(t, html, ">Thu Apr 23</h3>") | |||
| 70 | + | assert.NotContains(t, html, `<section class="sv-timeline-group"><ul class="sv-list">`) | |||
| 71 | + | assert.Contains(t, html, `data-sv-history-merge-date="2026-04-23"`) | |||
| 72 | + | assert.Contains(t, html, "Same day") | |||
| 73 | + | assert.Contains(t, html, `data-sv-history-date="2026-04-22"`) | |||
| 74 | + | assert.Contains(t, html, ">Wed Apr 22</h3>") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestHistoryItemURLTruncatesLongURLs(t *testing.T) { | |||
| 78 | + | t.Parallel() | |||
| 79 | + | ||||
| 80 | + | longURL := "https://example.com/" + strings.Repeat("a", historyURLDisplayMaxRunes) | |||
| 81 | + | entry := &entity.HistoryEntry{URL: longURL} | |||
| 82 | + | ||||
| 83 | + | displayURL := historyItemURL(entry) | |||
| 84 | + | ||||
| 85 | + | assert.LessOrEqual(t, len([]rune(displayURL)), historyURLDisplayMaxRunes) | |||
| 86 | + | assert.Contains(t, displayURL, "…") | |||
| 87 | + | assert.NotContains(t, displayURL, strings.Repeat("a", historyURLDisplayMaxRunes)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestHistoryItemFaviconURLUsesLocalCachedFaviconAPI(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | ||||
| 93 | + | entry := &entity.HistoryEntry{URL: "https://example.com/docs?token=secret#section"} | |||
| 94 | + | ||||
| 95 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHistoryItemFaviconURLKeepsPortsForCacheKeys(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | entry := &entity.HistoryEntry{URL: "http://localhost:1455/success?token=secret"} | |||
| 102 | + | ||||
| 103 | + | assert.Equal(t, "/api/favicon?domain=localhost%3A1455&size=32", historyItemFaviconURL(entry)) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestHistoryItemFaviconURLIgnoresStoredRemoteFaviconURL(t *testing.T) { | |||
| 107 | + | t.Parallel() | |||
| 108 | + | ||||
| 109 | + | entry := &entity.HistoryEntry{ | |||
| 110 | + | URL: "https://example.com/docs", | |||
| 111 | + | FaviconURL: "https://tracker.example/favicon.png", | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |||
| 115 | + | } | |||
A internal/ui/systemviews/icons.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | type systemIcon string | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | iconTrash systemIcon = "trash-2" | |||
| 7 | + | iconX systemIcon = "x" | |||
| 8 | + | iconSearch systemIcon = "search" | |||
| 9 | + | iconSave systemIcon = "save" | |||
| 10 | + | iconPlus systemIcon = "plus" | |||
| 11 | + | iconExternal systemIcon = "external-link" | |||
| 12 | + | iconRotateCCW systemIcon = "rotate-ccw" | |||
| 13 | + | iconCheck systemIcon = "check" | |||
| 14 | + | iconHistory systemIcon = "history" | |||
| 15 | + | iconFolder systemIcon = "folder" | |||
| 16 | + | iconTag systemIcon = "tag" | |||
| 17 | + | iconSettings systemIcon = "settings" | |||
| 18 | + | iconAlert systemIcon = "alert-triangle" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func iconClass(name systemIcon) string { | |||
| 22 | + | if name == "" { | |||
| 23 | + | return "sv-icon" | |||
| 24 | + | } | |||
| 25 | + | return "sv-icon sv-icon-" + string(name) | |||
| 26 | + | } | |||
A internal/ui/systemviews/icons.templ +65 -0
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | templ Icon(name systemIcon) { | |||
| 4 | + | <svg class={ iconClass(name) } xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> | |||
| 5 | + | if name == iconTrash { | |||
| 6 | + | <path d="M10 11v6"></path> | |||
| 7 | + | <path d="M14 11v6"></path> | |||
| 8 | + | <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path> | |||
| 9 | + | <path d="M3 6h18"></path> | |||
| 10 | + | <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> | |||
| 11 | + | } else if name == iconX { | |||
| 12 | + | <path d="M18 6 6 18"></path> | |||
| 13 | + | <path d="m6 6 12 12"></path> | |||
| 14 | + | } else if name == iconSearch { | |||
| 15 | + | <path d="m21 21-4.34-4.34"></path> | |||
| 16 | + | <circle cx="11" cy="11" r="8"></circle> | |||
| 17 | + | } else if name == iconSave { | |||
| 18 | + | <path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path> | |||
| 19 | + | <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path> | |||
| 20 | + | <path d="M7 3v4a1 1 0 0 0 1 1h7"></path> | |||
| 21 | + | } else if name == iconPlus { | |||
| 22 | + | <path d="M5 12h14"></path> | |||
| 23 | + | <path d="M12 5v14"></path> | |||
| 24 | + | } else if name == iconExternal { | |||
| 25 | + | <path d="M15 3h6v6"></path> | |||
| 26 | + | <path d="M10 14 21 3"></path> | |||
| 27 | + | <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> | |||
| 28 | + | } else if name == iconRotateCCW { | |||
| 29 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |||
| 30 | + | <path d="M3 3v5h5"></path> | |||
| 31 | + | } else if name == iconCheck { | |||
| 32 | + | <path d="M20 6 9 17l-5-5"></path> | |||
| 33 | + | } else if name == iconHistory { | |||
| 34 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |||
| 35 | + | <path d="M3 3v5h5"></path> | |||
| 36 | + | <path d="M12 7v5l4 2"></path> | |||
| 37 | + | } else if name == iconFolder { | |||
| 38 | + | <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path> | |||
| 39 | + | } else if name == iconTag { | |||
| 40 | + | <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path> | |||
| 41 | + | <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle> | |||
| 42 | + | } else if name == iconSettings { | |||
| 43 | + | <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path> | |||
| 44 | + | <circle cx="12" cy="12" r="3"></circle> | |||
| 45 | + | } else if name == iconAlert { | |||
| 46 | + | <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path> | |||
| 47 | + | <path d="M12 9v4"></path> | |||
| 48 | + | <path d="M12 17h.01"></path> | |||
| 49 | + | } else { | |||
| 50 | + | <circle cx="12" cy="12" r="10"></circle> | |||
| 51 | + | <path d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4"></path> | |||
| 52 | + | <path d="M12 17h.01"></path> | |||
| 53 | + | } | |||
| 54 | + | </svg> | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | templ ButtonIconLabel(name systemIcon, label string) { | |||
| 58 | + | @Icon(name) | |||
| 59 | + | <span>{ label }</span> | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | templ IconOnly(name systemIcon, label string) { | |||
| 63 | + | @Icon(name) | |||
| 64 | + | <span class="sv-sr-only">{ label }</span> | |||
| 65 | + | } | |||