1+ from pathlib import Path
2+ import argparse
3+
4+ class XDGPaths :
5+ def __init__ (self ):
6+ import os
7+ self .HOME = str (Path .home ())
8+ self .xdg_cache = os .environ .get ("XDG_CACHE_HOME" , os .path .join (self .HOME , ".cache" ))
9+ self .xdg_config = os .environ .get ("XDG_CONFIG_HOME" , os .path .join (self .HOME , ".config" ))
10+ self .xdg_runtime = os .environ .get ("XDG_RUNTIME_DIR" , os .path .join (self .HOME , ".local/share" ))
11+ self .xdg_data = os .environ .get ("XDG_DATA_HOME" , os .path .join (self .HOME , ".local/share" ))
12+ self .CACHE_DIR = os .path .join (self .xdg_cache , "hyde" )
13+ self .CONFIG_DIR = os .path .join (self .xdg_config , "hyde" )
14+ self .RUNTIME_DIR = os .path .join (self .xdg_runtime , "hyde" )
15+ self .DATA_DIR = os .path .join (self .xdg_data , "hyde" )
16+ self .RECENT_FILE = os .path .join (self .CACHE_DIR , "landing/show_bookmarks.recent" )
17+ self .RECENT_NUMBER = 5
18+
19+ class BookmarkManager :
20+ def __init__ (self , xdg : XDGPaths ):
21+ self .xdg = xdg
22+
23+ def find_bookmark_files (self ):
24+ import os
25+ files = []
26+ # Firefox
27+ for root , dirs , filelist in os .walk (os .path .join (self .xdg .HOME , ".mozilla/firefox" )):
28+ for file in filelist :
29+ if file == "places.sqlite" :
30+ files .append (os .path .join (root , file ))
31+ # Chromium/Brave/Chrome
32+ for path in [
33+ os .path .join (self .xdg .xdg_config , "BraveSoftware/Brave-Browser/Default/Bookmarks" ),
34+ os .path .join (self .xdg .xdg_config , "chromium/Default/Bookmarks" ),
35+ os .path .join (self .xdg .xdg_config , "google-chrome/Default/Bookmarks" ),
36+ ]:
37+ if os .path .exists (path ):
38+ files .append (path )
39+ # Custom .lst files
40+ for path in [
41+ os .path .join (self .xdg .xdg_config , "hyde/bookmarks.lst" ),
42+ ]:
43+ if os .path .exists (path ):
44+ files .append (path )
45+ return files
46+
47+ def read_firefox_bookmarks (self , places_file ):
48+ import sqlite3
49+ import sys
50+ query = """
51+ SELECT b.title, p.url
52+ FROM moz_bookmarks AS b
53+ LEFT JOIN moz_places AS p ON b.fk = p.id
54+ WHERE b.type = 1 AND p.hidden = 0 AND b.title IS NOT NULL
55+ """
56+ bookmarks = []
57+ try :
58+ conn = sqlite3 .connect (places_file )
59+ for title , url in conn .execute (query ):
60+ if not title :
61+ title = url
62+ bookmarks .append ({"title" : title , "url" : url })
63+ conn .close ()
64+ except Exception as e :
65+ print (f"Error reading Firefox bookmarks: { e } " , file = sys .stderr )
66+ return bookmarks
67+
68+ def read_chromium_bookmarks (self , bookmarks_file ):
69+ import json
70+ import sys
71+ bookmarks = []
72+ try :
73+ with open (bookmarks_file , "r" ) as f :
74+ data = json .load (f )
75+ for item in data .get ("roots" , {}).get ("bookmark_bar" , {}).get ("children" , []):
76+ if "url" in item :
77+ bookmarks .append ({"title" : item .get ("name" , item ["url" ]), "url" : item ["url" ]})
78+ for item in data .get ("roots" , {}).get ("other" , {}).get ("children" , []):
79+ if "url" in item :
80+ bookmarks .append ({"title" : item .get ("name" , item ["url" ]), "url" : item ["url" ]})
81+ except Exception as e :
82+ print (f"Error reading Chromium bookmarks: { e } " , file = sys .stderr )
83+ return bookmarks
84+
85+ def read_custom_lst (self , lst_file ):
86+ import sys
87+ bookmarks = []
88+ try :
89+ with open (lst_file , "r" ) as f :
90+ for line in f :
91+ line = line .strip ()
92+ if not line or "|" not in line :
93+ continue
94+ title , url = [x .strip () for x in line .split ("|" , 1 )]
95+ bookmarks .append ({"title" : title , "url" : url })
96+ except Exception as e :
97+ print (f"Error reading custom bookmarks: { e } " , file = sys .stderr )
98+ return bookmarks
99+
100+ def read_recent (self ):
101+ import os
102+ import sys
103+ bookmarks = []
104+ if not os .path .exists (self .xdg .RECENT_FILE ):
105+ return bookmarks
106+ try :
107+ with open (self .xdg .RECENT_FILE , "r" ) as f :
108+ for line in f :
109+ line = line .strip ()
110+ if "|" in line :
111+ title , url = [x .strip () for x in line .split ("|" , 1 )]
112+ bookmarks .append ({"title" : title , "url" : url })
113+ except Exception as e :
114+ print (f"Error reading recent bookmarks: { e } " , file = sys .stderr )
115+ return bookmarks
116+
117+ def save_recent (self , title , url ):
118+ import os
119+ lines = [f"{ title } | { url } " ]
120+ if os .path .exists (self .xdg .RECENT_FILE ):
121+ with open (self .xdg .RECENT_FILE , "r" ) as f :
122+ for line in f :
123+ line = line .strip ()
124+ if line and line != lines [0 ]:
125+ lines .append (line )
126+ seen = set ()
127+ unique_lines = []
128+ for line in lines :
129+ if line not in seen and line :
130+ unique_lines .append (line )
131+ seen .add (line )
132+ if len (unique_lines ) >= self .xdg .RECENT_NUMBER :
133+ break
134+ os .makedirs (os .path .dirname (self .xdg .RECENT_FILE ), exist_ok = True )
135+ with open (self .xdg .RECENT_FILE , "w" ) as f :
136+ for line in unique_lines :
137+ f .write (line + "\n " )
138+
139+ def get_all_bookmarks (self , isCustom = True ):
140+ files = self .find_bookmark_files ()
141+ all_bookmarks = []
142+ for file in files :
143+ if file .endswith (".sqlite" ):
144+ all_bookmarks .extend (self .read_firefox_bookmarks (file ))
145+ elif file .endswith (".lst" ):
146+ if isCustom :
147+ all_bookmarks .extend (self .read_custom_lst (file ))
148+ else :
149+ all_bookmarks .extend (self .read_chromium_bookmarks (file ))
150+ all_bookmarks .extend (self .read_recent ())
151+ # Deduplicate by title and url, sort by title
152+ seen = set ()
153+ unique_bookmarks = []
154+ for bm in all_bookmarks :
155+ key = (bm ["title" ], bm ["url" ])
156+ if key not in seen :
157+ unique_bookmarks .append (bm )
158+ seen .add (key )
159+ unique_bookmarks .sort (key = lambda x : x ["title" ].lower ())
160+ return unique_bookmarks
161+
162+ def list_bookmarks (self , bookmarks ):
163+ for idx , bm in enumerate (bookmarks , 1 ):
164+ print (f"{ idx } ) { bm ['title' ]} " )
165+
166+ def open_bookmark (self , url , browser = None ):
167+ import subprocess
168+ if browser :
169+ subprocess .run ([browser , url ])
170+ else :
171+ subprocess .run (["xdg-open" , url ])
172+
173+ def open_by_selection (self , selection , bookmarks , browser = None ):
174+ import sys
175+ # Parse index from selection string like '1) Title'
176+ try :
177+ index = int (selection .split (')' , 1 )[0 ].strip ())
178+ bm = bookmarks [index - 1 ]
179+ self .save_recent (bm ['title' ], bm ['url' ])
180+ self .open_bookmark (bm ['url' ], browser )
181+ sys .exit ()
182+ except Exception as e :
183+ print (f"Error: { e } " , file = sys .stderr )
184+
185+ def main ():
186+ parser = argparse .ArgumentParser (description = "Bookmarks manager (feature parity with bookmarks.sh)" )
187+ parser .add_argument ('--browser' , '-b' , type = str , help = 'Set browser command (default: $BROWSER env or xdg-open)' )
188+ parser .add_argument ('--no-custom' , action = 'store_true' , help = 'Run without custom .lst bookmark files' )
189+ parser .add_argument ('--list' , action = 'store_true' , help = 'List bookmarks and exit' )
190+ parser .add_argument ('selection' , nargs = '?' , type = str , help = 'Selected bookmark string from rofi (e.g. "1) Title")' )
191+ args = parser .parse_args ()
192+
193+ xdg = XDGPaths ()
194+ manager = BookmarkManager (xdg )
195+ bookmarks = manager .get_all_bookmarks (isCustom = not args .no_custom )
196+ if args .selection :
197+ manager .open_by_selection (args .selection , bookmarks , args .browser )
198+ return
199+ manager .list_bookmarks (bookmarks )
200+ if args .list :
201+ import sys
202+ sys .exit (0 )
203+
204+ if __name__ == "__main__" :
205+ main ()
0 commit comments