Skip to content

Commit d105a1f

Browse files
authored
feat: add Safari bookmark and download extraction from plist (#567)
* feat: add Safari bookmark and download extraction from plist files * test: add nested folder test for bookmark tree traversal Part of #565
1 parent 7bf1759 commit d105a1f

9 files changed

Lines changed: 458 additions & 1 deletion

File tree

browser/safari/extract_bookmark.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package safari
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"time"
7+
8+
"github.com/moond4rk/plist"
9+
10+
"github.com/moond4rk/hackbrowserdata/types"
11+
)
12+
13+
// safariBookmark mirrors the plist structure of Safari's Bookmarks.plist.
14+
type safariBookmark struct {
15+
Type string `plist:"WebBookmarkType"`
16+
Title string `plist:"Title"`
17+
URLString string `plist:"URLString"`
18+
URIDictionary uriDictionary `plist:"URIDictionary"`
19+
Children []safariBookmark `plist:"Children"`
20+
}
21+
22+
type uriDictionary struct {
23+
Title string `plist:"title"`
24+
}
25+
26+
const (
27+
bookmarkTypeLeaf = "WebBookmarkTypeLeaf"
28+
bookmarkTypeList = "WebBookmarkTypeList"
29+
)
30+
31+
func extractBookmarks(path string) ([]types.BookmarkEntry, error) {
32+
f, err := os.Open(path)
33+
if err != nil {
34+
return nil, fmt.Errorf("open bookmarks: %w", err)
35+
}
36+
defer f.Close()
37+
38+
var root safariBookmark
39+
if err := plist.NewDecoder(f).Decode(&root); err != nil {
40+
return nil, fmt.Errorf("decode bookmarks: %w", err)
41+
}
42+
43+
var bookmarks []types.BookmarkEntry
44+
walkBookmarks(root.Children, "", &bookmarks)
45+
return bookmarks, nil
46+
}
47+
48+
// walkBookmarks recursively traverses the bookmark tree, collecting leaf entries.
49+
func walkBookmarks(nodes []safariBookmark, folder string, out *[]types.BookmarkEntry) {
50+
for i, node := range nodes {
51+
switch node.Type {
52+
case bookmarkTypeLeaf:
53+
title := node.URIDictionary.Title
54+
if title == "" {
55+
title = node.Title
56+
}
57+
if node.URLString == "" {
58+
continue
59+
}
60+
*out = append(*out, types.BookmarkEntry{
61+
ID: int64(i),
62+
Name: title,
63+
URL: node.URLString,
64+
Folder: folder,
65+
Type: "bookmark",
66+
CreatedAt: time.Time{},
67+
})
68+
case bookmarkTypeList:
69+
name := node.Title
70+
if name == "com.apple.ReadingList" {
71+
name = "ReadingList"
72+
}
73+
walkBookmarks(node.Children, name, out)
74+
}
75+
}
76+
}
77+
78+
func countBookmarks(path string) (int, error) {
79+
bookmarks, err := extractBookmarks(path)
80+
if err != nil {
81+
return 0, err
82+
}
83+
return len(bookmarks), nil
84+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package safari
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/moond4rk/plist"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func buildTestBookmarksPlist(t *testing.T, root safariBookmark) string {
14+
t.Helper()
15+
path := filepath.Join(t.TempDir(), "Bookmarks.plist")
16+
f, err := os.Create(path)
17+
require.NoError(t, err)
18+
defer f.Close()
19+
require.NoError(t, plist.NewBinaryEncoder(f).Encode(root))
20+
return path
21+
}
22+
23+
func TestExtractBookmarks(t *testing.T) {
24+
root := safariBookmark{
25+
Type: bookmarkTypeList,
26+
Children: []safariBookmark{
27+
{
28+
Type: bookmarkTypeList,
29+
Title: "BookmarksBar",
30+
Children: []safariBookmark{
31+
{
32+
Type: bookmarkTypeLeaf,
33+
URLString: "https://github.com",
34+
URIDictionary: uriDictionary{Title: "GitHub"},
35+
},
36+
{
37+
Type: bookmarkTypeLeaf,
38+
URLString: "https://go.dev",
39+
URIDictionary: uriDictionary{Title: "Go"},
40+
},
41+
},
42+
},
43+
{
44+
Type: bookmarkTypeList,
45+
Title: "BookmarksMenu",
46+
Children: []safariBookmark{
47+
{
48+
Type: bookmarkTypeLeaf,
49+
URLString: "https://example.com",
50+
URIDictionary: uriDictionary{Title: "Example"},
51+
},
52+
},
53+
},
54+
},
55+
}
56+
57+
path := buildTestBookmarksPlist(t, root)
58+
bookmarks, err := extractBookmarks(path)
59+
require.NoError(t, err)
60+
require.Len(t, bookmarks, 3)
61+
62+
// Verify folder assignment
63+
assert.Equal(t, "GitHub", bookmarks[0].Name)
64+
assert.Equal(t, "https://github.com", bookmarks[0].URL)
65+
assert.Equal(t, "BookmarksBar", bookmarks[0].Folder)
66+
67+
assert.Equal(t, "Go", bookmarks[1].Name)
68+
assert.Equal(t, "BookmarksBar", bookmarks[1].Folder)
69+
70+
assert.Equal(t, "Example", bookmarks[2].Name)
71+
assert.Equal(t, "BookmarksMenu", bookmarks[2].Folder)
72+
}
73+
74+
func TestExtractBookmarks_ReadingList(t *testing.T) {
75+
root := safariBookmark{
76+
Type: bookmarkTypeList,
77+
Children: []safariBookmark{
78+
{
79+
Type: bookmarkTypeList,
80+
Title: "com.apple.ReadingList",
81+
Children: []safariBookmark{
82+
{
83+
Type: bookmarkTypeLeaf,
84+
URLString: "https://blog.example.com/post",
85+
URIDictionary: uriDictionary{Title: "Blog Post"},
86+
},
87+
},
88+
},
89+
},
90+
}
91+
92+
path := buildTestBookmarksPlist(t, root)
93+
bookmarks, err := extractBookmarks(path)
94+
require.NoError(t, err)
95+
require.Len(t, bookmarks, 1)
96+
assert.Equal(t, "ReadingList", bookmarks[0].Folder)
97+
}
98+
99+
func TestExtractBookmarks_SkipsEmptyURL(t *testing.T) {
100+
root := safariBookmark{
101+
Type: bookmarkTypeList,
102+
Children: []safariBookmark{
103+
{
104+
Type: bookmarkTypeLeaf,
105+
URLString: "", // no URL, should be skipped
106+
URIDictionary: uriDictionary{Title: "Empty"},
107+
},
108+
{
109+
Type: bookmarkTypeLeaf,
110+
URLString: "https://valid.com",
111+
URIDictionary: uriDictionary{Title: "Valid"},
112+
},
113+
},
114+
}
115+
116+
path := buildTestBookmarksPlist(t, root)
117+
bookmarks, err := extractBookmarks(path)
118+
require.NoError(t, err)
119+
require.Len(t, bookmarks, 1)
120+
assert.Equal(t, "Valid", bookmarks[0].Name)
121+
}
122+
123+
func TestExtractBookmarks_NestedFolders(t *testing.T) {
124+
root := safariBookmark{
125+
Type: bookmarkTypeList,
126+
Children: []safariBookmark{
127+
{
128+
Type: bookmarkTypeList,
129+
Title: "Work",
130+
Children: []safariBookmark{
131+
{
132+
Type: bookmarkTypeList,
133+
Title: "Projects",
134+
Children: []safariBookmark{
135+
{Type: bookmarkTypeLeaf, URLString: "https://deep.com", URIDictionary: uriDictionary{Title: "Deep"}},
136+
},
137+
},
138+
{Type: bookmarkTypeLeaf, URLString: "https://shallow.com", URIDictionary: uriDictionary{Title: "Shallow"}},
139+
},
140+
},
141+
},
142+
}
143+
144+
path := buildTestBookmarksPlist(t, root)
145+
bookmarks, err := extractBookmarks(path)
146+
require.NoError(t, err)
147+
require.Len(t, bookmarks, 2)
148+
149+
// Nested leaf gets the immediate parent folder name
150+
assert.Equal(t, "Deep", bookmarks[0].Name)
151+
assert.Equal(t, "Projects", bookmarks[0].Folder)
152+
153+
assert.Equal(t, "Shallow", bookmarks[1].Name)
154+
assert.Equal(t, "Work", bookmarks[1].Folder)
155+
}
156+
157+
func TestCountBookmarks(t *testing.T) {
158+
root := safariBookmark{
159+
Type: bookmarkTypeList,
160+
Children: []safariBookmark{
161+
{Type: bookmarkTypeLeaf, URLString: "https://a.com", URIDictionary: uriDictionary{Title: "A"}},
162+
{Type: bookmarkTypeLeaf, URLString: "https://b.com", URIDictionary: uriDictionary{Title: "B"}},
163+
},
164+
}
165+
166+
path := buildTestBookmarksPlist(t, root)
167+
count, err := countBookmarks(path)
168+
require.NoError(t, err)
169+
assert.Equal(t, 2, count)
170+
}
171+
172+
func TestExtractBookmarks_Empty(t *testing.T) {
173+
root := safariBookmark{Type: bookmarkTypeList}
174+
path := buildTestBookmarksPlist(t, root)
175+
176+
bookmarks, err := extractBookmarks(path)
177+
require.NoError(t, err)
178+
assert.Empty(t, bookmarks)
179+
}

