I'm making a script, which updates bookmarks on my macOS Safari to always have all of my subscribed subreddits as individual bookmarks in a specific folder. I've gotten to a point where I have all the subreddits as a sorted list of tuples in Python, with the wanted bookmark name as the first element and the bookmark url as the second element:
bookmarks = [
('r/Android', 'https://www.reddit.com/r/Android/'),
('r/Apple', 'https://www.reddit.com/r/Apple/'),
('r/Mac', 'https://www.reddit.com/r/Mac/'),
('r/ProgrammerHumor', 'https://www.reddit.com/r/ProgrammerHumor/')
]
How could I clear my subreddit bookmarks folder in Safari and create these new bookmarks in that folder?
I've used Python till this point, but calling an external AppleScript or Shell script from the Python program would be no problem.
Here's an image of the wanted result, every bookmark links to their respective subreddit url:
tl;dr It's necessary to edit Safari's Bookmarks.plist
to create bookmarks programmatically. Checkout the "Using a Python script" section below. It entails utilizing a XSLT stylesheet in a Bash script and invoking it via your .py
file. All tooling required to achieve this are builtin on macOS.
Important: Using macOS Mojave (10.14.x)+
you need to perform steps 1-10 in the "MacOS Mojave Restrictions" section below. Those changes permit modifications to Bookmarks.plist
.
Before proceeding create a copy of Bookmarks.plist
which can be found at ~/Library/Safari/Bookmarks.plist
. You can run the following command to copy it to your Desktop:
cp ~/Library/Safari/Bookmarks.plist ~/Desktop/Bookmarks.plist
To restore Bookmarks.plist
later run:
cp ~/Desktop/Bookmarks.plist ~/Library/Safari/Bookmarks.plist
Property Lists
MacOS has builtin Property List (.plist
) related command line tools, namely plutil
, and defaults
, which lend themselves to editing application preferences that typically contain flat data structures. However Safari's Bookmarks.plist
has a deeply nested structure, which neither of these tools are good at editing.
Tranforming .plist
files to XML
plutil
provides a -convert
option to transform .plist
from binary to XML. For instance:
plutil -convert xml1 ~/Library/Safari/Bookmarks.plist
Similarily, the following command transforms to binary:
plutil -convert binary1 ~/Library/Safari/Bookmarks.plist
Converting to XML enables use of XSLT which is ideal for transforming complex XML structures.
Using a XSLT Stylesheet
This custom XSLT stylesheet transforms Bookmarks.plist
adding element nodes to create bookmarks:
template.xsl
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:strip-space elements="*"/>
<xsl:output
method="xml"
indent="yes"
doctype-system="http://www.apple.com/DTDs/PropertyList-1.0.dtd"
doctype-public="-//Apple//DTD PLIST 1.0//EN"/>
<xsl:param name="bkmarks-folder"/>
<xsl:param name="bkmarks"/>
<xsl:param name="guid"/>
<xsl:param name="keep-existing" select="false" />
<xsl:variable name="bmCount">
<xsl:value-of select="string-length($bkmarks) -
string-length(translate($bkmarks, ',', '')) + 1"/>
</xsl:variable>
<xsl:template match="@*|node()">
<xsl:copy>
<xsl:apply-templates select="@*|node()" />
</xsl:copy>
</xsl:template>
<xsl:template name="getNthValue">
<xsl:param name="list"/>
<xsl:param name="n"/>
<xsl:param name="delimiter"/>
<xsl:choose>
<xsl:when test="$n = 1">
<xsl:value-of select=
"substring-before(concat($list, $delimiter), $delimiter)"/>
</xsl:when>
<xsl:when test="contains($list, $delimiter) and $n > 1">
<!-- recursive call -->
<xsl:call-template name="getNthValue">
<xsl:with-param name="list"
select="substring-after($list, $delimiter)"/>
<xsl:with-param name="n" select="$n - 1"/>
<xsl:with-param name="delimiter" select="$delimiter"/>
</xsl:call-template>
</xsl:when>
</xsl:choose>
</xsl:template>
<xsl:template name="createBmEntryFragment">
<xsl:param name="loopCount" select="1"/>
<xsl:variable name="bmInfo">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bkmarks"/>
<xsl:with-param name="delimiter" select="','"/>
<xsl:with-param name="n" select="$loopCount"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmkName">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="1"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmURL">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="2"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmGUID">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="3"/>
</xsl:call-template>
</xsl:variable>
<xsl:if test="$loopCount > 0">
<dict>
<key>ReadingListNonSync</key>
<dict>
<key>neverFetchMetadata</key>
<false/>
</dict>
<key>URIDictionary</key>
<dict>
<key>title</key>
<string>
<xsl:value-of select="$bmkName"/>
</string>
</dict>
<key>URLString</key>
<string>
<xsl:value-of select="$bmURL"/>
</string>
<key>WebBookmarkType</key>
<string>WebBookmarkTypeLeaf</string>
<key>WebBookmarkUUID</key>
<string>
<xsl:value-of select="$bmGUID"/>
</string>
</dict>
<!-- recursive call -->
<xsl:call-template name="createBmEntryFragment">
<xsl:with-param name="loopCount" select="$loopCount - 1"/>
</xsl:call-template>
</xsl:if>
</xsl:template>
<xsl:template name="createBmFolderFragment">
<dict>
<key>Children</key>
<array>
<xsl:call-template name="createBmEntryFragment">
<xsl:with-param name="loopCount" select="$bmCount"/>
</xsl:call-template>
<xsl:if test="$keep-existing = 'true'">
<xsl:copy-of select="./array/node()|@*"/>
</xsl:if>
</array>
<key>Title</key>
<string>
<xsl:value-of select="$bkmarks-folder"/>
</string>
<key>WebBookmarkType</key>
<string>WebBookmarkTypeList</string>
<key>WebBookmarkUUID</key>
<string>
<xsl:value-of select="$guid"/>
</string>
</dict>
</xsl:template>
<xsl:template match="dict[string[text()='BookmarksBar']]/array">
<array>
<xsl:for-each select="dict">
<xsl:choose>
<xsl:when test="string[text()=$bkmarks-folder]">
<xsl:call-template name="createBmFolderFragment"/>
</xsl:when>
<xsl:otherwise>
<xsl:copy>
<xsl:apply-templates select="@*|node()" />
</xsl:copy>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
<xsl:if test="not(./dict/string[text()=$bkmarks-folder])">
<xsl:call-template name="createBmFolderFragment"/>
</xsl:if>
</array>
</xsl:template>
</xsl:stylesheet>
Running a transformation:
This .xsl
requires parameters that specify the properties of each required bookmark.
Firstly ensure that Bookmarks.plits
is XML formatted:
plutil -convert xml1 ~/Library/Safari/Bookmarks.plist
Utilize the builtin xsltproc
to apply template.xsl
to Bookmarks.plist
.
Firstly, cd
to where template.xsl
resides, and run this compound command:
guid1=$(uuidgen) && guid2=$(uuidgen) && guid3=$(uuidgen) && xsltproc --novalid --stringparam bkmarks-folder "QUUX" --stringparam bkmarks "r/Android https://www.reddit.com/r/Android/ ${guid1},r/Apple https://www.reddit.com/r/Apple/ ${guid2}" --stringparam guid "$guid3" ./template.xsl - <~/Library/Safari/Bookmarks.plist > ~/Desktop/result-plist.xml
This creates result-plist.xml
on your Desktop
containing a new bookmarks folder named QUUX
with two new bookmarks.
Let's further understand each part in the aforementioned compound command:
uuidgen
generates three UUID's that are required in the new Bookmarks.plist
(one for the folder, and one for each bookmark entry). We generate them upfront and pass them to the XSLT because:
- XSLT 1.0 doesn't have a feature for UUID generation.
xsltproc
requires XSLT 1.0
xsltproc
's --stringparam
option denotes custom arguments as follows:
--stringparam bkmarks-folder <value>
- Name of the bookmark folder.
--stringparam bkmarks <value>
- Properties for each bookmark.
Each bookmark spec is delimited with a comma (,
). Each delimited string has three values; the name of bookmark, the URL, and GUID. These 3 values are space delimited.
--stringparam guid <value>
- GUID for the bookmark folder.
The last parts:
./template.xsl - <~/Library/Safari/Bookmarks.plist > ~/Desktop/result-plist.xml
define paths to; the .xsl
, source XML, and destination.
To evaluate the transformation that just occurred utilize diff
to display differences between the two files. For instance run:
diff -yb --width 200 ~/Library/Safari/Bookmarks.plist ~/Desktop/result-plist.xml | less
Then press the F key several times to navigate forward to each page until you see >
symbols in the middle of the two columns - they indicate where new element nodes have been addede. Press the B key to move back a page, and type Q to exit diff.
Using a Bash script.
We can now utilize the aforementioned .xsl
in a Bash script.
script.sh
#!/usr/bin/env bash
declare -r plist_path=~/Library/Safari/Bookmarks.plist
# ANSI/VT100 Control sequences for colored error log.
declare -r fmt_red='\x1b[31m'
declare -r fmt_norm='\x1b[0m'
declare -r fmt_green='\x1b[32m'
declare -r fmt_bg_black='\x1b[40m'
declare -r error_badge="${fmt_red}${fmt_bg_black}ERR!${fmt_norm}"
declare -r tick_symbol="${fmt_green}\\xE2\\x9C\\x94${fmt_norm}"
if [ -z "$1" ] || [ -z "$2" ]; then
echo -e "${error_badge} Missing required arguments" >&2
exit 1
fi
bkmarks_folder_name=$1
bkmarks_spec=$2
keep_existing_bkmarks=${3:-false}
# Transform bookmark spec string into array using comma `,` as delimiter.
IFS=',' read -r -a bkmarks_spec <<< "${bkmarks_spec//, /,}"
# Append UUID/GUID to each bookmark spec element.
bkmarks_spec_with_uuid=()
while read -rd ''; do
[[ $REPLY ]] && bkmarks_spec_with_uuid+=("${REPLY} $(uuidgen)")
done < <(printf '%s\0' "${bkmarks_spec[@]}")
# Transform bookmark spec array back to string using comma `,` as delimiter.
bkmarks_spec_str=$(printf '%s,' "${bkmarks_spec_with_uuid[@]}")
bkmarks_spec_str=${bkmarks_spec_str%,} # Omit trailing comma character.
# Check the .plist file exists.
if [ ! -f "$plist_path" ]; then
echo -e "${error_badge} File not found: ${plist_path}" >&2
exit 1
fi
# Verify that plist exists and contains no syntax errors.
if ! plutil -lint -s "$plist_path" >/dev/null; then
echo -e "${error_badge} Broken or missing plist: ${plist_path}" >&2
exit 1
fi
# Ignore ShellCheck errors regarding XSLT variable references in template below.
# shellcheck disable=SC2154
xslt() {
cat <<'EOX'
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:strip-space elements="*"/>
<xsl:output
method="xml"
indent="yes"
doctype-system="http://www.apple.com/DTDs/PropertyList-1.0.dtd"
doctype-public="-//Apple//DTD PLIST 1.0//EN"/>
<xsl:param name="bkmarks-folder"/>
<xsl:param name="bkmarks"/>
<xsl:param name="guid"/>
<xsl:param name="keep-existing" select="false" />
<xsl:variable name="bmCount">
<xsl:value-of select="string-length($bkmarks) -
string-length(translate($bkmarks, ',', '')) + 1"/>
</xsl:variable>
<xsl:template match="@*|node()">
<xsl:copy>
<xsl:apply-templates select="@*|node()" />
</xsl:copy>
</xsl:template>
<xsl:template name="getNthValue">
<xsl:param name="list"/>
<xsl:param name="n"/>
<xsl:param name="delimiter"/>
<xsl:choose>
<xsl:when test="$n = 1">
<xsl:value-of select=
"substring-before(concat($list, $delimiter), $delimiter)"/>
</xsl:when>
<xsl:when test="contains($list, $delimiter) and $n > 1">
<!-- recursive call -->
<xsl:call-template name="getNthValue">
<xsl:with-param name="list"
select="substring-after($list, $delimiter)"/>
<xsl:with-param name="n" select="$n - 1"/>
<xsl:with-param name="delimiter" select="$delimiter"/>
</xsl:call-template>
</xsl:when>
</xsl:choose>
</xsl:template>
<xsl:template name="createBmEntryFragment">
<xsl:param name="loopCount" select="1"/>
<xsl:variable name="bmInfo">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bkmarks"/>
<xsl:with-param name="delimiter" select="','"/>
<xsl:with-param name="n" select="$loopCount"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmkName">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="1"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmURL">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="2"/>
</xsl:call-template>
</xsl:variable>
<xsl:variable name="bmGUID">
<xsl:call-template name="getNthValue">
<xsl:with-param name="list" select="$bmInfo"/>
<xsl:with-param name="delimiter" select="' '"/>
<xsl:with-param name="n" select="3"/>
</xsl:call-template>
</xsl:variable>
<xsl:if test="$loopCount > 0">
<dict>
<key>ReadingListNonSync</key>
<dict>
<key>neverFetchMetadata</key>
<false/>
</dict>
<key>URIDictionary</key>
<dict>
<key>title</key>
<string>
<xsl:value-of select="$bmkName"/>
</string>
</dict>
<key>URLString</key>
<string>
<xsl:value-of select="$bmURL"/>
</string>
<key>WebBookmarkType</key>
<string>WebBookmarkTypeLeaf</string>
<key>WebBookmarkUUID</key>
<string>
<xsl:value-of select="$bmGUID"/>
</string>
</dict>
<!-- recursive call -->
<xsl:call-template name="createBmEntryFragment">
<xsl:with-param name="loopCount" select="$loopCount - 1"/>
</xsl:call-template>
</xsl:if>
</xsl:template>
<xsl:template name="createBmFolderFragment">
<dict>
<key>Children</key>
<array>
<xsl:call-template name="createBmEntryFragment">
<xsl:with-param name="loopCount" select="$bmCount"/>
</xsl:call-template>
<xsl:if test="$keep-existing = 'true'">
<xsl:copy-of select="./array/node()|@*"/>
</xsl:if>
</array>
<key>Title</key>
<string>
<xsl:value-of select="$bkmarks-folder"/>
</string>
<key>WebBookmarkType</key>
<string>WebBookmarkTypeList</string>
<key>WebBookmarkUUID</key>
<string>
<xsl:value-of select="$guid"/>
</string>
</dict>
</xsl:template>
<xsl:template match="dict[string[text()='BookmarksBar']]/array">
<array>
<xsl:for-each select="dict">
<xsl:choose>
<xsl:when test="string[text()=$bkmarks-folder]">
<xsl:call-template name="createBmFolderFragment"/>
</xsl:when>
<xsl:otherwise>
<xsl:copy>
<xsl:apply-templates select="@*|node()" />
</xsl:copy>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
<xsl:if test="not(./dict/string[text()=$bkmarks-folder])">
<xsl:call-template name="createBmFolderFragment"/>
</xsl:if>
</array>
</xsl:template>
</xsl:stylesheet>
EOX
}
# Convert the .plist to XML format
plutil -convert xml1 -- "$plist_path" >/dev/null || {
echo -e "${error_badge} Cannot convert .plist to xml format" >&2
exit 1
}
# Generate a UUID/GUID for the folder.
folder_guid=$(uuidgen)
xsltproc --novalid \
--stringparam keep-existing "$keep_existing_bkmarks" \
--stringparam bkmarks-folder "$bkmarks_folder_name" \
--stringparam bkmarks "$bkmarks_spec_str" \
--stringparam guid "$folder_guid" \
<(xslt) - <"$plist_path" > "${TMPDIR}result-plist.xml"
# Convert the .plist to binary format
plutil -convert binary1 -- "${TMPDIR}result-plist.xml" >/dev/null || {
echo -e "${error_badge} Cannot convert .plist to binary format" >&2
exit 1
}
mv -- "${TMPDIR}result-plist.xml" "$plist_path" 2>/dev/null || {
echo -e "${error_badge} Cannot move .plist from TMPDIR to ${plist_path}" >&2
exit 1
}
echo -e "${tick_symbol} Successfully created Safari bookmarks."
Explanation
script.sh
provides the following features:
- Simplified API that will be beneficial when executing via Python.
- Verifies the
.plist
is not broken.
- Error handling/logging.
- Transforms
.plist
via xsltproc
using template.xsl
inlined.
- Creates GUID(s) for passing to XSLT based on the no. of bookmarks specified in the given arguments.
- Converts
.plist
to XML, and back to binary.
- Writes a new file to the OS's temp folder, then moves it to the
Bookmarks.plist
directory, effectively replacing the orginal.
Running the shell script
cd
to where script.sh
resides and run the following chmod
command to make script.sh
executable:
chmod +ux script.sh
Run the following command:
./script.sh "stackOverflow" "bash https://stackoverflow.com/questions/tagged/bash,python https://stackoverflow.com/questions/tagged/python"
The following is then printed to your CLI:
✔ Successfully created Safari bookmarks.
Safari now has a bookmarks folder named stackOverflow
containing two bookmarks (bash
and python
).
Using a Python script
There's a couple of ways to execute script.sh
via your .py
file.
Method A: External shell script
The following .py
file executes the external script.sh
file. Let's name the file create-safari-bookmarks.py
and save it in the same folder as script.sh
.
create-safari-bookmarks.py
#!/usr/bin/env python
import subprocess
def run_script(folder_name, bkmarks):
subprocess.call(["./script.sh", folder_name, bkmarks])
def tuple_to_shell_arg(tup):
return ",".join("%s %s" % t for t in tup)
reddit_bkmarks = [
('r/Android', 'https://www.reddit.com/r/Android/'),
('r/Apple', 'https://www.reddit.com/r/Apple/'),
('r/Mac', 'https://www.reddit.com/r/Mac/'),
('r/ProgrammerHumor', 'https://www.reddit.com/r/ProgrammerHumor/'),
('r/gaming', 'https://www.reddit.com/r/gaming/')
]
so_bkmarks = [
('bash', 'https://stackoverflow.com/questions/tagged/bash'),
('python', 'https://stackoverflow.com/questions/tagged/python'),
('xslt', 'https://stackoverflow.com/questions/tagged/xslt'),
('xml', 'https://stackoverflow.com/questions/tagged/xml')
]
run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks))
run_script("stackOverflow", tuple_to_shell_arg(so_bkmarks))
Explanation:
The first def
statement defines a run-script
function. It has two parameters; folder_name
and bkmarks
. The subprocess
modules call
method essentially executes script.sh
with the required arguments.
The second def
statement defines a tuple_to_shell_arg
function. It has one parameter tup
. The String join()
method transforms a list of tuples into a format required by script.sh
. It essentially transforms a list of tuples such as:
[
('foo', 'https://www.foo.com/'),
('quux', 'https://www.quux.com')
]
and returns a string:
foo https://www.foo.com/,quux https://www.quux.com
The run_script
function is invoked as follows:
run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks))
This passes two arguments; subreddit
(the name of the bookmarks folder), and the spec for each required bookmark (formatted as previously described in point no. 2).
Running create-safari-bookmarks.py
Make create-safari-bookmarks.py
executable:
chmod +ux ./create-safari-bookmarks.py
Then invoke it with:
./create-safari-bookmarks.py
Method B: Inline shell script
Depending on your exact use case, you may want to consider inlining script.sh
in your .py
file instead of calling an external .sh
file. Let's name this file create-safari-bookmarks-inlined.py
and save it to the same directory where create-safari-bookmarks.py
resides.
Important:
You'll need to copy and paste all the content from script.sh
into create-safari-bookmarks-inlined.py
where indicated.
Paste it on the next line following the bash_script = """\
part.
- The
"""
part in create-safari-bookmarks-inlined.py
should be on it's own line following the last line of the pasted script.sh
content.
Line 31 of script.sh
when inlined in .py
must have the '%s\0'
part (\0
is a null character) escaped with another backslash, i.e. line 31 of script.sh
should appear like this:
...
done < <(printf '%s\\0' "${bkmarks_spec[@]}")
^
...
This line will probably be on line 37 in create-safari-bookmarks-inlined.py
.
create-safari-bookmarks-inlined.py
#!/usr/bin/env python
import tempfile
import subprocess
bash_script = """\
# <--- Copy and paste content of `script.sh` here and modify its line 31.
"""
def run_script(script, folder_name, bkmarks):
with tempfile.NamedTemporaryFile() as scriptfile:
scriptfile.write(script)
scriptfile.flush()
subprocess.call(["/bin/bash", scriptfile.name, folder_name, bkmarks])
def tuple_to_shell_arg(tup):
return ",".join("%s %s" % t for t in tup)
reddit_bkmarks = [
('r/Android', 'https://www.reddit.com/r/Android/'),
('r/Apple', 'https://www.reddit.com/r/Apple/'),
('r/Mac', 'https://www.reddit.com/r/Mac/'),
('r/ProgrammerHumor', 'https://www.reddit.com/r/ProgrammerHumor/'),
('r/gaming', 'https://www.reddit.com/r/gaming/')
]
so_bkmarks = [
('bash', 'https://stackoverflow.com/questions/tagged/bash'),
('python', 'https://stackoverflow.com/questions/tagged/python'),
('xslt', 'https://stackoverflow.com/questions/tagged/xslt'),
('xml', 'https://stackoverflow.com/questions/tagged/xml')
]
run_script(bash_script, "subreddit", tuple_to_shell_arg(reddit_bkmarks))
run_script(bash_script, "stackOverflow", tuple_to_shell_arg(so_bkmarks))
Explanation
This file achieves the same result as create-safari-bookmarks.py
.
This modified .py
script includes a modified run_script
function that utilizes Python's tempfile
module to save the inline shell script to a temporary file.
Python's subprocess
modules call
method then executes the temporary created shell file.
Running create-safari-bookmarks-inlined.py
Make create-safari-bookmarks-inlined.py
executable:
chmod +ux ./create-safari-bookmarks-inlined.py
Then invoke it by running:
./create-safari-bookmarks-inlined.py
Additional Note: Appending bookmarks to an existing folder
Currently, each time the aforementioned scripts/commands are run again we are effectively replacing any existing named Safari bookmark folder, (which has the same name as the given bookmark folder name), with a completely new one and creating the specified bookmarks.
However, if you wanted to append bookmarks to an exiting folder then template.xsl
includes one additional parameter/argument to be passed to it. Note the part on line 14 that reads:
<xsl:param name="keep-existing" select="false" />
It's default value is false
. So, if we were to change the run_script
function in let's say create-safari-bookmarks.py
to the following.
def run_script(folder_name, bkmarks, keep_existing):
subprocess.call(["./script.sh", folder_name, bkmarks, keep_existing])
That is to add a third parameter named keep_existing
, and include a reference to it in the subprocess.call([...])
, i.e. so that it gets passed as the third argument to script.sh
(...and subsequently to the XSLT stylesheet).
We can then invoke the run_script
function and pass in an additional String argument, either "true"
or "false"
like so:
run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks), "true")
run_script("stackOverflow", tuple_to_shell_arg(so_bkmarks), "false")
However, making the changes above, (i.e. passing in "true"
to keep existing bookmarks), does have potential to result in duplicate bookmarks being created. For example; duplicate bookmarks will occur when we have an exiting bookmark (name and URL) which is then reprovided with the same name and URL at a later time.
Limitations: Currently any name argument provided for a bookmark cannot include a space character(s) because they are used as delimiters by the script(s).
MacOS Mojave Restrictions
Due to stricter security policies on macOS Mojave (10.14.x) access to ~/Library/Safari/Bookmarks.plist
is not permitted by default (as mentioned in this answer).
Therefore, it is necessary to grant the Terminal.app, (or other preferred CLI tool such as iTerm), access to your whole disk. To do this you'll need to:
- Select System Preferences from the Apple menu.
- In the System Preferences window click the Security & Policy icon.
- In the Security & Policy pane click the Privacy tab.
- Choose Full Disk Access in the left-hand column.
- Click the lock icon in the bottom left corner to allow changes.
- Enter the administrator password, then click the Unlock button.
- Next click the plus icon (+).
- Choose the Terminal.app, which can be located at
/Applications/Utilities/
, then click the Open button.
- The Terminal.app will be added to the list.
- Click the lock icon to prevent any further changes, and quit System Preferences.
I never found the AS commands to manage bookmarks in Safari (not in AS dictionary). So I built my own routines to play with Safari bookmark plist file. However, they are subject to unexpected changes made by Apple in the way bookmarks will be handled in future ! up to now , it is still working, but I do not use yet the 10.14
First you must get this plist file to change it. This part must be in your main code. it gives you the patch to your plist file :
set D_Lib to ((path to library folder from user domain) as string) & "Safari"
set SafariPlistFile to D_Lib & ":Bookmarks.plist"
Here are 2 sub-routine to manage bookmarks. The first one checks if a bookmark exists
on Exist_BM(FPlist, BM_Name) -- Search bookmark named BM_Name in Plist file. returns number or 0 if not found. This search is limited to main bar, not sub menus
tell application "System Events"
set numBM to 0
set Main_Bar to property list item "Children" of property list item 2 of property list item "Children" of property list file FPlist
tell Main_Bar
set myBM to every property list item of Main_Bar
repeat with I from 1 to (count of myBM)
set myType to value of property list item "WebBookmarkType" of (item I of myBM)
if (myType = "WebBookmarkTypeLeaf") then
if (value of property list item "title" of property list item "URIDictionary" of (item I of myBM)) = BM_Name then
set numBM to I
exit repeat
end if
end if
end repeat
end tell
end tell
return numBM
end Exist_BM
You can call this handler like bellow :
Set myAndroid to Exist_BM(SafariPlistFile,"r/Android")
if myAndroid >0 then -- set here the code to update : the bookmark already exists
else -- set here the code to add new bookmark "r/Android"
end if
The second handler creates a new bookmark :
on New_BM(FPlist, BM_Name, N_URL) -- create new bookmark at right end side of bookmarks and return its number
tell application "System Events"
set Main_Bar to property list item "Children" of property list item 2 of property list item "Children" of property list file FPlist
set numBM to count of property list item of Main_Bar
tell Main_Bar
set my_UUID to do shell script "uuidgen" -- create unique Apple UID
set myNewBM to make new property list item at the end with properties {kind:record}
tell myNewBM
set URIDict to make new property list item with properties {kind:record, name:"URIDictionary"}
tell URIDict to make new property list item with properties {name:"title", kind:string, value:BM_Name}
make new property list item with properties {name:"URLString", kind:string, value:N_URL}
make new property list item with properties {name:"WebBookmarkType", kind:string, value:"WebBookmarkTypeLeaf"}
make new property list item with properties {name:"WebBookmarkUUID", kind:string, value:my_UUID}
end tell -- myNewBM
end tell
end tell
return (numBM + 1)
end New_BM
I used these routines to add, check and change a bookmark at right side of my bookmarks. In your case, you need to play with bookmark sub menu, and then you have to adjust this code, but the main concept is the same.
To make it easier, I recommend you to start looking your plist file (Library/Safari/Bookmarks.plist) to see its structure when you have your bookmarks in sub menu.
I hope it helps !