1package cli
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11
12 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/db"
14 "github.com/spf13/cobra"
15)
16
17// toTitle capitalizes the first letter of the string
18func toTitle(s string) string {
19 if len(s) == 0 {
20 return s
21 }
22 runes := []rune(s)
23 runes[0] = unicode.ToUpper(runes[0])
24 return string(runes)
25}
26
27// PurgeFlags holds all the purge command flags
28type PurgeFlags struct {
29 Database bool
30 HistoryCache bool
31 BrowserCache bool
32 BrowserData bool
33 FilterCache bool
34 State bool
35 Config bool
36 All bool
37 Force bool
38}
39
40// NewPurgeCmd creates the purge command
41func NewPurgeCmd() *cobra.Command {
42 var flags PurgeFlags
43
44 cmd := &cobra.Command{
45 Use: "purge",
46 Short: "Purge dumber data and cache files",
47 Long: `Purge various dumber data and cache files. By default, purges everything.
48
49Available purge targets:
50 --database, -d Purge SQLite databases (dumber.sqlite and cookies.db)
51 Includes: history, zoom levels, certificates, cookies, favorites
52 --history-cache, -H Purge browsing history from database and dmenu fuzzy search cache
53 (binary file). Keeps cookies, zoom levels, and other database data.
54 --browser-cache, -b Purge WebKit browser cache (cached images, files, etc.)
55 --browser-data, -B Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)
56 Note: Cookies are in cookies.db and deleted via --database
57 --filter-cache, -F Purge compiled ad blocking filters cache
58 --state, -s Purge all state data (includes databases and caches)
59 --config, -c Purge configuration files
60 --all, -a Purge everything (default if no specific flags are provided)
61
62Use --force to skip the confirmation prompt.
63
64Database Storage:
65 Cookies are stored in cookies.db (separate from dumber.sqlite due to WebKit requirements).
66 Use --database to delete both dumber.sqlite and cookies.db.
67 The --browser-data flag only affects localStorage and other WebKit-managed data.
68
69Examples:
70 dumber purge # Purge everything (with confirmation)
71 dumber purge --force # Purge everything (no confirmation)
72 dumber purge -d -H -b -F # Purge database and all caches
73 dumber purge --filter-cache -f # Force purge filter cache only
74 dumber purge -d # Purge database (includes cookies)
75 dumber purge -H # Purge only browsing history (keep cookies)
76 dumber purge -c # Purge config file only`,
77 RunE: func(_ *cobra.Command, _ []string) error {
78 return executePurge(flags)
79 },
80 }
81
82 // Add flags
83 cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge SQLite databases (dumber.sqlite and cookies.db)")
84 cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge browsing history and dmenu fuzzy cache")
85 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "b", false, "Purge WebKit browser cache")
86 cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "B", false, "Purge WebKit browser data (localStorage, sessionStorage)")
87 cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")
88 cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data (databases and caches)")
89 cmd.Flags().BoolVarP(&flags.Config, "config", "c", false, "Purge configuration files")
90 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
91 cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")
92
93 return cmd
94}
95
96// executePurge handles the purge logic
97func executePurge(flags PurgeFlags) error {
98 // Determine what to purge
99 purgeItems := determinePurgeItems(flags)
100 if len(purgeItems) == 0 {
101 fmt.Println("Nothing to purge.")
102 return nil
103 }
104
105 // Get file paths for each item
106 paths, err := getPurgePaths(purgeItems)
107 if err != nil {
108 return fmt.Errorf("failed to get purge paths: %w", err)
109 }
110
111 // Show confirmation unless --force is used
112 if !flags.Force {
113 if !confirmPurge(purgeItems, paths) {
114 fmt.Println("Purge cancelled.")
115 return nil
116 }
117 }
118
119 // Execute the purge
120 return performPurge(purgeItems, paths)
121}
122
123// determinePurgeItems determines what should be purged based on flags
124func determinePurgeItems(flags PurgeFlags) []string {
125 var items []string
126
127 // If no specific flags are set, or --all is set, purge everything
128 if flags.All || (!flags.Database && !flags.HistoryCache && !flags.BrowserCache && !flags.BrowserData && !flags.FilterCache && !flags.State && !flags.Config) {
129 return []string{"database", "history-cache", "browser-cache", "browser-data", "filter-cache", "config"}
130 }
131
132 // Add items based on flags
133 if flags.Database {
134 items = append(items, "database")
135 }
136 if flags.HistoryCache {
137 items = append(items, "history-cache")
138 }
139 if flags.BrowserCache {
140 items = append(items, "browser-cache")
141 }
142 if flags.BrowserData {
143 items = append(items, "browser-data")
144 }
145 if flags.FilterCache {
146 items = append(items, "filter-cache")
147 }
148 if flags.State {
149 // State includes database and all caches
150 items = append(items, "database", "history-cache", "browser-cache", "filter-cache")
151 }
152 if flags.Config {
153 items = append(items, "config")
154 }
155
156 // Remove duplicates
157 seen := make(map[string]bool)
158 var result []string
159 for _, item := range items {
160 if !seen[item] {
161 seen[item] = true
162 result = append(result, item)
163 }
164 }
165
166 return result
167}
168
169// getPurgePaths gets the file/directory paths for each purge item
170func getPurgePaths(items []string) (map[string][]string, error) {
171 paths := make(map[string][]string)
172
173 for _, item := range items {
174 switch item {
175 case "database":
176 // Get main database path
177 dbPath, err := config.GetDatabaseFile()
178 if err != nil {
179 return nil, fmt.Errorf("failed to get database path: %w", err)
180 }
181
182 // Get data dir for cookies.db
183 dataDir, err := config.GetDataDir()
184 if err != nil {
185 return nil, fmt.Errorf("failed to get data directory: %w", err)
186 }
187 cookiesPath := filepath.Join(dataDir, "cookies.db")
188
189 paths[item] = []string{dbPath, cookiesPath}
190
191 case "history-cache":
192 stateDir, err := config.GetStateDir()
193 if err != nil {
194 return nil, fmt.Errorf("failed to get state directory: %w", err)
195 }
196 dmenuCache := filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
197 paths[item] = []string{dmenuCache}
198
199 case "browser-cache":
200 stateDir, err := config.GetStateDir()
201 if err != nil {
202 return nil, fmt.Errorf("failed to get state directory: %w", err)
203 }
204 webkitCache := filepath.Join(stateDir, "webkit-cache")
205 paths[item] = []string{webkitCache}
206
207 case "browser-data":
208 dataDir, err := config.GetDataDir()
209 if err != nil {
210 return nil, fmt.Errorf("failed to get data directory: %w", err)
211 }
212 webkitData := filepath.Join(dataDir, "webkit")
213 paths[item] = []string{webkitData}
214
215 case "filter-cache":
216 filterCacheDir, err := config.GetFilterCacheDir()
217 if err != nil {
218 return nil, fmt.Errorf("failed to get filter cache directory: %w", err)
219 }
220 paths[item] = []string{filterCacheDir}
221
222 case "config":
223 configFile, err := config.GetConfigFile()
224 if err != nil {
225 return nil, fmt.Errorf("failed to get config file path: %w", err)
226 }
227 paths[item] = []string{configFile}
228 }
229 }
230
231 return paths, nil
232}
233
234// confirmPurge shows a confirmation prompt and returns true if user confirms
235func confirmPurge(items []string, paths map[string][]string) bool {
236 fmt.Printf("This will delete the following:\n\n")
237
238 for _, item := range items {
239 fmt.Printf("• %s:\n", toTitle(item))
240
241 // Special message for history-cache
242 if item == "history-cache" {
243 fmt.Printf(" - All browsing history from database\n")
244 }
245
246 for _, path := range paths[item] {
247 // Check if path exists
248 if _, err := os.Stat(path); err == nil {
249 fmt.Printf(" - %s\n", path)
250 } else {
251 fmt.Printf(" - %s (not found)\n", path)
252 }
253 }
254 }
255
256 fmt.Printf("\nAre you sure you want to continue? (y/N): ")
257
258 scanner := bufio.NewScanner(os.Stdin)
259 if scanner.Scan() {
260 response := strings.TrimSpace(strings.ToLower(scanner.Text()))
261 return response == "y" || response == "yes"
262 }
263
264 return false
265}
266
267// performPurge executes the actual deletion
268func performPurge(items []string, paths map[string][]string) error {
269 var errors []string
270
271 for _, item := range items {
272 fmt.Printf("Purging %s...\n", item)
273
274 // Special handling for history-cache: delete from database first
275 if item == "history-cache" {
276 if err := purgeHistoryFromDB(); err != nil {
277 errors = append(errors, fmt.Sprintf("history table: %v", err))
278 fmt.Printf(" ✗ Failed to purge history from database: %v\n", err)
279 } else {
280 fmt.Printf(" ✓ Purged history from database\n")
281 }
282 }
283
284 // Then proceed with file/directory deletions
285 for _, path := range paths[item] {
286 if err := deletePath(path); err != nil {
287 errors = append(errors, fmt.Sprintf("%s: %v", path, err))
288 fmt.Printf(" ✗ Failed to delete %s: %v\n", path, err)
289 } else {
290 fmt.Printf(" ✓ Deleted %s\n", path)
291 }
292 }
293 }
294
295 if len(errors) > 0 {
296 return fmt.Errorf("some items could not be deleted:\n%s", strings.Join(errors, "\n"))
297 }
298
299 fmt.Println("\nPurge completed successfully!")
300 return nil
301}
302
303// purgeHistoryFromDB deletes all history entries from the database
304func purgeHistoryFromDB() error {
305 dbPath, err := config.GetDatabaseFile()
306 if err != nil {
307 return fmt.Errorf("failed to get database path: %w", err)
308 }
309
310 // Check if database exists
311 if _, err := os.Stat(dbPath); os.IsNotExist(err) {
312 // Database doesn't exist, nothing to purge
313 return nil
314 }
315
316 // Initialize database connection with proper configuration
317 database, err := db.InitDB(dbPath)
318 if err != nil {
319 return fmt.Errorf("failed to initialize database: %w", err)
320 }
321 defer database.Close()
322
323 // Create queries instance
324 queries := db.New(database)
325
326 // Execute delete all history
327 ctx := context.Background()
328 if err := queries.DeleteAllHistory(ctx); err != nil {
329 return fmt.Errorf("failed to delete history: %w", err)
330 }
331
332 return nil
333}
334
335// deletePath deletes a file or directory
336func deletePath(path string) error {
337 // Check if path exists
338 info, err := os.Stat(path)
339 if os.IsNotExist(err) {
340 // Path doesn't exist, consider it successful
341 return nil
342 }
343 if err != nil {
344 return fmt.Errorf("failed to check path: %w", err)
345 }
346
347 // Delete based on type
348 if info.IsDir() {
349 return os.RemoveAll(path)
350 }
351 return os.Remove(path)
352}