Importing Time Entries and Projects from a Toggl Export

Please follow the steps below “Exporting time entries” on this Toggl knowledge bbase page to generate a Timing-compatible report, then run the script below.

Simply copy the following script(s) into a new "Script Editor" document and press run.
After pasting them, consider saving the scripts to disk for future re-use. You could even save them to iCloud Drive (or e.g. Dropbox or Google Drive) to have them available on all your Macs.
Feel free to customize them for your use cases; if you need details on how a particular method works, please refer to our AppleScript reference.

Note: We do not take responsibility for any data loss incurred by running these scripts.
Make sure to back up your data (e.g. by copying the directories mentioned here to a different location) before running these scripts.

-- Copyright (c) 2024 Timing Software GmbH. All rights reserved.
-- This script is licensed only to extend the functionality of Timing. Redistribution and any other uses are not allowed without prior permission from us.
tell application "TimingHelper"
	if not advanced scripting support available then
		error "This script requires a Timing Connect subscription. Please contact support via https://timingapp.com/contact to upgrade."
	end if
end tell

-- AppleScript to import Toggl time entries (and the corresponding projects) into Timing.
-- This script requires a CSV export from Toggl's "Detailed Report" screen.
-- Please see https://support.toggl.com/detailed-reports-toggl-new/#export on how to generate that report.

set csvFile to ((choose file with prompt "Please select a Toggl Export to process:") as string)
-- If you want to specify a file path directly, uncomment this line:
--set csvFile to ":Users:daniel:Documents:AppleScript:Timing:Toggl_time_entries_2017-06-05_to_2017-06-11.csv"

-- Start CSV to list script by Nigel Garvey.
-- Source: http://macscripter.net/viewtopic.php?pid=125444#p125444
(* Assumes that the CSV text adheres to the convention:
   Records are delimited by LFs or CRLFs (but CRs are also allowed here).
   The last record in the text may or may not be followed by an LF or CRLF (or CR).
   Fields in the same record are separated by commas (unless specified differently by parameter).
   The last field in a record must not be followed by a comma.
   Trailing or leading spaces in unquoted fields are not ignored (unless so specified by parameter).
   Fields containing quoted text are quoted in their entirety, any space outside them being ignored.
   Fields enclosed in double-quotes are to be taken verbatim, except for any included double-quote pairs, which are to be translated as double-quote characters.
   
   No other variations are currently supported. *)

