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.
This commit is contained in:
qianlifeng 2025-07-13 00:55:36 +08:00
parent 2f43afedd4
commit 7793308f27
2 changed files with 394 additions and 15 deletions

View File

@ -55,13 +55,48 @@ func (c *BrowserBookmarkPlugin) GetMetadata() plugin.Metadata {
func (c *BrowserBookmarkPlugin) Init(ctx context.Context, initParams plugin.InitParams) {
c.api = initParams.API
if util.IsMacOS() {
profiles := []string{"Default", "Profile 1", "Profile 2", "Profile 3"}
if util.IsMacOS() {
// 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
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{}
}
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
}
groups := util.FindRegexGroups(`(?ms)name": "(?P<name>.*?)",.*?type": "url",.*?"url": "(?P<url>.*?)".*?}, {`, 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<name>[^"]*)",.*?"type": "url",.*?"url": "(?P<url>[^"]*)"`, string(file))
for _, group := range groups {
if name, nameOk := group["name"]; nameOk {
if url, urlOk := group["url"]; urlOk {
results = append(results, Bookmark{
Name: group["name"],
Url: group["url"],
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
}

View File

@ -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
}