browser/safari/extract_download.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package safari
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/moond4rk/plist"
8+
9+
"github.com/moond4rk/hackbrowserdata/types"
10+
)
11+
12+
// safariDownloads mirrors the plist structure of Safari's Downloads.plist.
13+
type safariDownloads struct {
14+
DownloadHistory []safariDownloadEntry `plist:"DownloadHistory"`
15+
}
16+
17+
type safariDownloadEntry struct {
18+
URL string `plist:"DownloadEntryURL"`
19+
Path string `plist:"DownloadEntryPath"`
20+
TotalBytes float64 `plist:"DownloadEntryProgressTotalToLoad"`
21+
RemoveWhenDone bool `plist:"DownloadEntryRemoveWhenDoneKey"`
22+
DownloadIdentifier string `plist:"DownloadEntryIdentifier"`
23+
}
24+
25+
func extractDownloads(path string) ([]types.DownloadEntry, error) {
26+
f, err := os.Open(path)
27+
if err != nil {
28+
return nil, fmt.Errorf("open downloads: %w", err)
29+
}
30+
defer f.Close()
31+
32+
var dl safariDownloads
33+
if err := plist.NewDecoder(f).Decode(&dl); err != nil {
34+
return nil, fmt.Errorf("decode downloads: %w", err)
35+
}
36+
37+
var downloads []types.DownloadEntry
38+
for _, d := range dl.DownloadHistory {
39+
downloads = append(downloads, types.DownloadEntry{
40+
URL: d.URL,
41+
TargetPath: d.Path,
42+
TotalBytes: int64(d.TotalBytes),
43+
})
44+
}
45+
return downloads, nil
46+
}
47+
48+
func countDownloads(path string) (int, error) {
49+
downloads, err := extractDownloads(path)
50+
if err != nil {
51+
return 0, err
52+
}
53+
return len(downloads), nil
54+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package safari
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/moond4rk/plist"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func buildTestDownloadsPlist(t *testing.T, dl safariDownloads) string {
14+
t.Helper()
15+
path := filepath.Join(t.TempDir(), "Downloads.plist")
16+
f, err := os.Create(path)
17+
require.NoError(t, err)
18+
defer f.Close()
19+
require.NoError(t, plist.NewBinaryEncoder(f).Encode(dl))
20+
return path
21+
}
22+
23+
func TestExtractDownloads(t *testing.T) {
24+
dl := safariDownloads{
25+
DownloadHistory: []safariDownloadEntry{
26+
{
27+
URL: "https://example.com/file.zip",
28+
Path: "/Users/test/Downloads/file.zip",
29+
TotalBytes: 1024000,
30+
},
31+
{
32+
URL: "https://go.dev/dl/go1.20.tar.gz",
33+
Path: "/Users/test/Downloads/go1.20.tar.gz",
34+
TotalBytes: 98765432,
35+
},
36+
},
37+
}
38+
39+
path := buildTestDownloadsPlist(t, dl)
40+
downloads, err := extractDownloads(path)
41+
require.NoError(t, err)
42+
require.Len(t, downloads, 2)
43+
44+
assert.Equal(t, "https://example.com/file.zip", downloads[0].URL)
45+
assert.Equal(t, "/Users/test/Downloads/file.zip", downloads[0].TargetPath)
46+
assert.Equal(t, int64(1024000), downloads[0].TotalBytes)
47+
48+
assert.Equal(t, "https://go.dev/dl/go1.20.tar.gz", downloads[1].URL)
49+
assert.Equal(t, int64(98765432), downloads[1].TotalBytes)
50+
}
51+
52+
func TestCountDownloads(t *testing.T) {
53+
dl := safariDownloads{
54+
DownloadHistory: []safariDownloadEntry{
55+
{URL: "https://a.com/1.zip", Path: "/tmp/1.zip", TotalBytes: 100},
56+
{URL: "https://b.com/2.zip", Path: "/tmp/2.zip", TotalBytes: 200},
57+
{URL: "https://c.com/3.zip", Path: "/tmp/3.zip", TotalBytes: 300},
58+
},
59+
}
60+
61+
path := buildTestDownloadsPlist(t, dl)
62+
count, err := countDownloads(path)
63+
require.NoError(t, err)
64+
assert.Equal(t, 3, count)
65+
}
66+
67+
func TestExtractDownloads_Empty(t *testing.T) {
68+
dl := safariDownloads{}
69+
path := buildTestDownloadsPlist(t, dl)
70+
71+
downloads, err := extractDownloads(path)
72+
require.NoError(t, err)
73+
assert.Empty(t, downloads)
74+
}

0 commit comments

Comments
 (0)