on csvToList(csvText, implementation)
	-- The 'implementation' parameter must be a record. Leave it empty ({}) for the default assumptions: ie. comma separator, leading and trailing spaces in unquoted fields not to be trimmed. Otherwise it can have a 'separator' property with a text value (eg. {separator:tab}) and/or a 'trimming' property with a boolean value ({trimming:true}).
	set {separator:separator, trimming:trimming} to (implementation & {separator:",", trimming:false})
	
	script o -- Lists for fast access.
		property qdti : getTextItems(csvText, "\"")
		property currentRecord : {}
		property possibleFields : missing value
		property recordList : {}
	end script
	
	-- o's qdti is a list of the CSV's text items, as delimited by double-quotes.
	-- Assuming the convention mentioned above, the number of items is always odd.
	-- Even-numbered items (if any) are quoted field values and don't need parsing.
	-- Odd-numbered items are everything else. Empty strings in odd-numbered slots
	-- (except at the beginning and end) indicate escaped quotes in quoted fields.
	
	set astid to AppleScript's text item delimiters
	set qdtiCount to (count o's qdti)
	set quoteInProgress to false
	considering case
		repeat with i from 1 to qdtiCount by 2 -- Parse odd-numbered items only.
			set thisBit to item i of o's qdti
			if ((count thisBit) > 0) or (i is qdtiCount) then
				-- This is either a non-empty string or the last item in the list, so it doesn't
				-- represent a quoted quote. Check if we've just been dealing with any.
				if (quoteInProgress) then
					-- All the parts of a quoted field containing quoted quotes have now been
					-- passed over. Coerce them together using a quote delimiter.
					set AppleScript's text item delimiters to "\""
					set thisField to (items a thru (i - 1) of o's qdti) as string
					-- Replace the reconstituted quoted quotes with literal quotes.
					set AppleScript's text item delimiters to "\"\""
					set thisField to thisField's text items
					set AppleScript's text item delimiters to "\""
					-- Store the field in the "current record" list and cancel the "quote in progress" flag.
					set end of o's currentRecord to thisField as string
					set quoteInProgress to false
				else if (i > 1) then
					-- The preceding, even-numbered item is a complete quoted field. Store it.
					set end of o's currentRecord to item (i - 1) of o's qdti
				end if
				
				-- Now parse this item's field-separator-delimited text items, which are either non-quoted fields or stumps from the removal of quoted fields. Any that contain line breaks must be further split to end one record and start another. These could include multiple single-field records without field separators.
				set o's possibleFields to getTextItems(thisBit, separator)
				set possibleFieldCount to (count o's possibleFields)
				repeat with j from 1 to possibleFieldCount
					set thisField to item j of o's possibleFields
					if ((count thisField each paragraph) > 1) then
						-- This "field" contains one or more line endings. Split it at those points.
						set theseFields to thisField's paragraphs
						-- With each of these end-of-record fields except the last, complete the field list for the current record and initialise another. Omit the first "field" if it's just the stub from a preceding quoted field.
						repeat with k from 1 to (count theseFields) - 1
							set thisField to item k of theseFields
							if ((k > 1) or (j > 1) or (i is 1) or ((count trim(thisField, true)) > 0)) then set end of o's currentRecord to trim(thisField, trimming)
							set end of o's recordList to o's currentRecord
							set o's currentRecord to {}
						end repeat
						-- With the last end-of-record "field", just complete the current field list if the field's not the stub from a following quoted field.
						set thisField to end of theseFields
						if ((j < possibleFieldCount) or ((count thisField) > 0)) then set end of o's currentRecord to trim(thisField, trimming)
					else
						-- This is a "field" not containing a line break. Insert it into the current field list if it's not just a stub from a preceding or following quoted field.
						if (((j > 1) and ((j < possibleFieldCount) or (i is qdtiCount))) or ((j is 1) and (i is 1)) or ((count trim(thisField, true)) > 0)) then set end of o's currentRecord to trim(thisField, trimming)
					end if
				end repeat
				
				-- Otherwise, this item IS an empty text representing a quoted quote.
			else if (quoteInProgress) then
				-- It's another quote in a field already identified as having one. Do nothing for now.
			else if (i > 1) then
				-- It's the first quoted quote in a quoted field. Note the index of the
				-- preceding even-numbered item (the first part of the field) and flag "quote in
				-- progress" so that the repeat idles past the remaining part(s) of the field.
				set a to i - 1
				set quoteInProgress to true
			end if
		end repeat
	end considering
	
	-- At the end of the repeat, store any remaining "current record".
	if (o's currentRecord is not {}) then set end of o's recordList to o's currentRecord
	set AppleScript's text item delimiters to astid
	
	return o's recordList
end csvToList

-- Get the possibly more than 4000 text items from a text.
on getTextItems(txt, delim)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to delim
	set tiCount to (count txt's text items)
	set textItems to {}
	repeat with i from 1 to tiCount by 4000
		set j to i + 3999
		if (j > tiCount) then set j to tiCount
		set textItems to textItems & text items i thru j of txt
	end repeat
	set AppleScript's text item delimiters to astid
	
	return textItems
end getTextItems

-- Trim any leading or trailing spaces from a string.
on trim(txt, trimming)
	if (trimming) then
		repeat with i from 1 to (count txt) - 1
			if (txt begins with space) then
				set txt to text 2 thru -1 of txt
			else
				exit repeat
			end if
		end repeat
		repeat with i from 1 to (count txt) - 1
			if (txt ends with space) then
				set txt to text 1 thru -2 of txt
			else
				exit repeat
			end if
		end repeat
		if (txt is space) then set txt to ""
	end if
	
	return txt
end trim
-- End CSV to list script.

-- Start string to date script by Richard Hyde.
-- Source: https://gist.github.com/RichardHyde/3386ac57b55455b71140
to convertDate(textDate)
	set resultDate to the current date
	
	set the year of resultDate to (text 1 thru 4 of textDate)
	set the month of resultDate to (text 6 thru 7 of textDate)
	set the day of resultDate to (text 9 thru 10 of textDate)
	set the time of resultDate to 0
	
	if (length of textDate) > 10 then
		set the hours of resultDate to (text 12 thru 13 of textDate)
		set the minutes of resultDate to (text 15 thru 16 of textDate)
		
		if (length of textDate) > 16 then
			set the seconds of resultDate to (text 18 thru 19 of textDate)
		end if
	end if
	
	return resultDate
end convertDate
-- End string to date script.

set alertResult to display alert "Please back up your data!" message "Before proceeding, please make sure to back up your Timing database.\n\nSee https://timingapp.com/help/faq#data on which folders you need to back up." buttons {"Cancel", "I Have A Backup"}
if button returned of alertResult is "Cancel" then
	error number -128
end if

on getOrCreateProject(projectName, parentProject)
	if projectName = "" then
		return missing value
	end if
	
	tell application "TimingHelper"
		if parentProject is missing value then
			set results to (projects whose name is projectName)
			if results = {} then
				return (create project name projectName)
			else
				return (first item of results)
			end if
		else
			set results to (projects of parentProject whose name is projectName)
			if results = {} then
				return (create project name projectName parent project parentProject)
			else
				return (first item of results)
			end if
		end if
	end tell
end getOrCreateProject

set csvData to csvToList(read file csvFile as «class utf8», {})
set csvData to items 2 thru -1 of csvData

repeat with row in csvData
	set client to item 3 of row
	set project to item 4 of row
	set task to item 5 of row
	set itemDescription to item 6 of row
	set startDay to item 8 of row
	set startTime to item 9 of row
	set endDay to item 10 of row
	set endTime to item 11 of row
	set startDate to convertDate(startDay & " " & startTime)
	set endDate to convertDate(endDay & " " & endTime)
	
	set timingProject to getOrCreateProject(project, getOrCreateProject(client, missing value))
	
	set taskDescription to ""
	if task is not equal to "" and itemDescription is not equal to "" then
		set taskDescription to task & " " & itemDescription
	else if task is not equal to "" then
		set taskDescription to task
	else if itemDescription is not equal to "" then
		set taskDescription to itemDescription
	end if
	
	if startDate is greater than endDate then
		-- swap start and end date if neeeded
		set tmp to startDate
		set startDate to endDate
		set endDate to tmp
	end if
	
	if endDate is greater than startDate then
		-- ignore zero-duration time entries
		tell application "TimingHelper"
			add time entry from startDate to endDate with title taskDescription project timingProject
		end tell
	end if
end repeat

Take our free 5-day course to get started with Timing.