├── .editorconfig
├── .eslintrc.json
├── .gitignore
├── .husky
└── pre-commit
├── LICENSE
├── README.md
├── assets
└── scriptable-calendar-widget.jpg
├── calendar.js
├── jest.config.cjs
├── package-lock.json
├── package.json
├── src
├── addWidgetTextLine.ts
├── buildCalendar.ts
├── buildCalendarView.ts
├── buildEventsView.ts
├── buildLargeWidget.ts
├── buildWidget.ts
├── countEvents.ts
├── createDateImage.ts
├── createUrl.ts
├── formatEvent.ts
├── formatTime.ts
├── getEventIcon.ts
├── getEvents.ts
├── getMonthBoundaries.ts
├── getMonthOffset.ts
├── getSuffix.ts
├── getWeekLetters.ts
├── index.ts
├── isDateFromBoundingMonth.ts
├── isWeekend.ts
├── setWidgetBackground.ts
└── settings.ts
├── test
└── getWeekLetters.test.ts
├── tsconfig.json
└── util
├── postBundle.js
└── watchBuildMove.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_size = 2
8 | indent_style = space
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@typescript-eslint","prettier", "import"],
3 | "extends": [
4 | "plugin:@typescript-eslint/recommended",
5 | "prettier",
6 | "plugin:prettier/recommended"
7 | ],
8 | "parser": "@typescript-eslint/parser",
9 | "parserOptions": {
10 | "ecmaVersion": 11,
11 | "sourceType": "module"
12 | },
13 | "settings": {
14 | "import/resolver": {
15 | "node": {
16 | "moduleDirectory": ["node_modules", "src/"],
17 | "extensions": [".js", ".jsx", ".ts", ".tsx"]
18 | }
19 | }
20 | },
21 | "rules": {
22 | "quotes": "off",
23 | "object-curly-newline": "off",
24 | "comma-dangle": "off",
25 | "import/extensions": [
26 | "error",
27 | "ignorePackages",
28 | {
29 | "js": "never",
30 | "jsx": "never",
31 | "ts": "never",
32 | "tsx": "never"
33 | }
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dev
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run build && git add calendar.js
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Raigo Jerva
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | **As of October 2023 this project is now archived. However, there is a continuation with even more amazing features over at [ReinforceZwei/scriptable-calendar-widget](https://github.com/ReinforceZwei/scriptable-calendar-widget).**
2 |
3 | ---
4 |
5 |
6 |
7 |
8 |
9 | - [Setting Up](#setting-up)
10 | - [Customization](#customization)
11 | - [Small Widgets](#small-widgets)
12 | - [Large Widgets](#large-widgets)
13 | - [Development](#development)
14 |
15 | ## Setting Up
16 |
17 | - Copy the script in [calendar.js](./calendar.js) to a new script in Scriptable app.
18 | - Run the script first which should prompt Scriptable to ask for calendar access.
19 | - If it didn't and you haven't given Scriptable calendar access before, try changing the `debug` setting to `true` and trying again.
20 | - **To have the widget open the iOS calendar app, switch `debug` back to `false` afterwards.**
21 | - Add a medium sized Scriptable widget to your homescreen.
22 | - Long press the widget and choose "Edit Widget".
23 | - Set the `Script` to be the script you just created and `When Interacting` to `Run Script` which will then launch Calendar app when you tap on the widget.
24 | - Return to your home screen which should now hopefully show the Scriptable calendar widget.
25 |
26 | ## Customization
27 |
28 | - `debug` - set to `true` to show the widget in Scriptable, `false` to open a
29 | calendar app.
30 | - `calendarApp` - Tapping on the widget launches a calendar app (as long as `debug: false`), by default it launches the iOS Calendar app, however it can be changed to anything as long as the app supports callback URLs. Changing the `calshow` to something else would open other apps. E.g. for Google Calendar it is `googlecalendar`, for Fantastical it is `x-fantastical3`.
31 | - `backgroundImage` - Image path to use as the widget background, which is taken either from the widget parameters, from the `params` variable at the top, or just replace `params.bg` with the image path. To get an image that can then be used to have a "transparent" widget background use [this](https://gist.github.com/mzeryck/3a97ccd1e059b3afa3c6666d27a496c9#gistcomment-3468585) script and save it to the _Scriptable_ folder on iCloud. Then set either the widget parameter (long press on the widget -> edit widget -> parameter) to `{ "bg": "my-image.jpg"}` where `my-image` is the name of your transparent background **OR** change the line which has `{ bg: "1121.jpg" }` to include your image name.
32 | - `calFilter` - Optionally an array of calendars to show, shows all calendars if empty. Can be supplied as a widget parameter to only affect that particular widget.
33 | - `widgetBackgroundColor` - In case of no background image, what color to use.
34 | - `todayTextColor` - color of today's date
35 | - `markToday` - show a circle around today or not
36 | - `todayCircleColor` - if we mark days, then in what color
37 | - `showEventCircles` - adds colored background for all days that have an event. The color intensity is based roughly on how many events take place that day.
38 | - `discountAllDayEvents` - if true, all-day events don't count towards eventCircle intensity value
39 | - `eventCircleColor` - if showing event circles, then in what color
40 | - `weekdayTextColor` - color of weekdays
41 | - `weekendLetters` - color of the letters in the top row
42 | - `weekendLettersOpacity` - a value between 0 and 1 to dim the color of the letters
43 | - `weekendDates` - color of the weekend days
44 | - `locale` - a Unicode locale identifier string, this would change the weekday letters to the specified language.
45 | - `textColor` - color of all the other text
46 | - `eventDateTimeOpacity` - opacity value for event times
47 | - `widgetType` - for small widgets it determines which side to show. This would be set through widget parameters in order to set it per widget basis, rather than setting here and having all small widgets be the same type. (check: [Small widgets](#small-widgets))
48 | - `showAllDayEvents` - would either show or hide all day events.
49 | - `showCalendarBullet` - would show a `●` in front of the event name which matches the calendar color from which the event originates.
50 | - `startWeekOnSunday` - would start the week either on a Sunday or a Monday.
51 | - `showEventsOnlyForToday` - would either limit the events to today or a specified number of future days with `nextNumOfDays`
52 | - `nextNumOfDays` - if `showEventsOnlyForToday` is set to `false`, this allows specifying how far into the future to look for events. There is probably a limit by iOS on how far into the future it can look.
53 | - `showCompleteTitle` - would truncate long event titles so that they can fit onto a single line to fit more events into the view.
54 | - `showPrevMonth` - would show days from the previous month if they fit into the calendar view.
55 | - `showNextMonth` - would show days from the next month if they fit into the calendar view.
56 | - `individualDateTargets` - would allow tapping on a date to open that specific day in the calendar set by the `calendarApp` setting. (atm, supports default iOS calendar and Fantastical callback urls, should be possible to add more).
57 | - `flipped` - the layout for the medium-sized widget can be either the default, `events - calendar`, or a flipped, `calendar - events` layout. This setting can also be given as a widget parameter (something like: `{ "flipped": true }`) to just affect that particular widget.
58 |
59 | ## Small Widgets
60 |
61 | The script also supports small widgets in which case the widget parameter (long press on the widget -> edit widget -> parameter) should be set to something like:
62 |
63 | - `{ "bg": "top-left.jpg", "view": "events" }`
64 | - `{ "bg": "top-right.jpg", "view": "cal" }`
65 |
66 | Where `"events"` specifies the events view and `"cal"` the calendar view. (Setting the background is not necessary).
67 |
68 | ## Large Widgets
69 |
70 | The script should detect on its own that it is running in a large widget and will adjust accordingly.
71 |
72 | ## Development
73 |
74 | - `npm install` - install dev dependencies
75 | - `npm run dev` - this watches for file changes, bundles them, fixes syntax and copies the output file to iCloud. This workflow is not tested on any other system but mine which is a macOS, so it is very likely to break on anything else.
76 |
--------------------------------------------------------------------------------
/assets/scriptable-calendar-widget.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rudotriton/scriptable-calendar-widget/680f60293b62eb9dfcdfa11fea8f4872bd5c0dff/assets/scriptable-calendar-widget.jpg
--------------------------------------------------------------------------------
/calendar.js:
--------------------------------------------------------------------------------
1 | // src/settings.ts
2 | var params = JSON.parse(args.widgetParameter) || {};
3 | var settings = {
4 | debug: false,
5 | calendarApp: "calshow",
6 | backgroundImage: params.bg ? params.bg : "transparent.jpg",
7 | calFilter: params.calFilter ? params.calFilter : [],
8 | widgetBackgroundColor: "#000000",
9 | todayTextColor: "#000000",
10 | markToday: true,
11 | todayCircleColor: "#FFB800",
12 | showEventCircles: true,
13 | discountAllDayEvents: false,
14 | eventCircleColor: "#1E5C7B",
15 | weekdayTextColor: "#ffffff",
16 | weekendLetters: "#FFB800",
17 | weekendLetterOpacity: 1,
18 | weekendDates: "#FFB800",
19 | locale: "en-US",
20 | textColor: "#ffffff",
21 | eventDateTimeOpacity: 0.7,
22 | widgetType: params.view ? params.view : "cal",
23 | showAllDayEvents: true,
24 | showCalendarBullet: true,
25 | startWeekOnSunday: false,
26 | showEventsOnlyForToday: false,
27 | nextNumOfDays: 7,
28 | showCompleteTitle: false,
29 | showPrevMonth: true,
30 | showNextMonth: true,
31 | individualDateTargets: false,
32 | flipped: params.flipped ? params.flipped : false,
33 | };
34 | var settings_default = settings;
35 |
36 | // src/setWidgetBackground.ts
37 | function setWidgetBackground(widget, imageName) {
38 | const imageUrl = getImageUrl(imageName);
39 | const image = Image.fromFile(imageUrl);
40 | widget.backgroundImage = image;
41 | }
42 | function getImageUrl(name) {
43 | const fm = FileManager.iCloud();
44 | const dir = fm.documentsDirectory();
45 | return fm.joinPath(dir, `${name}`);
46 | }
47 | var setWidgetBackground_default = setWidgetBackground;
48 |
49 | // src/addWidgetTextLine.ts
50 | function addWidgetTextLine(
51 | text,
52 | widget,
53 | {
54 | textColor = "#ffffff",
55 | textSize = 12,
56 | opacity = 1,
57 | align,
58 | font,
59 | lineLimit = 0,
60 | }
61 | ) {
62 | const textLine = widget.addText(text);
63 | textLine.textColor = new Color(textColor, 1);
64 | textLine.lineLimit = lineLimit;
65 | if (typeof font === "string") {
66 | textLine.font = new Font(font, textSize);
67 | } else {
68 | textLine.font = font;
69 | }
70 | textLine.textOpacity = opacity;
71 | switch (align) {
72 | case "left":
73 | textLine.leftAlignText();
74 | break;
75 | case "center":
76 | textLine.centerAlignText();
77 | break;
78 | case "right":
79 | textLine.rightAlignText();
80 | break;
81 | default:
82 | textLine.leftAlignText();
83 | break;
84 | }
85 | }
86 | var addWidgetTextLine_default = addWidgetTextLine;
87 |
88 | // src/getMonthBoundaries.ts
89 | function getMonthBoundaries(date) {
90 | const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1);
91 | const lastOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0);
92 | return { firstOfMonth, lastOfMonth };
93 | }
94 | var getMonthBoundaries_default = getMonthBoundaries;
95 |
96 | // src/getMonthOffset.ts
97 | function getMonthOffset(date, offset) {
98 | const newDate = new Date(date);
99 | let offsetMonth = date.getMonth() + offset;
100 | if (offsetMonth < 0) {
101 | offsetMonth += 12;
102 | newDate.setFullYear(date.getFullYear() - 1);
103 | } else if (offsetMonth > 11) {
104 | offsetMonth -= 12;
105 | newDate.setFullYear(date.getFullYear() + 1);
106 | }
107 | newDate.setMonth(offsetMonth, 1);
108 | return newDate;
109 | }
110 | var getMonthOffset_default = getMonthOffset;
111 |
112 | // src/getWeekLetters.ts
113 | function getWeekLetters(locale = "en-US", startWeekOnSunday = false) {
114 | let week = [];
115 | for (let i = 1; i <= 7; i += 1) {
116 | const day = new Date(`February 0${i}, 2021`);
117 | week.push(day.toLocaleDateString(locale, { weekday: "long" }));
118 | }
119 | week = week.map((day) => [day.slice(0, 1).toUpperCase()]);
120 | if (startWeekOnSunday) {
121 | const sunday = week.pop();
122 | week.unshift(sunday);
123 | }
124 | return week;
125 | }
126 | var getWeekLetters_default = getWeekLetters;
127 |
128 | // src/buildCalendar.ts
129 | function buildCalendar(
130 | date = new Date(),
131 | {
132 | locale,
133 | showPrevMonth = true,
134 | showNextMonth = true,
135 | startWeekOnSunday = false,
136 | }
137 | ) {
138 | const currentMonth = getMonthBoundaries_default(date);
139 | const prevMonth = getMonthBoundaries_default(
140 | getMonthOffset_default(date, -1)
141 | );
142 | const calendar = getWeekLetters_default(locale, startWeekOnSunday);
143 | let daysFromPrevMonth = 0;
144 | let daysFromNextMonth = 0;
145 | let index = 1;
146 | let offset = 1;
147 | let firstDay =
148 | currentMonth.firstOfMonth.getDay() !== 0
149 | ? currentMonth.firstOfMonth.getDay()
150 | : 7;
151 | if (startWeekOnSunday) {
152 | index = 0;
153 | offset = 0;
154 | firstDay = firstDay % 7;
155 | }
156 | let dayStackCounter = 0;
157 | for (; index < firstDay; index += 1) {
158 | if (showPrevMonth) {
159 | calendar[index - offset].push(
160 | `${prevMonth.lastOfMonth.getMonth()}/${
161 | prevMonth.lastOfMonth.getDate() - firstDay + 1 + index
162 | }`
163 | );
164 | daysFromPrevMonth += 1;
165 | } else {
166 | calendar[index - offset].push(" ");
167 | }
168 | dayStackCounter = (dayStackCounter + 1) % 7;
169 | }
170 | for (
171 | let indexDate = 1;
172 | indexDate <= currentMonth.lastOfMonth.getDate();
173 | indexDate += 1
174 | ) {
175 | calendar[dayStackCounter].push(`${date.getMonth()}/${indexDate}`);
176 | dayStackCounter = (dayStackCounter + 1) % 7;
177 | }
178 | let longestColumn = calendar.reduce(
179 | (acc, dayStacks) => (dayStacks.length > acc ? dayStacks.length : acc),
180 | 0
181 | );
182 | if (showNextMonth && longestColumn < 6) {
183 | longestColumn += 1;
184 | }
185 | const nextMonth = getMonthOffset_default(date, 1);
186 | calendar.forEach((dayStacks, index2) => {
187 | while (dayStacks.length < longestColumn) {
188 | if (showNextMonth) {
189 | daysFromNextMonth += 1;
190 | calendar[index2].push(`${nextMonth.getMonth()}/${daysFromNextMonth}`);
191 | } else {
192 | calendar[index2].push(" ");
193 | }
194 | }
195 | });
196 | return { calendar, daysFromPrevMonth, daysFromNextMonth };
197 | }
198 | var buildCalendar_default = buildCalendar;
199 |
200 | // src/countEvents.ts
201 | async function countEvents(
202 | date,
203 | extendToPrev = 0,
204 | extendToNext = 0,
205 | settings2
206 | ) {
207 | const { firstOfMonth } = getMonthBoundaries_default(date);
208 | const { startDate, endDate } = extendBoundaries(
209 | firstOfMonth,
210 | extendToPrev,
211 | extendToNext
212 | );
213 | let events = await CalendarEvent.between(startDate, endDate);
214 | events = trimEvents(events, settings2);
215 | const eventCounts = /* @__PURE__ */ new Map();
216 | events.forEach((event) => {
217 | if (event.isAllDay) {
218 | const date2 = event.startDate;
219 | do {
220 | updateEventCounts(date2, eventCounts);
221 | date2.setDate(date2.getDate() + 1);
222 | } while (date2 < event.endDate);
223 | } else {
224 | updateEventCounts(event.startDate, eventCounts);
225 | }
226 | });
227 | const intensity = calculateIntensity(eventCounts);
228 | return { eventCounts, intensity };
229 | }
230 | function trimEvents(events, settings2) {
231 | let trimmedEvents = events;
232 | if (settings2.calFilter.length) {
233 | trimmedEvents = events.filter((event) =>
234 | settings2.calFilter.includes(event.calendar.title)
235 | );
236 | }
237 | if (settings2.discountAllDayEvents || !settings2.showAllDayEvents) {
238 | trimmedEvents = trimmedEvents.filter((event) => !event.isAllDay);
239 | }
240 | return trimmedEvents;
241 | }
242 | function extendBoundaries(first, extendToPrev, extendToNext) {
243 | const startDate = new Date(
244 | first.getFullYear(),
245 | first.getMonth(),
246 | first.getDate() - extendToPrev
247 | );
248 | const endDate = new Date(
249 | first.getFullYear(),
250 | first.getMonth() + 1,
251 | first.getDate() + extendToNext
252 | );
253 | return { startDate, endDate };
254 | }
255 | function updateEventCounts(date, eventCounts) {
256 | if (eventCounts.has(`${date.getMonth()}/${date.getDate()}`)) {
257 | eventCounts.set(
258 | `${date.getMonth()}/${date.getDate()}`,
259 | eventCounts.get(`${date.getMonth()}/${date.getDate()}`) + 1
260 | );
261 | } else {
262 | eventCounts.set(`${date.getMonth()}/${date.getDate()}`, 1);
263 | }
264 | }
265 | function calculateIntensity(eventCounts) {
266 | const counter = eventCounts.values();
267 | const counts = [];
268 | for (const count of counter) {
269 | counts.push(count);
270 | }
271 | const max = Math.max(...counts);
272 | const min = Math.min(...counts);
273 | let intensity = 1 / (max - min + 1);
274 | intensity = intensity < 0.3 ? 0.3 : intensity;
275 | return intensity;
276 | }
277 | var countEvents_default = countEvents;
278 |
279 | // src/createDateImage.ts
280 | function createDateImage(
281 | text,
282 | { backgroundColor, textColor, intensity, toFullSize }
283 | ) {
284 | const size = toFullSize ? 50 : 35;
285 | const drawing = new DrawContext();
286 | drawing.respectScreenScale = true;
287 | const contextSize = 50;
288 | drawing.size = new Size(contextSize, contextSize);
289 | drawing.opaque = false;
290 | drawing.setFillColor(new Color(backgroundColor, intensity));
291 | drawing.fillEllipse(
292 | new Rect(
293 | (contextSize - (size - 2)) / 2,
294 | (contextSize - (size - 2)) / 2,
295 | size - 2,
296 | size - 2
297 | )
298 | );
299 | drawing.setFont(Font.boldSystemFont(size * 0.5));
300 | drawing.setTextAlignedCenter();
301 | drawing.setTextColor(new Color(textColor, 1));
302 | const textBox = new Rect(
303 | (contextSize - size) / 2,
304 | (contextSize - size * 0.5) / 2 - 3,
305 | size,
306 | size * 0.5
307 | );
308 | drawing.drawTextInRect(text, textBox);
309 | return drawing.getImage();
310 | }
311 | var createDateImage_default = createDateImage;
312 |
313 | // src/isDateFromBoundingMonth.ts
314 | function isDateFromBoundingMonth(row, column, date, calendar) {
315 | const [month] = calendar[row][column].split("/");
316 | const currentMonth = date.getMonth().toString();
317 | return month === currentMonth;
318 | }
319 | var isDateFromBoundingMonth_default = isDateFromBoundingMonth;
320 |
321 | // src/isWeekend.ts
322 | function isWeekend(index, startWeekOnSunday = false) {
323 | if (startWeekOnSunday) {
324 | switch (index) {
325 | case 0:
326 | case 6:
327 | return true;
328 | default:
329 | return false;
330 | }
331 | }
332 | return index > 4;
333 | }
334 | var isWeekend_default = isWeekend;
335 |
336 | // src/createUrl.ts
337 | function createUrl(day, month, date, settings2) {
338 | let url;
339 | let year;
340 | const currentMonth = date.getMonth();
341 | if (currentMonth === 11 && Number(month) === 1) {
342 | year = date.getFullYear() + 1;
343 | } else if (currentMonth === 0 && Number(month) === 11) {
344 | year = date.getFullYear() - 1;
345 | } else {
346 | year = date.getFullYear();
347 | }
348 | if (settings2.calendarApp === "calshow") {
349 | const appleDate = new Date("2001/01/01");
350 | const timestamp =
351 | (new Date(`${year}/${Number(month) + 1}/${day}`).getTime() -
352 | appleDate.getTime()) /
353 | 1e3;
354 | url = `calshow:${timestamp}`;
355 | } else if (settings2.calendarApp === "x-fantastical3") {
356 | url = `${settings2.calendarApp}://show/calendar/${year}-${
357 | Number(month) + 1
358 | }-${day}`;
359 | } else {
360 | url = "";
361 | }
362 | return url;
363 | }
364 | var createUrl_default = createUrl;
365 |
366 | // src/buildCalendarView.ts
367 | async function buildCalendarView(date, stack, settings2) {
368 | const rightStack = stack.addStack();
369 | rightStack.layoutVertically();
370 | const dateFormatter = new DateFormatter();
371 | dateFormatter.dateFormat = "MMMM";
372 | dateFormatter.locale = settings2.locale.split("-")[0];
373 | const spacing = config.widgetFamily === "small" ? 18 : 19;
374 | const monthLine = rightStack.addStack();
375 | monthLine.addSpacer(4);
376 | addWidgetTextLine_default(
377 | dateFormatter.string(date).toUpperCase(),
378 | monthLine,
379 | {
380 | textColor: settings2.textColor,
381 | textSize: 14,
382 | font: Font.boldSystemFont(13),
383 | }
384 | );
385 | const calendarStack = rightStack.addStack();
386 | calendarStack.spacing = 2;
387 | const { calendar, daysFromPrevMonth, daysFromNextMonth } =
388 | buildCalendar_default(date, settings2);
389 | const { eventCounts, intensity } = await countEvents_default(
390 | date,
391 | daysFromPrevMonth,
392 | daysFromNextMonth,
393 | settings2
394 | );
395 | for (let i = 0; i < calendar.length; i += 1) {
396 | const weekdayStack = calendarStack.addStack();
397 | weekdayStack.layoutVertically();
398 | for (let j = 0; j < calendar[i].length; j += 1) {
399 | const dayStack = weekdayStack.addStack();
400 | dayStack.size = new Size(spacing, spacing);
401 | dayStack.centerAlignContent();
402 | const [day, month] = calendar[i][j].split("/").reverse();
403 | if (settings2.individualDateTargets) {
404 | const callbackUrl = createUrl_default(day, month, date, settings2);
405 | if (j > 0) dayStack.url = callbackUrl;
406 | }
407 | if (calendar[i][j] === `${date.getMonth()}/${date.getDate()}`) {
408 | if (settings2.markToday) {
409 | const highlightedDate = createDateImage_default(day, {
410 | backgroundColor: settings2.todayCircleColor,
411 | textColor: settings2.todayTextColor,
412 | intensity: 1,
413 | toFullSize: true,
414 | });
415 | dayStack.addImage(highlightedDate);
416 | } else {
417 | addWidgetTextLine_default(day, dayStack, {
418 | textColor: settings2.todayTextColor,
419 | font: Font.boldSystemFont(10),
420 | align: "center",
421 | });
422 | }
423 | } else if (j > 0 && calendar[i][j] !== " ") {
424 | const toFullSize = isDateFromBoundingMonth_default(
425 | i,
426 | j,
427 | date,
428 | calendar
429 | );
430 | const dateImage = createDateImage_default(day, {
431 | backgroundColor: settings2.eventCircleColor,
432 | textColor: isWeekend_default(i, settings2.startWeekOnSunday)
433 | ? settings2.weekendDates
434 | : settings2.weekdayTextColor,
435 | intensity: settings2.showEventCircles
436 | ? eventCounts.get(calendar[i][j]) * intensity
437 | : 0,
438 | toFullSize,
439 | });
440 | dayStack.addImage(dateImage);
441 | } else {
442 | addWidgetTextLine_default(day, dayStack, {
443 | textColor: isWeekend_default(i, settings2.startWeekOnSunday)
444 | ? settings2.weekendLetters
445 | : settings2.textColor,
446 | opacity: isWeekend_default(i, settings2.startWeekOnSunday)
447 | ? settings2.weekendLetterOpacity
448 | : 1,
449 | font: Font.boldSystemFont(10),
450 | align: "center",
451 | });
452 | }
453 | }
454 | }
455 | }
456 | var buildCalendarView_default = buildCalendarView;
457 |
458 | // src/formatTime.ts
459 | function formatTime(date) {
460 | const dateFormatter = new DateFormatter();
461 | dateFormatter.useNoDateStyle();
462 | dateFormatter.useShortTimeStyle();
463 | return dateFormatter.string(date);
464 | }
465 | var formatTime_default = formatTime;
466 |
467 | // src/getSuffix.ts
468 | function getSuffix(date) {
469 | if (date > 3 && date < 21) return "th";
470 | switch (date % 10) {
471 | case 1:
472 | return "st";
473 | case 2:
474 | return "nd";
475 | case 3:
476 | return "rd";
477 | default:
478 | return "th";
479 | }
480 | }
481 | var getSuffix_default = getSuffix;
482 |
483 | // src/getEventIcon.ts
484 | function getEventIcon(event) {
485 | if (event.attendees === null) {
486 | return "\u25CF ";
487 | }
488 | const status = event.attendees.filter((attendee) => attendee.isCurrentUser)[0]
489 | .status;
490 | switch (status) {
491 | case "accepted":
492 | return "\u2713 ";
493 | case "tentative":
494 | return "~ ";
495 | case "declined":
496 | return "\u2718 ";
497 | default:
498 | return "\u25CF ";
499 | }
500 | }
501 | var getEventIcon_default = getEventIcon;
502 |
503 | // src/formatEvent.ts
504 | function formatEvent(
505 | stack,
506 | event,
507 | { eventDateTimeOpacity, textColor, showCalendarBullet, showCompleteTitle }
508 | ) {
509 | const eventLine = stack.addStack();
510 | if (showCalendarBullet) {
511 | const icon = getEventIcon_default(event);
512 | addWidgetTextLine_default(icon, eventLine, {
513 | textColor: event.calendar.color.hex,
514 | font: Font.mediumSystemFont(14),
515 | lineLimit: showCompleteTitle ? 0 : 1,
516 | });
517 | }
518 | addWidgetTextLine_default(event.title, eventLine, {
519 | textColor,
520 | font: Font.mediumSystemFont(14),
521 | lineLimit: showCompleteTitle ? 0 : 1,
522 | });
523 | let time;
524 | if (event.isAllDay) {
525 | time = "All Day";
526 | } else {
527 | time = `${formatTime_default(event.startDate)} - ${formatTime_default(
528 | event.endDate
529 | )}`;
530 | }
531 | const today = new Date().getDate();
532 | const eventDate = event.startDate.getDate();
533 | if (eventDate !== today) {
534 | time = `${eventDate}${getSuffix_default(eventDate)} ${time}`;
535 | }
536 | addWidgetTextLine_default(time, stack, {
537 | textColor,
538 | opacity: eventDateTimeOpacity,
539 | font: Font.regularSystemFont(14),
540 | });
541 | }
542 | var formatEvent_default = formatEvent;
543 |
544 | // src/buildEventsView.ts
545 | async function buildEventsView(
546 | events,
547 | stack,
548 | settings2,
549 | {
550 | horizontalAlign = "left",
551 | verticalAlign = "center",
552 | eventLimit = 3,
553 | eventSpacer = 8,
554 | showMsg = true,
555 | } = {}
556 | ) {
557 | const leftStack = stack.addStack();
558 | if (horizontalAlign === "left") {
559 | stack.addSpacer();
560 | }
561 | leftStack.layoutVertically();
562 | if (verticalAlign === "bottom" || verticalAlign === "center") {
563 | leftStack.addSpacer();
564 | }
565 | if (events.length !== 0) {
566 | const numEvents = events.length > eventLimit ? eventLimit : events.length;
567 | for (let i = 0; i < numEvents; i += 1) {
568 | formatEvent_default(leftStack, events[i], settings2);
569 | if (i < numEvents - 1) {
570 | leftStack.addSpacer(eventSpacer);
571 | }
572 | }
573 | } else if (showMsg) {
574 | addWidgetTextLine_default(`No more events.`, leftStack, {
575 | textColor: settings2.textColor,
576 | opacity: settings2.eventDateTimeOpacity,
577 | font: Font.regularSystemFont(15),
578 | });
579 | }
580 | if (verticalAlign === "top" || verticalAlign === "center") {
581 | leftStack.addSpacer();
582 | }
583 | }
584 | var buildEventsView_default = buildEventsView;
585 |
586 | // src/getEvents.ts
587 | async function getEvents(date, settings2) {
588 | let events = [];
589 | if (settings2.showEventsOnlyForToday) {
590 | events = await CalendarEvent.today([]);
591 | } else {
592 | const dateLimit = new Date();
593 | dateLimit.setDate(dateLimit.getDate() + settings2.nextNumOfDays);
594 | events = await CalendarEvent.between(date, dateLimit);
595 | }
596 | if (settings2.calFilter.length) {
597 | events = events.filter((event) =>
598 | settings2.calFilter.includes(event.calendar.title)
599 | );
600 | }
601 | const futureEvents = [];
602 | for (const event of events) {
603 | if (
604 | event.isAllDay &&
605 | settings2.showAllDayEvents &&
606 | event.startDate.getTime() >
607 | new Date(new Date().setDate(new Date().getDate() - 1)).getTime()
608 | ) {
609 | futureEvents.push(event);
610 | } else if (
611 | !event.isAllDay &&
612 | event.endDate.getTime() > date.getTime() &&
613 | !event.title.startsWith("Canceled:")
614 | ) {
615 | futureEvents.push(event);
616 | }
617 | }
618 | return futureEvents;
619 | }
620 | var getEvents_default = getEvents;
621 |
622 | // src/buildLargeWidget.ts
623 | async function buildLargeWidget(date, events, stack, settings2) {
624 | const leftSide = stack.addStack();
625 | stack.addSpacer();
626 | const rightSide = stack.addStack();
627 | leftSide.layoutVertically();
628 | rightSide.layoutVertically();
629 | rightSide.addSpacer();
630 | rightSide.centerAlignContent();
631 | const leftSideEvents = events.slice(0, 8);
632 | const rightSideEvents = events.slice(8, 12);
633 | await buildEventsView_default(leftSideEvents, leftSide, settings2, {
634 | eventLimit: 8,
635 | eventSpacer: 6,
636 | });
637 | await buildCalendarView_default(date, rightSide, settings2);
638 | rightSide.addSpacer();
639 | await buildEventsView_default(rightSideEvents, rightSide, settings2, {
640 | eventLimit: 4,
641 | eventSpacer: 6,
642 | verticalAlign: "top",
643 | showMsg: false,
644 | });
645 | }
646 | var buildLargeWidget_default = buildLargeWidget;
647 |
648 | // src/buildWidget.ts
649 | async function buildWidget(settings2) {
650 | const widget = new ListWidget();
651 | widget.backgroundColor = new Color(settings2.widgetBackgroundColor, 1);
652 | setWidgetBackground_default(widget, settings2.backgroundImage);
653 | widget.setPadding(16, 16, 16, 16);
654 | const today = new Date();
655 | const globalStack = widget.addStack();
656 | const events = await getEvents_default(today, settings2);
657 | switch (config.widgetFamily) {
658 | case "small":
659 | if (settings2.widgetType === "events") {
660 | await buildEventsView_default(events, globalStack, settings2);
661 | } else {
662 | await buildCalendarView_default(today, globalStack, settings2);
663 | }
664 | break;
665 | case "large":
666 | await buildLargeWidget_default(today, events, globalStack, settings2);
667 | break;
668 | default:
669 | if (settings2.flipped) {
670 | await buildCalendarView_default(today, globalStack, settings2);
671 | globalStack.addSpacer(10);
672 | await buildEventsView_default(events, globalStack, settings2);
673 | } else {
674 | await buildEventsView_default(events, globalStack, settings2);
675 | await buildCalendarView_default(today, globalStack, settings2);
676 | }
677 | break;
678 | }
679 | return widget;
680 | }
681 | var buildWidget_default = buildWidget;
682 |
683 | // src/index.ts
684 | async function main() {
685 | if (config.runsInWidget) {
686 | const widget = await buildWidget_default(settings_default);
687 | Script.setWidget(widget);
688 | Script.complete();
689 | } else if (settings_default.debug) {
690 | Script.complete();
691 | const widget = await buildWidget_default(settings_default);
692 | await widget.presentMedium();
693 | } else {
694 | const appleDate = new Date("2001/01/01");
695 | const timestamp = (new Date().getTime() - appleDate.getTime()) / 1e3;
696 | const callback = new CallbackURL(
697 | `${settings_default.calendarApp}:` + timestamp
698 | );
699 | callback.open();
700 | Script.complete();
701 | }
702 | }
703 |
704 | await main();
705 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | roots: ["/test/"],
4 | preset: "ts-jest",
5 | testEnvironment: "node",
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scriptable-calendar-widget",
3 | "version": "0.0.1",
4 | "description": "A script for iOS Scriptable app.",
5 | "main": "calendar.js",
6 | "type": "module",
7 | "scripts": {
8 | "build": "npm run bundle && npm run postBundle",
9 | "bundle": "esbuild src/index.ts --bundle --platform=node --outfile=calendar.js",
10 | "postBundle": "node util/postBundle.js calendar.js --out-file=calendar.js",
11 | "bundle:watch": "esbuild src/index.ts --bundle --watch --platform=node --outfile=dev/bundle.js",
12 | "dev": "concurrently \"npm run bundle:watch\" \"node util/watchBuildMove.js\"",
13 | "test": "jest --watch",
14 | "prepare": "husky install"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/rudotriton/scriptable-calendar-widget.git"
19 | },
20 | "keywords": [],
21 | "author": "Raigo Jerva",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/rudotriton/scriptable-calendar-widget/issues"
25 | },
26 | "homepage": "https://github.com/rudotriton/scriptable-calendar-widget#readme",
27 | "devDependencies": {
28 | "@types/jest": "^27.4.0",
29 | "@types/scriptable-ios": "^1.6.5",
30 | "@typescript-eslint/eslint-plugin": "^5.12.1",
31 | "@typescript-eslint/parser": "^5.12.1",
32 | "chokidar": "^3.5.3",
33 | "commander": "^9.0.0",
34 | "concurrently": "^7.0.0",
35 | "esbuild": "^0.14.23",
36 | "eslint": "^8.9.0",
37 | "eslint-config-prettier": "^8.4.0",
38 | "eslint-plugin-import": "^2.25.4",
39 | "eslint-plugin-prettier": "^4.0.0",
40 | "husky": "^8.0.1",
41 | "jest": "^27.5.1",
42 | "prettier": "^2.5.1",
43 | "ts-jest": "^27.1.3",
44 | "typescript": "^4.5.5"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/addWidgetTextLine.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Adds WidgetText to WidgetStack
3 | *
4 | */
5 | function addWidgetTextLine(
6 | text: string,
7 | widget: WidgetStack,
8 | {
9 | textColor = "#ffffff",
10 | textSize = 12,
11 | opacity = 1,
12 | align,
13 | font,
14 | lineLimit = 0,
15 | }: {
16 | textColor?: string;
17 | textSize?: number;
18 | opacity?: number;
19 | align?: string;
20 | font?: Font;
21 | lineLimit?: number;
22 | }
23 | ): void {
24 | const textLine = widget.addText(text);
25 | textLine.textColor = new Color(textColor, 1);
26 | textLine.lineLimit = lineLimit;
27 | if (typeof font === "string") {
28 | textLine.font = new Font(font, textSize);
29 | } else {
30 | textLine.font = font;
31 | }
32 | textLine.textOpacity = opacity;
33 | switch (align) {
34 | case "left":
35 | textLine.leftAlignText();
36 | break;
37 | case "center":
38 | textLine.centerAlignText();
39 | break;
40 | case "right":
41 | textLine.rightAlignText();
42 | break;
43 | default:
44 | textLine.leftAlignText();
45 | break;
46 | }
47 | }
48 |
49 | export default addWidgetTextLine;
50 |
--------------------------------------------------------------------------------
/src/buildCalendar.ts:
--------------------------------------------------------------------------------
1 | import getMonthBoundaries from "./getMonthBoundaries";
2 | import getMonthOffset from "./getMonthOffset";
3 | import getWeekLetters from "./getWeekLetters";
4 | import { Settings } from "./settings";
5 |
6 | export interface CalendarInfo {
7 | calendar: string[][];
8 | daysFromPrevMonth: number;
9 | daysFromNextMonth: number;
10 | }
11 |
12 | /**
13 | * Creates an array of arrays, where the inner arrays include the same weekdays
14 | * along with a weekday identifier in the 0th position
15 | * days are in the format of MM/DD, months are 0 indexed
16 | * [
17 | * [ 'M', ' ', '8/7', '8/14', '8/21', '8/28' ],
18 | * [ 'T', '8/1', '8/8', '8/15', '8/22', '8/29' ],
19 | * [ 'W', '8/2', '8/9', '8/16', '8/23', '8/30' ],
20 | * ...
21 | * ]
22 | *
23 | */
24 | function buildCalendar(
25 | date: Date = new Date(),
26 | {
27 | locale,
28 | showPrevMonth = true,
29 | showNextMonth = true,
30 | startWeekOnSunday = false,
31 | }: Partial
32 | ): CalendarInfo {
33 | const currentMonth = getMonthBoundaries(date);
34 |
35 | // NOTE: 31 Oct when the clocks change there is now a +2 diff instead of +3,
36 | // so a prev month won't be september, but oct, as 2 doesn't push it over,
37 | // the built month would have the days from prev month be from Oct instead of
38 | // Sept. a highlight lights 31 twice as they're both "09/30"
39 | const prevMonth = getMonthBoundaries(getMonthOffset(date, -1));
40 | const calendar = getWeekLetters(locale, startWeekOnSunday);
41 | let daysFromPrevMonth = 0;
42 | let daysFromNextMonth = 0;
43 | let index = 1;
44 | let offset = 1;
45 |
46 | // weekdays are 0 indexed starting with a Sunday
47 | let firstDay =
48 | currentMonth.firstOfMonth.getDay() !== 0
49 | ? currentMonth.firstOfMonth.getDay()
50 | : 7;
51 |
52 | if (startWeekOnSunday) {
53 | index = 0;
54 | offset = 0;
55 | firstDay = firstDay % 7;
56 | }
57 |
58 | // increment from 0 to 6 until the month has been built
59 | let dayStackCounter = 0;
60 |
61 | // fill with empty slots or days from the prev month, up to the firstDay
62 | for (; index < firstDay; index += 1) {
63 | if (showPrevMonth) {
64 | calendar[index - offset].push(
65 | `${prevMonth.lastOfMonth.getMonth()}/${
66 | prevMonth.lastOfMonth.getDate() - firstDay + 1 + index
67 | // e.g. prev has 31 days, ending on a Friday, firstDay is 6
68 | // we fill Mon - Fri (27-31): 31 - 6 + 1 + 1
69 | // if week starts on a Sunday (26-31): 31 - 6 + 1 + 0
70 | }`
71 | );
72 | daysFromPrevMonth += 1;
73 | } else {
74 | calendar[index - offset].push(" ");
75 | }
76 | dayStackCounter = (dayStackCounter + 1) % 7;
77 | }
78 |
79 | for (
80 | let indexDate = 1;
81 | indexDate <= currentMonth.lastOfMonth.getDate();
82 | indexDate += 1
83 | ) {
84 | calendar[dayStackCounter].push(`${date.getMonth()}/${indexDate}`);
85 | dayStackCounter = (dayStackCounter + 1) % 7;
86 | }
87 |
88 | // find the longest weekday array
89 | let longestColumn = calendar.reduce(
90 | (acc, dayStacks) => (dayStacks.length > acc ? dayStacks.length : acc),
91 | 0
92 | );
93 |
94 | // about once in 9-10 years, february can fit into just 4 rows, so a column is
95 | // 5 tall with day indicators
96 | if (showNextMonth && longestColumn < 6) {
97 | longestColumn += 1;
98 | }
99 | // fill the end of the month with spacers, if the weekday array is shorter
100 | // than the longest
101 | const nextMonth = getMonthOffset(date, 1);
102 | calendar.forEach((dayStacks, index) => {
103 | while (dayStacks.length < longestColumn) {
104 | if (showNextMonth) {
105 | daysFromNextMonth += 1;
106 | calendar[index].push(`${nextMonth.getMonth()}/${daysFromNextMonth}`);
107 | } else {
108 | calendar[index].push(" ");
109 | }
110 | }
111 | });
112 |
113 | return { calendar, daysFromPrevMonth, daysFromNextMonth };
114 | }
115 |
116 | export default buildCalendar;
117 |
--------------------------------------------------------------------------------
/src/buildCalendarView.ts:
--------------------------------------------------------------------------------
1 | import addWidgetTextLine from "./addWidgetTextLine";
2 | import buildCalendar from "./buildCalendar";
3 | import countEvents from "./countEvents";
4 | import createDateImage from "./createDateImage";
5 | import isDateFromBoundingMonth from "./isDateFromBoundingMonth";
6 | import isWeekend from "./isWeekend";
7 | import createUrl from "./createUrl";
8 | import { Settings } from "./settings";
9 |
10 | /**
11 | * Builds the calendar view
12 | *
13 | * @param {WidgetStack} stack - onto which the calendar is built
14 | */
15 | async function buildCalendarView(
16 | date: Date,
17 | stack: WidgetStack,
18 | settings: Settings
19 | ): Promise {
20 | const rightStack = stack.addStack();
21 | rightStack.layoutVertically();
22 |
23 | const dateFormatter = new DateFormatter();
24 | dateFormatter.dateFormat = "MMMM";
25 | dateFormatter.locale = settings.locale.split("-")[0];
26 |
27 | // if calendar is on a small widget make it a bit smaller to fit
28 | const spacing = config.widgetFamily === "small" ? 18 : 19;
29 |
30 | // Current month line
31 | const monthLine = rightStack.addStack();
32 | // since dates are centered in their squares we need to add some space
33 | monthLine.addSpacer(4);
34 | addWidgetTextLine(dateFormatter.string(date).toUpperCase(), monthLine, {
35 | textColor: settings.textColor,
36 | textSize: 14,
37 | font: Font.boldSystemFont(13),
38 | });
39 |
40 | const calendarStack = rightStack.addStack();
41 | calendarStack.spacing = 2;
42 |
43 | const { calendar, daysFromPrevMonth, daysFromNextMonth } = buildCalendar(
44 | date,
45 | settings
46 | );
47 |
48 | const { eventCounts, intensity } = await countEvents(
49 | date,
50 | daysFromPrevMonth,
51 | daysFromNextMonth,
52 | settings
53 | );
54 |
55 | for (let i = 0; i < calendar.length; i += 1) {
56 | const weekdayStack = calendarStack.addStack();
57 | weekdayStack.layoutVertically();
58 |
59 | for (let j = 0; j < calendar[i].length; j += 1) {
60 | const dayStack = weekdayStack.addStack();
61 | dayStack.size = new Size(spacing, spacing);
62 | dayStack.centerAlignContent();
63 |
64 | // splitting "month/day" or "D"
65 | // a day marker won't split so if we reverse and take first we get correct
66 | const [day, month] = calendar[i][j].split("/").reverse();
67 | // add callbacks to each date
68 | if (settings.individualDateTargets) {
69 | const callbackUrl = createUrl(day, month, date, settings);
70 | if (j > 0) dayStack.url = callbackUrl;
71 | }
72 | // if the day is today, highlight it
73 | if (calendar[i][j] === `${date.getMonth()}/${date.getDate()}`) {
74 | if (settings.markToday) {
75 | const highlightedDate = createDateImage(day, {
76 | backgroundColor: settings.todayCircleColor,
77 | textColor: settings.todayTextColor,
78 | intensity: 1,
79 | toFullSize: true,
80 | });
81 | dayStack.addImage(highlightedDate);
82 | } else {
83 | addWidgetTextLine(day, dayStack, {
84 | textColor: settings.todayTextColor,
85 | font: Font.boldSystemFont(10),
86 | align: "center",
87 | });
88 | }
89 | // j == 0, contains the letters, so this creates all the other dates
90 | } else if (j > 0 && calendar[i][j] !== " ") {
91 | const toFullSize = isDateFromBoundingMonth(i, j, date, calendar);
92 |
93 | const dateImage = createDateImage(day, {
94 | backgroundColor: settings.eventCircleColor,
95 | textColor: isWeekend(i, settings.startWeekOnSunday)
96 | ? settings.weekendDates
97 | : settings.weekdayTextColor,
98 | intensity: settings.showEventCircles
99 | ? eventCounts.get(calendar[i][j]) * intensity
100 | : 0,
101 | toFullSize,
102 | });
103 | dayStack.addImage(dateImage);
104 | } else {
105 | // first line and empty dates from other months
106 | addWidgetTextLine(day, dayStack, {
107 | textColor: isWeekend(i, settings.startWeekOnSunday)
108 | ? settings.weekendLetters
109 | : settings.textColor,
110 | opacity: isWeekend(i, settings.startWeekOnSunday)
111 | ? settings.weekendLetterOpacity
112 | : 1,
113 | font: Font.boldSystemFont(10),
114 | align: "center",
115 | });
116 | }
117 | }
118 | }
119 | }
120 |
121 | export default buildCalendarView;
122 |
--------------------------------------------------------------------------------
/src/buildEventsView.ts:
--------------------------------------------------------------------------------
1 | import addWidgetTextLine from "./addWidgetTextLine";
2 | import formatEvent from "./formatEvent";
3 | import { Settings } from "./settings";
4 |
5 | /**
6 | * Builds the events view
7 | *
8 | * @param {WidgetStack} stack - onto which the events view is built
9 | */
10 | async function buildEventsView(
11 | events: CalendarEvent[],
12 | stack: WidgetStack,
13 | settings: Settings,
14 | {
15 | horizontalAlign = "left",
16 | verticalAlign = "center",
17 | eventLimit = 3,
18 | eventSpacer = 8,
19 | showMsg = true,
20 | }: {
21 | horizontalAlign?: string;
22 | verticalAlign?: string;
23 | eventLimit?: number;
24 | eventSpacer?: number;
25 | showMsg?: boolean;
26 | } = {}
27 | ): Promise {
28 | const leftStack = stack.addStack();
29 | // add, spacer to the right side, this pushes event view to the left
30 | if (horizontalAlign === "left") {
31 | stack.addSpacer();
32 | }
33 |
34 | leftStack.layoutVertically();
35 | // center the whole left part of the widget
36 | if (verticalAlign === "bottom" || verticalAlign === "center") {
37 | leftStack.addSpacer();
38 | }
39 |
40 | // if we have events today; else if we don't
41 | if (events.length !== 0) {
42 | // show the next 3 events at most
43 | const numEvents = events.length > eventLimit ? eventLimit : events.length;
44 | for (let i = 0; i < numEvents; i += 1) {
45 | formatEvent(leftStack, events[i], settings);
46 | // don't add a spacer after the last event
47 | if (i < numEvents - 1) {
48 | leftStack.addSpacer(eventSpacer);
49 | }
50 | }
51 | } else if (showMsg) {
52 | addWidgetTextLine(`No more events.`, leftStack, {
53 | textColor: settings.textColor,
54 | opacity: settings.eventDateTimeOpacity,
55 | font: Font.regularSystemFont(15),
56 | });
57 | }
58 | // for centering, pushes up from the bottom
59 | if (verticalAlign === "top" || verticalAlign === "center") {
60 | leftStack.addSpacer();
61 | }
62 | }
63 |
64 | export default buildEventsView;
65 |
--------------------------------------------------------------------------------
/src/buildLargeWidget.ts:
--------------------------------------------------------------------------------
1 | import buildCalendarView from "./buildCalendarView";
2 | import buildEventsView from "./buildEventsView";
3 | import { Settings } from "./settings";
4 |
5 | async function buildLargeWidget(
6 | date: Date,
7 | events: CalendarEvent[],
8 | stack: WidgetStack,
9 | settings: Settings
10 | ): Promise {
11 | const leftSide = stack.addStack();
12 | stack.addSpacer();
13 | const rightSide = stack.addStack();
14 | leftSide.layoutVertically();
15 | rightSide.layoutVertically();
16 |
17 | // add space to the top of the calendar
18 | rightSide.addSpacer();
19 | rightSide.centerAlignContent();
20 |
21 | const leftSideEvents = events.slice(0, 8);
22 | const rightSideEvents = events.slice(8, 12);
23 |
24 | await buildEventsView(leftSideEvents, leftSide, settings, {
25 | eventLimit: 8,
26 | eventSpacer: 6,
27 | });
28 | await buildCalendarView(date, rightSide, settings);
29 | // add space between the calendar and any events below it
30 | rightSide.addSpacer();
31 | await buildEventsView(rightSideEvents, rightSide, settings, {
32 | eventLimit: 4,
33 | eventSpacer: 6,
34 | verticalAlign: "top",
35 | showMsg: false,
36 | });
37 | }
38 |
39 | export default buildLargeWidget;
40 |
--------------------------------------------------------------------------------
/src/buildWidget.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from "./settings";
2 | import setWidgetBackground from "./setWidgetBackground";
3 | import buildCalendarView from "./buildCalendarView";
4 | import buildEventsView from "./buildEventsView";
5 | import getEvents from "./getEvents";
6 | import buildLargeWidget from "./buildLargeWidget";
7 |
8 | async function buildWidget(settings: Settings): Promise {
9 | const widget = new ListWidget();
10 | widget.backgroundColor = new Color(settings.widgetBackgroundColor, 1);
11 | setWidgetBackground(widget, settings.backgroundImage);
12 | widget.setPadding(16, 16, 16, 16);
13 |
14 | const today = new Date();
15 | // layout horizontally
16 | const globalStack = widget.addStack();
17 |
18 | const events = await getEvents(today, settings);
19 |
20 | switch (config.widgetFamily) {
21 | case "small":
22 | if (settings.widgetType === "events") {
23 | await buildEventsView(events, globalStack, settings);
24 | } else {
25 | await buildCalendarView(today, globalStack, settings);
26 | }
27 | break;
28 | case "large":
29 | await buildLargeWidget(today, events, globalStack, settings);
30 | break;
31 | default:
32 | if (settings.flipped) {
33 | await buildCalendarView(today, globalStack, settings);
34 | globalStack.addSpacer(10);
35 | await buildEventsView(events, globalStack, settings);
36 | } else {
37 | await buildEventsView(events, globalStack, settings);
38 | await buildCalendarView(today, globalStack, settings);
39 | }
40 | break;
41 | }
42 |
43 | return widget;
44 | }
45 |
46 | export default buildWidget;
47 |
--------------------------------------------------------------------------------
/src/countEvents.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from "settings";
2 | import getMonthBoundaries from "./getMonthBoundaries";
3 |
4 | /**
5 | * Counts the number of events for each day in the visible calendar view, which
6 | * may include days from the previous month and from the next month
7 | *
8 | */
9 | async function countEvents(
10 | date: Date,
11 | extendToPrev = 0,
12 | extendToNext = 0,
13 | settings: Settings
14 | ): Promise {
15 | const { firstOfMonth } = getMonthBoundaries(date);
16 | const { startDate, endDate } = extendBoundaries(
17 | firstOfMonth,
18 | extendToPrev,
19 | extendToNext
20 | );
21 | let events = await CalendarEvent.between(startDate, endDate);
22 |
23 | events = trimEvents(events, settings);
24 |
25 | const eventCounts: EventCounts = new Map();
26 |
27 | events.forEach((event) => {
28 | if (event.isAllDay) {
29 | const date = event.startDate;
30 | do {
31 | updateEventCounts(date, eventCounts);
32 | date.setDate(date.getDate() + 1);
33 | } while (date < event.endDate);
34 | } else {
35 | updateEventCounts(event.startDate, eventCounts);
36 | }
37 | });
38 |
39 | const intensity = calculateIntensity(eventCounts);
40 |
41 | return { eventCounts, intensity };
42 | }
43 |
44 | /**
45 | * Remove events that we don't care about from the array, so that they won't
46 | * affect the intensity of the eventCircles
47 | */
48 | function trimEvents(events: CalendarEvent[], settings: Settings) {
49 | let trimmedEvents = events;
50 |
51 | if (settings.calFilter.length) {
52 | trimmedEvents = events.filter((event) =>
53 | settings.calFilter.includes(event.calendar.title)
54 | );
55 | }
56 |
57 | if (settings.discountAllDayEvents || !settings.showAllDayEvents) {
58 | trimmedEvents = trimmedEvents.filter((event) => !event.isAllDay);
59 | }
60 |
61 | return trimmedEvents;
62 | }
63 |
64 | /**
65 | * Find the boundaries between which the events are counted, when showing the
66 | * previous and/or the next month then the boundaries are wider than just the
67 | * first of the month to the last of the month.
68 | */
69 | function extendBoundaries(
70 | first: Date,
71 | extendToPrev: number,
72 | extendToNext: number
73 | ): { startDate: Date; endDate: Date } {
74 | const startDate = new Date(
75 | first.getFullYear(),
76 | first.getMonth(),
77 | first.getDate() - extendToPrev
78 | );
79 |
80 | const endDate = new Date(
81 | first.getFullYear(),
82 | first.getMonth() + 1,
83 | first.getDate() + extendToNext
84 | );
85 | return { startDate, endDate };
86 | }
87 |
88 | /**
89 | * set or update a "month/date" type of key in the map
90 | */
91 | function updateEventCounts(date: Date, eventCounts: EventCounts) {
92 | if (eventCounts.has(`${date.getMonth()}/${date.getDate()}`)) {
93 | eventCounts.set(
94 | `${date.getMonth()}/${date.getDate()}`,
95 | eventCounts.get(`${date.getMonth()}/${date.getDate()}`) + 1
96 | );
97 | } else {
98 | eventCounts.set(`${date.getMonth()}/${date.getDate()}`, 1);
99 | }
100 | }
101 |
102 | function calculateIntensity(eventCounts: EventCounts): number {
103 | const counter = eventCounts.values();
104 | const counts = [];
105 | for (const count of counter) {
106 | counts.push(count);
107 | }
108 | const max = Math.max(...counts);
109 | const min = Math.min(...counts);
110 | let intensity = 1 / (max - min + 1);
111 | intensity = intensity < 0.3 ? 0.3 : intensity;
112 | return intensity;
113 | }
114 |
115 | type EventCounts = Map;
116 |
117 | interface EventCountInfo {
118 | // eventCounts: number[];
119 | eventCounts: EventCounts;
120 | intensity: number;
121 | }
122 |
123 | export default countEvents;
124 |
--------------------------------------------------------------------------------
/src/createDateImage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates the image for a date, if set, also draws the circular background
3 | * indicating events, for bounding months these are drawn smaller
4 | */
5 | function createDateImage(
6 | text: string,
7 | {
8 | backgroundColor,
9 | textColor,
10 | intensity,
11 | toFullSize,
12 | }: {
13 | backgroundColor: string;
14 | textColor: string;
15 | intensity: number;
16 | toFullSize: boolean;
17 | }
18 | ): Image {
19 | const size = toFullSize ? 50 : 35;
20 |
21 | const drawing = new DrawContext();
22 |
23 | drawing.respectScreenScale = true;
24 | const contextSize = 50;
25 | drawing.size = new Size(contextSize, contextSize);
26 | // won't show a drawing sized square background
27 | drawing.opaque = false;
28 |
29 | // circle color
30 | drawing.setFillColor(new Color(backgroundColor, intensity));
31 |
32 | // so that edges stay round and are not clipped by the box
33 | // 50 48 1
34 | // (contextSize - (size - 2)) / 2
35 | // size - 2 makes them a bit smaller than the drawing context
36 | drawing.fillEllipse(
37 | new Rect(
38 | (contextSize - (size - 2)) / 2,
39 | (contextSize - (size - 2)) / 2,
40 | size - 2,
41 | size - 2
42 | )
43 | );
44 |
45 | drawing.setFont(Font.boldSystemFont(size * 0.5));
46 | drawing.setTextAlignedCenter();
47 | drawing.setTextColor(new Color(textColor, 1));
48 | // the text aligns to the bottom of the rectangle while not extending to the
49 | // top, so y is pulled up here 3 pixels
50 | const textBox = new Rect(
51 | (contextSize - size) / 2,
52 | (contextSize - size * 0.5) / 2 - 3,
53 | size,
54 | size * 0.5
55 | );
56 | drawing.drawTextInRect(text, textBox);
57 | return drawing.getImage();
58 | }
59 |
60 | export default createDateImage;
61 |
--------------------------------------------------------------------------------
/src/createUrl.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from "settings";
2 |
3 | /**
4 | * Create a callback url to open a calendar app on that day
5 | *
6 | * @name createUrl
7 | * @function
8 | * @param {string} day
9 | * @param {Date} date
10 | * @param {Settings} settings
11 | */
12 | function createUrl(
13 | day: string,
14 | month: string,
15 | date: Date,
16 | settings: Settings
17 | ): string {
18 | let url: string;
19 | let year: number;
20 |
21 | const currentMonth = date.getMonth();
22 | if (currentMonth === 11 && Number(month) === 1) {
23 | year = date.getFullYear() + 1;
24 | } else if (currentMonth === 0 && Number(month) === 11) {
25 | year = date.getFullYear() - 1;
26 | } else {
27 | year = date.getFullYear();
28 | }
29 |
30 | if (settings.calendarApp === "calshow") {
31 | const appleDate = new Date("2001/01/01");
32 | const timestamp =
33 | (new Date(`${year}/${Number(month) + 1}/${day}`).getTime() -
34 | appleDate.getTime()) /
35 | 1000;
36 | url = `calshow:${timestamp}`;
37 | } else if (settings.calendarApp === "x-fantastical3") {
38 | url = `${settings.calendarApp}://show/calendar/${year}-${
39 | Number(month) + 1
40 | }-${day}`;
41 | } else {
42 | url = "";
43 | }
44 | return url;
45 | }
46 |
47 | export default createUrl;
48 |
--------------------------------------------------------------------------------
/src/formatEvent.ts:
--------------------------------------------------------------------------------
1 | import addWidgetTextLine from "./addWidgetTextLine";
2 | import formatTime from "./formatTime";
3 | import getSuffix from "./getSuffix";
4 | import getEventIcon from "getEventIcon";
5 | import { Settings } from "./settings";
6 |
7 | /**
8 | * Adds a event name along with start and end times to widget stack
9 | *
10 | */
11 | function formatEvent(
12 | stack: WidgetStack,
13 | event: CalendarEvent,
14 | {
15 | eventDateTimeOpacity,
16 | textColor,
17 | showCalendarBullet,
18 | showCompleteTitle,
19 | }: Partial
20 | ): void {
21 | const eventLine = stack.addStack();
22 |
23 | if (showCalendarBullet) {
24 | // show calendar bullet in front of event name
25 | const icon = getEventIcon(event);
26 | addWidgetTextLine(icon, eventLine, {
27 | textColor: event.calendar.color.hex,
28 | font: Font.mediumSystemFont(14),
29 | lineLimit: showCompleteTitle ? 0 : 1,
30 | });
31 | }
32 |
33 | // event title
34 | addWidgetTextLine(event.title, eventLine, {
35 | textColor,
36 | font: Font.mediumSystemFont(14),
37 | lineLimit: showCompleteTitle ? 0 : 1,
38 | });
39 | // event duration
40 | let time: string;
41 | if (event.isAllDay) {
42 | time = "All Day";
43 | } else {
44 | time = `${formatTime(event.startDate)} - ${formatTime(event.endDate)}`;
45 | }
46 |
47 | const today = new Date().getDate();
48 | const eventDate = event.startDate.getDate();
49 | // if a future event is not today, we want to show it's date
50 | if (eventDate !== today) {
51 | time = `${eventDate}${getSuffix(eventDate)} ${time}`;
52 | }
53 |
54 | // event time
55 | addWidgetTextLine(time, stack, {
56 | textColor,
57 | opacity: eventDateTimeOpacity,
58 | font: Font.regularSystemFont(14),
59 | });
60 | }
61 | export default formatEvent;
62 |
--------------------------------------------------------------------------------
/src/formatTime.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * formats the event times into just hours
3 | *
4 | */
5 | function formatTime(date: Date): string {
6 | const dateFormatter = new DateFormatter();
7 | dateFormatter.useNoDateStyle();
8 | dateFormatter.useShortTimeStyle();
9 | return dateFormatter.string(date);
10 | }
11 |
12 | export default formatTime;
13 |
--------------------------------------------------------------------------------
/src/getEventIcon.ts:
--------------------------------------------------------------------------------
1 | function getEventIcon(event: CalendarEvent): string {
2 | if (event.attendees === null) {
3 | return "● ";
4 | }
5 | const status = event.attendees.filter((attendee) => attendee.isCurrentUser)[0]
6 | .status;
7 | switch (status) {
8 | case "accepted":
9 | return "✓ ";
10 | case "tentative":
11 | return "~ ";
12 | case "declined":
13 | return "✘ ";
14 | default:
15 | return "● ";
16 | }
17 | }
18 |
19 | export default getEventIcon;
20 |
--------------------------------------------------------------------------------
/src/getEvents.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from "./settings";
2 |
3 | async function getEvents(
4 | date: Date,
5 | settings: Settings
6 | ): Promise {
7 | let events: CalendarEvent[] = [];
8 | if (settings.showEventsOnlyForToday) {
9 | events = await CalendarEvent.today([]);
10 | } else {
11 | const dateLimit = new Date();
12 | dateLimit.setDate(dateLimit.getDate() + settings.nextNumOfDays);
13 | events = await CalendarEvent.between(date, dateLimit);
14 | }
15 |
16 | if (settings.calFilter.length) {
17 | events = events.filter((event) =>
18 | settings.calFilter.includes(event.calendar.title)
19 | );
20 | }
21 |
22 | const futureEvents: CalendarEvent[] = [];
23 |
24 | // if we show events for the whole week, then we need to filter allDay events
25 | // to not show past allDay events
26 | // if allDayEvent's start date is later than a day ago from now then show it
27 | for (const event of events) {
28 | if (
29 | event.isAllDay &&
30 | settings.showAllDayEvents &&
31 | event.startDate.getTime() >
32 | new Date(new Date().setDate(new Date().getDate() - 1)).getTime()
33 | ) {
34 | futureEvents.push(event);
35 | } else if (
36 | !event.isAllDay &&
37 | event.endDate.getTime() > date.getTime() &&
38 | !event.title.startsWith("Canceled:")
39 | ) {
40 | futureEvents.push(event);
41 | }
42 | }
43 | return futureEvents;
44 | }
45 |
46 | export default getEvents;
47 |
--------------------------------------------------------------------------------
/src/getMonthBoundaries.ts:
--------------------------------------------------------------------------------
1 | function getMonthBoundaries(date: Date): {
2 | firstOfMonth: Date;
3 | lastOfMonth: Date;
4 | } {
5 | const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1);
6 | const lastOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0);
7 | return { firstOfMonth, lastOfMonth };
8 | }
9 | export default getMonthBoundaries;
10 |
--------------------------------------------------------------------------------
/src/getMonthOffset.ts:
--------------------------------------------------------------------------------
1 | function getMonthOffset(date: Date, offset: number): Date {
2 | const newDate = new Date(date);
3 | let offsetMonth = date.getMonth() + offset;
4 | if (offsetMonth < 0) {
5 | offsetMonth += 12;
6 | newDate.setFullYear(date.getFullYear() - 1);
7 | } else if (offsetMonth > 11) {
8 | offsetMonth -= 12;
9 | newDate.setFullYear(date.getFullYear() + 1);
10 | }
11 | newDate.setMonth(offsetMonth, 1);
12 | return newDate;
13 | }
14 | export default getMonthOffset;
15 |
--------------------------------------------------------------------------------
/src/getSuffix.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * get suffix for a given date
3 | *
4 | * @param {number} date
5 | *
6 | * @returns {string} suffix
7 | */
8 | function getSuffix(date: number): string {
9 | if (date > 3 && date < 21) return "th";
10 | switch (date % 10) {
11 | case 1:
12 | return "st";
13 | case 2:
14 | return "nd";
15 | case 3:
16 | return "rd";
17 | default:
18 | return "th";
19 | }
20 | }
21 |
22 | export default getSuffix;
23 |
--------------------------------------------------------------------------------
/src/getWeekLetters.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates an array of arrays of weekday letters e.g.
3 | *
4 | * [[ 'M' ], [ 'T' ], [ 'W' ], [ 'T' ], [ 'F' ], [ 'S' ], [ 'S' ]]
5 | *
6 | */
7 | function getWeekLetters(
8 | locale = "en-US",
9 | startWeekOnSunday = false
10 | ): string[][] {
11 | let week = [];
12 | for (let i = 1; i <= 7; i += 1) {
13 | // create days from Monday to Sunday
14 | const day = new Date(`February 0${i}, 2021`);
15 | week.push(day.toLocaleDateString(locale, { weekday: "long" }));
16 | }
17 | // get the first letter and capitalize it as some locales have them lowercase
18 | week = week.map((day) => [day.slice(0, 1).toUpperCase()]);
19 | if (startWeekOnSunday) {
20 | const sunday = week.pop();
21 | week.unshift(sunday);
22 | }
23 | return week;
24 | }
25 |
26 | export default getWeekLetters;
27 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import settings from "./settings";
2 | import buildWidget from "./buildWidget";
3 |
4 | async function main() {
5 | if (config.runsInWidget) {
6 | const widget = await buildWidget(settings);
7 | Script.setWidget(widget);
8 | Script.complete();
9 | } else if (settings.debug) {
10 | Script.complete();
11 | const widget = await buildWidget(settings);
12 | await widget.presentMedium();
13 | } else {
14 | const appleDate = new Date("2001/01/01");
15 | const timestamp = (new Date().getTime() - appleDate.getTime()) / 1000;
16 | const callback = new CallbackURL(`${settings.calendarApp}:` + timestamp);
17 | callback.open();
18 | Script.complete();
19 | }
20 | }
21 |
22 | main();
23 |
--------------------------------------------------------------------------------
/src/isDateFromBoundingMonth.ts:
--------------------------------------------------------------------------------
1 | import { CalendarInfo } from "./buildCalendar";
2 |
3 | /**
4 | * Given row, column, currentDate, and a calendar, returns true if the indexed
5 | * value is from the current month
6 | *
7 | */
8 | function isDateFromBoundingMonth(
9 | row: number,
10 | column: number,
11 | date: Date,
12 | calendar: CalendarInfo["calendar"]
13 | ): boolean {
14 | const [month] = calendar[row][column].split("/");
15 | const currentMonth = date.getMonth().toString();
16 | return month === currentMonth;
17 | }
18 |
19 | export default isDateFromBoundingMonth;
20 |
--------------------------------------------------------------------------------
/src/isWeekend.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * If the week starts on a Sunday indeces 0 and 6 are for weekends
3 | * else indices 5 and 6
4 | *
5 | * @name isWeekend
6 | * @function
7 | * @param {number} index
8 | * @param {boolean} settings
9 | */
10 | function isWeekend(index: number, startWeekOnSunday = false): boolean {
11 | if (startWeekOnSunday) {
12 | switch (index) {
13 | case 0:
14 | case 6:
15 | return true;
16 | default:
17 | return false;
18 | }
19 | }
20 | return index > 4;
21 | }
22 |
23 | export default isWeekend;
24 |
--------------------------------------------------------------------------------
/src/setWidgetBackground.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Sets the background of the WidgetStack to the given imageName
3 | *
4 | */
5 | function setWidgetBackground(widget: ListWidget, imageName: string): void {
6 | const imageUrl = getImageUrl(imageName);
7 | const image = Image.fromFile(imageUrl);
8 | widget.backgroundImage = image;
9 | }
10 |
11 | /**
12 | * Creates a path for the given image name
13 | *
14 | */
15 | function getImageUrl(name: string): string {
16 | const fm: FileManager = FileManager.iCloud();
17 | const dir: string = fm.documentsDirectory();
18 | return fm.joinPath(dir, `${name}`);
19 | }
20 |
21 | export default setWidgetBackground;
22 |
--------------------------------------------------------------------------------
/src/settings.ts:
--------------------------------------------------------------------------------
1 | // get widget params
2 | const params = JSON.parse(args.widgetParameter) || {};
3 |
4 | const settings: Settings = {
5 | // set to true to initially give Scriptable calendar access
6 | // set to false to open Calendar when script is run - when tapping on the widget
7 | debug: false,
8 | // what app to open when the script is run in a widget,
9 | // "calshow" is the ios calendar app
10 | // "x-fantastical3" for fantastical
11 | calendarApp: "calshow",
12 | // a separate image can be specified per widget in widget params:
13 | // Long press on widget -> Edit Widget -> Parameter
14 | // parameter config would look like this:
15 | // { "bg": "2111.jpg", "view": "events" }
16 | backgroundImage: params.bg ? params.bg : "transparent.jpg",
17 | // what calendars to show, all if empty or something like: ["Work"]
18 | calFilter: params.calFilter ? params.calFilter : [],
19 | widgetBackgroundColor: "#000000",
20 | todayTextColor: "#000000",
21 | markToday: true,
22 | // background color for today
23 | todayCircleColor: "#FFB800",
24 | // background for all other days, only applicable if showEventCircles is true
25 | // show a circle behind each date that has an event then
26 | showEventCircles: true,
27 | // if true, all-day events don't count towards eventCircle intensity value
28 | discountAllDayEvents: false,
29 | eventCircleColor: "#1E5C7B",
30 | // color of all the other dates
31 | weekdayTextColor: "#ffffff",
32 |
33 | // weekend colors
34 | weekendLetters: "#FFB800",
35 | weekendLetterOpacity: 1,
36 | weekendDates: "#FFB800",
37 |
38 | // changes some locale specific values, such as weekday letters
39 | locale: "en-US",
40 |
41 | // color for events
42 | textColor: "#ffffff",
43 | // opacity value for event times
44 | eventDateTimeOpacity: 0.7,
45 | // what the widget shows
46 | widgetType: params.view ? params.view : "cal",
47 | // show or hide all day events
48 | showAllDayEvents: true,
49 | // show calendar colored bullet for each event
50 | showCalendarBullet: true,
51 | // week starts on a Sunday
52 | startWeekOnSunday: false,
53 | // show events for the whole week or limit just to the day
54 | showEventsOnlyForToday: false,
55 | // shows events for that many days if showEventsOnlyForToday is false
56 | nextNumOfDays: 7,
57 | // show full title or truncate to a single line
58 | showCompleteTitle: false,
59 | // shows the last days of the previous month if they fit
60 | showPrevMonth: true,
61 | // shows the last days of the previous month if they fit
62 | showNextMonth: true,
63 | // tapping on a date opens that specific one
64 | individualDateTargets: false,
65 | // events-calendar OR a flipped calendar-events type of view for medium widget
66 | flipped: params.flipped ? params.flipped : false,
67 | };
68 |
69 | export interface Settings {
70 | debug: boolean;
71 | calendarApp: string;
72 | backgroundImage: string;
73 | calFilter: string[];
74 | widgetBackgroundColor: string;
75 | todayTextColor: string;
76 | markToday: boolean;
77 | todayCircleColor: string;
78 | weekdayTextColor: string;
79 | showEventCircles: boolean;
80 | discountAllDayEvents: boolean;
81 | eventCircleColor: string;
82 | locale: string;
83 | weekendLetters: string;
84 | weekendLetterOpacity: number;
85 | weekendDates: string;
86 | textColor: string;
87 | eventDateTimeOpacity: number;
88 | widgetType: string;
89 | showAllDayEvents: boolean;
90 | showCalendarBullet: boolean;
91 | startWeekOnSunday: boolean;
92 | showEventsOnlyForToday: boolean;
93 | nextNumOfDays: number;
94 | showCompleteTitle: boolean;
95 | showPrevMonth: boolean;
96 | showNextMonth: boolean;
97 | individualDateTargets: boolean;
98 | flipped: boolean;
99 | }
100 |
101 | export default settings;
102 |
--------------------------------------------------------------------------------
/test/getWeekLetters.test.ts:
--------------------------------------------------------------------------------
1 | import getWeekLetters from "../src/getWeekLetters";
2 |
3 | const weekMon = [["M"], ["T"], ["W"], ["T"], ["F"], ["S"], ["S"]];
4 | const weekSun = [["S"], ["M"], ["T"], ["W"], ["T"], ["F"], ["S"]];
5 |
6 | test("getWeekLetters starting with Monday", () => {
7 | const week = getWeekLetters("en-US", false);
8 | expect(week).toStrictEqual(weekMon);
9 | });
10 |
11 | test("getWeekLetters starting with Sunday", () => {
12 | const week = getWeekLetters("en-US", true);
13 | expect(week).toStrictEqual(weekSun);
14 | });
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "module": "commonjs", // ts-node breaks if this is something else
5 | "baseUrl": "src",
6 | "lib": ["es2017"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/util/postBundle.js:
--------------------------------------------------------------------------------
1 | import { readFile, existsSync, writeFile } from "fs";
2 | import * as util from "util";
3 | import prettier from "prettier";
4 | import { Command } from "commander";
5 |
6 | const readScript = util.promisify(readFile);
7 |
8 | const program = new Command();
9 |
10 | program.option("--out-file ", "the output file");
11 |
12 | const {
13 | args: [inFile],
14 | } = program.parse();
15 | const { outFile } = program.opts();
16 |
17 | async function addAwait(inputPath, outputPath) {
18 | // check if the file exists
19 | if (existsSync(inputPath)) {
20 | const script = await readScript(inputPath, {
21 | encoding: "utf-8",
22 | });
23 |
24 | let fixedScript = script
25 | .split("\n")
26 | .filter((line) => !/(^await main\(\);$|^main\(\);$)/.test(line));
27 |
28 | fixedScript.push("await main();");
29 | fixedScript = fixedScript.join("\n");
30 | fixedScript = prettier.format(fixedScript, {
31 | parser: "babel",
32 | });
33 | writeFile(outputPath, fixedScript, "utf-8", () =>
34 | console.log(`Script written to: ${outputPath}`)
35 | );
36 | }
37 | }
38 |
39 | await addAwait(inFile, outFile);
40 |
--------------------------------------------------------------------------------
/util/watchBuildMove.js:
--------------------------------------------------------------------------------
1 | import chokidar from "chokidar";
2 | import { exec } from "child_process";
3 |
4 | chokidar.watch("dev/bundle.js").on("change", (path) => {
5 | console.log(`file changed: ${path}`);
6 | exec(
7 | "node util/postBundle.js dev/bundle.js --out-file=dev/calendar-dev.js",
8 | (error, stdout, stderr) => {
9 | if (error) {
10 | console.log(`error: ${error.message}`);
11 | return;
12 | }
13 | if (stderr) {
14 | console.log(`stderr: ${stderr}`);
15 | return;
16 | }
17 | console.log(`stdout: ${stdout}`);
18 | }
19 | );
20 | });
21 |
22 | chokidar.watch("dev/calendar-dev.js").on("change", (path) => {
23 | console.log(`file changed: ${path}`);
24 | exec(
25 | "cp dev/calendar-dev.js ~/Library/Mobile\\ Documents/iCloud\\~dk\\~simonbs\\~Scriptable/Documents/calendar-dev.js",
26 | (error, stdout, stderr) => {
27 | if (error) {
28 | console.log(`error: ${error.message}`);
29 | return;
30 | }
31 | if (stderr) {
32 | console.log(`stderr: ${stderr}`);
33 | return;
34 | }
35 | console.log(`calendar-dev.js copied to iCloud`);
36 | }
37 | );
38 | });
39 |
--------------------------------------------------------------------------------