How to Write an AppleScript to Arrange Safari Tabs by Website?

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.

Thank you in advance for your help!

Best regards,
Julian Luke

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.

Hi @JulianLuke. Welcome to MacScripter.

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
1 Like

Duh! A simpler alternative would be to reassign the sorted URLs to the existing tabs. :roll_eyes:

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
1 Like

Hi @peavine.

Thanks for trying out the script.

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

But your version’s undoubtedly better practice! :grin:

Thanks Nigel. That’s some very poor thinking on my part and makes the script unusable. I’ll delete it.

I do like the idea of sorting on the host component of the URL, and I’ll give that some additional thought. I’ll also test your regex suggestion.

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

Hi peavine.

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
1 Like

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
2 Likes

Hi peavine. Nice! :sunglasses:

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: :slightly_smiling_face:

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
2 Likes

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!

Hi peavine.

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.

1 Like

Thanks Nigel for the explanation. It took some additional thought and testing, but I understand now.