From 7793308f27767cb14f068bd9ff61c80ab8521115 Mon Sep 17 00:00:00 2001 From: qianlifeng Date: Sun, 13 Jul 2025 00:55:36 +0800 Subject: [PATCH] feat(bookmark): add support for loading Edge bookmarks across platforms #4232 * Implemented loading of Edge bookmarks for macOS, Windows, and Linux. * Added functionality to remove duplicate bookmarks based on name and URL. * Enhanced the bookmark loading process with a more robust regex pattern for both Chrome and Edge formats. * Created unit tests to verify the loading and deduplication of bookmarks. --- wox.core/plugin/system/browser_bookmark.go | 137 ++++++++- .../plugin/system/browser_bookmark_test.go | 272 ++++++++++++++++++ 2 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 wox.core/plugin/system/browser_bookmark_test.go diff --git a/wox.core/plugin/system/browser_bookmark.go b/wox.core/plugin/system/browser_bookmark.go index 26d44240..cb7594f1 100644 --- a/wox.core/plugin/system/browser_bookmark.go +++ b/wox.core/plugin/system/browser_bookmark.go @@ -55,13 +55,48 @@ func (c *BrowserBookmarkPlugin) GetMetadata() plugin.Metadata { func (c *BrowserBookmarkPlugin) Init(ctx context.Context, initParams plugin.InitParams) { c.api = initParams.API + profiles := []string{"Default", "Profile 1", "Profile 2", "Profile 3"} + if util.IsMacOS() { - profiles := []string{"Default", "Profile 1", "Profile 2", "Profile 3"} + // Load Chrome bookmarks for _, profile := range profiles { chromeBookmarks := c.loadChromeBookmarkInMacos(ctx, profile) c.bookmarks = append(c.bookmarks, chromeBookmarks...) } + + // Load Edge bookmarks + for _, profile := range profiles { + edgeBookmarks := c.loadEdgeBookmarkInMacos(ctx, profile) + c.bookmarks = append(c.bookmarks, edgeBookmarks...) + } + } else if util.IsWindows() { + // Load Chrome bookmarks + for _, profile := range profiles { + chromeBookmarks := c.loadChromeBookmarkInWindows(ctx, profile) + c.bookmarks = append(c.bookmarks, chromeBookmarks...) + } + + // Load Edge bookmarks + for _, profile := range profiles { + edgeBookmarks := c.loadEdgeBookmarkInWindows(ctx, profile) + c.bookmarks = append(c.bookmarks, edgeBookmarks...) + } + } else if util.IsLinux() { + // Load Chrome bookmarks + for _, profile := range profiles { + chromeBookmarks := c.loadChromeBookmarkInLinux(ctx, profile) + c.bookmarks = append(c.bookmarks, chromeBookmarks...) + } + + // Load Edge bookmarks + for _, profile := range profiles { + edgeBookmarks := c.loadEdgeBookmarkInLinux(ctx, profile) + c.bookmarks = append(c.bookmarks, edgeBookmarks...) + } } + + // Remove duplicate bookmarks (same name and url) + c.bookmarks = c.removeDuplicateBookmarks(c.bookmarks) } func (c *BrowserBookmarkPlugin) Query(ctx context.Context, query plugin.Query) (results []plugin.QueryResult) { @@ -107,24 +142,96 @@ func (c *BrowserBookmarkPlugin) Query(ctx context.Context, query plugin.Query) ( return } -func (c *BrowserBookmarkPlugin) loadChromeBookmarkInMacos(ctx context.Context, profile string) (results []Bookmark) { - bookmarkLocation, _ := homedir.Expand(fmt.Sprintf("~/Library/Application Support/Google/Chrome/%s/Bookmarks", profile)) - if _, err := os.Stat(bookmarkLocation); os.IsNotExist(err) { - return - } - file, readErr := os.ReadFile(bookmarkLocation) - if readErr != nil { - c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error reading chrome bookmark file: %s", readErr.Error())) - return +func (c *BrowserBookmarkPlugin) loadChromeBookmarkInMacos(ctx context.Context, profile string) []Bookmark { + return c.loadBookmarkFromFile(ctx, fmt.Sprintf("~/Library/Application Support/Google/Chrome/%s/Bookmarks", profile), "Chrome") +} + +func (c *BrowserBookmarkPlugin) loadChromeBookmarkInWindows(ctx context.Context, profile string) []Bookmark { + // Use a different approach to avoid fmt.Sprintf converting %% to % + path := "%%LOCALAPPDATA%%\\Google\\Chrome\\User Data\\" + profile + "\\Bookmarks" + return c.loadBookmarkFromFile(ctx, path, "Chrome") +} + +func (c *BrowserBookmarkPlugin) loadChromeBookmarkInLinux(ctx context.Context, profile string) []Bookmark { + return c.loadBookmarkFromFile(ctx, fmt.Sprintf("~/.config/google-chrome/%s/Bookmarks", profile), "Chrome") +} + +func (c *BrowserBookmarkPlugin) loadEdgeBookmarkInMacos(ctx context.Context, profile string) []Bookmark { + return c.loadBookmarkFromFile(ctx, fmt.Sprintf("~/Library/Application Support/Microsoft Edge/%s/Bookmarks", profile), "Edge") +} + +func (c *BrowserBookmarkPlugin) loadEdgeBookmarkInWindows(ctx context.Context, profile string) []Bookmark { + // Use a different approach to avoid fmt.Sprintf converting %% to % + path := "%%LOCALAPPDATA%%\\Microsoft\\Edge\\User Data\\" + profile + "\\Bookmarks" + return c.loadBookmarkFromFile(ctx, path, "Edge") +} + +func (c *BrowserBookmarkPlugin) loadEdgeBookmarkInLinux(ctx context.Context, profile string) []Bookmark { + return c.loadBookmarkFromFile(ctx, fmt.Sprintf("~/.config/microsoft-edge/%s/Bookmarks", profile), "Edge") +} + +func (c *BrowserBookmarkPlugin) loadBookmarkFromFile(ctx context.Context, bookmarkPath string, browserName string) []Bookmark { + var bookmarkLocation string + var err error + + if strings.Contains(bookmarkPath, "%%LOCALAPPDATA%%") { + // Windows path with environment variable + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return []Bookmark{} + } + bookmarkLocation = strings.Replace(bookmarkPath, "%%LOCALAPPDATA%%", localAppData, 1) + } else { + // Unix-style path + bookmarkLocation, err = homedir.Expand(bookmarkPath) + if err != nil { + c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error expanding %s bookmark path: %s", browserName, err.Error())) + return []Bookmark{} + } } - groups := util.FindRegexGroups(`(?ms)name": "(?P.*?)",.*?type": "url",.*?"url": "(?P.*?)".*?}, {`, string(file)) + if _, err := os.Stat(bookmarkLocation); os.IsNotExist(err) { + return []Bookmark{} + } + + file, readErr := os.ReadFile(bookmarkLocation) + if readErr != nil { + c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("error reading %s bookmark file: %s", browserName, readErr.Error())) + return []Bookmark{} + } + + // Use a more robust regex pattern that works for both Chrome and Edge bookmark formats + var results []Bookmark + groups := util.FindRegexGroups(`(?ms)"name": "(?P[^"]*)",.*?"type": "url",.*?"url": "(?P[^"]*)"`, string(file)) + for _, group := range groups { - results = append(results, Bookmark{ - Name: group["name"], - Url: group["url"], - }) + if name, nameOk := group["name"]; nameOk { + if url, urlOk := group["url"]; urlOk { + results = append(results, Bookmark{ + Name: name, + Url: url, + }) + } + } } return results } + +// removeDuplicateBookmarks removes duplicate bookmarks based on name and url +func (c *BrowserBookmarkPlugin) removeDuplicateBookmarks(bookmarks []Bookmark) []Bookmark { + seen := make(map[string]bool) + var result []Bookmark + + for _, bookmark := range bookmarks { + // Create a unique key based on name and url + key := bookmark.Name + "|" + bookmark.Url + + if !seen[key] { + seen[key] = true + result = append(result, bookmark) + } + } + + return result +} diff --git a/wox.core/plugin/system/browser_bookmark_test.go b/wox.core/plugin/system/browser_bookmark_test.go new file mode 100644 index 00000000..28dd3588 --- /dev/null +++ b/wox.core/plugin/system/browser_bookmark_test.go @@ -0,0 +1,272 @@ +package system + +import ( + "context" + "os" + "path/filepath" + "testing" + "wox/common" + "wox/plugin" + + "github.com/stretchr/testify/assert" +) + +func TestBrowserBookmarkPlugin_loadBookmarkFromFile(t *testing.T) { + // Create a temporary bookmark file for testing + tempDir := t.TempDir() + bookmarkFile := filepath.Join(tempDir, "Bookmarks") + + // Sample bookmark JSON content (more realistic format) + bookmarkContent := `{ + "roots": { + "bookmark_bar": { + "children": [ + { + "date_added": "13285874237000000", + "date_last_used": "0", + "guid": "00000000-0000-4000-A000-000000000001", + "id": "5", + "name": "Google", + "type": "url", + "url": "https://www.google.com" + }, + { + "date_added": "13285874237000000", + "date_last_used": "0", + "guid": "00000000-0000-4000-A000-000000000002", + "id": "6", + "name": "GitHub", + "type": "url", + "url": "https://github.com" + } + ] + } + } + }` + + err := os.WriteFile(bookmarkFile, []byte(bookmarkContent), 0644) + assert.NoError(t, err) + + // Create plugin instance + plugin := &BrowserBookmarkPlugin{} + plugin.api = &mockAPI{} + + // Test loading bookmarks + ctx := context.Background() + + bookmarks := plugin.loadBookmarkFromFile(ctx, bookmarkFile, "TestBrowser") + + // Verify results + assert.Len(t, bookmarks, 2) + if len(bookmarks) >= 2 { + assert.Equal(t, "Google", bookmarks[0].Name) + assert.Equal(t, "https://www.google.com", bookmarks[0].Url) + assert.Equal(t, "GitHub", bookmarks[1].Name) + assert.Equal(t, "https://github.com", bookmarks[1].Url) + } +} + +func TestBrowserBookmarkPlugin_ChromeBookmarkPaths(t *testing.T) { + plugin := &BrowserBookmarkPlugin{} + plugin.api = &mockAPI{} + ctx := context.Background() + + // Test Windows Chrome path - only if Chrome is installed + if os.Getenv("LOCALAPPDATA") != "" { + chromeDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "User Data") + if _, err := os.Stat(chromeDir); err == nil { + t.Log("Chrome detected on Windows, testing Chrome bookmark loading") + bookmarks := plugin.loadChromeBookmarkInWindows(ctx, "Default") + // Should return a slice (even if empty) and not panic + assert.NotNil(t, bookmarks) + assert.IsType(t, []Bookmark{}, bookmarks) + t.Logf("Chrome Windows test: found %d bookmarks", len(bookmarks)) + } else { + t.Log("Chrome not found on Windows, skipping Chrome bookmark test") + } + } + + // Test macOS Chrome path - only if Chrome is installed + homeDir, _ := os.UserHomeDir() + if homeDir != "" { + chromeDir := filepath.Join(homeDir, "Library", "Application Support", "Google", "Chrome") + if _, err := os.Stat(chromeDir); err == nil { + t.Log("Chrome detected on macOS, testing Chrome bookmark loading") + bookmarks := plugin.loadChromeBookmarkInMacos(ctx, "Default") + assert.NotNil(t, bookmarks) + } else { + t.Log("Chrome not found on macOS, skipping Chrome bookmark test") + } + } + + // Test Linux Chrome path - only if Chrome is installed + if homeDir, _ := os.UserHomeDir(); homeDir != "" { + chromeDir := filepath.Join(homeDir, ".config", "google-chrome") + if _, err := os.Stat(chromeDir); err == nil { + t.Log("Chrome detected on Linux, testing Chrome bookmark loading") + bookmarks := plugin.loadChromeBookmarkInLinux(ctx, "Default") + assert.NotNil(t, bookmarks) + } else { + t.Log("Chrome not found on Linux, skipping Chrome bookmark test") + } + } +} + +func TestBrowserBookmarkPlugin_EdgeBookmarkPaths(t *testing.T) { + plugin := &BrowserBookmarkPlugin{} + plugin.api = &mockAPI{} + ctx := context.Background() + + // Test Windows Edge path - only if Edge is installed + if os.Getenv("LOCALAPPDATA") != "" { + edgeDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "Edge", "User Data") + if _, err := os.Stat(edgeDir); err == nil { + t.Log("Edge detected on Windows, testing Edge bookmark loading") + + // Check if Default profile exists + defaultProfilePath := filepath.Join(edgeDir, "Default") + if _, err := os.Stat(defaultProfilePath); err == nil { + t.Logf("Default profile found at: %s", defaultProfilePath) + + // Check if Bookmarks file exists + bookmarkFile := filepath.Join(defaultProfilePath, "Bookmarks") + if _, err := os.Stat(bookmarkFile); err == nil { + t.Logf("Bookmarks file found at: %s", bookmarkFile) + + // Read and log first 500 characters of bookmark file for debugging + content, err := os.ReadFile(bookmarkFile) + if err == nil { + contentStr := string(content) + if len(contentStr) > 500 { + contentStr = contentStr[:500] + "..." + } + t.Logf("Bookmark file content preview: %s", contentStr) + } else { + t.Logf("Error reading bookmark file: %v", err) + } + } else { + t.Logf("Bookmarks file not found at: %s", bookmarkFile) + } + } else { + t.Logf("Default profile not found at: %s", defaultProfilePath) + + // List available profiles + entries, err := os.ReadDir(edgeDir) + if err == nil { + t.Log("Available profiles:") + for _, entry := range entries { + if entry.IsDir() { + t.Logf(" - %s", entry.Name()) + } + } + } + } + + bookmarks := plugin.loadEdgeBookmarkInWindows(ctx, "Default") + // Should return a slice (even if empty) and not panic + assert.NotNil(t, bookmarks) + t.Logf("Edge Windows test: found %d bookmarks", len(bookmarks)) + } else { + t.Log("Edge not found on Windows, skipping Edge bookmark test") + } + } + + // Test macOS Edge path - only if Edge is installed + homeDir, _ := os.UserHomeDir() + if homeDir != "" { + edgeDir := filepath.Join(homeDir, "Library", "Application Support", "Microsoft Edge") + if _, err := os.Stat(edgeDir); err == nil { + t.Log("Edge detected on macOS, testing Edge bookmark loading") + bookmarks := plugin.loadEdgeBookmarkInMacos(ctx, "Default") + assert.NotNil(t, bookmarks) + } else { + t.Log("Edge not found on macOS, skipping Edge bookmark test") + } + } + + // Test Linux Edge path - only if Edge is installed + if homeDir, _ := os.UserHomeDir(); homeDir != "" { + edgeDir := filepath.Join(homeDir, ".config", "microsoft-edge") + if _, err := os.Stat(edgeDir); err == nil { + t.Log("Edge detected on Linux, testing Edge bookmark loading") + bookmarks := plugin.loadEdgeBookmarkInLinux(ctx, "Default") + assert.NotNil(t, bookmarks) + } else { + t.Log("Edge not found on Linux, skipping Edge bookmark test") + } + } +} + +func TestBrowserBookmarkPlugin_RemoveDuplicateBookmarks(t *testing.T) { + plugin := &BrowserBookmarkPlugin{} + plugin.api = &mockAPI{} + + // Create test bookmarks with duplicates + bookmarks := []Bookmark{ + {Name: "Google", Url: "https://www.google.com"}, + {Name: "GitHub", Url: "https://github.com"}, + {Name: "Google", Url: "https://www.google.com"}, // Duplicate + {Name: "Stack Overflow", Url: "https://stackoverflow.com"}, + {Name: "GitHub", Url: "https://github.com"}, // Duplicate + {Name: "Google", Url: "https://www.google.cn"}, // Different URL, should keep + {Name: "Google Search", Url: "https://www.google.com"}, // Different name, should keep + } + + // Remove duplicates + result := plugin.removeDuplicateBookmarks(bookmarks) + + // Verify results + assert.Len(t, result, 5) // Should have 5 unique bookmarks + + // Verify each bookmark exists and is unique + seen := make(map[string]bool) + for _, bookmark := range result { + key := bookmark.Name + "|" + bookmark.Url + assert.False(t, seen[key], "Duplicate bookmark found: %s", key) + seen[key] = true + } + + // Verify that we have the expected unique combinations + expectedKeys := []string{ + "Google|https://www.google.com", + "GitHub|https://github.com", + "Stack Overflow|https://stackoverflow.com", + "Google|https://www.google.cn", + "Google Search|https://www.google.com", + } + + for _, expectedKey := range expectedKeys { + assert.True(t, seen[expectedKey], "Expected bookmark not found: %s", expectedKey) + } +} + +// Mock API for testing +type mockAPI struct{} + +func (m *mockAPI) Log(ctx context.Context, level plugin.LogLevel, msg string) { + // Output log messages during testing + if testing.Verbose() { + println("LOG:", msg) + } +} +func (m *mockAPI) Notify(ctx context.Context, msg string) {} +func (m *mockAPI) GetTranslation(ctx context.Context, key string) string { return key } +func (m *mockAPI) GetSetting(ctx context.Context, key string) string { return "" } +func (m *mockAPI) SaveSetting(ctx context.Context, key string, value string, isGlobal bool) { +} +func (m *mockAPI) GetAllSettings(ctx context.Context) map[string]string { return nil } +func (m *mockAPI) OpenSettingDialog(ctx context.Context) {} +func (m *mockAPI) HideApp(ctx context.Context) {} +func (m *mockAPI) ShowApp(ctx context.Context) {} +func (m *mockAPI) ChangeQuery(ctx context.Context, query common.PlainQuery) {} +func (m *mockAPI) RestartApp(ctx context.Context) {} +func (m *mockAPI) ReloadPlugin(ctx context.Context, pluginId string) {} +func (m *mockAPI) RemovePlugin(ctx context.Context, pluginId string) {} +func (m *mockAPI) OnSettingChanged(ctx context.Context, callback func(key string, value string)) {} +func (m *mockAPI) OnGetDynamicSetting(ctx context.Context, callback func(key string) string) {} +func (m *mockAPI) OnDeepLink(ctx context.Context, callback func(arguments map[string]string)) {} +func (m *mockAPI) OnUnload(ctx context.Context, callback func()) {} +func (m *mockAPI) RegisterQueryCommands(ctx context.Context, commands []plugin.MetadataCommand) {} +func (m *mockAPI) AIChatStream(ctx context.Context, model common.Model, conversations []common.Conversation, options common.ChatOptions, callback common.ChatStreamFunc) error { + return nil +}