diff --git a/README.md b/README.md index 6b7275f..072ddca 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,10 @@ ## Features -- **Rule-based routing**: Automatically open URLs in specific browsers based on powerful patterns. -- **Multi-condition rules**: Combine multiple conditions with AND/OR logic for precise control. -- **Multiple pattern types**: Exact Domain, URL Contains, Wildcard, and Regex matching. -- **Negative patterns**: Exclude specific URLs by inverting any condition (e.g., "all GitHub except Gist"). -- **Custom URI scheme**: Create links that specify browser preferences directly with `switchyard://` URLs. -- **Quick browser launcher**: When no rule matches, quickly select a browser with keyboard or mouse. -- **Keyboard shortcuts**: Press Ctrl+1-9 to instantly select a browser. -- **Lightweight**: Runs only when needed, no background processes. +- **Browser rules**: Automatically open links in specific browsers based on conditions you define. +- **Link redirections**: Clean up links before they open—remove tracking parameters, swap domains, and more. +- **Quick launcher**: When no rule matches, choose a browser with a click or keyboard shortcut. +- **Lightweight**: Runs only when you click a link. No background processes. - **GTK4 + libadwaita**: Native GNOME look and feel. ## Installation diff --git a/data/io.github.alyraffauf.Switchyard.metainfo.xml b/data/io.github.alyraffauf.Switchyard.metainfo.xml index 80b63ba..61bbf00 100644 --- a/data/io.github.alyraffauf.Switchyard.metainfo.xml +++ b/data/io.github.alyraffauf.Switchyard.metainfo.xml @@ -32,10 +32,10 @@

- Work links in Chrome. Personal links in Firefox. Switchyard automatically routes URLs to the right browser based on your rules. + Switchyard puts you in control of where links open and what they open. Work links go to Chrome, personal links to Firefox. You decide the rules.

- Match URLs by domain, keyword, or regular expression. No matching rule? Choose your browser with a quick keystroke or mouse click. + Create rules using simple domain, keyword, wildcard, or regex matching. Combine conditions for precise control. Strip tracking parameters or redirect to privacy-friendly alternatives before links even open. Set it once and forget it.

