Testing for a Leap Year

…use AppleScriptObjC

use AppleScript version "2.5"
use framework "Foundation"
use scripting additions

set startYear to 2000
set endYear to 2200

set cal to my NSCalendar's currentCalendar()
set dateComponents to my NSDateComponents's new()
set |month| of dateComponents to 2

set leapYears to {}
repeat with i from startYear to endYear
	set |year| of dateComponents to i
	set theDate to (cal's dateFromComponents:dateComponents)
	set theRange to (cal's rangeOfUnit:(my NSCalendarUnitDay) inUnit:(my NSCalendarUnitMonth) forDate:theDate)
	if theRange's |length| as integer is 29 then
		set end of leapYears to (i as text)
	end if
end repeat
set {saveTID, text item delimiters} to {text item delimiters, {", "}}
set theResult to leapYears as text
set AppleScript's text item delimiters to saveTID
display dialog theResult buttons {"Cancel", "OK"} default button "OK"

Update: The script can be sped up even further by ensuring that the starting year is divisible by 4, using this loop.

repeat with i from startYear to endYear by 4
1 Like
set startYear to 1998
set endYear to 2507

set leapYears to {}
repeat with y from ((startYear + 3) div 4 * 4) to endYear by 4
	if ((y mod 100 > 0) or (y mod 400 = 0)) then set leapYears's end to y
end repeat

set output to {"Leap years between " & startYear & " and " & endYear & ":", ""}
set leapYearCount to (count leapYears)
repeat with i from 1 to leapYearCount by 8
	set j to i + 7
	if (j > leapYearCount) then set j to leapYearCount
	set output's end to join(leapYears's items i thru j, space)
end repeat
set output to join(output, linefeed)
display dialog output

on join(lst, delim)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to delim
	set txt to lst as text
	set AppleScript's text item delimiters to astid
	return txt
end join

This is quick! If I ever need to calculate every leap year between 0 and 5000 AD this is the method I plan to use. :blush:

1 Like

:rofl: !!

But of course it only works properly for AD years in the Proleptic Gregorian Calendar. It needs a couple of minor adjustments to handle BC years (if these are treated as negatives) and to omit the non-existent year 0. :slightly_smiling_face:

The approach mentioned above didn’t work, so I used the shell to get leap years. The timing result was 120 milliseconds with the Run Shell Script action not in memory and 60 milliseconds with the Run Shell Script action in memory. This is for the period from 1 to 5000 AD and includes writing the leap years to a text file. The screenshot below is of the shell script.

Save Leap Years in File.shortcut (23.7 KB)

1 Like

Another approach to testing for Leap year without calculations…

Determine Leap Year

Tested: macOS Tahoe 26.5.1

Having opened my big mouth, I thought I’d better have a go at this. BC year numbers are notated and handled as negatives.

set startYear to -401
set endYear to 401

set leapYears to {}
-- The assumption here is that the 400-year cycles continue unbroken back into the BC era
-- despite there being no year 0 in the proleptic Gregorian calendar. In this scenario,
-- BC leap year numbers are 1 less than they'd have been otherwise. For convenience, 
-- the numbers tested are all multiples of 4 and successful results below 1 are decremented.

-- Get the first multiple of 4 after or including startYear.
-- If startYear's 1 or higher, round up by adding 3 and truncating the result: (startYear + 3) div 4 * 4.
-- But if startYear's < 1, add 1 to align with AD, then round up by simply truncating: (startYear + 1) div 4 * 4.
set firstCandidate to (startYear + 3 - 2 * ((startYear < 1) as integer)) div 4 * 4
-- Test successive multiples of 4.
repeat with y from firstCandidate to endYear by 4
	if ((y mod 100 ≠ 0) or (y mod 400 = 0)) then
		if (y < 1) then set y to y - 1 -- Subtract 1 from successful numbers < 1.
		set leapYears's end to y
	end if
end repeat

set output to {"Leap years between " & startYear & " and " & endYear & ":", ""}
set leapYearCount to (count leapYears)
repeat with i from 1 to leapYearCount by 8
	set j to i + 7
	if (j > leapYearCount) then set j to leapYearCount
	set output's end to join(leapYears's items i thru j, space)
end repeat
set output to join(output, linefeed)
display dialog output

on join(lst, delim)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to delim
	set txt to lst as text
	set AppleScript's text item delimiters to astid
	return txt
end join
1 Like

Nigel. Thanks for the script. I don’t know much about the different calendars but read a Wikipedia article.

Your script treats negative calendar years as BC calendar years. FWIW, I made a minor edit to my earlier shortcut to treat negative calendar years as negative calendar years using what is generally referred to as astronomical year numbering. Just as a point of information, the first part of your script calculates this same information, and the shell script in my shortcut could easily be modified to return BC leap years.

Save Leap Years in File.shortcut (23.7 KB)

Please consider that the leap day has been introduced by Julius Caesar on 1 January 45 BC in the Roman/Julian Calendar :wink:

That date itself is a prolepsis. Obviously it wasn’t called 45 BC at the time. :wink:

Just in case anyone wonders what the proleptic Gregorian calendar is, I’ve included below an edited quote from Wikipedia.

The proleptic Gregorian calendar extends the Gregorian Calendar backwards proleptically creating a consistent dating system across history. For this calendar, one can distinguish two systems of numbering years BC. Bede and later historians did not enumerate any year as zero; therefore the year preceding AD 1 is 1 BC. In this system, the year 1 BC is a leap year. Mathematically, it is more convenient to include a year 0 and represent earlier years as negative numbers for the specific purpose of facilitating the calculation of the number of years between a negative (BC) year and a positive (AD) year. This is the convention in astronomical year numbering and the international standard date system, ISO 8601. In these systems, the year 0 is a leap year.

My shortcut uses astronomical year numbering and Nigel’s AppleScript does not. However, a simple edit will make the shortcut and AppleScript work with the other year numbering system.

set output to {}
repeat with indx from -401 to 401
	if Date_Info_Is_Leap_Year(indx) then set the end of output to indx & " "
end repeat
display dialog "" & output


on Date_Info_Is_Leap_Year(dateYear)
	--https://www.macscripter.net/u/Nigel_Garvey	https://www.macscripter.net/t/testing-for-a-leap-year/77975/4
	if dateYear < 1 then set dateYear to (dateYear * -1) - 1
	return ((dateYear mod 4 is 0) and ((dateYear mod 100 > 0) or (dateYear mod 400 is 0)))
end Date_Info_Is_Leap_Year

A nice adaptation of the handler! :sunglasses:

The rest’s quick and dirty though — building a list of lists and assuming the TIDs will be {“”} or “” when it’s coerced to text. :grin:

Thanks. I love tersely perspicuous AppleScript code. :wink:

Fair enough. But the TIDS are going to be {“”}. :stuck_out_tongue_winking_eye:

FWIW, the Shortcuts Get Date from Input action doesn’t work with dates in the BC era and cannot be used to get leap years. Somewhat surprisingly, the Adjust Date action will return a leap year (actually a leap day), but this would seem to be of no practical value. For the BC era, the math approach seems the best solution.

Sorry. A couple more takes on the slightly off-topic “leap years between … using AppleScript” theme. The first uses AppleScript date object math and can handle years between 0000 and 9999:

set startYear to 0
set endYear to 9999

leapYears(startYear, endYear)

on leapYears(startYear, endYear)
	script o
		property output : {}
	end script
	
	tell (startYear + 3) to set startYear to it - it mod 4
	tell (current date) to set {testDate, its day, its month, its year} to {it, 1, January, startYear}
	set fourYears to 1461 * days
	repeat with y from startYear to endYear by 4
		set testDate to testDate + fourYears
		if (testDate's day is 1) then
			set o's output's end to y
		else
			set testDate's day to 1
		end if
	end repeat
	
	return o's output
end leapYears

The other simply runs the 400-year cycles, jumping in and out at the appropriate years. It’s slightly faster than the date object script over the same range and can go way beyond it in either direction using the astronomical year convention:

set startYear to -9999
set endYear to 12000

leapYears(startYear, endYear)

on leapYears(startYear, endYear)
	script o
		property output : {}
	end script
	
	-- Initialise a loop variable y to the next multiple of 4 after or including startYear.
	set roundingAdjustment to 3 * ((startYear comes after 0) as integer) -- Positives and negatives are rounded up slightly differently.
	tell (startYear + roundingAdjustment) to set y to it - it mod 4
	if (y mod 400 = 0) then
		-- y's the last year of a 400-year cycle. Store it immediately and set up to start the next cycle.
		set o's output's end to y
		set y to y + 4
		set centuriesToEndOfCycle to 4
	else
		-- Otherwise, if it's one of the other century years, skip it.
		if (y mod 100 = 0) then set y to y + 4
		-- Work out which of the cycle's centuries, counting from the end, contains y.
		set centuriesToEndOfCycle to 1 + roundingAdjustment - y div 100 mod 4
	end if
	-- Initialise a variable representing the 96th year of each century to that before the current y.
	set centuryLimit to y - y mod 100 - 100 * ((y comes before 1) as integer) - 4
	
	repeat -- Each 400-year cycle.
		repeat centuriesToEndOfCycle times
			-- Generate the leap year numbers from y to this century's 96th year, or until endYear if that comes first.
			set centuryLimit to centuryLimit + 100
			if (centuryLimit comes after endYear) then set centuryLimit to endYear
			repeat with y from y to centuryLimit by 4
				set o's output's end to y
			end repeat
			set y to y + 8 -- Skip the century year …
		end repeat
		set cycleEndYear to y - 4 -- … except at the end of the cycle.
		if (cycleEndYear comes after endYear) then exit repeat
		set o's output's end to cycleEndYear
		set centuriesToEndOfCycle to 4
	end repeat
	
	return o's output
end leapYears

Nigel. I happened to notice that -2004 is shown as a year leap if -2004 is the startYear, but -2000 is not shown as a leap year if -2000 is the startYear.

Thanks, peavine. How annoying. I’ve tracked down what was causing the problem, but fixing it caused something else to fall apart! I’ve removed the script from my post while I do some more work on it….

Thanks again, peavine. I’ve now special-cased the start year being the last year of a 400-year cycle, which seems to have fixed the problem. :crossed_fingers:

Just for the sake of completeness, there’s another approach that’s probably obvious but should be mentioned, and that’s to include a list of leap years in the shortcut. This approach may give an erroneous result if any year contains three or fewer digits. The timing result is less than 10 milliseconds.

Leap Year Test.shortcut (22.4 KB)