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 ];