Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add link redirections (#26)

* add rewrite rules

* fixup messaging/language

* add support for regex redirections

* fixup language

* add more redirection tests

* add separators to settings window

* rm unused conditiosn edit gialog

* set redirection dialog to fit element size

* clean up dialog helpers

* fmt

* abstract scrolled windows with createScrolledWindow()

* README.md: cleanup features

* data/metainfo: add new description to include redirect rules

authored by

Aly Raffauf and committed by
GitHub
(Jan 24, 2026, 5:33 PM EST) bebd6cd1 ee26c1b5

+1342 -207
+4 -8
README.md
··· 16 16 17 17 ## Features 18 18 19 - - **Rule-based routing**: Automatically open URLs in specific browsers based on powerful patterns. 20 - - **Multi-condition rules**: Combine multiple conditions with AND/OR logic for precise control. 21 - - **Multiple pattern types**: Exact Domain, URL Contains, Wildcard, and Regex matching. 22 - - **Negative patterns**: Exclude specific URLs by inverting any condition (e.g., "all GitHub except Gist"). 23 - - **Custom URI scheme**: Create links that specify browser preferences directly with `switchyard://` URLs. 24 - - **Quick browser launcher**: When no rule matches, quickly select a browser with keyboard or mouse. 25 - - **Keyboard shortcuts**: Press Ctrl+1-9 to instantly select a browser. 26 - - **Lightweight**: Runs only when needed, no background processes. 19 + - **Browser rules**: Automatically open links in specific browsers based on conditions you define. 20 + - **Link redirections**: Clean up links before they open—remove tracking parameters, swap domains, and more. 21 + - **Quick launcher**: When no rule matches, choose a browser with a click or keyboard shortcut. 22 + - **Lightweight**: Runs only when you click a link. No background processes. 27 23 - **GTK4 + libadwaita**: Native GNOME look and feel. 28 24 29 25 ## Installation
+2 -2
data/io.github.alyraffauf.Switchyard.metainfo.xml
··· 32 32 33 33 <description> 34 34 <p> 35 - Work links in Chrome. Personal links in Firefox. Switchyard automatically routes URLs to the right browser based on your rules. 35 + 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. 36 36 </p> 37 37 <p> 38 - Match URLs by domain, keyword, or regular expression. No matching rule? Choose your browser with a quick keystroke or mouse click. 38 + 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. 39 39 </p> 40 40 </description> 41 41
+98 -34
docs/Configuration.md
··· 11 11 12 12 ```toml 13 13 prompt_on_click = true 14 - favorite_browser = "" 14 + favorite_browser = '' 15 15 check_default_browser = true 16 16 17 - # Simple rule with a single condition 17 + # Link redirections (domain type is default) 18 + [[redirections]] 19 + find = 'x.com' 20 + replace = 'twitter.com' 21 + 22 + [[redirections]] 23 + find = 'reddit.com' 24 + replace = 'old.reddit.com' 25 + 26 + # Pattern redirection to remove query parameters 27 + [[redirections]] 28 + type = 'wildcard' 29 + find = '?utm_*' 30 + replace = '' 31 + 32 + # Browser rule with a single condition 18 33 [[rules]] 19 - name = "Work GitHub" 20 - browser = "firefox.desktop" 34 + name = 'Work GitHub' 35 + browser = 'firefox.desktop' 21 36 22 37 [[rules.conditions]] 23 - type = "domain" 24 - pattern = "github.com" 38 + type = 'domain' 39 + pattern = 'github.com' 25 40 26 41 # Multi-condition rule with AND logic 27 42 [[rules]] 28 - name = "Google Docs" 29 - logic = "all" # all conditions must match 30 - browser = "google-chrome.desktop" 43 + name = 'Google Docs' 44 + logic = 'all' # all conditions must match 45 + browser = 'google-chrome.desktop' 31 46 32 47 [[rules.conditions]] 33 - type = "domain" 34 - pattern = "docs.google.com" 48 + type = 'domain' 49 + pattern = 'docs.google.com' 35 50 36 51 [[rules.conditions]] 37 - type = "keyword" 38 - pattern = "edit" 52 + type = 'keyword' 53 + pattern = 'edit' 39 54 40 55 # Multi-condition rule with OR logic 41 56 [[rules]] 42 - name = "Video Sites" 43 - logic = "any" # any condition can match 44 - browser = "brave-browser.desktop" 57 + name = 'Video Sites' 58 + logic = 'any' # any condition can match 59 + browser = 'brave-browser.desktop' 45 60 46 61 [[rules.conditions]] 47 - type = "domain" 48 - pattern = "youtube.com" 62 + type = 'domain' 63 + pattern = 'youtube.com' 49 64 50 65 [[rules.conditions]] 51 - type = "domain" 52 - pattern = "vimeo.com" 66 + type = 'domain' 67 + pattern = 'vimeo.com' 53 68 54 69 [[rules.conditions]] 55 - type = "domain" 56 - pattern = "twitch.tv" 70 + type = 'domain' 71 + pattern = 'twitch.tv' 57 72 58 73 # Rule with negated condition 59 74 [[rules]] 60 - name = "GitHub (not Gist)" 61 - logic = "all" 62 - browser = "firefox.desktop" 75 + name = 'GitHub (not Gist)' 76 + logic = 'all' 77 + browser = 'firefox.desktop' 63 78 64 79 [[rules.conditions]] 65 - type = "glob" 66 - pattern = "*.github.com" 80 + type = 'glob' 81 + pattern = '*.github.com' 67 82 68 83 [[rules.conditions]] 69 - type = "domain" 70 - pattern = "gist.github.com" 84 + type = 'domain' 85 + pattern = 'gist.github.com' 71 86 negate = true # URL must NOT match this pattern 72 87 73 88 # Rule with always ask 74 89 [[rules]] 75 - name = "Shopping Sites" 90 + name = 'Shopping Sites' 76 91 always_ask = true 77 92 78 93 [[rules.conditions]] 79 - type = "keyword" 80 - pattern = "amazon" 94 + type = 'keyword' 95 + pattern = 'amazon' 81 96 ``` 82 97 83 98 ## Settings ··· 86 101 - **favorite_browser**: Favorite browser that always appears first in launcher and is used as fallback when launcher is disabled. 87 102 - **check_default_browser**: Prompt to set Switchyard as system default browser on startup (default: true). 88 103 89 - ## Rules 104 + ## Browser Rules 90 105 91 - Rules define how URLs are routed to browsers. Each rule has conditions that determine when it matches. 106 + Browser rules define how URLs are routed to browsers. Each rule has conditions that determine when it matches. 92 107 93 108 - **name**: Optional friendly name displayed in the UI. 94 109 - **conditions**: Array of conditions to match (see below). ··· 117 132 - **any** (OR): Any single condition matching triggers the rule. 118 133 119 134 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"). 135 + 136 + ## Link Redirections 137 + 138 + Link redirections modify URLs before browser rules are evaluated. 139 + 140 + - **type**: `domain` (default), `wildcard`, or `regex`. 141 + - **find**: Pattern to match. 142 + - **replace**: Text to replace with. Leave empty to remove matches. 143 + 144 + ### Redirection Types 145 + 146 + **Domain** redirections match the exact hostname, including the ports (if any). Use this to switch between sites: 147 + 148 + ```toml 149 + [[redirections]] 150 + find = 'reddit.com' 151 + replace = 'old.reddit.com' 152 + 153 + [[redirections]] 154 + find = 'x.com' 155 + replace = 'twitter.com' 156 + ``` 157 + 158 + **Wildcard** redirections match anywhere in the URL and support `*` wildcards. Use this to clean up links: 159 + 160 + ```toml 161 + [[redirections]] 162 + type = 'wildcard' 163 + find = '?utm_*' 164 + replace = '' 165 + 166 + [[redirections]] 167 + type = 'wildcard' 168 + find = '&fbclid=*' 169 + replace = '' 170 + ``` 171 + 172 + **Regex** redirections use regular expressions with capture group support (`$1`, `$2`, etc.) for complex transformations: 173 + 174 + ```toml 175 + # Clean Amazon URLs - keep only the product ID 176 + # amazon.com/dp/B0DZD91W4F/?tag=thewire06-20&linkCode=xm2&ascsubtag=... → amazon.com/dp/B0DZD91W4F 177 + [[redirections]] 178 + type = 'regex' 179 + find = '(amazon\.[a-z.]+/dp/[A-Z0-9]+).*' 180 + replace = '$1' 181 + ``` 182 + 183 + Redirections are applied in order. Domain and pattern matching is case-insensitive; regex matching is case-sensitive.
+2 -2
justfile
··· 52 52 # Run unit tests 53 53 test: 54 54 @echo "Running unit tests..." 55 - 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 55 + 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 56 56 57 57 # Run tests with coverage report 58 58 test-coverage: 59 59 @echo "Running tests with coverage..." 60 - 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 60 + 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 61 61 go tool cover -func=coverage.out 62 62 @echo "" 63 63 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out"
+15 -7
src/config.go
··· 13 13 ) 14 14 15 15 type Config struct { 16 - PromptOnClick bool `toml:"prompt_on_click"` 17 - FavoriteBrowser string `toml:"favorite_browser"` 18 - HiddenBrowsers []string `toml:"hidden_browsers"` 19 - CheckDefaultBrowser bool `toml:"check_default_browser"` 20 - ShowAppNames bool `toml:"show_app_names"` 21 - ForceDarkMode bool `toml:"force_dark_mode"` 22 - Rules []Rule `toml:"rules"` 16 + PromptOnClick bool `toml:"prompt_on_click"` 17 + FavoriteBrowser string `toml:"favorite_browser"` 18 + HiddenBrowsers []string `toml:"hidden_browsers"` 19 + CheckDefaultBrowser bool `toml:"check_default_browser"` 20 + ShowAppNames bool `toml:"show_app_names"` 21 + ForceDarkMode bool `toml:"force_dark_mode"` 22 + Redirections []Redirection `toml:"redirections,omitempty"` 23 + Rules []Rule `toml:"rules"` 24 + } 25 + 26 + type Redirection struct { 27 + Name string `toml:"name,omitempty"` 28 + Type string `toml:"type,omitempty"` // "domain", "wildcard", or "regex", defaults to "domain" 29 + Find string `toml:"find"` 30 + Replace string `toml:"replace"` 23 31 } 24 32 25 33 type Condition struct {
-103
src/dialog_condition_edit.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "regexp" 7 - 8 - "github.com/diamondburned/gotk4-adwaita/pkg/adw" 9 - "github.com/diamondburned/gotk4/pkg/gtk/v4" 10 - ) 11 - 12 - // showEditConditionDialog displays a dialog for editing a single condition. 13 - func showEditConditionDialog(parent *adw.Window, cond *Condition, onSave func()) { 14 - var dialog *adw.Dialog 15 - header, saveBtn := dialogHeader("Cancel", "Save", func() { dialog.Close() }, nil) 16 - dialog, content := simpleDialogWithToolbar("Edit Condition", 400, 300, header) 17 - 18 - // Preferences group 19 - group := adw.NewPreferencesGroup() 20 - 21 - // Type row 22 - typeRow := adw.NewComboRow() 23 - typeRow.SetTitle("Match Type") 24 - typeRow.SetModel(gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"})) 25 - typeRow.SetSelected(conditionTypeToIndex(cond.Type)) 26 - group.Add(typeRow) 27 - 28 - // Pattern row 29 - patternRow := adw.NewEntryRow() 30 - patternRow.SetTitle("Pattern") 31 - patternRow.SetText(cond.Pattern) 32 - group.Add(patternRow) 33 - 34 - // Negate row 35 - negateRow := adw.NewSwitchRow() 36 - negateRow.SetTitle("Invert Match") 37 - negateRow.SetSubtitle("URL must NOT match this pattern") 38 - negateRow.SetActive(cond.Negate) 39 - group.Add(negateRow) 40 - 41 - // Error label for validation 42 - errorLabel := gtk.NewLabel("") 43 - errorLabel.SetWrap(true) 44 - errorLabel.AddCSSClass("error") 45 - errorLabel.SetVisible(false) 46 - 47 - // Validate and update error display 48 - updateValidation := func() { 49 - condType := indexToConditionType(typeRow.Selected()) 50 - pattern := patternRow.Text() 51 - 52 - err := validateConditionPattern(condType, pattern) 53 - 54 - if err != nil { 55 - errorLabel.SetLabel(err.Error()) 56 - errorLabel.SetVisible(true) 57 - patternRow.AddCSSClass("error") 58 - saveBtn.SetSensitive(false) 59 - } else { 60 - errorLabel.SetVisible(false) 61 - patternRow.RemoveCSSClass("error") 62 - saveBtn.SetSensitive(pattern != "") 63 - } 64 - } 65 - 66 - patternRow.Connect("changed", func() { 67 - updateValidation() 68 - }) 69 - 70 - typeRow.Connect("notify::selected", func() { 71 - updateValidation() 72 - }) 73 - 74 - content.Append(group) 75 - content.Append(errorLabel) 76 - 77 - // Initial validation check 78 - updateValidation() 79 - 80 - saveBtn.ConnectClicked(func() { 81 - pattern := patternRow.Text() 82 - if pattern == "" { 83 - return 84 - } 85 - 86 - // Final validation 87 - selectedType := typeRow.Selected() 88 - if selectedType == 3 { // Regex 89 - if _, err := regexp.Compile(pattern); err != nil { 90 - return 91 - } 92 - } 93 - 94 - cond.Type = indexToConditionType(selectedType) 95 - cond.Pattern = pattern 96 - cond.Negate = negateRow.Active() 97 - 98 - onSave() 99 - dialog.Close() 100 - }) 101 - 102 - dialog.Present(parent) 103 - }
+23 -11
src/dialog_helpers.go
··· 40 40 toolbarView := adw.NewToolbarView() 41 41 toolbarView.AddTopBar(header) 42 42 43 - scrolledWindow := gtk.NewScrolledWindow() 44 - scrolledWindow.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 45 - scrolledWindow.SetVExpand(true) 43 + scrolledWindow := createScrolledWindow() 46 44 47 45 content := gtk.NewBox(gtk.OrientationVertical, 18) 48 46 content.SetMarginStart(18) ··· 112 110 } 113 111 } 114 112 115 - func conditionTypeComboRow(title string, initialType string) *adw.ComboRow { 116 - typeRow := adw.NewComboRow() 117 - typeRow.SetTitle(title) 118 - typeRow.SetModel(gtk.NewStringList([]string{"Exact Domain", "URL Contains", "Wildcard", "Regex"})) 119 - typeRow.SetSelected(conditionTypeToIndex(initialType)) 120 - return typeRow 113 + func redirectionTypeToIndex(rwType string) uint { 114 + switch rwType { 115 + case "wildcard": 116 + return 1 117 + case "regex": 118 + return 2 119 + default: 120 + return 0 // "domain" is default 121 + } 121 122 } 122 123 123 - func getConditionTypeLabels() []string { 124 - return []string{"Exact Domain", "URL Contains", "Wildcard", "Regex"} 124 + func indexToRedirectionType(index uint) string { 125 + switch index { 126 + case 1: 127 + return "wildcard" 128 + case 2: 129 + return "regex" 130 + default: 131 + return "domain" 132 + } 133 + } 134 + 135 + func getRedirectionTypeLabels() []string { 136 + return []string{"Domain", "Wildcard", "Regex"} 125 137 }
+2 -8
src/dialog_hidden_browsers.go
··· 13 13 "You can still use hidden browsers in rules.", 14 14 ) 15 15 16 - // Create a scrolled window for the browser list 17 - scrolled := gtk.NewScrolledWindow() 18 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 19 - scrolled.SetVExpand(true) 16 + scrolled := createScrolledWindow() 20 17 scrolled.SetSizeRequest(400, 300) 21 18 22 - // Create a list box for browsers 23 - listBox := gtk.NewListBox() 24 - listBox.SetSelectionMode(gtk.SelectionNone) 25 - listBox.AddCSSClass("boxed-list") 19 + listBox := createBoxedListBox() 26 20 27 21 // Create a map for quick lookup of hidden browsers 28 22 hiddenSet := make(map[string]bool)
+120
src/dialog_redirection.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 7 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 8 + ) 9 + 10 + func showAddRedirectionDialog(parent *adw.Window, cfg *Config, onSave func()) { 11 + showRedirectionDialog(parent, cfg, nil, onSave) 12 + } 13 + 14 + func showEditRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirection, onSave func()) { 15 + showRedirectionDialog(parent, cfg, redirection, onSave) 16 + } 17 + 18 + func showRedirectionDialog(parent *adw.Window, cfg *Config, redirection *Redirection, onSave func()) { 19 + isNew := redirection == nil 20 + 21 + var title, actionLabel string 22 + if isNew { 23 + title = "Add Redirection" 24 + actionLabel = "Add" 25 + } else { 26 + title = "Edit Redirection" 27 + actionLabel = "Save" 28 + } 29 + 30 + var dialog *adw.Dialog 31 + header, saveBtn := dialogHeader("Cancel", actionLabel, func() { dialog.Close() }, nil) 32 + dialog, content, _ := dialogWithToolbar(title, 450, 450, header) 33 + 34 + // Name section 35 + nameGroup := adw.NewPreferencesGroup() 36 + nameGroup.SetTitle("Name") 37 + nameGroup.SetDescription("Give this redirection a descriptive name (optional)") 38 + 39 + nameRow := adw.NewEntryRow() 40 + nameRow.SetTitle("Name") 41 + if !isNew { 42 + nameRow.SetText(redirection.Name) 43 + } 44 + nameGroup.Add(nameRow) 45 + content.Append(nameGroup) 46 + 47 + // Redirection section 48 + group := adw.NewPreferencesGroup() 49 + group.SetTitle("Redirection") 50 + group.SetDescription("Define how links are modified") 51 + 52 + typeRow := adw.NewComboRow() 53 + typeRow.SetTitle("Type") 54 + typeRow.SetModel(gtk.NewStringList(getRedirectionTypeLabels())) 55 + if !isNew { 56 + typeRow.SetSelected(redirectionTypeToIndex(redirection.Type)) 57 + } 58 + group.Add(typeRow) 59 + 60 + findRow := adw.NewEntryRow() 61 + findRow.SetTitle("Match") 62 + if !isNew { 63 + findRow.SetText(redirection.Find) 64 + } 65 + group.Add(findRow) 66 + 67 + replaceRow := adw.NewEntryRow() 68 + replaceRow.SetTitle("Replace With") 69 + if !isNew { 70 + replaceRow.SetText(redirection.Replace) 71 + } 72 + group.Add(replaceRow) 73 + 74 + content.Append(group) 75 + 76 + validateInputs := func() { 77 + find := findRow.Text() 78 + rwType := indexToRedirectionType(typeRow.Selected()) 79 + r := Redirection{Type: rwType, Find: find, Replace: replaceRow.Text()} 80 + err := validateRedirection(r) 81 + 82 + if find != "" && err != nil { 83 + findRow.AddCSSClass("error") 84 + } else { 85 + findRow.RemoveCSSClass("error") 86 + } 87 + saveBtn.SetSensitive(err == nil) 88 + } 89 + 90 + findRow.Connect("changed", validateInputs) 91 + replaceRow.Connect("changed", validateInputs) 92 + typeRow.Connect("notify::selected", validateInputs) 93 + validateInputs() 94 + 95 + saveBtn.ConnectClicked(func() { 96 + name := nameRow.Text() 97 + find := findRow.Text() 98 + rwType := indexToRedirectionType(typeRow.Selected()) 99 + 100 + if isNew { 101 + cfg.Redirections = append(cfg.Redirections, Redirection{ 102 + Name: name, 103 + Type: rwType, 104 + Find: find, 105 + Replace: replaceRow.Text(), 106 + }) 107 + } else { 108 + redirection.Name = name 109 + redirection.Type = rwType 110 + redirection.Find = find 111 + redirection.Replace = replaceRow.Text() 112 + } 113 + 114 + saveConfig(cfg) 115 + onSave() 116 + dialog.Close() 117 + }) 118 + 119 + dialog.Present(parent) 120 + }
+1 -3
src/dialog_rule_common.go
··· 54 54 conditionsGroup.SetTitle("Conditions") 55 55 conditionsGroup.SetDescription("Define conditions to match URLs") 56 56 57 - conditionsListBox := gtk.NewListBox() 58 - conditionsListBox.SetSelectionMode(gtk.SelectionNone) 59 - conditionsListBox.AddCSSClass("boxed-list") 57 + conditionsListBox := createBoxedListBox() 60 58 61 59 // Logic selector row 62 60 logicRow = adw.NewComboRow()
+9
src/main.go
··· 56 56 cmd.Start() 57 57 return 58 58 } 59 + 60 + if len(cfg.Redirections) > 0 { 61 + sanitized = applyRedirections(sanitized, cfg.Redirections) 62 + } 63 + 59 64 handleURL(app, browsers, cfg, sanitized) 60 65 }) 61 66 ··· 99 104 cmd := hostCommand("xdg-open", targetURL) 100 105 cmd.Start() 101 106 return 107 + } 108 + 109 + if len(cfg.Redirections) > 0 { 110 + sanitized = applyRedirections(sanitized, cfg.Redirections) 102 111 } 103 112 104 113 // If browser preferences specified, try each in order
+72
src/redirection.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "net/url" 7 + "regexp" 8 + "strings" 9 + ) 10 + 11 + func applyRedirections(rawURL string, redirections []Redirection) string { 12 + for _, r := range redirections { 13 + rawURL = applyRedirection(rawURL, r) 14 + } 15 + return rawURL 16 + } 17 + 18 + func applyRedirection(rawURL string, r Redirection) string { 19 + rwType := r.Type 20 + if rwType == "" { 21 + rwType = "domain" 22 + } 23 + 24 + switch rwType { 25 + case "domain": 26 + return applyDomainRedirection(rawURL, r) 27 + case "wildcard": 28 + return applyWildcardRedirection(rawURL, r) 29 + case "regex": 30 + return applyRegexRedirection(rawURL, r) 31 + default: 32 + return rawURL 33 + } 34 + } 35 + 36 + func applyDomainRedirection(rawURL string, r Redirection) string { 37 + u, err := url.Parse(rawURL) 38 + if err != nil { 39 + return rawURL 40 + } 41 + 42 + if !strings.EqualFold(u.Hostname(), r.Find) { 43 + return rawURL 44 + } 45 + 46 + u.Host = strings.Replace(u.Host, u.Hostname(), r.Replace, 1) 47 + return u.String() 48 + } 49 + 50 + func applyWildcardRedirection(rawURL string, r Redirection) string { 51 + pattern := wildcardToRegex(r.Find) 52 + re, ok := getCompiledRegex("(?i)" + pattern) // case-insensitive 53 + if !ok { 54 + return rawURL 55 + } 56 + return re.ReplaceAllString(rawURL, r.Replace) 57 + } 58 + 59 + func applyRegexRedirection(rawURL string, r Redirection) string { 60 + re, ok := getCompiledRegex(r.Find) 61 + if !ok { 62 + return rawURL 63 + } 64 + return re.ReplaceAllString(rawURL, r.Replace) 65 + } 66 + 67 + func wildcardToRegex(pattern string) string { 68 + // Escape regex special chars except * 69 + escaped := regexp.QuoteMeta(pattern) 70 + // Convert \* back to .* 71 + return strings.ReplaceAll(escaped, `\*`, `.*`) 72 + }
+710
src/redirection_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "testing" 7 + ) 8 + 9 + func TestWildcardToRegex(t *testing.T) { 10 + tests := []struct { 11 + wildcard string 12 + want string 13 + }{ 14 + {"youtube.com", `youtube\.com`}, 15 + {"*.example.com", `.*\.example\.com`}, 16 + {"utm_*", `utm_.*`}, 17 + {"foo*bar", `foo.*bar`}, 18 + {"a.b.c", `a\.b\.c`}, 19 + {"test?param", `test\?param`}, // ? is escaped, not a wildcard 20 + } 21 + 22 + for _, tt := range tests { 23 + t.Run(tt.wildcard, func(t *testing.T) { 24 + got := wildcardToRegex(tt.wildcard) 25 + if got != tt.want { 26 + t.Errorf("wildcardToRegex(%q) = %q, want %q", tt.wildcard, got, tt.want) 27 + } 28 + }) 29 + } 30 + } 31 + 32 + func TestApplyDomainRedirection(t *testing.T) { 33 + tests := []struct { 34 + name string 35 + url string 36 + find string 37 + replace string 38 + want string 39 + }{ 40 + { 41 + name: "exact domain match", 42 + url: "https://reddit.com/r/test", 43 + find: "reddit.com", 44 + replace: "old.reddit.com", 45 + want: "https://old.reddit.com/r/test", 46 + }, 47 + { 48 + name: "no match on subdomain", 49 + url: "https://old.reddit.com/r/test", 50 + find: "reddit.com", 51 + replace: "old.reddit.com", 52 + want: "https://old.reddit.com/r/test", // unchanged 53 + }, 54 + { 55 + name: "case insensitive", 56 + url: "https://Reddit.COM/r/test", 57 + find: "reddit.com", 58 + replace: "old.reddit.com", 59 + want: "https://old.reddit.com/r/test", 60 + }, 61 + { 62 + name: "preserves port", 63 + url: "https://reddit.com:8080/r/test", 64 + find: "reddit.com", 65 + replace: "old.reddit.com", 66 + want: "https://old.reddit.com:8080/r/test", 67 + }, 68 + { 69 + name: "preserves path and query", 70 + url: "https://twitter.com/user?tab=posts", 71 + find: "twitter.com", 72 + replace: "nitter.net", 73 + want: "https://nitter.net/user?tab=posts", 74 + }, 75 + { 76 + name: "no match different domain", 77 + url: "https://github.com/user", 78 + find: "twitter.com", 79 + replace: "nitter.net", 80 + want: "https://github.com/user", 81 + }, 82 + { 83 + name: "x.com to twitter.com", 84 + url: "https://x.com/user", 85 + find: "x.com", 86 + replace: "twitter.com", 87 + want: "https://twitter.com/user", 88 + }, 89 + { 90 + name: "preserves fragment", 91 + url: "https://example.com/page#section", 92 + find: "example.com", 93 + replace: "new.example.com", 94 + want: "https://new.example.com/page#section", 95 + }, 96 + { 97 + name: "preserves username and password", 98 + url: "https://user:pass@example.com/path", 99 + find: "example.com", 100 + replace: "new.example.com", 101 + want: "https://user:pass@new.example.com/path", 102 + }, 103 + { 104 + name: "http scheme preserved", 105 + url: "http://example.com/path", 106 + find: "example.com", 107 + replace: "new.example.com", 108 + want: "http://new.example.com/path", 109 + }, 110 + { 111 + name: "does not match partial domain", 112 + url: "https://notexample.com/path", 113 + find: "example.com", 114 + replace: "new.example.com", 115 + want: "https://notexample.com/path", 116 + }, 117 + { 118 + name: "does not match domain in path", 119 + url: "https://other.com/example.com/path", 120 + find: "example.com", 121 + replace: "new.example.com", 122 + want: "https://other.com/example.com/path", 123 + }, 124 + { 125 + name: "empty replace removes domain", 126 + url: "https://www.example.com/path", 127 + find: "www.example.com", 128 + replace: "example.com", 129 + want: "https://example.com/path", 130 + }, 131 + { 132 + name: "handles malformed URL gracefully", 133 + url: "not a url", 134 + find: "example.com", 135 + replace: "new.example.com", 136 + want: "not a url", 137 + }, 138 + { 139 + name: "preserves complex query string", 140 + url: "https://example.com/search?q=test&page=1&sort=date", 141 + find: "example.com", 142 + replace: "new.example.com", 143 + want: "https://new.example.com/search?q=test&page=1&sort=date", 144 + }, 145 + { 146 + name: "handles encoded characters in path", 147 + url: "https://example.com/path%20with%20spaces", 148 + find: "example.com", 149 + replace: "new.example.com", 150 + want: "https://new.example.com/path%20with%20spaces", 151 + }, 152 + } 153 + 154 + for _, tt := range tests { 155 + t.Run(tt.name, func(t *testing.T) { 156 + r := Redirection{Type: "domain", Find: tt.find, Replace: tt.replace} 157 + got := applyRedirection(tt.url, r) 158 + if got != tt.want { 159 + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) 160 + } 161 + }) 162 + } 163 + } 164 + 165 + func TestApplyWildcardRedirection(t *testing.T) { 166 + tests := []struct { 167 + name string 168 + url string 169 + find string 170 + replace string 171 + want string 172 + }{ 173 + { 174 + name: "wildcard removes utm mid-url", 175 + url: "https://example.com?utm_source=twitter&id=1", 176 + find: "utm_source=*&", 177 + replace: "", 178 + want: "https://example.com?id=1", 179 + }, 180 + { 181 + name: "wildcard removes fbclid at end", 182 + url: "https://example.com?id=1&fbclid=abc123", 183 + find: "&fbclid=*", 184 + replace: "", 185 + want: "https://example.com?id=1", 186 + }, 187 + { 188 + name: "wildcard at start of param", 189 + url: "https://example.com?fbclid=abc123", 190 + find: "fbclid=*", 191 + replace: "", 192 + want: "https://example.com?", 193 + }, 194 + { 195 + name: "multiple occurrences all replaced", 196 + url: "https://foo.com/foo/foo", 197 + find: "foo", 198 + replace: "bar", 199 + want: "https://bar.com/bar/bar", 200 + }, 201 + { 202 + name: "empty replace removes match", 203 + url: "https://example.com/tracking/page", 204 + find: "/tracking", 205 + replace: "", 206 + want: "https://example.com/page", 207 + }, 208 + { 209 + name: "wildcard matches anything", 210 + url: "https://cdn.example.com/image.png", 211 + find: "cdn.*", 212 + replace: "static.newsite.com", 213 + want: "https://static.newsite.com", 214 + }, 215 + { 216 + name: "case insensitive", 217 + url: "https://example.com?UTM_SOURCE=twitter", 218 + find: "utm_source=*", 219 + replace: "", 220 + want: "https://example.com?", 221 + }, 222 + { 223 + name: "wildcard at beginning matches greedily", 224 + url: "https://www.example.com/path", 225 + find: "*www.", 226 + replace: "", 227 + want: "example.com/path", // wildcard matches "https://" too 228 + }, 229 + { 230 + name: "multiple wildcards", 231 + url: "https://example.com/a/b/c/d", 232 + find: "/a/*/c/*", 233 + replace: "/x", 234 + want: "https://example.com/x", 235 + }, 236 + { 237 + name: "wildcard with special regex chars", 238 + url: "https://example.com/path?query=value", 239 + find: "?query=*", 240 + replace: "", 241 + want: "https://example.com/path", 242 + }, 243 + { 244 + name: "no match returns original", 245 + url: "https://example.com/path", 246 + find: "nomatch*", 247 + replace: "replaced", 248 + want: "https://example.com/path", 249 + }, 250 + { 251 + name: "wildcard matches empty string", 252 + url: "https://example.com/path", 253 + find: "path*", 254 + replace: "newpath", 255 + want: "https://example.com/newpath", 256 + }, 257 + { 258 + name: "replace with literal text", 259 + url: "https://old.example.com/path", 260 + find: "old.", 261 + replace: "new.", 262 + want: "https://new.example.com/path", 263 + }, 264 + { 265 + name: "handles URL with fragment", 266 + url: "https://example.com/path?utm_source=test#section", 267 + find: "?utm_source=*#", 268 + replace: "#", 269 + want: "https://example.com/path#section", 270 + }, 271 + { 272 + name: "greedy wildcard behavior", 273 + url: "https://example.com/a/b/a/c", 274 + find: "/a/*", 275 + replace: "/x", 276 + want: "https://example.com/x", 277 + }, 278 + } 279 + 280 + for _, tt := range tests { 281 + t.Run(tt.name, func(t *testing.T) { 282 + r := Redirection{Type: "wildcard", Find: tt.find, Replace: tt.replace} 283 + got := applyRedirection(tt.url, r) 284 + if got != tt.want { 285 + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) 286 + } 287 + }) 288 + } 289 + } 290 + 291 + func TestApplyRegexRedirection(t *testing.T) { 292 + tests := []struct { 293 + name string 294 + url string 295 + find string 296 + replace string 297 + want string 298 + }{ 299 + { 300 + name: "youtube shorts to watch", 301 + url: "https://youtube.com/shorts/abc123xyz", 302 + find: "youtube\\.com/shorts/([^?]+)", 303 + replace: "youtube.com/watch?v=$1", 304 + want: "https://youtube.com/watch?v=abc123xyz", 305 + }, 306 + { 307 + name: "capture group replacement", 308 + url: "https://foo.example.com/123", 309 + find: "([a-z]+)\\.example\\.com/([0-9]+)", 310 + replace: "new-$1.example.org/id/$2", 311 + want: "https://new-foo.example.org/id/123", 312 + }, 313 + { 314 + name: "remove utm parameters", 315 + url: "https://example.com?utm_source=twitter&utm_medium=social&id=1", 316 + find: "[?&]utm_[a-z_]+=[^&]*", 317 + replace: "", 318 + want: "https://example.com&id=1", 319 + }, 320 + { 321 + name: "strip amazon tracking", 322 + url: "https://amazon.com/dp/B001234/ref=sr_1_1?keywords=test", 323 + find: "(amazon\\.[^/]+/dp/[^/]+).*", 324 + replace: "$1", 325 + want: "https://amazon.com/dp/B001234", 326 + }, 327 + { 328 + name: "no match returns original", 329 + url: "https://example.com/page", 330 + find: "nomatch", 331 + replace: "replaced", 332 + want: "https://example.com/page", 333 + }, 334 + { 335 + name: "empty replace removes match", 336 + url: "https://example.com/tracking/page", 337 + find: "/tracking", 338 + replace: "", 339 + want: "https://example.com/page", 340 + }, 341 + // Edge cases 342 + { 343 + name: "multiple capture groups", 344 + url: "https://example.com/user/123/post/456", 345 + find: "/user/([0-9]+)/post/([0-9]+)", 346 + replace: "/u/$1/p/$2", 347 + want: "https://example.com/u/123/p/456", 348 + }, 349 + { 350 + name: "named-style capture groups with numbers", 351 + url: "https://example.com/2024/01/15/article", 352 + find: "/([0-9]{4})/([0-9]{2})/([0-9]{2})/", 353 + replace: "/archive/$1-$2-$3/", 354 + want: "https://example.com/archive/2024-01-15/article", 355 + }, 356 + { 357 + name: "case sensitive by default", 358 + url: "https://Example.COM/PATH", 359 + find: "example\\.com/path", 360 + replace: "new.example.com/newpath", 361 + want: "https://Example.COM/PATH", // no match - case sensitive 362 + }, 363 + { 364 + name: "case insensitive with flag", 365 + url: "https://Example.COM/PATH", 366 + find: "(?i)example\\.com/path", 367 + replace: "new.example.com/newpath", 368 + want: "https://new.example.com/newpath", 369 + }, 370 + { 371 + name: "invalid regex returns original", 372 + url: "https://example.com/page", 373 + find: "[invalid", 374 + replace: "replaced", 375 + want: "https://example.com/page", 376 + }, 377 + { 378 + name: "backreference to non-existent group", 379 + url: "https://example.com/test", 380 + find: "test", 381 + replace: "$1", // no capture group 382 + want: "https://example.com/", 383 + }, 384 + { 385 + name: "literal dollar sign in replacement", 386 + url: "https://example.com/price", 387 + find: "price", 388 + replace: "cost$$100", 389 + want: "https://example.com/cost$100", 390 + }, 391 + { 392 + name: "google to kagi search", 393 + url: "https://www.google.com/search?q=test+query&sourceid=chrome", 394 + find: "google\\.com/search\\?(.*)q=([^&]+)(.*)", 395 + replace: "kagi.com/search?q=$2", 396 + want: "https://www.kagi.com/search?q=test+query", 397 + }, 398 + { 399 + name: "handles special regex chars in URL", 400 + url: "https://example.com/path?a=1&b=2", 401 + find: "\\?a=1&b=2", 402 + replace: "?x=y", 403 + want: "https://example.com/path?x=y", 404 + }, 405 + { 406 + name: "preserves unmatched parts", 407 + url: "https://example.com/prefix/match/suffix", 408 + find: "/match/", 409 + replace: "/replaced/", 410 + want: "https://example.com/prefix/replaced/suffix", 411 + }, 412 + { 413 + name: "handles empty capture group", 414 + url: "https://example.com/test", 415 + find: "/(test)(.*)", 416 + replace: "/$1-end$2", 417 + want: "https://example.com/test-end", 418 + }, 419 + } 420 + 421 + for _, tt := range tests { 422 + t.Run(tt.name, func(t *testing.T) { 423 + r := Redirection{Type: "regex", Find: tt.find, Replace: tt.replace} 424 + got := applyRedirection(tt.url, r) 425 + if got != tt.want { 426 + t.Errorf("applyRedirection() = %q, want %q", got, tt.want) 427 + } 428 + }) 429 + } 430 + } 431 + 432 + func TestApplyRedirectionDefaultType(t *testing.T) { 433 + // Empty type should default to domain 434 + r := Redirection{Find: "reddit.com", Replace: "old.reddit.com"} 435 + got := applyRedirection("https://reddit.com/r/test", r) 436 + want := "https://old.reddit.com/r/test" 437 + if got != want { 438 + t.Errorf("applyRedirection() with empty type = %q, want %q", got, want) 439 + } 440 + 441 + // Should not match subdomain with default type 442 + got = applyRedirection("https://old.reddit.com/r/test", r) 443 + want = "https://old.reddit.com/r/test" 444 + if got != want { 445 + t.Errorf("applyRedirection() should not match subdomain = %q, want %q", got, want) 446 + } 447 + } 448 + 449 + func TestApplyRedirections(t *testing.T) { 450 + tests := []struct { 451 + name string 452 + url string 453 + redirections []Redirection 454 + want string 455 + }{ 456 + { 457 + name: "domain then wildcard redirection", 458 + url: "https://twitter.com/user?utm_source=share", 459 + redirections: []Redirection{ 460 + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, 461 + {Type: "wildcard", Find: "?utm_source=*", Replace: ""}, 462 + }, 463 + want: "https://nitter.net/user", 464 + }, 465 + { 466 + name: "empty redirections list", 467 + url: "https://example.com", 468 + redirections: []Redirection{}, 469 + want: "https://example.com", 470 + }, 471 + { 472 + name: "chained domain redirections", 473 + url: "https://x.com/user", 474 + redirections: []Redirection{ 475 + {Type: "domain", Find: "x.com", Replace: "twitter.com"}, 476 + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, 477 + }, 478 + want: "https://nitter.net/user", 479 + }, 480 + } 481 + 482 + for _, tt := range tests { 483 + t.Run(tt.name, func(t *testing.T) { 484 + got := applyRedirections(tt.url, tt.redirections) 485 + if got != tt.want { 486 + t.Errorf("applyRedirections() = %q, want %q", got, tt.want) 487 + } 488 + }) 489 + } 490 + } 491 + 492 + func TestValidateRedirection(t *testing.T) { 493 + tests := []struct { 494 + name string 495 + redirection Redirection 496 + wantErr bool 497 + }{ 498 + { 499 + name: "valid domain redirection", 500 + redirection: Redirection{Type: "domain", Find: "reddit.com", Replace: "old.reddit.com"}, 501 + wantErr: false, 502 + }, 503 + { 504 + name: "valid domain redirection default type", 505 + redirection: Redirection{Find: "twitter.com", Replace: "nitter.net"}, 506 + wantErr: false, 507 + }, 508 + { 509 + name: "valid wildcard redirection with wildcard", 510 + redirection: Redirection{Type: "wildcard", Find: "utm_*", Replace: ""}, 511 + wantErr: false, 512 + }, 513 + { 514 + name: "domain redirection with wildcard invalid", 515 + redirection: Redirection{Type: "domain", Find: "*.reddit.com", Replace: "old.reddit.com"}, 516 + wantErr: true, 517 + }, 518 + { 519 + name: "empty find wildcard", 520 + redirection: Redirection{Type: "domain", Find: "", Replace: "something"}, 521 + wantErr: true, 522 + }, 523 + { 524 + name: "empty replace is valid", 525 + redirection: Redirection{Type: "wildcard", Find: "tracking", Replace: ""}, 526 + wantErr: false, 527 + }, 528 + { 529 + name: "invalid redirection type", 530 + redirection: Redirection{Type: "invalid", Find: "test", Replace: ""}, 531 + wantErr: true, 532 + }, 533 + { 534 + name: "valid regex redirection", 535 + redirection: Redirection{Type: "regex", Find: "test.*pattern", Replace: "replaced"}, 536 + wantErr: false, 537 + }, 538 + { 539 + name: "invalid regex syntax", 540 + redirection: Redirection{Type: "regex", Find: "[invalid", Replace: "replaced"}, 541 + wantErr: true, 542 + }, 543 + { 544 + name: "regex with capture groups valid", 545 + redirection: Redirection{Type: "regex", Find: "([a-z]+)/([0-9]+)", Replace: "$2/$1"}, 546 + wantErr: false, 547 + }, 548 + { 549 + name: "wildcard without asterisk valid", 550 + redirection: Redirection{Type: "wildcard", Find: "exactmatch", Replace: "replaced"}, 551 + wantErr: false, 552 + }, 553 + { 554 + name: "domain with port invalid", 555 + redirection: Redirection{Type: "domain", Find: "example.com:8080", Replace: "example.com"}, 556 + wantErr: true, // colon is invalid in domain pattern 557 + }, 558 + { 559 + name: "domain with path invalid", 560 + redirection: Redirection{Type: "domain", Find: "example.com/path", Replace: "new.com"}, 561 + wantErr: true, 562 + }, 563 + { 564 + name: "domain with protocol invalid", 565 + redirection: Redirection{Type: "domain", Find: "https://example.com", Replace: "new.com"}, 566 + wantErr: true, 567 + }, 568 + { 569 + name: "whitespace only find invalid", 570 + redirection: Redirection{Type: "domain", Find: " ", Replace: "example.com"}, 571 + wantErr: true, 572 + }, 573 + { 574 + name: "name field does not affect validation", 575 + redirection: Redirection{Name: "My Rule", Type: "domain", Find: "example.com", Replace: "new.com"}, 576 + wantErr: false, 577 + }, 578 + } 579 + 580 + for _, tt := range tests { 581 + t.Run(tt.name, func(t *testing.T) { 582 + err := validateRedirection(tt.redirection) 583 + if (err != nil) != tt.wantErr { 584 + t.Errorf("validateRedirection() error = %v, wantErr %v", err, tt.wantErr) 585 + } 586 + }) 587 + } 588 + } 589 + 590 + // TestRealWorldRedirections tests common real-world URL transformations 591 + func TestRealWorldRedirections(t *testing.T) { 592 + tests := []struct { 593 + name string 594 + url string 595 + redirections []Redirection 596 + want string 597 + }{ 598 + { 599 + name: "twitter/x.com to nitter", 600 + url: "https://x.com/user/status/123456", 601 + redirections: []Redirection{ 602 + {Type: "domain", Find: "x.com", Replace: "nitter.net"}, 603 + }, 604 + want: "https://nitter.net/user/status/123456", 605 + }, 606 + { 607 + name: "reddit to old reddit", 608 + url: "https://reddit.com/r/programming/comments/abc123", 609 + redirections: []Redirection{ 610 + {Type: "domain", Find: "reddit.com", Replace: "old.reddit.com"}, 611 + }, 612 + want: "https://old.reddit.com/r/programming/comments/abc123", 613 + }, 614 + { 615 + name: "remove facebook tracking params", 616 + url: "https://example.com/article?fbclid=ABC123&utm_source=facebook", 617 + redirections: []Redirection{ 618 + // Use regex for more reliable param removal 619 + {Type: "regex", Find: "[?&]fbclid=[^&]*", Replace: ""}, 620 + {Type: "regex", Find: "[?&]utm_source=[^&]*", Replace: ""}, 621 + }, 622 + want: "https://example.com/article", 623 + }, 624 + { 625 + name: "clean amazon product URL", 626 + url: "https://www.amazon.com/dp/B0DZD91W4F/?tag=affiliate-20&linkCode=xyz&ref=abc", 627 + redirections: []Redirection{ 628 + {Type: "regex", Find: "(amazon\\.[a-z.]+/dp/[A-Z0-9]+).*", Replace: "$1"}, 629 + }, 630 + want: "https://www.amazon.com/dp/B0DZD91W4F", 631 + }, 632 + { 633 + name: "google to kagi search", 634 + url: "https://www.google.com/search?q=test+search&sourceid=chrome&ie=UTF-8", 635 + redirections: []Redirection{ 636 + {Type: "regex", Find: "google\\.com/search\\?(.*)q=([^&]+)(.*)", Replace: "kagi.com/search?q=$2"}, 637 + }, 638 + want: "https://www.kagi.com/search?q=test+search", 639 + }, 640 + { 641 + name: "youtube mobile to desktop", 642 + url: "https://m.youtube.com/watch?v=abc123", 643 + redirections: []Redirection{ 644 + {Type: "domain", Find: "m.youtube.com", Replace: "youtube.com"}, 645 + }, 646 + want: "https://youtube.com/watch?v=abc123", 647 + }, 648 + { 649 + name: "instagram to bibliogram", 650 + url: "https://www.instagram.com/p/ABC123/", 651 + redirections: []Redirection{ 652 + {Type: "domain", Find: "www.instagram.com", Replace: "bibliogram.art"}, 653 + {Type: "domain", Find: "instagram.com", Replace: "bibliogram.art"}, 654 + }, 655 + want: "https://bibliogram.art/p/ABC123/", 656 + }, 657 + { 658 + name: "medium to scribe", 659 + url: "https://medium.com/@user/article-title-123abc", 660 + redirections: []Redirection{ 661 + {Type: "domain", Find: "medium.com", Replace: "scribe.rip"}, 662 + }, 663 + want: "https://scribe.rip/@user/article-title-123abc", 664 + }, 665 + { 666 + name: "multiple tracking params removal", 667 + url: "https://example.com/page?id=1&utm_source=twitter&utm_medium=social&utm_campaign=test&ref=abc", 668 + redirections: []Redirection{ 669 + {Type: "regex", Find: "[?&]utm_[a-z_]+=[^&]*", Replace: ""}, 670 + {Type: "regex", Find: "[?&]ref=[^&]*", Replace: ""}, 671 + }, 672 + want: "https://example.com/page?id=1", 673 + }, 674 + { 675 + name: "chained redirections - domain then cleanup", 676 + url: "https://twitter.com/user?utm_source=share&s=20", 677 + redirections: []Redirection{ 678 + {Type: "domain", Find: "twitter.com", Replace: "nitter.net"}, 679 + {Type: "wildcard", Find: "?utm_source=*", Replace: ""}, 680 + {Type: "wildcard", Find: "&s=*", Replace: ""}, 681 + }, 682 + want: "https://nitter.net/user", 683 + }, 684 + { 685 + name: "tiktok tracking removal", 686 + url: "https://www.tiktok.com/@user/video/123456?is_from_webapp=1&sender_device=pc", 687 + redirections: []Redirection{ 688 + {Type: "regex", Find: "\\?.*", Replace: ""}, 689 + }, 690 + want: "https://www.tiktok.com/@user/video/123456", 691 + }, 692 + { 693 + name: "amp link cleanup", 694 + url: "https://www.google.com/amp/s/example.com/article", 695 + redirections: []Redirection{ 696 + {Type: "regex", Find: "google\\.com/amp/s/", Replace: ""}, 697 + }, 698 + want: "https://www.example.com/article", 699 + }, 700 + } 701 + 702 + for _, tt := range tests { 703 + t.Run(tt.name, func(t *testing.T) { 704 + got := applyRedirections(tt.url, tt.redirections) 705 + if got != tt.want { 706 + t.Errorf("applyRedirections() = %q, want %q", got, tt.want) 707 + } 708 + }) 709 + } 710 + }
+181
src/settings_redirections.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "fmt" 7 + "html" 8 + 9 + "github.com/diamondburned/gotk4-adwaita/pkg/adw" 10 + "github.com/diamondburned/gotk4/pkg/gtk/v4" 11 + ) 12 + 13 + func formatRedirectionSubtitle(r *Redirection) string { 14 + rwType := r.Type 15 + if rwType == "" { 16 + rwType = "domain" 17 + } 18 + 19 + var typeLabel string 20 + switch rwType { 21 + case "domain": 22 + typeLabel = "Domain" 23 + case "wildcard": 24 + typeLabel = "Wildcard" 25 + case "regex": 26 + typeLabel = "Regex" 27 + default: 28 + typeLabel = "Domain" 29 + } 30 + 31 + if r.Replace == "" { 32 + return fmt.Sprintf("%s · Removes match", typeLabel) 33 + } 34 + return fmt.Sprintf("%s · Replaces with %s", typeLabel, html.EscapeString(r.Replace)) 35 + } 36 + 37 + func createRedirectionsPage(win *adw.Window, cfg *Config) gtk.Widgetter { 38 + toolbarView := adw.NewToolbarView() 39 + 40 + header := adw.NewHeaderBar() 41 + header.SetShowEndTitleButtons(true) 42 + titleLabel := gtk.NewLabel("Link Redirections") 43 + titleLabel.AddCSSClass("title") 44 + header.SetTitleWidget(titleLabel) 45 + 46 + // Add button in header 47 + addButton := gtk.NewButton() 48 + addButton.SetIconName("list-add-symbolic") 49 + addButton.SetTooltipText("Add Redirection") 50 + addButton.SetHasFrame(false) 51 + header.PackEnd(addButton) 52 + 53 + toolbarView.AddTopBar(header) 54 + 55 + scrolled := createScrolledWindow() 56 + 57 + content := gtk.NewBox(gtk.OrientationVertical, 12) 58 + content.SetMarginStart(12) 59 + content.SetMarginEnd(12) 60 + content.SetMarginTop(12) 61 + content.SetMarginBottom(12) 62 + 63 + // Info banner 64 + infoLabel := gtk.NewLabel("Redirections modify links before rules are applied.") 65 + infoLabel.SetWrap(true) 66 + infoLabel.SetXAlign(0) 67 + infoLabel.AddCSSClass("dim-label") 68 + infoLabel.SetMarginStart(12) 69 + infoLabel.SetMarginEnd(12) 70 + infoLabel.SetMarginBottom(6) 71 + content.Append(infoLabel) 72 + 73 + redirectionsListBox := createBoxedListBox() 74 + emptyState := createEmptyState("edit-find-replace-symbolic", "No Redirections", "Change links before they open in a browser") 75 + 76 + var rebuildRedirectionsList func() 77 + 78 + createRedirectionRow := func(redirectionIndex int) *adw.ActionRow { 79 + redirection := &cfg.Redirections[redirectionIndex] 80 + 81 + row := adw.NewActionRow() 82 + if redirection.Name != "" { 83 + row.SetTitle(redirection.Name) 84 + row.SetSubtitle(fmt.Sprintf("%s · %s", html.EscapeString(redirection.Find), formatRedirectionSubtitle(redirection))) 85 + } else { 86 + row.SetTitle(redirection.Find) 87 + row.SetSubtitle(formatRedirectionSubtitle(redirection)) 88 + } 89 + row.SetActivatable(true) 90 + 91 + icon := gtk.NewImageFromIconName("edit-find-replace-symbolic") 92 + icon.SetPixelSize(24) 93 + row.AddPrefix(icon) 94 + 95 + reorderBox := gtk.NewBox(gtk.OrientationHorizontal, 0) 96 + reorderBox.SetVAlign(gtk.AlignCenter) 97 + 98 + upBtn := gtk.NewButton() 99 + upBtn.SetIconName("go-up-symbolic") 100 + upBtn.AddCSSClass("flat") 101 + upBtn.SetSensitive(redirectionIndex > 0) 102 + upBtn.SetTooltipText("Move up") 103 + upBtn.ConnectClicked(func() { 104 + if redirectionIndex > 0 { 105 + cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex-1] = cfg.Redirections[redirectionIndex-1], cfg.Redirections[redirectionIndex] 106 + saveConfig(cfg) 107 + rebuildRedirectionsList() 108 + } 109 + }) 110 + reorderBox.Append(upBtn) 111 + 112 + downBtn := gtk.NewButton() 113 + downBtn.SetIconName("go-down-symbolic") 114 + downBtn.AddCSSClass("flat") 115 + downBtn.SetSensitive(redirectionIndex < len(cfg.Redirections)-1) 116 + downBtn.SetTooltipText("Move down") 117 + downBtn.ConnectClicked(func() { 118 + if redirectionIndex < len(cfg.Redirections)-1 { 119 + cfg.Redirections[redirectionIndex], cfg.Redirections[redirectionIndex+1] = cfg.Redirections[redirectionIndex+1], cfg.Redirections[redirectionIndex] 120 + saveConfig(cfg) 121 + rebuildRedirectionsList() 122 + } 123 + }) 124 + reorderBox.Append(downBtn) 125 + 126 + row.AddSuffix(reorderBox) 127 + 128 + // Delete button 129 + deleteBtn := gtk.NewButton() 130 + deleteBtn.SetIconName("edit-delete-symbolic") 131 + deleteBtn.AddCSSClass("flat") 132 + deleteBtn.AddCSSClass("destructive-action") 133 + deleteBtn.SetTooltipText("Remove") 134 + deleteBtn.ConnectClicked(func() { 135 + cfg.Redirections = append(cfg.Redirections[:redirectionIndex], cfg.Redirections[redirectionIndex+1:]...) 136 + saveConfig(cfg) 137 + rebuildRedirectionsList() 138 + }) 139 + row.AddSuffix(deleteBtn) 140 + 141 + // Edit on click 142 + row.ConnectActivated(func() { 143 + showEditRedirectionDialog(win, cfg, redirection, rebuildRedirectionsList) 144 + }) 145 + 146 + return row 147 + } 148 + 149 + rebuildRedirectionsList = func() { 150 + clearListBox(redirectionsListBox) 151 + 152 + // handle empty state 153 + if len(cfg.Redirections) == 0 { 154 + infoLabel.SetVisible(false) 155 + redirectionsListBox.SetVisible(false) 156 + emptyState.SetVisible(true) 157 + } else { 158 + infoLabel.SetVisible(true) 159 + redirectionsListBox.SetVisible(true) 160 + emptyState.SetVisible(false) 161 + 162 + for i := range cfg.Redirections { 163 + row := createRedirectionRow(i) 164 + redirectionsListBox.Append(row) 165 + } 166 + } 167 + } 168 + 169 + rebuildRedirectionsList() 170 + 171 + content.Append(redirectionsListBox) 172 + content.Append(emptyState) 173 + scrolled.SetChild(content) 174 + toolbarView.SetContent(scrolled) 175 + 176 + addButton.ConnectClicked(func() { 177 + showAddRedirectionDialog(win, cfg, rebuildRedirectionsList) 178 + }) 179 + 180 + return toolbarView 181 + }
+7 -26
src/settings_rules.go
··· 80 80 81 81 header := adw.NewHeaderBar() 82 82 header.SetShowEndTitleButtons(true) 83 - titleLabel := gtk.NewLabel("Rules") 83 + titleLabel := gtk.NewLabel("Browser Rules") 84 84 titleLabel.AddCSSClass("title") 85 85 header.SetTitleWidget(titleLabel) 86 86 87 87 // Add Rule button in header 88 88 addButton := gtk.NewButton() 89 89 addButton.SetIconName("list-add-symbolic") 90 - addButton.SetTooltipText("Add New Rule") 90 + addButton.SetTooltipText("Add Rule") 91 91 addButton.SetHasFrame(false) 92 92 header.PackEnd(addButton) 93 93 94 94 toolbarView.AddTopBar(header) 95 95 96 - // Scrolled window for rules list 97 - scrolled := gtk.NewScrolledWindow() 98 - scrolled.SetVExpand(true) 99 - scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 96 + scrolled := createScrolledWindow() 100 97 101 98 content := gtk.NewBox(gtk.OrientationVertical, 12) 102 99 content.SetMarginStart(12) ··· 105 102 content.SetMarginBottom(12) 106 103 107 104 // Info banner 108 - infoLabel := gtk.NewLabel("Rules are evaluated in order. First match wins.") 105 + infoLabel := gtk.NewLabel("Rules route links to browsers. First match wins.") 109 106 infoLabel.SetWrap(true) 110 107 infoLabel.SetXAlign(0) 111 108 infoLabel.AddCSSClass("dim-label") ··· 114 111 infoLabel.SetMarginBottom(6) 115 112 content.Append(infoLabel) 116 113 117 - // Rules list 118 - rulesListBox := gtk.NewListBox() 119 - rulesListBox.SetSelectionMode(gtk.SelectionNone) 120 - rulesListBox.AddCSSClass("boxed-list") 121 - 122 - // Empty state 123 - emptyState := adw.NewStatusPage() 124 - emptyState.SetIconName("list-add-symbolic") 125 - emptyState.SetTitle("No Rules") 126 - emptyState.SetDescription("Add rules to automatically route URLs to specific browsers") 127 - emptyState.SetVExpand(true) 114 + rulesListBox := createBoxedListBox() 115 + emptyState := createEmptyState("list-add-symbolic", "No Browser Rules", "Add rules to automatically open links in specific browsers") 128 116 129 117 // Helper to get browser name from ID 130 118 getBrowserName := func(id string) string { ··· 227 215 } 228 216 229 217 rebuildRulesList = func() { 230 - // Remove all children 231 - for { 232 - child := rulesListBox.FirstChild() 233 - if child == nil { 234 - break 235 - } 236 - rulesListBox.Remove(child) 237 - } 218 + clearListBox(rulesListBox) 238 219 239 220 // Show/hide empty state vs rules list 240 221 if len(cfg.Rules) == 0 {
+37
src/ui_helpers.go
··· 96 96 return toolbarView, content, header 97 97 } 98 98 99 + // createScrolledWindow creates a standard scrolled window for list content. 100 + func createScrolledWindow() *gtk.ScrolledWindow { 101 + scrolled := gtk.NewScrolledWindow() 102 + scrolled.SetVExpand(true) 103 + scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) 104 + return scrolled 105 + } 106 + 107 + // createBoxedListBox creates a ListBox with standard boxed-list styling. 108 + func createBoxedListBox() *gtk.ListBox { 109 + listBox := gtk.NewListBox() 110 + listBox.SetSelectionMode(gtk.SelectionNone) 111 + listBox.AddCSSClass("boxed-list") 112 + return listBox 113 + } 114 + 115 + // createEmptyState creates a status page for empty list states. 116 + func createEmptyState(iconName, title, description string) *adw.StatusPage { 117 + emptyState := adw.NewStatusPage() 118 + emptyState.SetIconName(iconName) 119 + emptyState.SetTitle(title) 120 + emptyState.SetDescription(description) 121 + emptyState.SetVExpand(true) 122 + return emptyState 123 + } 124 + 125 + // clearListBox removes all children from a ListBox. 126 + func clearListBox(listBox *gtk.ListBox) { 127 + for { 128 + child := listBox.FirstChild() 129 + if child == nil { 130 + break 131 + } 132 + listBox.Remove(child) 133 + } 134 + } 135 + 99 136 // configFileFilters creates the standard file filter list for config import/export. 100 137 func configFileFilters() *gio.ListStore { 101 138 tomlFilter := gtk.NewFileFilter()
+41
src/validation.go
··· 145 145 } 146 146 return true 147 147 } 148 + 149 + func validateRedirection(r Redirection) error { 150 + if r.Find == "" { 151 + return fmt.Errorf("Find pattern cannot be empty") 152 + } 153 + 154 + rwType := r.Type 155 + if rwType == "" { 156 + rwType = "domain" 157 + } 158 + 159 + switch rwType { 160 + case "domain": 161 + return validateDomainPattern(r.Find) 162 + case "wildcard": 163 + pattern := wildcardToRegex(r.Find) 164 + if _, err := regexp.Compile("(?i)" + pattern); err != nil { 165 + return fmt.Errorf("Invalid pattern: %w", err) 166 + } 167 + case "regex": 168 + if _, err := regexp.Compile(r.Find); err != nil { 169 + return fmt.Errorf("Invalid regex: %w", err) 170 + } 171 + default: 172 + return fmt.Errorf("Invalid redirection type: %s", rwType) 173 + } 174 + return nil 175 + } 176 + 177 + func isRedirectionValid(r Redirection) bool { 178 + return validateRedirection(r) == nil 179 + } 180 + 181 + func areAllRedirectionsValid(redirections []Redirection) bool { 182 + for _, r := range redirections { 183 + if !isRedirectionValid(r) { 184 + return false 185 + } 186 + } 187 + return true 188 + }
+18 -3
src/window_settings.go
··· 117 117 behaviorRow.AddPrefix(gtk.NewImageFromIconName("preferences-system-symbolic")) 118 118 listBox.Append(behaviorRow) 119 119 120 + redirectionsRow := adw.NewActionRow() 121 + redirectionsRow.SetTitle("Link Redirections") 122 + redirectionsRow.AddPrefix(gtk.NewImageFromIconName("edit-find-replace-symbolic")) 123 + listBox.Append(redirectionsRow) 124 + 120 125 rulesRow := adw.NewActionRow() 121 - rulesRow.SetTitle("Rules") 126 + rulesRow.SetTitle("Browser Rules") 122 127 rulesRow.AddPrefix(gtk.NewImageFromIconName("view-list-symbolic")) 123 128 listBox.Append(rulesRow) 124 129 ··· 127 132 advancedRow.AddPrefix(gtk.NewImageFromIconName("preferences-other-symbolic")) 128 133 listBox.Append(advancedRow) 129 134 135 + listBox.SetHeaderFunc(func(row, before *gtk.ListBoxRow) { 136 + if row.Index() == 2 || row.Index() == 4 { 137 + row.SetHeader(gtk.NewSeparator(gtk.OrientationHorizontal)) 138 + } 139 + }) 140 + 130 141 scrolled.SetChild(listBox) 131 142 toolbarView.SetContent(scrolled) 132 143 ··· 145 156 page = createBehaviorPage(win, browsers, cfg) 146 157 title = "Behavior" 147 158 case 2: 159 + page = createRedirectionsPage(win, cfg) 160 + title = "Link Redirections" 161 + case 3: 148 162 page = createRulesPage(win, browsers, cfg) 149 - title = "Rules" 150 - case 3: 163 + title = "Browser Rules" 164 + case 4: 151 165 page = createAdvancedPage(win, cfg) 152 166 title = "Advanced" 153 167 } ··· 193 207 cfg.ShowAppNames = newCfg.ShowAppNames 194 208 cfg.ForceDarkMode = newCfg.ForceDarkMode 195 209 cfg.HiddenBrowsers = newCfg.HiddenBrowsers 210 + cfg.Redirections = newCfg.Redirections 196 211 cfg.Rules = newCfg.Rules 197 212 198 213 if onChange != nil {