I’m interested in automating the organization of my Safari tabs based on the websites they belong to using AppleScript. Specifically, I’d like to create a script that sorts and groups tabs by website, streamlining my browsing experience.
Could anyone provide guidance on how to write an AppleScript for this purpose? I’m relatively new to scripting, so a step-by-step explanation or example code would be greatly appreciated.
What you are asking for is non-trivial. Anyhow, here’s a script to do the sorting part:
(* It is actually a selection sort. *)
on sort(myList)
if myList is missing value then return missing value
set the index_list to {}
set the sorted_list to {}
repeat (the number of items in myList) times
set the low_item to ""
repeat with i from 1 to (number of items in myList)
if i is not in the index_list then
set this_item to item i of myList as text
if the low_item is "" then
set the low_item to this_item
set the low_item_index to i
else if this_item is less than low_item then
set the low_item to this_item
set the low_item_index to i
end if
end if
end repeat
set the end of sorted_list to the low_item
set the end of the index_list to the low_item_index
end repeat
sorted_list
end sort
Then you’ll need to be able to move tabs by:
tell application "Safari"
move tab targetTabIndex of front window to before tab sourceTabIndex of front window
end tell
To retrieve all the tabs:
tell application "Safari"
tabs of front window
end tell
Now you’ll need to fuse these codes together and do a lot of cycles to test.
Rearranging the tabs in a Safari window is a difficult one to script. But it’s fairly simple to create a new window and add tabs to it in the required order — say, sorted by URL. You can then close the original window or not as required:
tell application "Safari"
activate
set oldWindow to front window
set tabURLs to oldWindow's tabs's URL
end tell
insertionSort(tabURLs, 1, -1)
tell application "Safari"
set newDoc to (make new document with properties {URL:tabURLs's beginning})
set newWindow to first window where (its document is newDoc)
repeat with i from 2 to (count tabURLs)
make new tab at newWindow with properties {URL:tabURLs's item i}
end repeat
close oldWindow
end tell
on insertionSort(theList, l, r) -- Sort items l thru r of theList.
set listLength to (count theList)
if (listLength < 2) then return
-- Convert negative and/or transposed range indices.
if (l < 0) then set l to listLength + l + 1
if (r < 0) then set r to listLength + r + 1
if (l > r) then set {l, r} to {r, l}
script o
property lst : theList
end script
set highestSoFar to o's lst's item l
set rv to o's lst's item (l + 1)
if (highestSoFar > rv) then
set o's lst's item l to rv
else
set highestSoFar to rv
end if
repeat with j from (l + 2) to r
set rv to o's lst's item j
if (highestSoFar > rv) then
repeat with i from (j - 2) to l by -1
set lv to o's lst's item i
if (lv > rv) then
set o's lst's item (i + 1) to lv
else
set i to i + 1
exit repeat
end if
end repeat
set o's lst's item i to rv
else
set o's lst's item (j - 1) to highestSoFar
set highestSoFar to rv
end if
end repeat
set o's lst's item r to highestSoFar
return -- nothing.
end insertionSort
Duh! A simpler alternative would be to reassign the sorted URLs to the existing tabs.
tell application "Safari"
activate
set tabURLs to front window's tabs's URL
end tell
insertionSort(tabURLs, 1, -1)
tell application "Safari"
repeat with i from 1 to (count tabURLs)
set front window's tab i's URL to tabURLs's item i
end repeat
end tell
on insertionSort(theList, l, r) -- Sort items l thru r of theList.
set listLength to (count theList)
if (listLength < 2) then return
-- Convert negative and/or transposed range indices.
if (l < 0) then set l to listLength + l + 1
if (r < 0) then set r to listLength + r + 1
if (l > r) then set {l, r} to {r, l}
script o
property lst : theList
end script
set highestSoFar to o's lst's item l
set rv to o's lst's item (l + 1)
if (highestSoFar > rv) then
set o's lst's item l to rv
else
set highestSoFar to rv
end if
repeat with j from (l + 2) to r
set rv to o's lst's item j
if (highestSoFar > rv) then
repeat with i from (j - 2) to l by -1
set lv to o's lst's item i
if (lv > rv) then
set o's lst's item (i + 1) to lv
else
set i to i + 1
exit repeat
end if
end repeat
set o's lst's item i to rv
else
set o's lst's item (j - 1) to highestSoFar
set highestSoFar to rv
end if
end repeat
set o's lst's item r to highestSoFar
return -- nothing.
end insertionSort
Knowing only that the OP wanted guidance on how to group Safari tabs by Web site, I simply showed a way to sort them by URL, which essentially does that. If he has some further requirement, such as arranging the sites themselves in a particular order, or the tabs in a particular order within the sites (and I can’t think of a reliable way to obtain the necessary information for these from the tabs themselves), it would be up to him to provide more information. As it is, there’s been no response at all at the time I write this.
The problem with using the host names as keys in a dictionary is that each key can only appear once, so you end up with only one key and one URL per site.
Something that’s bothering me looking at your dictionary construction code is that it uses a sorted list of hosts and an unsorted list of URLs — and yet the keys and values have been correctly paired every time I’ve tried it so far! Weird. I must be missing something.
I’d possibly be lazy and write your getHost() handler like this:
on getHost(theString) -- needs improvement
return theString's stringByReplacingOccurrencesOfString:"https?://(?:www\\.)?([^/]++).*+" withString:"$1" options:(current application's NSRegularExpressionSearch) range:{0, theString's |length|()}
end getHost
I rewrote my script to use a list of lists instead of a dictionary. Tabs with no URL are closed and tab 1 is set frontmost.
use framework "Foundation"
use scripting additions
tell application "Safari"
activate
set theURLs to front window's tabs's URL
end tell
set theList to {}
repeat with aURL in theURLs
if contents of aURL is (missing value) then
set end of theList to {"", ""}
else
set aHost to getHost(aURL)
set end of theList to {aHost, aURL}
end if
end repeat
set sortedList to getSortedList(theList)
tell application "Safari"
repeat with i from (count sortedList) to 1 by -1
if item 1 of item i of sortedList is "" then
close front window's tab i
else
set front window's tab i's URL to item 2 of sortedList's item i
end if
end repeat
set current tab of window 1 to tab 1 of window 1
end tell
on getHost(theString)
set theString to current application's NSString's stringWithString:theString
return (theString's stringByReplacingOccurrencesOfString:"https?://(?:www\\.)?([^/]++).*+" withString:"$1" options:(current application's NSRegularExpressionSearch) range:{0, theString's |length|()}) as text
end getHost
on getSortedList(theList)
repeat with i from (count theList) to 2 by -1
repeat with j from 1 to i - 1
if item 1 of item j of theList > item 1 of item (j + 1) of theList then
set {item j of theList, item (j + 1) of theList} to {item (j + 1) of theList, item j of theList}
end if
end repeat
end repeat
return theList
end getSortedList
I think you probably had at the back of your mind sorting an array (or mutable array) of dictionaries, each dictionary having a host and a URL as values and, say, “host” and “url” as its keys:
use AppleScript version "2.4" -- OS X 10.10 (Yosemite) or later
use framework "Foundation"
use scripting additions
set theArray to current application's class "NSMutableArray"'s new()
-- Pretend this is a repeat loop. ;)
theArray's addObject:(current application's class "NSDictionary"'s dictionaryWithObjects:{"macscripter.net", "https://www.macscripter.net/t/how-to-write-an-applescript-to-arrange-safari-tabs-by-website/75863/6"} forKeys:{"host", "url"})
theArray's addObject:(current application's class "NSDictionary"'s dictionaryWithObjects:{"bbc.co.uk", "https://www.bbc.co.uk/weather/cv99"} forKeys:{"host", "url"})
----
set aDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"host" ascending:true selector:"caseInsensitiveCompare:"
theArray's sortUsingDescriptors:{aDescriptor}
set URLsSortedByHost to (theArray's valueForKey:"url") as list
Thanks Nigel for the suggestion, which I’ve implemented in the script included below. I ran timing tests on this script, along with my earlier one, with 25 open Safari tabs. As you would expect, the timing results varied a fair amount, but both scripts took about 120 milliseconds. With 3 open Safari tabs and one empty tab, both scripts took about 70 milliseconds.
BTW, the use of a sorting dictionary, as you suggest, opens the possibility of doing a subsort on some other URL component (perhaps path). However, the OP is MIA and this is probably of academic interest only, so I’ll leave that for another day.
use framework "Foundation"
use scripting additions
tell application "Safari"
activate
set theURLs to front window's tabs's URL
end tell
set theArray to current application's NSMutableArray's new()
repeat with aURL in theURLs
if contents of aURL is (missing value) then
set theDictionary to (current application's class "NSDictionary"'s dictionaryWithObjects:{"none", "none"} forKeys:{"host", "url"})
else
set aHost to getHost(aURL)
set theDictionary to (current application's class "NSDictionary"'s dictionaryWithObjects:{aHost, aURL} forKeys:{"host", "url"})
end if
(theArray's addObject:theDictionary)
end repeat
set aDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"host" ascending:true selector:"caseInsensitiveCompare:"
theArray's sortUsingDescriptors:{aDescriptor}
set sortedURLs to (theArray's valueForKey:"url") as list
tell application "Safari"
repeat with i from (count sortedURLs) to 1 by -1
if item i of sortedURLs is "none" then
close front window's tab i
else
set front window's tab i's URL to sortedURLs's item i
end if
end repeat
set current tab of window 1 to tab 1 of window 1 -- if desired
end tell
on getHost(theString)
set theString to current application's NSString's stringWithString:theString
return (theString's stringByReplacingOccurrencesOfString:"https?://(?:www\\.)?([^/]++).*+" withString:"$1" options:(current application's NSRegularExpressionSearch) range:{0, theString's |length|()}) as text
end getHost
I wanted to learn a little about the NSURLComponents class, and the above task seemed a good vehicle for this. For me, the most helpful thing that can be done with this class is to get the component parts of a URL, and the following script gets every property that is shown in the documentation (here) under the heading Accessing Components in Native Format.
use framework "Foundation"
use scripting additions
tell application "Safari" to set theURL to the URL of tab 1 of window 1
set urlComponents to getURLComponents(theURL)
on getURLComponents(theURL)
set theComponents to current application's NSURLComponents's componentsWithString:theURL
set theScheme to theComponents's |scheme|() as text
set theHost to theComponents's |host|() as text
set thePath to theComponents's |path|() as text
set theQuery to theComponents's query() as text
set thePassword to theComponents's |password|() as text
set thePort to theComponents's |port|() as text
-- set theQueryItems to theComponents's |queryItems|() as list
set theUser to theComponents's user() as text
set theFragment to theComponents's fragment() as text
return {schemeKey:theScheme, hostKey:theHost, pathKey:thePath, queryKey:theQuery, passwordKey:thePassword, portKey:thePort, userKey:theUser, fragmentKey:theFragment}
end getURLComponents
I viewed a lot of web sites with this script and concluded that the URL components of interest within the context of this thread are host and path. That being the case, the simplest solution to the above task seems to be to modify Nigels regex to return the host and path as a string. However, just for learning purposes and maximum flexibility, the following script gets and sorts on separate path components:
use framework "Foundation"
use scripting additions
tell application "Safari"
activate
set theURLs to front window's tabs's URL
end tell
set theArray to current application's NSMutableArray's new()
repeat with aURL in theURLs
if contents of aURL is (missing value) then
set aDictionary to (current application's class "NSDictionary"'s dictionaryWithObjects:{"none", "none", "none"} forKeys:{"hostKey", "pathKey", "urlKey"})
else
set {aHost, aPath} to getURLComponents(aURL)
set aDictionary to (current application's class "NSDictionary"'s dictionaryWithObjects:{aHost, aPath, aURL} forKeys:{"hostKey", "pathKey", "urlKey"})
end if
(theArray's addObject:aDictionary)
end repeat
set hostDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"hostKey" ascending:true selector:"caseInsensitiveCompare:"
set pathDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"pathKey" ascending:true selector:"caseInsensitiveCompare:"
theArray's sortUsingDescriptors:{hostDescriptor, pathDescriptor}
set sortedURLs to (theArray's valueForKey:"urlKey") as list
tell application "Safari"
repeat with i from (count sortedURLs) to 1 by -1
if item i of sortedURLs is "none" then
close front window's tab i
else
set front window's tab i's URL to sortedURLs's item i
end if
end repeat
set front window's current tab to front window's tab 1 --if desired
end tell
on getURLComponents(theURL)
set theComponents to current application's NSURLComponents's componentsWithString:theURL
set theHost to theComponents's |host|()
set theHost to theHost's stringByReplacingOccurrencesOfString:"www." withString:"" -- remove "www."
set thePath to theComponents's |path|()
return {theHost, thePath}
end getURLComponents
It turns out (on my Ventura machine, at least) that NSURLComponents objects can be treated pretty much as dictionaries in their own right. So, just for fun:
use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions
tell application "Safari"
activate
set theURLs to front window's tabs's URL
end tell
set theArray to current application's NSMutableArray's new()
repeat with i from (count theURLs) to 1 by -1
set aURL to theURLs's item i
if (aURL is missing value) then
tell application "Safari" to close front window's tab i
else
(theArray's addObject:(getURLComponents(aURL)))
end if
end repeat
set hostDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"shortHost" ascending:true selector:"caseInsensitiveCompare:"
set pathDescriptor to current application's class "NSSortDescriptor"'s sortDescriptorWithKey:"components.path" ascending:true selector:"caseInsensitiveCompare:"
theArray's sortUsingDescriptors:{hostDescriptor, pathDescriptor}
set sortedURLs to (theArray's valueForKeyPath:"components.string") as list
repeat with i from 1 to (count sortedURLs)
tell application "Safari" to set front window's tab i's URL to sortedURLs's item i
end repeat
-- Return an NSDictionary in the form:
-- {"components":(NSURLComponents object), "shortHost":(the object's host(), possiby edited)}
on getURLComponents(theURL)
set theComponents to current application's NSURLComponents's componentsWithString:theURL
set theHost to theComponents's |host|()
-- Remove "www.", ensuring it's removed from the beginning. (Just in case! 8=})
set shortHost to theHost's stringByReplacingOccurrencesOfString:"(?i)^www\\." withString:"" options:(current application's NSRegularExpressionSearch) range:{0, theHost's |length|()}
-- Alternatively:
(* set www to current application's NSString's stringWithString:"www."
set shortHost to theHost's stringByReplacingOccurrencesOfString:www withString:"" options:(current application's NSCaseInsensitiveSearch) range:{0, www's |length|()} *)
return current application's NSDictionary's dictionaryWithObjects:{theComponents, shortHost} forKeys:{"components", "shortHost"}
end getURLComponents
Nigel. I tested your script, and it works great. Is there some documentation on URL Components objects, and, more specifically, on how they can be treated as dictionaries? I searched but couldn’t find anything. Thanks!
I hadn’t seen any documentation relating NSURLComponents objects to NSDictionaries when I posted the script, nor anything saying they conformed to the NSKeyValueEncoding protocol. It was just a case of “Hmm. I wonder if this would work?”
But both do inherit from NSObject, and nosing around the NSObject documentation this morning, I see an +instancesRespondToSelector: method which gives encouraging results:
use AppleScript version "2.4" -- OS X 10.10 (Yosemite) or later
use framework "Foundation"
use scripting additions
current application's class "NSURLComponents"'s instancesRespondToSelector:"valueForKey:"
--> true
current application's class "NSURLComponents"'s instancesRespondToSelector:"valueForKeyPath:"
--> true
There’s also an instance method -respondsToSelector:, which is mentioned in Shane’s book and is equally encouraging:
use AppleScript version "2.4" -- OS X 10.10 (Yosemite) or later
use framework "Foundation"
use scripting additions
set theComponents to current application's class "NSURLComponents"'s componentsWithString:"https://macscripter.net"
theComponents's respondsToSelector:"valueForKey:"
--> true
theComponents's respondsToSelector:"valueForKeyPath:"
--> true
The documentation for valueForKeyPath: suggests that it “walks” a path with successive valueForKey: calls, so I’d guess that switching from an NSDictionary to an NSURLComponents object in mid-path isn’t a problem.