diff --git a/docs/Configuration.md b/docs/Configuration.md index d108276..679ef47 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -11,73 +11,88 @@ Switchyard can be configured through its settings UI or by editing the config fi ```toml prompt_on_click = true -favorite_browser = "" +favorite_browser = '' check_default_browser = true -# Simple rule with a single condition +# Link redirections (domain type is default) +[[redirections]] +find = 'x.com' +replace = 'twitter.com' + +[[redirections]] +find = 'reddit.com' +replace = 'old.reddit.com' + +# Pattern redirection to remove query parameters +[[redirections]] +type = 'wildcard' +find = '?utm_*' +replace = '' + +# Browser rule with a single condition [[rules]] -name = "Work GitHub" -browser = "firefox.desktop" +name = 'Work GitHub' +browser = 'firefox.desktop' [[rules.conditions]] -type = "domain" -pattern = "github.com" +type = 'domain' +pattern = 'github.com' # Multi-condition rule with AND logic [[rules]] -name = "Google Docs" -logic = "all" # all conditions must match -browser = "google-chrome.desktop" +name = 'Google Docs' +logic = 'all' # all conditions must match +browser = 'google-chrome.desktop' [[rules.conditions]] -type = "domain" -pattern = "docs.google.com" +type = 'domain' +pattern = 'docs.google.com' [[rules.conditions]] -type = "keyword" -pattern = "edit" +type = 'keyword' +pattern = 'edit' # Multi-condition rule with OR logic [[rules]] -name = "Video Sites" -logic = "any" # any condition can match -browser = "brave-browser.desktop" +name = 'Video Sites' +logic = 'any' # any condition can match +browser = 'brave-browser.desktop' [[rules.conditions]] -type = "domain" -pattern = "youtube.com" +type = 'domain' +pattern = 'youtube.com' [[rules.conditions]] -type = "domain" -pattern = "vimeo.com" +type = 'domain' +pattern = 'vimeo.com' [[rules.conditions]] -type = "domain" -pattern = "twitch.tv" +type = 'domain' +pattern = 'twitch.tv' # Rule with negated condition [[rules]] -name = "GitHub (not Gist)" -logic = "all" -browser = "firefox.desktop" +name = 'GitHub (not Gist)' +logic = 'all' +browser = 'firefox.desktop' [[rules.conditions]] -type = "glob" -pattern = "*.github.com" +type = 'glob' +pattern = '*.github.com' [[rules.conditions]] -type = "domain" -pattern = "gist.github.com" +type = 'domain' +pattern = 'gist.github.com' negate = true # URL must NOT match this pattern # Rule with always ask [[rules]] -name = "Shopping Sites" +name = 'Shopping Sites' always_ask = true [[rules.conditions]] -type = "keyword" -pattern = "amazon" +type = 'keyword' +pattern = 'amazon' ``` ## Settings @@ -86,9 +101,9 @@ pattern = "amazon" - **favorite_browser**: Favorite browser that always appears first in launcher and is used as fallback when launcher is disabled. - **check_default_browser**: Prompt to set Switchyard as system default browser on startup (default: true). -## Rules +## Browser Rules -Rules define how URLs are routed to browsers. Each rule has conditions that determine when it matches. +Browser rules define how URLs are routed to browsers. Each rule has conditions that determine when it matches. - **name**: Optional friendly name displayed in the UI. - **conditions**: Array of conditions to match (see below). @@ -117,3 +132,52 @@ Each condition specifies a pattern to match against the URL. - **any** (OR): Any single condition matching triggers the rule. Use `all` for precise targeting (e.g., "docs.google.com AND contains 'edit'") and `any` for broad matching (e.g., "youtube.com OR vimeo.com OR twitch.tv"). + +## Link Redirections + +Link redirections modify URLs before browser rules are evaluated. + +- **type**: `domain` (default), `wildcard`, or `regex`. +- **find**: Pattern to match. +- **replace**: Text to replace with. Leave empty to remove matches. + +### Redirection Types + +**Domain** redirections match the exact hostname, including the ports (if any). Use this to switch between sites: + +```toml +[[redirections]] +find = 'reddit.com' +replace = 'old.reddit.com' + +[[redirections]] +find = 'x.com' +replace = 'twitter.com' +``` + +**Wildcard** redirections match anywhere in the URL and support `*` wildcards. Use this to clean up links: + +```toml +[[redirections]] +type = 'wildcard' +find = '?utm_*' +replace = '' + +[[redirections]] +type = 'wildcard' +find = '&fbclid=*' +replace = '' +``` + +**Regex** redirections use regular expressions with capture group support (`$1`, `$2`, etc.) for complex transformations: + +```toml +# Clean Amazon URLs - keep only the product ID +# amazon.com/dp/B0DZD91W4F/?tag=thewire06-20&linkCode=xm2&ascsubtag=... → amazon.com/dp/B0DZD91W4F +[[redirections]] +type = 'regex' +find = '(amazon\.[a-z.]+/dp/[A-Z0-9]+).*' +replace = '$1' +``` + +Redirections are applied in order. Domain and pattern matching is case-insensitive; regex matching is case-sensitive. diff --git a/justfile b/justfile index a6a21c0..606c181 100644 --- a/justfile +++ b/justfile @@ -52,12 +52,12 @@ vendor: # Run unit tests test: @echo "Running unit tests..." - go test -v ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go + go test -v ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/redirection_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go ./src/redirection.go # Run tests with coverage report test-coverage: @echo "Running tests with coverage..." - go test -coverprofile=coverage.out ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go + go test -coverprofile=coverage.out ./src/config_test.go ./src/url_test.go ./src/pattern_test.go ./src/validation_test.go ./src/redirection_test.go ./src/app.go ./src/config.go ./src/url.go ./src/pattern.go ./src/validation.go ./src/redirection.go go tool cover -func=coverage.out @echo "" @echo "To view HTML coverage report, run: go tool cover -html=coverage.out" diff --git a/src/config.go b/src/config.go index df2595d..74766c9 100644 --- a/src/config.go +++ b/src/config.go @@ -13,13 +13,21 @@ import ( ) type Config struct { - PromptOnClick bool `toml:"prompt_on_click"` - FavoriteBrowser string `toml:"favorite_browser"` - HiddenBrowsers []string `toml:"hidden_browsers"` - CheckDefaultBrowser bool `toml:"check_default_browser"` - ShowAppNames bool `toml:"show_app_names"` - ForceDarkMode bool `toml:"force_dark_mode"` - Rules []Rule `toml:"rules"` + PromptOnClick bool `toml:"prompt_on_click"` + FavoriteBrowser string `toml:"favorite_browser"` + HiddenBrowsers []string `toml:"hidden_browsers"` + CheckDefaultBrowser bool `toml:"check_default_browser"` + ShowAppNames bool `toml:"show_app_names"` + ForceDarkMode bool `toml:"force_dark_mode"` + Redirections []Redirection `toml:"redirections,omitempty"` + Rules []Rule `toml:"rules"` +} + +type Redirection struct { + Name string `toml:"name,omitempty"` + Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" + Find string `toml:"find"` + Replace string `toml:"replace"` } type Condition struct { diff --git a/src/dialog_condition_edit.go b/src/dialog_condition_edit.go deleted file mode 100644 index 3085f94..0000000 --- a/src/dialog_condition_edit.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package main - -import ( - "regexp" - - "github.com/diamondburned/gotk4-adwaita/pkg/adw" - "github.com/diamondburned/gotk4/pkg/gtk/v4" -) - -// showEditConditionDialog displays a dialog for editing a single condition. -func showEditConditionDialog(parent *adw.Window, cond *Condition, onSave func()) { - var dialog *adw.Dialog - header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) - dialog, content := simpleDialogWithToolbar("Edit Condition", 400, 300, header) - - // Preferences group - group := adw.NewPreferencesGroup() - - // Type row - typeRow := adw.NewComboRow() - typeRow.SetTitle("Match Type") - typeRow.SetModel(gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"})) - typeRow.SetSelected(conditionTypeToIndex(cond.Type)) - group.Add(typeRow) - - // Pattern row - patternRow := adw.NewEntryRow() - patternRow.SetTitle("Pattern") - patternRow.SetText(cond.Pattern) - group.Add(patternRow) - - // Negate row - negateRow := adw.NewSwitchRow() - negateRow.SetTitle("Invert Match") - negateRow.SetSubtitle("URL must NOT match this pattern") - negateRow.SetActive(cond.Negate) - group.Add(negateRow) - - // Error label for validation - errorLabel := gtk.NewLabel("") - errorLabel.SetWrap(true) - errorLabel.AddCSSClass("error") - errorLabel.SetVisible(false) - - // Validate and update error display - updateValidation := func() { - condType := indexToConditionType(typeRow.Selected()) - pattern := patternRow.Text() - - err := validateConditionPattern(condType, pattern) - - if err != nil { - errorLabel.SetLabel(err.Error()) - errorLabel.SetVisible(true) - patternRow.AddCSSClass("error") - saveBtn.SetSensitive(false) - } else { - errorLabel.SetVisible(false) - patternRow.RemoveCSSClass("error") - saveBtn.SetSensitive(pattern != "") - } - } - - patternRow.Connect("changed", func() { - updateValidation() - }) - - typeRow.Connect("notify::selected", func() { - updateValidation() - }) - - content.Append(group) - content.Append(errorLabel) - - // Initial validation check - updateValidation() - - saveBtn.ConnectClicked(func() { - pattern := patternRow.Text() - if pattern == "" { - return - } - - // Final validation - selectedType := typeRow.Selected() - if selectedType == 3 { // Regex - if _, err := regexp.Compile(pattern); err != nil { - return - } - } - - cond.Type = indexToConditionType(selectedType) - cond.Pattern = pattern - cond.Negate = negateRow.Active() - - onSave() - dialog.Close() - }) - - dialog.Present(parent) -} diff --git a/src/dialog_helpers.go b/src/dialog_helpers.go index 901d355..04eb147 100644 --- a/src/dialog_helpers.go +++ b/src/dialog_helpers.go @@ -40,9 +40,7 @@ func dialogWithToolbar(title string, width, height int, header *adw.HeaderBar) ( toolbarView := adw.NewToolbarView() toolbarView.AddTopBar(header) - scrolledWindow := gtk.NewScrolledWindow() - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scrolledWindow.SetVExpand(true) + scrolledWindow := createScrolledWindow() content := gtk.NewBox(gtk.OrientationVertical, 18) content.SetMarginStart(18) @@ -112,14 +110,28 @@ func indexToConditionType(index uint) string { } } -func conditionTypeComboRow(title string, initialType string) *adw.ComboRow { - typeRow := adw.NewComboRow() - typeRow.SetTitle(title) - typeRow.SetModel(gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"})) - typeRow.SetSelected(conditionTypeToIndex(initialType)) - return typeRow +func redirectionTypeToIndex(rwType string) uint { + switch rwType { + case "wildcard": + return 1 + case "regex": + return 2 + default: + return 0 // "domain" is default + } +} + +func indexToRedirectionType(index uint) string { + switch index { + case 1: + return "wildcard" + case 2: + return "regex" + default: + return "domain" + } } -func getConditionTypeLabels() []string { - return []string{"Exact Domain", "URL Contains", "Wildcard", "Regex"} +func getRedirectionTypeLabels() []string { + return []string{"Domain", "Wildcard", "Regex"} } diff --git a/src/dialog_hidden_browsers.go b/src/dialog_hidden_browsers.go index 773ddd9..ddcab56 100644 --- a/src/dialog_hidden_browsers.go +++ b/src/dialog_hidden_browsers.go @@ -13,16 +13,10 @@ func showHiddenBrowsersDialog(parent *adw.Window, cfg *Config, browsers []*Brows "You can still use hidden browsers in rules.", ) - // Create a scrolled window for the browser list - scrolled := gtk.NewScrolledWindow() - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) - scrolled.SetVExpand(true) + scrolled := createScrolledWindow() scrolled.SetSizeRequest(400, 300) - // Create a list box for browsers - listBox := gtk.NewListBox() - listBox.SetSelectionMode(gtk.SelectionNone) - listBox.AddCSSClass("boxed-list") + listBox := createBoxedListBox() // Create a map for quick lookup of hidden browsers hiddenSet := make(map[string]bool) diff --git a/src/dialog_redirection.go b/src/dialog_redirection.go new file mode 100644 index 0000000..a7a6285 --- /dev/null +++ b/src/dialog_redirection.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +func showAddRedirectionDialog(parent *adw.Window, cfg *Config, onSave func()) { + showRedirectionDialog(parent, cfg, nil, onSave) +} + +func showEditRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirection, onSave func()) { + showRedirectionDialog(parent, cfg, redirection, onSave) +} + +func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirection, onSave func()) { + isNew := redirection == nil + + var title, actionLabel string + if isNew { + title = "Add Redirection" + actionLabel = "Add" + } else { + title = "Edit Redirection" + actionLabel = "Save" + } + + var dialog *adw.Dialog + header, saveBtn := dialogHeader("Cancel", actionLabel, func() { dialog.Close() }, nil) + dialog, content, _ := dialogWithToolbar(title, 450, 450, header) + + // Name section + nameGroup := adw.NewPreferencesGroup() + nameGroup.SetTitle("Name") + nameGroup.SetDescription("Give this redirection a descriptive name (optional)") + + nameRow := adw.NewEntryRow() + nameRow.SetTitle("Name") + if !isNew { + nameRow.SetText(redirection.Name) + } + nameGroup.Add(nameRow) + content.Append(nameGroup) + + // Redirection section + group := adw.NewPreferencesGroup() + group.SetTitle("Redirection") + group.SetDescription("Define how links are modified") + + typeRow := adw.NewComboRow() + typeRow.SetTitle("Type") + typeRow.SetModel(gtk.NewStringList(getRedirectionTypeLabels())) + if !isNew { + typeRow.SetSelected(redirectionTypeToIndex(redirection.Type)) + } + group.Add(typeRow) + + findRow := adw.NewEntryRow() + findRow.SetTitle("Match") + if !isNew { + findRow.SetText(redirection.Find) + } + group.Add(findRow) + + replaceRow := adw.NewEntryRow() + replaceRow.SetTitle("Replace With") + if !isNew { + replaceRow.SetText(redirection.Replace) + } + group.Add(replaceRow) + + content.Append(group) + + validateInputs := func() { + find := findRow.Text() + rwType := indexToRedirectionType(typeRow.Selected()) + r := Redirection{Type: rwType, Find: find, Replace: replaceRow.Text()} + err := validateRedirection(r) + + if find != "" && err != nil { + findRow.AddCSSClass("error") + } else { + findRow.RemoveCSSClass("error") + } + saveBtn.SetSensitive(err == nil) + } + + findRow.Connect("changed", validateInputs) + replaceRow.Connect("changed", validateInputs) + typeRow.Connect("notify::selected", validateInputs) + validateInputs() + + saveBtn.ConnectClicked(func() { + name := nameRow.Text() + find := findRow.Text() + rwType := indexToRedirectionType(typeRow.Selected()) + + if isNew { + cfg.Redirections = append(cfg.Redirections, Redirection{ + Name: name, + Type: rwType, + Find: find, + Replace: replaceRow.Text(), + }) + } else { + redirection.Name = name + redirection.Type = rwType + redirection.Find = find + redirection.Replace = replaceRow.Text() + } + + saveConfig(cfg) + onSave() + dialog.Close() + }) + + dialog.Present(parent) +} diff --git a/src/dialog_rule_common.go b/src/dialog_rule_common.go index 3cc6784..363c7ab 100644 --- a/src/dialog_rule_common.go +++ b/src/dialog_rule_common.go @@ -54,9 +54,7 @@ func buildRuleDialogContent( conditionsGroup.SetTitle("Conditions") conditionsGroup.SetDescription("Define conditions to match URLs") - conditionsListBox := gtk.NewListBox() - conditionsListBox.SetSelectionMode(gtk.SelectionNone) - conditionsListBox.AddCSSClass("boxed-list") + conditionsListBox := createBoxedListBox() // Logic selector row logicRow = adw.NewComboRow() diff --git a/src/main.go b/src/main.go index e83d247..0937cd7 100644 --- a/src/main.go +++ b/src/main.go @@ -56,6 +56,11 @@ func main() { cmd.Start() return } + + if len(cfg.Redirections) > 0 { + sanitized = applyRedirections(sanitized, cfg.Redirections) + } + handleURL(app, browsers, cfg, sanitized) }) @@ -101,6 +106,10 @@ func handleSwitchyardURL(app *adw.Application, browsers []*Browser, cfg *Config, return } + if len(cfg.Redirections) > 0 { + sanitized = applyRedirections(sanitized, cfg.Redirections) + } + // If browser preferences specified, try each in order if len(browserPrefs) > 0 { for _, pref := range browserPrefs { diff --git a/src/redirection.go b/src/redirection.go new file mode 100644 index 0000000..af18f90 --- /dev/null +++ b/src/redirection.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "net/url" + "regexp" + "strings" +) + +func applyRedirections(rawURL string, redirections []Redirection) string { + for _, r := range redirections { + rawURL = applyRedirection(rawURL, r) + } + return rawURL +} + +func applyRedirection(rawURL string, r Redirection) string { + rwType := r.Type + if rwType == "" { + rwType = "domain" + } + + switch rwType { + case "domain": + return applyDomainRedirection(rawURL, r) + case "wildcard": + return applyWildcardRedirection(rawURL, r) + case "regex": + return applyRegexRedirection(rawURL, r) + default: + return rawURL + } +} + +func applyDomainRedirection(rawURL string, r Redirection) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + if !strings.EqualFold(u.Hostname(), r.Find) { + return rawURL + } + + u.Host = strings.Replace(u.Host, u.Hostname(), r.Replace, 1) + return u.String() +} + +func applyWildcardRedirection(rawURL string, r Redirection) string { + pattern := wildcardToRegex(r.Find) + re, ok := getCompiledRegex("(?i)" + pattern) // case-insensitive + if !ok { + return rawURL + } + return re.ReplaceAllString(rawURL, r.Replace) +} + +func applyRegexRedirection(rawURL string, r Redirection) string { + re, ok := getCompiledRegex(r.Find) + if !ok { + return rawURL + } + return re.ReplaceAllString(rawURL, r.Replace) +} + +func wildcardToRegex(pattern string) string { + // Escape regex special chars except * + escaped := regexp.QuoteMeta(pattern) + // Convert \* back to .* + return strings.ReplaceAll(escaped, `\*`, `.*`) +} diff --git a/src/redirection_test.go b/src/redirection_test.go new file mode 100644 index 0000000..8825d2c --- /dev/null +++ b/src/redirection_test.go @@ -0,0 +1,710 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "testing" +) + +func TestWildcardToRegex(t *testing.T) { + tests := []struct { + wildcard string + want string + }{ + {"youtube.com", `youtube\.com`}, + {"*.example.com", `.*\.example\.com`}, + {"utm_*", `utm_.*`}, + {"foo*bar", `foo.*bar`}, + {"a.b.c", `a\.b\.c`}, + {"test?param", `test\?param`}, // ? is escaped, not a wildcard + } + + for _, tt := range tests { + t.Run(tt.wildcard, func(t *testing.T) { + got := wildcardToRegex(tt.wildcard) + if got != tt.want { + t.Errorf("wildcardToRegex(%q) = %q, want %q", tt.wildcard, got, tt.want) + } + }) + } +} + +func TestApplyDomainRedirection(t *testing.T) { + tests := []struct { + name string + url string + find string + replace string + want string + }{ + { + name: "exact domain match", + url: "https://reddit.com/r/test", + find: "reddit.com", + replace: "old.reddit.com", + want: "https://old.reddit.com/r/test", + }, + { + name: "no match on subdomain", + url: "https://old.reddit.com/r/test", + find: "reddit.com", + replace: "old.reddit.com", + want: "https://old.reddit.com/r/test", // unchanged + }, + { + name: "case insensitive", + url: "https://Reddit.COM/r/test", + find: "reddit.com", + replace: "old.reddit.com", + want: "https://old.reddit.com/r/test", + }, + { + name: "preserves port", + url: "https://reddit.com:8080/r/test", + find: "reddit.com", + replace: "old.reddit.com", + want: "https://old.reddit.com:8080/r/test", + }, + { + name: "preserves path and query", + url: "https://twitter.com/user?tab=posts", + find: "twitter.com", + replace: "nitter.net", + want: "https://nitter.net/user?tab=posts", + }, + { + name: "no match different domain", + url: "https://github.com/user", + find: "twitter.com", + replace: "nitter.net", + want: "https://github.com/user", + }, + { + name: "x.com to twitter.com", + url: "https://x.com/user", + find: "x.com", + replace: "twitter.com", + want: "https://twitter.com/user", + }, + { + name: "preserves fragment", + url: "https://example.com/page#section", + find: "example.com", + replace: "new.example.com", + want: "https://new.example.com/page#section", + }, + { + name: "preserves username and password", + url: "https://user:pass@example.com/path", + find: "example.com", + replace: "new.example.com", + want: "https://user:pass@new.example.com/path", + }, + { + name: "http scheme preserved", + url: "http://example.com/path", + find: "example.com", + replace: "new.example.com", + want: "http://new.example.com/path", + }, + { + name: "does not match partial domain", + url: "https://notexample.com/path", + find: "example.com", + replace: "new.example.com", + want: "https://notexample.com/path", + }, + { + name: "does not match domain in path", + url: "https://other.com/example.com/path", + find: "example.com", + replace: "new.example.com", + want: "https://other.com/example.com/path", + }, + { + name: "empty replace removes domain", + url: "https://www.example.com/path", + find: "www.example.com", + replace: "example.com", + want: "https://example.com/path", + }, + { + name: "handles malformed URL gracefully", + url: "not a url", + find: "example.com", + replace: "new.example.com", + want: "not a url", + }, + { + name: "preserves complex query string", + url: "https://example.com/search?q=test&page=1&sort=date", + find: "example.com", + replace: "new.example.com", + want: "https://new.example.com/search?q=test&page=1&sort=date", + }, + { + name: "handles encoded characters in path", + url: "https://example.com/path%20with%20spaces", + find: "example.com", + replace: "new.example.com", + want: "https://new.example.com/path%20with%20spaces", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Redirection{Type: "domain", Find: tt.find, Replace: tt.replace} + got := applyRedirection(tt.url, r) + if got != tt.want { + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyWildcardRedirection(t *testing.T) { + tests := []struct { + name string + url string + find string + replace string + want string + }{ + { + name: "wildcard removes utm mid-url", + url: "https://example.com?utm_source=twitter&id=1", + find: "utm_source=*&", + replace: "", + want: "https://example.com?id=1", + }, + { + name: "wildcard removes fbclid at end", + url: "https://example.com?id=1&fbclid=abc123", + find: "&fbclid=*", + replace: "", + want: "https://example.com?id=1", + }, + { + name: "wildcard at start of param", + url: "https://example.com?fbclid=abc123", + find: "fbclid=*", + replace: "", + want: "https://example.com?", + }, + { + name: "multiple occurrences all replaced", + url: "https://foo.com/foo/foo", + find: "foo", + replace: "bar", + want: "https://bar.com/bar/bar", + }, + { + name: "empty replace removes match", + url: "https://example.com/tracking/page", + find: "/tracking", + replace: "", + want: "https://example.com/page", + }, + { + name: "wildcard matches anything", + url: "https://cdn.example.com/image.png", + find: "cdn.*", + replace: "static.newsite.com", + want: "https://static.newsite.com", + }, + { + name: "case insensitive", + url: "https://example.com?UTM_SOURCE=twitter", + find: "utm_source=*", + replace: "", + want: "https://example.com?", + }, + { + name: "wildcard at beginning matches greedily", + url: "https://www.example.com/path", + find: "*www.", + replace: "", + want: "example.com/path", // wildcard matches "https://" too + }, + { + name: "multiple wildcards", + url: "https://example.com/a/b/c/d", + find: "/a/*/c/*", + replace: "/x", + want: "https://example.com/x", + }, + { + name: "wildcard with special regex chars", + url: "https://example.com/path?query=value", + find: "?query=*", + replace: "", + want: "https://example.com/path", + }, + { + name: "no match returns original", + url: "https://example.com/path", + find: "nomatch*", + replace: "replaced", + want: "https://example.com/path", + }, + { + name: "wildcard matches empty string", + url: "https://example.com/path", + find: "path*", + replace: "newpath", + want: "https://example.com/newpath", + }, + { + name: "replace with literal text", + url: "https://old.example.com/path", + find: "old.", + replace: "new.", + want: "https://new.example.com/path", + }, + { + name: "handles URL with fragment", + url: "https://example.com/path?utm_source=test#section", + find: "?utm_source=*#", + replace: "#", + want: "https://example.com/path#section", + }, + { + name: "greedy wildcard behavior", + url: "https://example.com/a/b/a/c", + find: "/a/*", + replace: "/x", + want: "https://example.com/x", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Redirection{Type: "wildcard", Find: tt.find, Replace: tt.replace} + got := applyRedirection(tt.url, r) + if got != tt.want { + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyRegexRedirection(t *testing.T) { + tests := []struct { + name string + url string + find string + replace string + want string + }{ + { + name: "youtube shorts to watch", + url: "https://youtube.com/shorts/abc123xyz", + find: "youtube\\.com/shorts/([^?]+)", + replace: "youtube.com/watch?v=$1", + want: "https://youtube.com/watch?v=abc123xyz", + }, + { + name: "capture group replacement", + url: "https://foo.example.com/123", + find: "([a-z]+)\\.example\\.com/([0-9]+)", + replace: "new-$1.example.org/id/$2", + want: "https://new-foo.example.org/id/123", + }, + { + name: "remove utm parameters", + url: "https://example.com?utm_source=twitter&utm_medium=social&id=1", + find: "[?&]utm_[a-z_]+=[^&]*", + replace: "", + want: "https://example.com&id=1", + }, + { + name: "strip amazon tracking", + url: "https://amazon.com/dp/B001234/ref=sr_1_1?keywords=test", + find: "(amazon\\.[^/]+/dp/[^/]+).*", + replace: "$1", + want: "https://amazon.com/dp/B001234", + }, + { + name: "no match returns original", + url: "https://example.com/page", + find: "nomatch", + replace: "replaced", + want: "https://example.com/page", + }, + { + name: "empty replace removes match", + url: "https://example.com/tracking/page", + find: "/tracking", + replace: "", + want: "https://example.com/page", + }, + // Edge cases + { + name: "multiple capture groups", + url: "https://example.com/user/123/post/456", + find: "/user/([0-9]+)/post/([0-9]+)", + replace: "/u/$1/p/$2", + want: "https://example.com/u/123/p/456", + }, + { + name: "named-style capture groups with numbers", + url: "https://example.com/2024/01/15/article", + find: "/([0-9]{4})/([0-9]{2})/([0-9]{2})/", + replace: "/archive/$1-$2-$3/", + want: "https://example.com/archive/2024-01-15/article", + }, + { + name: "case sensitive by default", + url: "https://Example.COM/PATH", + find: "example\\.com/path", + replace: "new.example.com/newpath", + want: "https://Example.COM/PATH", // no match - case sensitive + }, + { + name: "case insensitive with flag", + url: "https://Example.COM/PATH", + find: "(?i)example\\.com/path", + replace: "new.example.com/newpath", + want: "https://new.example.com/newpath", + }, + { + name: "invalid regex returns original", + url: "https://example.com/page", + find: "[invalid", + replace: "replaced", + want: "https://example.com/page", + }, + { + name: "backreference to non-existent group", + url: "https://example.com/test", + find: "test", + replace: "$1", // no capture group + want: "https://example.com/", + }, + { + name: "literal dollar sign in replacement", + url: "https://example.com/price", + find: "price", + replace: "cost$$100", + want: "https://example.com/cost$100", + }, + { + name: "google to kagi search", + url: "https://www.google.com/search?q=test+query&sourceid=chrome", + find: "google\\.com/search\\?(.*)q=([^&]+)(.*)", + replace: "kagi.com/search?q=$2", + want: "https://www.kagi.com/search?q=test+query", + }, + { + name: "handles special regex chars in URL", + url: "https://example.com/path?a=1&b=2", + find: "\\?a=1&b=2", + replace: "?x=y", + want: "https://example.com/path?x=y", + }, + { + name: "preserves unmatched parts", + url: "https://example.com/prefix/match/suffix", + find: "/match/", + replace: "/replaced/", + want: "https://example.com/prefix/replaced/suffix", + }, + { + name: "handles empty capture group", + url: "https://example.com/test", + find: "/(test)(.*)", + replace: "/$1-end$2", + want: "https://example.com/test-end", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Redirection{Type: "regex", Find: tt.find, Replace: tt.replace} + got := applyRedirection(tt.url, r) + if got != tt.want { + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyRedirectionDefaultType(t *testing.T) { + // Empty type should default to domain + r := Redirection{Find: "reddit.com", Replace: "old.reddit.com"} + got := applyRedirection("https://reddit.com/r/test", r) + want := "https://old.reddit.com/r/test" + if got != want { + t.Errorf("applyRedirection() with empty type = %q, want %q", got, want) + } + + // Should not match subdomain with default type + got = applyRedirection("https://old.reddit.com/r/test", r) + want = "https://old.reddit.com/r/test" + if got != want { + t.Errorf("applyRedirection() should not match subdomain = %q, want %q", got, want) + } +} + +func TestApplyRedirections(t *testing.T) { + tests := []struct { + name string + url string + redirections []Redirection + want string + }{ + { + name: "domain then wildcard redirection", + url: "https://twitter.com/user?utm_source=share", + redirections: []Redirection{ + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, + {Type: "wildcard", Find: "?utm_source=*", Replace: ""}, + }, + want: "https://nitter.net/user", + }, + { + name: "empty redirections list", + url: "https://example.com", + redirections: []Redirection{}, + want: "https://example.com", + }, + { + name: "chained domain redirections", + url: "https://x.com/user", + redirections: []Redirection{ + {Type: "domain", Find: "x.com", Replace: "twitter.com"}, + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, + }, + want: "https://nitter.net/user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyRedirections(tt.url, tt.redirections) + if got != tt.want { + t.Errorf("applyRedirections() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateRedirection(t *testing.T) { + tests := []struct { + name string + redirection Redirection + wantErr bool + }{ + { + name: "valid domain redirection", + redirection: Redirection{Type: "domain", Find: "reddit.com", Replace: "old.reddit.com"}, + wantErr: false, + }, + { + name: "valid domain redirection default type", + redirection: Redirection{Find: "twitter.com", Replace: "nitter.net"}, + wantErr: false, + }, + { + name: "valid wildcard redirection with wildcard", + redirection: Redirection{Type: "wildcard", Find: "utm_*", Replace: ""}, + wantErr: false, + }, + { + name: "domain redirection with wildcard invalid", + redirection: Redirection{Type: "domain", Find: "*.reddit.com", Replace: "old.reddit.com"}, + wantErr: true, + }, + { + name: "empty find wildcard", + redirection: Redirection{Type: "domain", Find: "", Replace: "something"}, + wantErr: true, + }, + { + name: "empty replace is valid", + redirection: Redirection{Type: "wildcard", Find: "tracking", Replace: ""}, + wantErr: false, + }, + { + name: "invalid redirection type", + redirection: Redirection{Type: "invalid", Find: "test", Replace: ""}, + wantErr: true, + }, + { + name: "valid regex redirection", + redirection: Redirection{Type: "regex", Find: "test.*pattern", Replace: "replaced"}, + wantErr: false, + }, + { + name: "invalid regex syntax", + redirection: Redirection{Type: "regex", Find: "[invalid", Replace: "replaced"}, + wantErr: true, + }, + { + name: "regex with capture groups valid", + redirection: Redirection{Type: "regex", Find: "([a-z]+)/([0-9]+)", Replace: "$2/$1"}, + wantErr: false, + }, + { + name: "wildcard without asterisk valid", + redirection: Redirection{Type: "wildcard", Find: "exactmatch", Replace: "replaced"}, + wantErr: false, + }, + { + name: "domain with port invalid", + redirection: Redirection{Type: "domain", Find: "example.com:8080", Replace: "example.com"}, + wantErr: true, // colon is invalid in domain pattern + }, + { + name: "domain with path invalid", + redirection: Redirection{Type: "domain", Find: "example.com/path", Replace: "new.com"}, + wantErr: true, + }, + { + name: "domain with protocol invalid", + redirection: Redirection{Type: "domain", Find: "https://example.com", Replace: "new.com"}, + wantErr: true, + }, + { + name: "whitespace only find invalid", + redirection: Redirection{Type: "domain", Find: " ", Replace: "example.com"}, + wantErr: true, + }, + { + name: "name field does not affect validation", + redirection: Redirection{Name: "My Rule", Type: "domain", Find: "example.com", Replace: "new.com"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRedirection(tt.redirection) + if (err != nil) != tt.wantErr { + t.Errorf("validateRedirection() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestRealWorldRedirections tests common real-world URL transformations +func TestRealWorldRedirections(t *testing.T) { + tests := []struct { + name string + url string + redirections []Redirection + want string + }{ + { + name: "twitter/x.com to nitter", + url: "https://x.com/user/status/123456", + redirections: []Redirection{ + {Type: "domain", Find: "x.com", Replace: "nitter.net"}, + }, + want: "https://nitter.net/user/status/123456", + }, + { + name: "reddit to old reddit", + url: "https://reddit.com/r/programming/comments/abc123", + redirections: []Redirection{ + {Type: "domain", Find: "reddit.com", Replace: "old.reddit.com"}, + }, + want: "https://old.reddit.com/r/programming/comments/abc123", + }, + { + name: "remove facebook tracking params", + url: "https://example.com/article?fbclid=ABC123&utm_source=facebook", + redirections: []Redirection{ + // Use regex for more reliable param removal + {Type: "regex", Find: "[?&]fbclid=[^&]*", Replace: ""}, + {Type: "regex", Find: "[?&]utm_source=[^&]*", Replace: ""}, + }, + want: "https://example.com/article", + }, + { + name: "clean amazon product URL", + url: "https://www.amazon.com/dp/B0DZD91W4F/?tag=affiliate-20&linkCode=xyz&ref=abc", + redirections: []Redirection{ + {Type: "regex", Find: "(amazon\\.[a-z.]+/dp/[A-Z0-9]+).*", Replace: "$1"}, + }, + want: "https://www.amazon.com/dp/B0DZD91W4F", + }, + { + name: "google to kagi search", + url: "https://www.google.com/search?q=test+search&sourceid=chrome&ie=UTF-8", + redirections: []Redirection{ + {Type: "regex", Find: "google\\.com/search\\?(.*)q=([^&]+)(.*)", Replace: "kagi.com/search?q=$2"}, + }, + want: "https://www.kagi.com/search?q=test+search", + }, + { + name: "youtube mobile to desktop", + url: "https://m.youtube.com/watch?v=abc123", + redirections: []Redirection{ + {Type: "domain", Find: "m.youtube.com", Replace: "youtube.com"}, + }, + want: "https://youtube.com/watch?v=abc123", + }, + { + name: "instagram to bibliogram", + url: "https://www.instagram.com/p/ABC123/", + redirections: []Redirection{ + {Type: "domain", Find: "www.instagram.com", Replace: "bibliogram.art"}, + {Type: "domain", Find: "instagram.com", Replace: "bibliogram.art"}, + }, + want: "https://bibliogram.art/p/ABC123/", + }, + { + name: "medium to scribe", + url: "https://medium.com/@user/article-title-123abc", + redirections: []Redirection{ + {Type: "domain", Find: "medium.com", Replace: "scribe.rip"}, + }, + want: "https://scribe.rip/@user/article-title-123abc", + }, + { + name: "multiple tracking params removal", + url: "https://example.com/page?id=1&utm_source=twitter&utm_medium=social&utm_campaign=test&ref=abc", + redirections: []Redirection{ + {Type: "regex", Find: "[?&]utm_[a-z_]+=[^&]*", Replace: ""}, + {Type: "regex", Find: "[?&]ref=[^&]*", Replace: ""}, + }, + want: "https://example.com/page?id=1", + }, + { + name: "chained redirections - domain then cleanup", + url: "https://twitter.com/user?utm_source=share&s=20", + redirections: []Redirection{ + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, + {Type: "wildcard", Find: "?utm_source=*", Replace: ""}, + {Type: "wildcard", Find: "&s=*", Replace: ""}, + }, + want: "https://nitter.net/user", + }, + { + name: "tiktok tracking removal", + url: "https://www.tiktok.com/@user/video/123456?is_from_webapp=1&sender_device=pc", + redirections: []Redirection{ + {Type: "regex", Find: "\\?.*", Replace: ""}, + }, + want: "https://www.tiktok.com/@user/video/123456", + }, + { + name: "amp link cleanup", + url: "https://www.google.com/amp/s/example.com/article", + redirections: []Redirection{ + {Type: "regex", Find: "google\\.com/amp/s/", Replace: ""}, + }, + want: "https://www.example.com/article", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyRedirections(tt.url, tt.redirections) + if got != tt.want { + t.Errorf("applyRedirections() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/src/settings_redirections.go b/src/settings_redirections.go new file mode 100644 index 0000000..e85d3c6 --- /dev/null +++ b/src/settings_redirections.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "fmt" + "html" + + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +func formatRedirectionSubtitle(r *Redirection) string { + rwType := r.Type + if rwType == "" { + rwType = "domain" + } + + var typeLabel string + switch rwType { + case "domain": + typeLabel = "Domain" + case "wildcard": + typeLabel = "Wildcard" + case "regex": + typeLabel = "Regex" + default: + typeLabel = "Domain" + } + + if r.Replace == "" { + return fmt.Sprintf("%s · Removes match", typeLabel) + } + return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(r.Replace)) +} + +func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { + toolbarView := adw.NewToolbarView() + + header := adw.NewHeaderBar() + header.SetShowEndTitleButtons(true) + titleLabel := gtk.NewLabel("Link Redirections") + titleLabel.AddCSSClass("title") + header.SetTitleWidget(titleLabel) + + // Add button in header + addButton := gtk.NewButton() + addButton.SetIconName("list-add-symbolic") + addButton.SetTooltipText("Add Redirection") + addButton.SetHasFrame(false) + header.PackEnd(addButton) + + toolbarView.AddTopBar(header) + + scrolled := createScrolledWindow() + + content := gtk.NewBox(gtk.OrientationVertical, 12) + content.SetMarginStart(12) + content.SetMarginEnd(12) + content.SetMarginTop(12) + content.SetMarginBottom(12) + + // Info banner + infoLabel := gtk.NewLabel("Redirections modify links before rules are applied.") + infoLabel.SetWrap(true) + infoLabel.SetXAlign(0) + infoLabel.AddCSSClass("dim-label") + infoLabel.SetMarginStart(12) + infoLabel.SetMarginEnd(12) + infoLabel.SetMarginBottom(6) + content.Append(infoLabel) + + redirectionsListBox := createBoxedListBox() + emptyState := createEmptyState("edit-find-replace-symbolic", "No Redirections", "Change links before they open in a browser") + + var rebuildRedirectionsList func() + + createRedirectionRow := func(redirectionIndex int) *adw.ActionRow { + redirection := &cfg.Redirections[redirectionIndex] + + row := adw.NewActionRow() + if redirection.Name != "" { + row.SetTitle(redirection.Name) + row.SetSubtitle(fmt.Sprintf("%s · %s", html.EscapeString(redirection.Find), formatRedirectionSubtitle(redirection))) + } else { + row.SetTitle(redirection.Find) + row.SetSubtitle(formatRedirectionSubtitle(redirection)) + } + row.SetActivatable(true) + + icon := gtk.NewImageFromIconName("edit-find-replace-symbolic") + icon.SetPixelSize(24) + row.AddPrefix(icon) + + reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) + reorderBox.SetVAlign(gtk.AlignCenter) + + upBtn := gtk.NewButton() + upBtn.SetIconName("go-up-symbolic") + upBtn.AddCSSClass("flat") + upBtn.SetSensitive(redirectionIndex > 0) + upBtn.SetTooltipText("Move up") + upBtn.ConnectClicked(func() { + if redirectionIndex > 0 { + cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex-1] = cfg.Redirections[redirectionIndex-1], cfg.Redirections[redirectionIndex] + saveConfig(cfg) + rebuildRedirectionsList() + } + }) + reorderBox.Append(upBtn) + + downBtn := gtk.NewButton() + downBtn.SetIconName("go-down-symbolic") + downBtn.AddCSSClass("flat") + downBtn.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) + downBtn.SetTooltipText("Move down") + downBtn.ConnectClicked(func() { + if redirectionIndex < len(cfg.Redirections)-1 { + cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex+1] = cfg.Redirections[redirectionIndex+1], cfg.Redirections[redirectionIndex] + saveConfig(cfg) + rebuildRedirectionsList() + } + }) + reorderBox.Append(downBtn) + + row.AddSuffix(reorderBox) + + // Delete button + deleteBtn := gtk.NewButton() + deleteBtn.SetIconName("edit-delete-symbolic") + deleteBtn.AddCSSClass("flat") + deleteBtn.AddCSSClass("destructive-action") + deleteBtn.SetTooltipText("Remove") + deleteBtn.ConnectClicked(func() { + cfg.Redirections = append(cfg.Redirections[:redirectionIndex], cfg.Redirections[redirectionIndex+1:]...) + saveConfig(cfg) + rebuildRedirectionsList() + }) + row.AddSuffix(deleteBtn) + + // Edit on click + row.ConnectActivated(func() { + showEditRedirectionDialog(win, cfg, redirection, rebuildRedirectionsList) + }) + + return row + } + + rebuildRedirectionsList = func() { + clearListBox(redirectionsListBox) + + // handle empty state + if len(cfg.Redirections) == 0 { + infoLabel.SetVisible(false) + redirectionsListBox.SetVisible(false) + emptyState.SetVisible(true) + } else { + infoLabel.SetVisible(true) + redirectionsListBox.SetVisible(true) + emptyState.SetVisible(false) + + for i := range cfg.Redirections { + row := createRedirectionRow(i) + redirectionsListBox.Append(row) + } + } + } + + rebuildRedirectionsList() + + content.Append(redirectionsListBox) + content.Append(emptyState) + scrolled.SetChild(content) + toolbarView.SetContent(scrolled) + + addButton.ConnectClicked(func() { + showAddRedirectionDialog(win, cfg, rebuildRedirectionsList) + }) + + return toolbarView +} diff --git a/src/settings_rules.go b/src/settings_rules.go index 265b2a9..95e049c 100644 --- a/src/settings_rules.go +++ b/src/settings_rules.go @@ -80,23 +80,20 @@ func createRulesPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widg header := adw.NewHeaderBar() header.SetShowEndTitleButtons(true) - titleLabel := gtk.NewLabel("Rules") + titleLabel := gtk.NewLabel("Browser Rules") titleLabel.AddCSSClass("title") header.SetTitleWidget(titleLabel) // Add Rule button in header addButton := gtk.NewButton() addButton.SetIconName("list-add-symbolic") - addButton.SetTooltipText("Add New Rule") + addButton.SetTooltipText("Add Rule") addButton.SetHasFrame(false) header.PackEnd(addButton) toolbarView.AddTopBar(header) - // Scrolled window for rules list - scrolled := gtk.NewScrolledWindow() - scrolled.SetVExpand(true) - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + scrolled := createScrolledWindow() content := gtk.NewBox(gtk.OrientationVertical, 12) content.SetMarginStart(12) @@ -105,7 +102,7 @@ func createRulesPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widg content.SetMarginBottom(12) // Info banner - infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") + infoLabel := gtk.NewLabel("Rules route links to browsers. First match wins.") infoLabel.SetWrap(true) infoLabel.SetXAlign(0) infoLabel.AddCSSClass("dim-label") @@ -114,17 +111,8 @@ func createRulesPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widg infoLabel.SetMarginBottom(6) content.Append(infoLabel) - // Rules list - rulesListBox := gtk.NewListBox() - rulesListBox.SetSelectionMode(gtk.SelectionNone) - rulesListBox.AddCSSClass("boxed-list") - - // Empty state - emptyState := adw.NewStatusPage() - emptyState.SetIconName("list-add-symbolic") - emptyState.SetTitle("No Rules") - emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") - emptyState.SetVExpand(true) + rulesListBox := createBoxedListBox() + emptyState := createEmptyState("list-add-symbolic", "No Browser Rules", "Add rules to automatically open links in specific browsers") // Helper to get browser name from ID getBrowserName := func(id string) string { @@ -227,14 +215,7 @@ func createRulesPage(win *adw.Window, browsers []*Browser, cfg *Config) gtk.Widg } rebuildRulesList = func() { - // Remove all children - for { - child := rulesListBox.FirstChild() - if child == nil { - break - } - rulesListBox.Remove(child) - } + clearListBox(rulesListBox) // Show/hide empty state vs rules list if len(cfg.Rules) == 0 { diff --git a/src/ui_helpers.go b/src/ui_helpers.go index 851d0b5..b5f2f0e 100644 --- a/src/ui_helpers.go +++ b/src/ui_helpers.go @@ -96,6 +96,43 @@ func settingsPageLayout(title string) (*adw.ToolbarView, *gtk.Box, *adw.HeaderBa return toolbarView, content, header } +// createScrolledWindow creates a standard scrolled window for list content. +func createScrolledWindow() *gtk.ScrolledWindow { + scrolled := gtk.NewScrolledWindow() + scrolled.SetVExpand(true) + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + return scrolled +} + +// createBoxedListBox creates a ListBox with standard boxed-list styling. +func createBoxedListBox() *gtk.ListBox { + listBox := gtk.NewListBox() + listBox.SetSelectionMode(gtk.SelectionNone) + listBox.AddCSSClass("boxed-list") + return listBox +} + +// createEmptyState creates a status page for empty list states. +func createEmptyState(iconName, title, description string) *adw.StatusPage { + emptyState := adw.NewStatusPage() + emptyState.SetIconName(iconName) + emptyState.SetTitle(title) + emptyState.SetDescription(description) + emptyState.SetVExpand(true) + return emptyState +} + +// clearListBox removes all children from a ListBox. +func clearListBox(listBox *gtk.ListBox) { + for { + child := listBox.FirstChild() + if child == nil { + break + } + listBox.Remove(child) + } +} + // configFileFilters creates the standard file filter list for config import/export. func configFileFilters() *gio.ListStore { tomlFilter := gtk.NewFileFilter() diff --git a/src/validation.go b/src/validation.go index cb24660..a4dba91 100644 --- a/src/validation.go +++ b/src/validation.go @@ -145,3 +145,44 @@ func areAllConditionsValid(conditions []Condition) bool { } return true } + +func validateRedirection(r Redirection) error { + if r.Find == "" { + return fmt.Errorf("Find pattern cannot be empty") + } + + rwType := r.Type + if rwType == "" { + rwType = "domain" + } + + switch rwType { + case "domain": + return validateDomainPattern(r.Find) + case "wildcard": + pattern := wildcardToRegex(r.Find) + if _, err := regexp.Compile("(?i)" + pattern); err != nil { + return fmt.Errorf("Invalid pattern: %w", err) + } + case "regex": + if _, err := regexp.Compile(r.Find); err != nil { + return fmt.Errorf("Invalid regex: %w", err) + } + default: + return fmt.Errorf("Invalid redirection type: %s", rwType) + } + return nil +} + +func isRedirectionValid(r Redirection) bool { + return validateRedirection(r) == nil +} + +func areAllRedirectionsValid(redirections []Redirection) bool { + for _, r := range redirections { + if !isRedirectionValid(r) { + return false + } + } + return true +} diff --git a/src/window_settings.go b/src/window_settings.go index 448fece..7f9451b 100644 --- a/src/window_settings.go +++ b/src/window_settings.go @@ -117,8 +117,13 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView behaviorRow.AddPrefix(gtk.NewImageFromIconName("preferences-system-symbolic")) listBox.Append(behaviorRow) + redirectionsRow := adw.NewActionRow() + redirectionsRow.SetTitle("Link Redirections") + redirectionsRow.AddPrefix(gtk.NewImageFromIconName("edit-find-replace-symbolic")) + listBox.Append(redirectionsRow) + rulesRow := adw.NewActionRow() - rulesRow.SetTitle("Rules") + rulesRow.SetTitle("Browser Rules") rulesRow.AddPrefix(gtk.NewImageFromIconName("view-list-symbolic")) listBox.Append(rulesRow) @@ -127,6 +132,12 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView advancedRow.AddPrefix(gtk.NewImageFromIconName("preferences-other-symbolic")) listBox.Append(advancedRow) + listBox.SetHeaderFunc(func(row, before *gtk.ListBoxRow) { + if row.Index() == 2 || row.Index() == 4 { + row.SetHeader(gtk.NewSeparator(gtk.OrientationHorizontal)) + } + }) + scrolled.SetChild(listBox) toolbarView.SetContent(scrolled) @@ -145,9 +156,12 @@ func createSidebar(win *adw.Window, cfg *Config, browsers []*Browser, splitView page = createBehaviorPage(win, browsers, cfg) title = "Behavior" case 2: - page = createRulesPage(win, browsers, cfg) - title = "Rules" + page = createRedirectionsPage(win, cfg) + title = "Link Redirections" case 3: + page = createRulesPage(win, browsers, cfg) + title = "Browser Rules" + case 4: page = createAdvancedPage(win, cfg) title = "Advanced" } @@ -193,6 +207,7 @@ func watchConfigFile(cfg *Config, onChange func()) { cfg.ShowAppNames = newCfg.ShowAppNames cfg.ForceDarkMode = newCfg.ForceDarkMode cfg.HiddenBrowsers = newCfg.HiddenBrowsers + cfg.Redirections = newCfg.Redirections cfg.Rules = newCfg.Rules if onChange != nil {