Skip to content

Instantly share code, notes, and snippets.

@e-roux
Last active March 25, 2019 10:54
Show Gist options
  • Save e-roux/f09e56c6f1d15c3f4704fecaf4b8ff33 to your computer and use it in GitHub Desktop.
Save e-roux/f09e56c6f1d15c3f4704fecaf4b8ff33 to your computer and use it in GitHub Desktop.
Mac OS: python module for easily adding, removing, and moving positions of Safari bookmarks in the context of the currently logged in user.
#!/usr/bin/python
#
# From https://github.com/robperc/SafariBookmarkEditor
#
# Revision
# 23/10/2017 - ported to Python 3, PEP8 fixes
#
"""
Python module for easily adding, removing, and moving positions of Safari
bookmarks in the context of the currently logged in user.
On fresh installs that lack a Bookmarks.plist in the ~/Library/Safari/ folder or
in the case of a corrupt plist a boilerplate Bookmarks plist will be generated
with the proper format. Bookmark titles are checked against existing bookmarks
to ensure no collisions occur. Can also be run as a CLI tool.
"""
import argparse
import os
import plistlib
import subprocess
import uuid
def touch(fname, mode=0o666, dir_fd=None, **kwargs):
"""
Implementation of Unix utility
https://stackoverflow.com/questions/1158076
"""
flags = os.O_CREAT | os.O_APPEND
with os.fdopen(os.open(fname, flags=flags,
mode=mode, dir_fd=dir_fd)) as _file:
os.utime(_file.fileno() if os.utime in os.supports_fd else fname,
dir_fd=None if os.supports_fd else dir_fd, **kwargs)
class SafariBookmarks(object):
"""
Creates a Safari bookmark instance in the context of the current user.
Attributes
----------
pathtoplist (str)
Path to Bookmarks plist of current user.
plist (dict(dict, ..., dict)):
XML of bookmarks dictionary parsed into plist.
titles (list(str, ..., str))
List of titles of current bookmarks.
bookmarks (list(dict, ..., dict))
Reference to bookmarks entry of parsed plist dictionary.
"""
def __init__(self):
self.plist = dict()
self.titles = list()
self.bookmarks = list()
self.read()
@property
def pathtoplist(self):
"""
Check to see Bookmarks plist exists and has correct form.
If either of these conditions aren't met replaces existing
plist with new empty one.
Returns
-------
Expanded path to ~/Library/Safari/Bookmarks.plist
"""
_path = os.path.expanduser('~/Library/Safari/Bookmarks.plist')
if not os.path.isfile(_path):
print("Bookmarks.plist doesn't appear to exist."
"Generating new Bookmarks.plist.")
self.generate()
return _path
@staticmethod
def generate():
"""
Generate a boilerplate Safari Bookmarks plist at plist path.
Raises
------
CalledProcessError if creation of plist fails.
"""
pathtoplist = os.path.expanduser('~/Library/Safari/Bookmarks.plist')
touch(pathtoplist)
contents = dict(
Children=list((
dict(
Title="History",
WebBookmarkIdentifier="History",
WebBookmarkType="WebBookmarkTypeProxy",
WebBookmarkUUID=str(uuid.uuid5(uuid.NAMESPACE_DNS,
"History")),
),
dict(
Children=list(),
Title="BookmarksBar",
WebBookmarkType="WebBookmarkTypeList",
WebBookmarkUUID=str(uuid.uuid5(uuid.NAMESPACE_DNS,
"BookmarksBar")),
),
dict(
Title="BookmarksMenu",
WebBookmarkType="WebBookmarkTypeList",
WebBookmarkUUID=str(uuid.uuid5(uuid.NAMESPACE_DNS,
"BookmarksMenu")),
),
)),
Title="",
WebBookmarkFileVersion=1,
WebBookmarkType="WebBookmarkTypeList",
WebBookmarkUUID=str(uuid.uuid5(uuid.NAMESPACE_DNS, "")),
)
plistlib.dump(contents, pathtoplist)
def read(self):
"""
Parse plist into dictionary. Creates a new empty bookmarks plist
if plist can't be read.
Returns
-------
Dictionary containing info parsed from bookmarks plist.
"""
subprocess.call(['plutil', '-convert', 'xml1', self.pathtoplist])
try:
plist = plistlib.load(self.pathtoplist)
except Exception as _ex:
# handle any other exception
print("Error '{_ex.message}' occured. Arguments {_ex.args}."
"Bookmarks.plist appears to be corrupted."
"Generating new Bookmarks.plist.")
self.generate() # if plist can't be read generate new empty one.
with open(self.pathtoplist, 'rb') as _file:
plist = plistlib.load(_file)
self.plist = plist
self.bookmarks = self.plist['Children'][1]['Children']
self.titles = [bm["URIDictionary"]["title"] for bm in self.bookmarks
if bm.get("URIDictionary") is not None]
def add(self, title, url, index=-1):
"""
Add a bookmark to plist dictionary.
Parameters
----------
title (str)
Title to label bookmark with.
url (str)
Url to bookmark.
"""
if title in self.titles:
print(f"Warning: Found preexisting bookmark with "
f"title {title}, skipping.")
return
if index == -1 or index > len(self.bookmarks):
index = len(self.bookmarks)
elif index < -1:
index = 0
bookmark = dict(
WebBookmarkType='WebBookmarkTypeLeaf',
WebBookmarkUUID=str(uuid.uuid5(uuid.NAMESPACE_DNS, title)),
URLString=url,
URIDictionary=dict(
title=title
),
)
self.bookmarks.insert(index, bookmark)
self.titles.append(title)
def remove(self, title):
"""
Removes bookmark identified by title from plist dictionary if found.
Parameters
----------
title (str): Title bookmark is identified by.
"""
if title not in self.titles:
return
for bookmark in self.bookmarks:
if bookmark.get("URIDictionary") and \
bookmark["URIDictionary"]["title"] == title:
self.titles.remove(title)
self.bookmarks.remove(bookmark)
return
def remove_all(self):
"""
Removes all bookmarks from the plist dictionary.
"""
# Remove bookmarks in reveresed order to avoid shifting issues
for bookmark in reversed(self.bookmarks):
self.bookmarks.remove(bookmark)
self.titles = list()
def move(self, title, index):
"""
Move bookmark identified by title to specified index
in order of bookmarks if found.
Parameters
----------
title (str): Title bookmark is identified by.
index (int): Index to move bookmark to.
"""
if title not in self.titles:
return
if index > len(self.bookmarks) or index == -1:
index = len(self.bookmarks)
elif index < -1:
index = 0
for bookmark in self.bookmarks:
if bookmark.get('URIDictionary') and \
bookmark['URIDictionary']['title'] == title:
to_mv = bookmark
break
self.bookmarks.remove(to_mv)
self.bookmarks.insert(index, to_mv)
def swap(self, title1, title2):
"""
Swap position of bookmark identified by title1 with bookmark
identified by title2 if both are found.
Parameters
----------
title1 (str): Title 1st bookmark is identified by.
title2 (str): Title 2nd bookmark is identified by.
"""
if (title1 not in self.titles) or (title2 not in self.titles) \
or (title1 == title2):
return
for index, bookmark in enumerate(self.bookmarks):
if bookmark.get('URIDictionary') and \
bookmark['URIDictionary']['title'] == title1:
index1 = index
bookmark1 = bookmark
if bookmark.get('URIDictionary') and \
bookmark['URIDictionary']['title'] == title2:
index2 = index
bookmark2 = bookmark
self.bookmarks[index1] = bookmark2
self.bookmarks[index2] = bookmark1
def write(self):
"""
Writes modified plist dictionary to bookmarks plist and
converts to binary format.
"""
plistlib.dump(self.plist, self.pathtoplist)
subprocess.call(['plutil', '-convert', 'binary1', self.pathtoplist])
def get_index(self, title):
"""
Return index of bookmark whose title is in URIDictionary.
"""
if title not in self.titles:
return None
for index, _bm in enumerate(self.bookmarks):
if _bm.get("URIDictionary") is None:
continue
bm_title = _bm["URIDictionary"]["title"]
if bm_title == title:
return index
def main():
"""
main function.
"""
parser = argparse.ArgumentParser(description='Command line tool for adding '
'and removing Safari bookmarks in the '
'context of the currently logged in user.')
parser.add_argument('--add', metavar='title::url', type=str, nargs='+',
help='double-colon separated title and bookmark(s) '
'url(s) to add in, e.g.: '
'--add myWebsite::http://www.mywebsite.com '
' MyOtherWebsite::http://www.myotherwebsite.com')
parser.add_argument('--remove', metavar='title', type=str, nargs='+',
help='title(s) of bookmark(s) to remove, e..: '
'--remove MyWebsite MyOtherWebsite')
parser.add_argument('--removeall', action='store_true',
help='remove all current bookmarks')
args = parser.parse_args()
bookmarks = SafariBookmarks()
if args.removeall:
bookmarks.remove_all()
if args.remove:
for title in args.remove:
bookmarks.remove(title)
if args.add:
for bookmark in args.add:
title, url = bookmark.split('::')
bookmarks.add(title, url)
bookmarks.write()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment