1 /++
2 
3 	OpenD could use automatic mixin to child class...
4 
5 	Extensions: color. exrule? trash day - if holiday occurred that week, move it forward a day
6 
7 	Standards: categories
8 
9 	UI idea for rrule: show a mini two year block with the day highlighted
10 		-> also just let user click on a bunch of days so they can make a list
11 
12 	Want ability to add special info to a single item of a recurring event
13 
14 	Can use inotify to reload ui when sqlite db changes (or a trigger on postgres?)
15 
16 	https://datatracker.ietf.org/doc/html/rfc5545
17 	https://icalendar.org/
18 +/
19 module arsd.calendar;
20 
21 import arsd.core;
22 import core.time;
23 import std.datetime;
24 
25 /++
26 	History:
27 		Added July 3, 2024
28 +/
29 SimplifiedUtcTimestamp parseTimestampString(string when, SysTime relativeTo) /*pure*/ {
30 	import std.string;
31 
32 	int parsingWhat;
33 	int bufferedNumber = int.max;
34 
35 	int secondsCount;
36 
37 	void addSeconds(string word, int bufferedNumber, int multiplier) {
38 		if(parsingWhat == 0)
39 			parsingWhat = 1;
40 		if(parsingWhat != 1)
41 			throw ArsdException!"unusable timestamp string"("you said 'at' but gave a relative time", when);
42 		if(bufferedNumber == int.max)
43 			throw ArsdException!"unusable timestamp string"("no number before unit", when, word);
44 		secondsCount += bufferedNumber * multiplier;
45 		bufferedNumber = int.max;
46 	}
47 
48 	foreach(word; when.split(" ")) {
49 		word = strip(word).toLower().replace(",", "");
50 		if(word == "in")
51 			parsingWhat = 1;
52 		else if(word == "at")
53 			parsingWhat = 2;
54 		else if(word == "and") {
55 			// intentionally blank
56 		} else if(word.indexOf(":") != -1) {
57 			if(secondsCount != 0)
58 				throw ArsdException!"unusable timestamp string"("cannot mix time styles", when, word);
59 
60 			if(parsingWhat == 0)
61 				parsingWhat = 2; // assume absolute time when this comes in
62 
63 			bool wasPm;
64 
65 			if(word.length > 2 && word[$-2 .. $] == "pm") {
66 				word = word[0 .. $-2];
67 				wasPm = true;
68 			} else if(word.length > 2 && word[$-2 .. $] == "am") {
69 				word = word[0 .. $-2];
70 			}
71 
72 			// FIXME: what about midnight?
73 			int multiplier = 3600;
74 			foreach(part; word.split(":")) {
75 				import arsd.conv;
76 				secondsCount += multiplier * to!int(part);
77 				multiplier /= 60;
78 			}
79 
80 			if(wasPm)
81 				secondsCount += 12 * 3600;
82 		} else if(word.isNumeric()) {
83 			import arsd.conv;
84 			bufferedNumber = to!int(word);
85 		} else if(word == "seconds" || word == "second") {
86 			addSeconds(word, bufferedNumber, 1);
87 		} else if(word == "minutes" || word == "minute") {
88 			addSeconds(word, bufferedNumber, 60);
89 		} else if(word == "hours" || word == "hour") {
90 			addSeconds(word, bufferedNumber, 60 * 60);
91 		} else
92 			throw ArsdException!"unusable timestamp string"("i dont know what this word means", when, word);
93 	}
94 
95 	if(parsingWhat == 0)
96 		throw ArsdException!"unusable timestamp string"("couldn't figure out what to do with this input", when);
97 
98 	else if(parsingWhat == 1) // relative time
99 		return SimplifiedUtcTimestamp((relativeTo + seconds(secondsCount)).stdTime);
100 	else if(parsingWhat == 2) { // absolute time (assuming it is today in our time zone)
101 		auto today = relativeTo;
102 		today.hour = 0;
103 		today.minute = 0;
104 		today.second = 0;
105 		return SimplifiedUtcTimestamp((today + seconds(secondsCount)).stdTime);
106 	} else
107 		assert(0);
108 }
109 
110 unittest {
111 	auto testTime = SysTime(std.datetime.DateTime(std.datetime.Date(2024, 07, 03), TimeOfDay(10, 0, 0)), UTC());
112 	void test(string what, string expected) {
113 		auto result = parseTimestampString(what, testTime).toString;
114 		assert(result == expected, result);
115 	}
116 
117 	test("in 5 minutes", "2024-07-03T10:05:00Z");
118 	test("in 5 minutes and 5 seconds", "2024-07-03T10:05:05Z");
119 	test("in 5 minutes, 45 seconds", "2024-07-03T10:05:45Z");
120 	test("at 5:44", "2024-07-03T05:44:00Z");
121 	test("at 5:44pm", "2024-07-03T17:44:00Z");
122 }
123 
124 private alias UErrorCode = int;
125 private enum UErrorCode U_ZERO_ERROR = 0;
126 private bool U_SUCCESS(UErrorCode code) { return code <= U_ZERO_ERROR; }
127 /+
128 int ucal_getWindowsTimeZoneID(
129 	const wchar* od, int len,
130 	wchar* winIdBuffer, int winIdLength,
131 	UErrorCode* status);
132 +/
133 private extern(C) alias TF = int function(const wchar*, int, wchar*, int, UErrorCode*);
134 
135 /++
136 	Gets a Phobos TimeZone object for the given tz-style location, including on newer Windows computers using their built in database.
137 
138 	History:
139 		Added December 13, 2025
140 
141 	See_Also:
142 		https://devblogs.microsoft.com/oldnewthing/20210527-00/?p=105255
143 +/
144 immutable(std.datetime.TimeZone) getTimeZoneForLocation(string location) {
145 	version(Windows) {
146 		import core.sys.windows.windows;
147 		auto handle = LoadLibrary("icu.dll");
148 		if(handle is null)
149 			throw new WindowsApiException("LoadLibrary", GetLastError());
150 		scope(exit)
151 			FreeLibrary(handle);
152 		auto addr = GetProcAddress(handle, "ucal_getWindowsTimeZoneID");
153 		if(addr is null)
154 			throw new WindowsApiException("GetProcAddress", GetLastError());
155 
156 		auto fn = cast(TF) addr;
157 
158 		WCharzBuffer wloc = location;
159 
160 		wchar[128] buffer = void;
161 		UErrorCode status;
162 		auto result = fn(wloc.ptr, -1, buffer.ptr, cast(int) buffer.length, &status);
163 		if(U_SUCCESS(status)) {
164 			buffer[result] = 0;
165 			string converted = makeUtf8StringFromWindowsString(buffer[0 .. result]);
166 			return WindowsTimeZone.getTimeZone(converted);
167 		} else {
168 			throw new Exception("failure in time zone lookup");
169 		}
170 	} else {
171 		return PosixTimeZone.getTimeZone(location);
172 	}
173 }
174 version(none)
175 unittest {
176 	getTimeZoneForLocation("America/New_York");
177 }
178 
179 /++
180 	Does an efficient search to determine which iteration of the interval on the given date comes closest to the target point without going past it.
181 +/
182 int findNearestIterationTo(PackedDateTime targetPoint, PackedDateTime startPoint, PackedInterval pi) {
183 	return 0;
184 }
185 
186 version(none)
187 void main() {
188 	auto e = new CalendarEvent(
189 		start: DateTime(2024, 4, 22),
190 		end: Date(2024, 04, 22),
191 	);
192 }
193 
194 class Calendar {
195 	CalendarEvent[] events;
196 }
197 
198 /++
199 
200 +/
201 class CalendarEvent {
202 	string tzlocation;
203 	PackedDateTime start;
204 	PackedDateTime end;
205 
206 	Recurrence recurrence;
207 
208 	int color;
209 	string title; // summary
210 	string details;
211 
212 	string uid;
213 
214 	this(PackedDateTime start, PackedDateTime end, Recurrence recurrence = Recurrence.none) {
215 		this.start = start;
216 		this.end = end;
217 		this.recurrence = recurrence;
218 	}
219 }
220 
221 /+
222 struct Date {
223 	int year;
224 	int month;
225 	int day;
226 }
227 
228 struct Time {
229 	int hour;
230 	int minute;
231 	int second;
232 	int fractionalSeconds;
233 }
234 
235 struct DateTime {
236 	Date date;
237 	Time time;
238 }
239 +/
240 
241 /++
242 
243 +/
244 struct Recurrence {
245 	static Recurrence none() {
246 		return Recurrence.init;
247 	}
248 }
249 
250 enum FREQ {
251 	SECONDLY,
252 	MINUTELY,
253 	HOURLY,
254 	DAILY,
255 	WEEKLY,
256 	MONTHLY,
257 	YEARLY,
258 }
259 
260 PackedInterval packedIntervalForRruleFreq(FREQ freq, int interval) {
261 	final switch(freq) {
262 		case FREQ.SECONDLY:
263 			return PackedInterval(0, 0, 1000 * interval);
264 		case FREQ.MINUTELY:
265 			return PackedInterval(0, 0, 60 * 1000 * interval);
266 		case FREQ.HOURLY:
267 			return PackedInterval(0, 0, 60 * 60 * 1000 * interval);
268 		case FREQ.DAILY:
269 			return PackedInterval(0, 1 * interval, 0);
270 		case FREQ.WEEKLY:
271 			return PackedInterval(0, 7 * interval, 0);
272 		case FREQ.MONTHLY:
273 			return PackedInterval(1 * interval, 0, 0);
274 		case FREQ.YEARLY:
275 			return PackedInterval(12 * interval, 0, 0);
276 	}
277 }
278 
279 // https://datatracker.ietf.org/doc/html/rfc5545
280 struct RRULE {
281 	FREQ freq;
282 	int interval;
283 	int count;
284 
285 	DAY wkst; // this determines, i think, how you determine how often a thing is allowed to occur. so if wkstart == wednesday and you set every other tuesday/thursday starting from a tuesday... it starts then, then +2 weeks for the next. but when you get to wednesday, it reset the counter figuring one happened last week, so it'll be another wek. thus it alternates tue/thurs each week. kinda nuts.
286 
287 	alias DAY = int;
288 	static struct DAYSET {
289 		ulong firstBits;
290 		ushort moreBits;
291 	}
292 	alias MONTHDAYSET = ulong;
293 	alias HOURSET = uint;
294 	alias MONTHDSET = ushort;
295 	alias WEEKSET = ulong;
296 
297 	// if there's a BYsomething available, that changes the start time for the interval. multiple by things make multiple intervals.
298 
299 	// i don't think it ever really filters.
300 
301 	// these can be negative too indicating the xth from the last...
302 	DAYSET byday; // ubyte bitmask... except it can also have numbers attached wtf.
303 	// so like `BYDAY=-2MO` means second-to-last monday
304 	// we can prolly have anything from -5 to +5 for each of the 7 days. 0 means all of them. and you can have multiple of any.
305 	// but who would ever say -5 lol? i guess that would be the first day in a month where there are 5.
306 	// so that's 11 numbers * 7 days = 77 bits.
307 
308 	MONTHDAYSET byMonthDay; // uint bitmask. can also be negative numbers so probably two.. or just a ulong.
309 	HOURSET byHour; // uint bitmask
310 	MONTHDSET byMonth; // ushort bitmask
311 
312 	WEEKSET byWeekNo; // ulong bitmask. can also be negative.
313 
314 	short[4] BYSETPOS; // can be like -365 inclusive to +365. you can have multiple of these but i don't think packing it is useful. just a sorted array prolly... if there's more than 4, meh, wtf.
315 
316 	PackedDateTime DTSTART;
317 	PackedDateTime UNTIL; // inclusive
318 }
319 
320 struct ICalParser {
321 	// if the following line starts with whitespace, remove the cr/lf/ and that ONE ws char, then add to the previous line
322 	// it is supposed to support this even if it is in the middle of a utf-8 sequence
323 	//      contentline   = name *(";" param ) ":" value CRLF
324 	// you're supposed to split lines longer than 75 octets when generating.
325 
326 	void feedEntireFile(in ubyte[] data) {
327 		feed(data);
328 		feed(null);
329 	}
330 	void feedEntireFile(in char[] data) {
331 		feed(data);
332 		feed(null);
333 	}
334 
335 	/++
336 		Feed it some data you have ready.
337 
338 		Feed it an empty array or `null` to indicate end of input.
339 	+/
340 	void feed(in char[] data) {
341 		feed(cast(const(ubyte)[]) data);
342 	}
343 
344 	/// ditto
345 	void feed(in ubyte[] data) {
346 		const(ubyte)[] toProcess;
347 		if(unprocessedData.length) {
348 			unprocessedData ~= data;
349 			toProcess = unprocessedData;
350 		} else {
351 			toProcess = data;
352 		}
353 
354 		auto eol = toProcess.indexOf("\n");
355 		if(eol == -1) {
356 			unprocessedData = cast(ubyte[]) toProcess;
357 		} else {
358 			// if it is \r\n, remove the \r FIXME
359 			// if it is \r\n<space>, need to concat
360 			// if it is \r\n\t, also need to concat
361 			processLine(toProcess[0 .. eol]);
362 		}
363 	}
364 
365 	/// ditto
366 	void feed(typeof(null)) {
367 		feed(cast(const(ubyte)[]) null);
368 	}
369 
370 	private ubyte[] unprocessedData;
371 
372 	private void processLine(in ubyte[] line) {
373 
374 	}
375 }
376 
377 immutable monthNames = [
378 	"",
379 	"January",
380 	"February",
381 	"March",
382 	"April",
383 	"May",
384 	"June",
385 	"July",
386 	"August",
387 	"September",
388 	"October",
389 	"November",
390 	"December"
391 ];
392 
393 immutable daysOfWeekNames = [
394 	"Sunday",
395 	"Monday",
396 	"Tuesday",
397 	"Wednesday",
398 	"Thursday",
399 	"Friday",
400 	"Saturday",
401 ];