├── .gitignore ├── .husky └── pre-commit ├── src ├── iconAllDay.ts ├── formatTime.ts ├── getMonthBoundaries.ts ├── formatString.ts ├── getSuffix.ts ├── getEventIcon.ts ├── getMonthOffset.ts ├── isWeekend.ts ├── isDateFromBoundingMonth.ts ├── setWidgetBackground.ts ├── index.ts ├── getWeekLetters.ts ├── dateToReadableDiff.ts ├── addWidgetTextLine.ts ├── createUrl.ts ├── buildLargeWidget.ts ├── getEvents.ts ├── buildWidget.ts ├── themes.ts ├── formatDuration.ts ├── createDateImage.ts ├── formatEvent.ts ├── countEvents.ts ├── buildCalendar.ts ├── buildEventsView.ts ├── settings.ts └── buildCalendarView.ts ├── assets └── scriptable-calendar-widget.jpg ├── .editorconfig ├── jest.config.cjs ├── tsconfig.json ├── test └── getWeekLetters.test.ts ├── .eslintrc.json ├── LICENSE ├── util ├── postBundle.js └── watchBuildMove.js ├── package.json ├── README.md └── calendar.js /.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 | -------------------------------------------------------------------------------- /src/iconAllDay.ts: -------------------------------------------------------------------------------- 1 | function iconFullDay(): Image { 2 | return SFSymbol.named('clock.badge').image; 3 | } 4 | 5 | export default iconFullDay; -------------------------------------------------------------------------------- /assets/scriptable-calendar-widget.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReinforceZwei/scriptable-calendar-widget/HEAD/assets/scriptable-calendar-widget.jpg -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/formatString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format a string 3 | * @param format Format string 4 | * @param args Arguments 5 | * @returns Formatted string 6 | */ 7 | function formatString(format: string, ...args: any[]): string { 8 | return format.replace(/{(\d+)}/g, function(match, number) { 9 | return typeof args[number] != 'undefined' 10 | ? args[number] 11 | : match 12 | ; 13 | }); 14 | }; 15 | 16 | export default formatString -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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: "narrow" })); 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/dateToReadableDiff.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare a date with current date and convert to readable date difference 3 | * 4 | * @param d1 Date to compare 5 | * @param [locale='en-GB'] 6 | */ 7 | function dateToReadableDiff(d1: Date, locale: string = 'en-GB') { 8 | const now = new Date(); 9 | now.setHours(0); 10 | now.setMinutes(0); 11 | now.setSeconds(0); 12 | now.setMilliseconds(0); 13 | const diff = d1.valueOf() - now.valueOf(); 14 | const dateDiff = Math.floor(diff / (1000*60*60*24)); 15 | const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); 16 | if (dateDiff < 0) { 17 | return ''; // date passed 18 | } else if (dateDiff <= 3) { 19 | return formatter.format(dateDiff, 'day'); 20 | } else { 21 | return d1.toLocaleDateString(locale, { month: 'long', day: 'numeric', weekday: 'short' }); 22 | } 23 | } 24 | 25 | export default dateToReadableDiff; -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 if (font !== undefined) { 30 | textLine.font = font; 31 | } else if (textSize !== undefined) { 32 | textLine.font = Font.systemFont(textSize); 33 | } 34 | textLine.textOpacity = opacity; 35 | switch (align) { 36 | case "left": 37 | textLine.leftAlignText(); 38 | break; 39 | case "center": 40 | textLine.centerAlignText(); 41 | break; 42 | case "right": 43 | textLine.rightAlignText(); 44 | break; 45 | default: 46 | textLine.leftAlignText(); 47 | break; 48 | } 49 | } 50 | 51 | export default addWidgetTextLine; 52 | -------------------------------------------------------------------------------- /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/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 | lineSpaceLimit: 16, 26 | eventSpacer: 6, 27 | verticalAlign: "top", 28 | }); 29 | await buildCalendarView(date, rightSide, settings, { 30 | verticalAlign: 'top', 31 | }); 32 | // add space between the calendar and any events below it 33 | rightSide.addSpacer(); 34 | await buildEventsView(rightSideEvents, rightSide, settings, { 35 | lineSpaceLimit: 12, 36 | eventSpacer: 6, 37 | verticalAlign: "top", 38 | showMsg: false, 39 | }); 40 | } 41 | 42 | export default buildLargeWidget; 43 | -------------------------------------------------------------------------------- /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/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.theme.widgetBackgroundColor, 1); 11 | setWidgetBackground(widget, settings.theme.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/themes.ts: -------------------------------------------------------------------------------- 1 | import { ThemeSetting } from "settings"; 2 | 3 | const darkTheme: ThemeSetting = { 4 | backgroundImage: "transparent.jpg", 5 | widgetBackgroundColor: "#000000", 6 | // background color for today 7 | todayTextColor: "#000000", 8 | todayCircleColor: "#FFB800", 9 | // color of all the other dates 10 | weekdayTextColor: "#ffffff", 11 | eventCircleColor: "#1E5C7B", 12 | // weekend colors 13 | weekendLetterColor: "#FFB800", 14 | weekendLetterOpacity: 1, 15 | weekendDateColor: "#FFB800", 16 | // text color for prev or next month 17 | textColorPrevNextMonth: "#9e9e9e", 18 | // color for events 19 | textColor: "#ffffff", 20 | // opacity value for event times 21 | eventDateTimeOpacity: 0.7, 22 | // opacity value for event item background in event view 23 | eventBackgroundOpacity: 0.3, 24 | } 25 | 26 | const lightTheme: ThemeSetting = { 27 | backgroundImage: "transparent.jpg", 28 | widgetBackgroundColor: "#FFFFFF", 29 | // background color for today 30 | todayTextColor: "#000000", 31 | todayCircleColor: "#FFB800", 32 | // color of all the other dates 33 | weekdayTextColor: "#000000", 34 | eventCircleColor: "#a5beca", 35 | // weekend colors 36 | weekendLetterColor: "#ff6600", 37 | weekendLetterOpacity: 1, 38 | weekendDateColor: "#ff6600", 39 | // text color for prev or next month 40 | textColorPrevNextMonth: "#403e3e", 41 | // color for events 42 | textColor: "#000000", 43 | // opacity value for event times 44 | eventDateTimeOpacity: 0.7, 45 | // opacity value for event item background in event view 46 | eventBackgroundOpacity: 0.3, 47 | } 48 | 49 | const autoTheme = Device.isUsingDarkAppearance() 50 | ? darkTheme 51 | : lightTheme; 52 | 53 | export { darkTheme, lightTheme, autoTheme } -------------------------------------------------------------------------------- /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/formatDuration.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "settings"; 2 | 3 | /** 4 | * formats the event start date and end date to duration 5 | * 6 | */ 7 | function formatDuration(startDate: Date, endDate: Date, { 8 | clock24Hour, 9 | locale 10 | }: Partial): string { 11 | if (clock24Hour) { 12 | const formatter = Intl.DateTimeFormat(locale, {hour: 'numeric', minute: 'numeric'}); 13 | return `${formatter.format(startDate)}-${formatter.format(endDate)}`; 14 | } else { 15 | const formatter = Intl.DateTimeFormat(locale, {hour: 'numeric', minute: 'numeric', hour12: true}); 16 | const startDayParts = formatter.formatToParts(startDate); 17 | const endDayParts = formatter.formatToParts(endDate); 18 | const startPeriod = startDayParts.find(p => p.type === "dayPeriod").value; 19 | const endPeriod = endDayParts.find(p => p.type === "dayPeriod").value; 20 | if (startPeriod === endPeriod) { 21 | if (isPeriodFirst(startDayParts)) { 22 | // Don't show same period if it come first for that locale 23 | // e.g. 下午1:00-下午2:00 -> 下午1:00-2:00 24 | log(`${joinDateParts(startDayParts)}-${joinDateParts(endDayParts.filter(p => p.type !== "dayPeriod"))}`); 25 | return `${joinDateParts(startDayParts)}-${joinDateParts(endDayParts.filter(p => p.type !== "dayPeriod"))}`; 26 | } 27 | } 28 | return `${joinDateParts(startDayParts)}-${joinDateParts(endDayParts)}`; 29 | } 30 | } 31 | 32 | function joinDateParts(parts: Intl.DateTimeFormatPart[]) { 33 | return parts.map((p) => p.value).join('') 34 | } 35 | 36 | function isPeriodFirst(parts: Intl.DateTimeFormatPart[]) { 37 | for (let part of parts) { 38 | if (part.type === "dayPeriod") return true; 39 | if (part.type === "hour" || part.type === "minute") return false; 40 | } 41 | } 42 | 43 | export default formatDuration; 44 | -------------------------------------------------------------------------------- /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 | textSize = 'medium', 13 | style = 'circle', 14 | }: { 15 | backgroundColor: string; 16 | textColor: string; 17 | intensity: number; 18 | toFullSize: boolean; 19 | textSize?: 'small' | 'medium' | 'large'; 20 | style?: 'circle' | 'dot'; 21 | } 22 | ): Image { 23 | const largeSize = 50; 24 | const smallSize = 35; 25 | const size = toFullSize ? largeSize : smallSize; 26 | 27 | const largeTextFactor = 0.65; 28 | const mediumTextFactor = 0.55; 29 | const smallTextFactor = 0.45; 30 | let textSizeFactor = mediumTextFactor; 31 | if (textSize === 'small') { 32 | textSizeFactor = smallTextFactor; 33 | } else if (textSize === 'medium') { 34 | textSizeFactor = mediumTextFactor; 35 | } else if (textSize === 'large') { 36 | textSizeFactor = largeTextFactor; 37 | } 38 | 39 | const drawing = new DrawContext(); 40 | 41 | drawing.respectScreenScale = true; 42 | const contextSize = largeSize; 43 | drawing.size = new Size(contextSize, contextSize); 44 | // won't show a drawing sized square background 45 | drawing.opaque = false; 46 | 47 | // circle color 48 | drawing.setFillColor(new Color(backgroundColor, intensity)); 49 | 50 | if (style === 'circle') { 51 | // so that edges stay round and are not clipped by the box 52 | // 50 48 1 53 | // (contextSize - (size - 2)) / 2 54 | // size - 2 makes them a bit smaller than the drawing context 55 | drawing.fillEllipse( 56 | new Rect( 57 | (contextSize - (size - 2)) / 2, 58 | (contextSize - (size - 2)) / 2, 59 | size - 2, 60 | size - 2 61 | ) 62 | ); 63 | } else if (style === 'dot') { 64 | const dotSize = contextSize / 5; 65 | drawing.fillEllipse( 66 | new Rect( 67 | contextSize / 2 - dotSize / 2, // center the dot 68 | contextSize - dotSize, // below the text 69 | dotSize, 70 | dotSize, 71 | ) 72 | ); 73 | } 74 | 75 | drawing.setFont(Font.boldSystemFont(size * textSizeFactor)); 76 | drawing.setTextAlignedCenter(); 77 | drawing.setTextColor(new Color(textColor, 1)); 78 | // the text aligns to the bottom of the rectangle while not extending to the 79 | // top, so y is pulled up here 3 pixels 80 | const textBox = new Rect( 81 | (contextSize - size) / 2, 82 | (contextSize - size * textSizeFactor) / 2 - 3, 83 | size, 84 | size * textSizeFactor 85 | ); 86 | drawing.drawTextInRect(text, textBox); 87 | return drawing.getImage(); 88 | } 89 | 90 | export default createDateImage; 91 | -------------------------------------------------------------------------------- /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 | import iconFullDay from "iconAllDay"; 7 | import formatDuration from "formatDuration"; 8 | 9 | /** 10 | * Adds a event name along with start and end times to widget stack 11 | * 12 | */ 13 | function formatEvent( 14 | stack: WidgetStack, 15 | event: CalendarEvent, 16 | { 17 | theme, 18 | showCalendarBullet, 19 | showCompleteTitle, 20 | showEventLocation, 21 | showEventTime, 22 | showIconForAllDayEvents, 23 | clock24Hour, 24 | locale, 25 | }: Partial 26 | ): number { 27 | const eventLine = stack.addStack(); 28 | const backgroundColor = new Color(event.calendar.color.hex, theme.eventBackgroundOpacity); 29 | eventLine.backgroundColor = backgroundColor; 30 | eventLine.layoutVertically(); 31 | eventLine.cornerRadius = 5; 32 | eventLine.setPadding(3, 3, 3, 3); 33 | eventLine.size = new Size(150, 0); 34 | 35 | let lineCount = 0; 36 | 37 | const titleStack = eventLine.addStack(); 38 | 39 | if (showCalendarBullet) { 40 | // show calendar bullet in front of event name 41 | const icon = getEventIcon(event); 42 | addWidgetTextLine(icon, titleStack, { 43 | textColor: event.calendar.color.hex, 44 | font: Font.mediumSystemFont(13), 45 | lineLimit: showCompleteTitle ? 0 : 1, 46 | }); 47 | } 48 | 49 | // event title 50 | addWidgetTextLine(event.title, titleStack, { 51 | textColor: theme.textColor, 52 | font: Font.mediumSystemFont(13), 53 | lineLimit: showCompleteTitle ? 0 : 1, 54 | }); 55 | if (showIconForAllDayEvents && event.isAllDay) { 56 | titleStack.addSpacer(); 57 | const icon = titleStack.addImage(iconFullDay()); 58 | icon.imageSize = new Size(15, 15); 59 | icon.rightAlignImage(); 60 | icon.tintColor = new Color(theme.textColor); 61 | } 62 | 63 | lineCount++; 64 | 65 | if (showEventLocation && event.location) { 66 | addWidgetTextLine(event.location, eventLine.addStack(), { 67 | textColor: theme.textColor, 68 | opacity: theme.eventDateTimeOpacity, 69 | font: Font.mediumSystemFont(12), 70 | lineLimit: showCompleteTitle ? 0 : 1, 71 | }); 72 | lineCount++; 73 | } 74 | 75 | if (showEventTime) { 76 | // event duration 77 | let time: string = ''; 78 | if (!event.isAllDay) { 79 | time = formatDuration(event.startDate, event.endDate, {clock24Hour, locale}); 80 | } 81 | 82 | // event time 83 | if (time) { 84 | const timeStack = eventLine.addStack(); 85 | addWidgetTextLine(time, timeStack, { 86 | textColor: theme.textColor, 87 | opacity: theme.eventDateTimeOpacity, 88 | font: Font.regularSystemFont(12), 89 | }); 90 | lineCount++; 91 | } 92 | } 93 | return lineCount; 94 | } 95 | export default formatEvent; 96 | -------------------------------------------------------------------------------- /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/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/buildEventsView.ts: -------------------------------------------------------------------------------- 1 | import createUrl from "createUrl"; 2 | import addWidgetTextLine from "./addWidgetTextLine"; 3 | import formatEvent from "./formatEvent"; 4 | import { Settings } from "./settings"; 5 | import dateToReadableDiff from "dateToReadableDiff"; 6 | 7 | /** 8 | * Builds the events view 9 | * 10 | * @param {WidgetStack} stack - onto which the events view is built 11 | */ 12 | async function buildEventsView( 13 | events: CalendarEvent[], 14 | stack: WidgetStack, 15 | settings: Settings, 16 | { 17 | horizontalAlign = "left", 18 | verticalAlign = "top", 19 | eventSpacer = 4, 20 | lineSpaceLimit = 8, 21 | showMsg = true, 22 | }: { 23 | horizontalAlign?: string; 24 | verticalAlign?: string; 25 | eventSpacer?: number; 26 | lineSpaceLimit?: number; 27 | showMsg?: boolean; 28 | } = {} 29 | ): Promise { 30 | const leftStack = stack.addStack(); 31 | leftStack.layoutVertically(); 32 | leftStack.setPadding(5, 0, 0, 0); 33 | // add, spacer to the right side, this pushes event view to the left 34 | if (horizontalAlign === "left") { 35 | stack.addSpacer(); 36 | } 37 | 38 | if (events.length == 0 && showMsg) { 39 | // No event 40 | const noEventStack = leftStack.addStack(); 41 | noEventStack.setPadding(5, 0, 0, 0); 42 | noEventStack.layoutVertically(); 43 | const checkmark = SFSymbol.named('checkmark.circle').image; 44 | 45 | const titleStack = noEventStack.addStack() 46 | titleStack.centerAlignContent() 47 | const formatter = Intl.DateTimeFormat(settings.locale, { day: 'numeric', weekday: 'long', }); 48 | const parts = formatter.formatToParts(new Date()) 49 | addWidgetTextLine(parts.find(v => v.type === 'day').value, titleStack, { 50 | textColor: settings.theme.textColor, 51 | textSize: 30, 52 | }); 53 | titleStack.addSpacer(5) 54 | addWidgetTextLine(parts.find(v => v.type === 'weekday').value, titleStack, { 55 | textColor: settings.theme.todayCircleColor, 56 | textSize: 15, 57 | }); 58 | noEventStack.addSpacer() 59 | const img = noEventStack.addImage(checkmark); 60 | img.imageSize = new Size(35, 35); 61 | img.centerAlignImage(); 62 | noEventStack.addSpacer(); 63 | return; 64 | } 65 | 66 | // center the whole left part of the widget 67 | if (verticalAlign === "bottom" || verticalAlign === "center") { 68 | leftStack.addSpacer(); 69 | } 70 | 71 | // if we have events today; else if we don't 72 | if (events.length !== 0) { 73 | const groupStack: Map = new Map(); 74 | // show the next 3 events at most 75 | const numEvents = events.length;// > eventLimit ? eventLimit : events.length; 76 | // don't show location if more than 2 events 77 | const showLocation = settings.showEventLocation; 78 | let spaceLeft = lineSpaceLimit; 79 | let i = 0; 80 | while (spaceLeft > 0 && i < numEvents) { 81 | let stack: WidgetStack; 82 | let eventDate = dateToReadableDiff(events[i].startDate, settings.locale); 83 | if (groupStack.has(eventDate)) { 84 | stack = groupStack.get(eventDate); 85 | } else { 86 | if (spaceLeft <= 1) { 87 | // Not enough space for new date group 88 | break; 89 | } 90 | stack = leftStack.addStack(); 91 | stack.layoutVertically(); 92 | groupStack.set(eventDate, stack); 93 | 94 | addWidgetTextLine(eventDate, stack, { 95 | textColor: settings.theme.textColorPrevNextMonth, 96 | font: Font.regularSystemFont(13), 97 | }); 98 | spaceLeft--; 99 | 100 | stack.url = createUrl( 101 | events[i].startDate.getDate().toString(), 102 | events[i].startDate.getMonth().toString(), 103 | events[i].startDate, settings) 104 | } 105 | const showTime = settings.showEventTime; 106 | const spaceUsed = formatEvent(stack, events[i], { 107 | ...settings, 108 | showEventLocation: spaceLeft >= 3 ? showLocation : false, 109 | showEventTime: spaceLeft >= 2 ? showTime : false, 110 | }); 111 | spaceLeft -= spaceUsed; 112 | // don't add a spacer after the last event 113 | if (spaceLeft > 0 && i < (numEvents - 1)) { 114 | stack.addSpacer(eventSpacer); 115 | } 116 | i++; 117 | } 118 | } 119 | // for centering, pushes up from the bottom 120 | if (verticalAlign === "top" || verticalAlign === "center") { 121 | leftStack.addSpacer(); 122 | } 123 | } 124 | 125 | export default buildEventsView; 126 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { autoTheme, lightTheme, darkTheme } from "themes"; 2 | // get widget params 3 | const params = JSON.parse(args.widgetParameter) || {}; 4 | 5 | let importedSettings: any = {}; 6 | try { 7 | importedSettings = importModule('calendar-settings'); 8 | } catch {} 9 | 10 | const defaultSettings: Settings = { 11 | // set to true to initially give Scriptable calendar access 12 | // set to false to open Calendar when script is run - when tapping on the widget 13 | debug: false, 14 | // what app to open when the script is run in a widget, 15 | // "calshow" is the ios calendar app 16 | // "x-fantastical3" for fantastical 17 | calendarApp: "calshow", 18 | // what calendars to show, all if empty or something like: ["Work"] 19 | calFilter: [], 20 | markToday: true, 21 | // show a circle behind each date that has an event then 22 | showEventCircles: true, 23 | // circle background style or a dot below text style 24 | eventCircleStyle: 'circle', 25 | // if true, all-day events don't count towards eventCircle intensity value 26 | discountAllDayEvents: false, 27 | // show smaller text for prev or next month 28 | smallerPrevNextMonth: false, 29 | // changes some locale specific values, such as weekday letters 30 | locale: 'en-GB', 31 | // specify font size for calendar text 32 | fontSize: 'medium', 33 | // what the widget shows 34 | widgetType: "cal", 35 | themeName: 'auto', 36 | theme: autoTheme, 37 | // show or hide all day events 38 | showAllDayEvents: true, 39 | // show an icon if the event is all day 40 | showIconForAllDayEvents: true, 41 | // show calendar colored bullet for each event 42 | showCalendarBullet: true, 43 | // week starts on a Sunday 44 | startWeekOnSunday: false, 45 | // show events for the whole week or limit just to the day 46 | showEventsOnlyForToday: false, 47 | // shows events for that many days if showEventsOnlyForToday is false 48 | nextNumOfDays: 7, 49 | // show full title or truncate to a single line 50 | showCompleteTitle: false, 51 | // show event location if available 52 | showEventLocation: true, 53 | // show event duration 54 | showEventTime: true, 55 | // Use 24 hour clock 56 | clock24Hour: false, 57 | // shows the last days of the previous month if they fit 58 | showPrevMonth: true, 59 | // shows the last days of the previous month if they fit 60 | showNextMonth: true, 61 | // tapping on a date opens that specific one 62 | individualDateTargets: false, 63 | // events-calendar OR a flipped calendar-events type of view for medium widget 64 | flipped: false, 65 | }; 66 | 67 | export interface Settings { 68 | debug: boolean; 69 | calendarApp: string; 70 | calFilter: string[]; 71 | markToday: boolean; 72 | showEventCircles: boolean; 73 | eventCircleStyle: 'circle' | 'dot'; 74 | discountAllDayEvents: boolean; 75 | locale: string; 76 | fontSize: 'small' | 'medium' | 'large'; 77 | smallerPrevNextMonth: boolean; 78 | widgetType: string; 79 | themeName: 'auto' | 'light' | 'dark' | 'custom'; 80 | theme: ThemeSetting; 81 | showAllDayEvents: boolean; 82 | showIconForAllDayEvents: boolean; 83 | showCalendarBullet: boolean; 84 | startWeekOnSunday: boolean; 85 | showEventsOnlyForToday: boolean; 86 | nextNumOfDays: number; 87 | showCompleteTitle: boolean; 88 | showEventLocation: boolean; 89 | showEventTime: boolean; 90 | clock24Hour: boolean; 91 | showPrevMonth: boolean; 92 | showNextMonth: boolean; 93 | individualDateTargets: boolean; 94 | flipped: boolean; 95 | } 96 | 97 | export interface ThemeSetting { 98 | backgroundImage: string; 99 | widgetBackgroundColor: string; 100 | textColor: string; 101 | todayTextColor: string; 102 | textColorPrevNextMonth: string; 103 | todayCircleColor: string; 104 | weekdayTextColor: string; 105 | eventCircleColor: string; 106 | weekendLetterColor: string; 107 | weekendLetterOpacity: number; 108 | weekendDateColor: string; 109 | eventDateTimeOpacity: number; 110 | eventBackgroundOpacity: number; 111 | } 112 | 113 | // Merge settings. Latest item takes priority 114 | const settings: Settings = Object.assign( 115 | defaultSettings, 116 | importedSettings, 117 | params 118 | ); 119 | 120 | if (params.bg) settings.theme.backgroundImage = params.bg; 121 | 122 | let theme; 123 | switch (settings.themeName) { 124 | case 'dark': theme = darkTheme; break; 125 | case 'light': theme = lightTheme; break; 126 | default: theme = autoTheme; break; 127 | } 128 | settings.theme = Object.assign( 129 | theme, 130 | importedSettings.theme, 131 | params.theme 132 | ); 133 | 134 | export default settings; 135 | -------------------------------------------------------------------------------- /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 | { 20 | verticalAlign = 'center' 21 | }: { 22 | verticalAlign?: 'top' | 'center' 23 | } = {} 24 | ): Promise { 25 | const rightStack = stack.addStack(); 26 | rightStack.layoutVertically(); 27 | 28 | if (verticalAlign === 'center') { 29 | rightStack.addSpacer(); 30 | } 31 | 32 | const dateFormatter = new DateFormatter(); 33 | dateFormatter.dateFormat = "MMMM"; 34 | dateFormatter.locale = settings.locale.split("-")[0]; 35 | 36 | // if calendar is on a small widget make it a bit smaller to fit 37 | const spacing = config.widgetFamily === "small" ? 18 : 19; 38 | 39 | // Current month line 40 | const monthLine = rightStack.addStack(); 41 | // since dates are centered in their squares we need to add some space 42 | monthLine.addSpacer(4); 43 | const monthFontSize = settings.fontSize === 'small' 44 | ? 12 45 | : settings.fontSize === 'medium' 46 | ? 14 47 | : 16; 48 | addWidgetTextLine(dateFormatter.string(date).toUpperCase(), monthLine, { 49 | textColor: settings.theme.textColor, 50 | font: Font.boldSystemFont(monthFontSize), 51 | }); 52 | 53 | const calendarStack = rightStack.addStack(); 54 | calendarStack.spacing = 2; 55 | 56 | const { calendar, daysFromPrevMonth, daysFromNextMonth } = buildCalendar( 57 | date, 58 | settings 59 | ); 60 | 61 | const { eventCounts, intensity } = await countEvents( 62 | date, 63 | daysFromPrevMonth, 64 | daysFromNextMonth, 65 | settings 66 | ); 67 | 68 | const fontSize = settings.fontSize === 'small' 69 | ? 10 70 | : settings.fontSize === 'medium' 71 | ? 11 72 | : 12; 73 | 74 | for (let i = 0; i < calendar.length; i += 1) { 75 | const weekdayStack = calendarStack.addStack(); 76 | weekdayStack.layoutVertically(); 77 | 78 | for (let j = 0; j < calendar[i].length; j += 1) { 79 | const dayStack = weekdayStack.addStack(); 80 | dayStack.size = new Size(spacing, spacing); 81 | dayStack.centerAlignContent(); 82 | 83 | // splitting "month/day" or "D" 84 | // a day marker won't split so if we reverse and take first we get correct 85 | const [day, month] = calendar[i][j].split("/").reverse(); 86 | // add callbacks to each date 87 | if (settings.individualDateTargets) { 88 | const callbackUrl = createUrl(day, month, date, settings); 89 | if (j > 0) dayStack.url = callbackUrl; 90 | } 91 | // if the day is today, highlight it 92 | if (calendar[i][j] === `${date.getMonth()}/${date.getDate()}`) { 93 | if (settings.markToday) { 94 | const highlightedDate = createDateImage(day, { 95 | backgroundColor: settings.theme.todayCircleColor, 96 | textColor: settings.theme.todayTextColor, 97 | intensity: 1, 98 | toFullSize: true, 99 | textSize: settings.fontSize, 100 | }); 101 | dayStack.addImage(highlightedDate); 102 | } else { 103 | addWidgetTextLine(day, dayStack, { 104 | textColor: settings.theme.todayTextColor, 105 | font: Font.boldSystemFont(fontSize), 106 | align: "center", 107 | }); 108 | } 109 | // j == 0, contains the letters, so this creates all the other dates 110 | } else if (j > 0 && calendar[i][j] !== " ") { 111 | const isCurrentMonth = isDateFromBoundingMonth(i, j, date, calendar); 112 | const toFullSize = !settings.smallerPrevNextMonth || isCurrentMonth; 113 | let textColor = isWeekend(i, settings.startWeekOnSunday) 114 | ? settings.theme.weekendDateColor 115 | : settings.theme.weekdayTextColor; 116 | if (!isCurrentMonth) textColor = settings.theme.textColorPrevNextMonth; 117 | 118 | const dateImage = createDateImage(day, { 119 | backgroundColor: settings.theme.eventCircleColor, 120 | textColor: textColor, 121 | intensity: settings.showEventCircles 122 | ? eventCounts.get(calendar[i][j]) * intensity 123 | : 0, 124 | toFullSize, 125 | style: settings.eventCircleStyle, 126 | textSize: settings.fontSize, 127 | }); 128 | dayStack.addImage(dateImage); 129 | } else { 130 | // first line and empty dates from other months 131 | addWidgetTextLine(day, dayStack, { 132 | textColor: isWeekend(i, settings.startWeekOnSunday) 133 | ? settings.theme.weekendLetterColor 134 | : settings.theme.textColor, 135 | opacity: isWeekend(i, settings.startWeekOnSunday) 136 | ? settings.theme.weekendLetterOpacity 137 | : 1, 138 | font: Font.boldSystemFont(fontSize), 139 | align: "center", 140 | }); 141 | } 142 | } 143 | } 144 | if (verticalAlign === 'center') { 145 | rightStack.addSpacer(); 146 | } 147 | } 148 | 149 | export default buildCalendarView; 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | scriptable calendar 3 |

4 | 5 | - [Setting Up](#setting-up) 6 | - [Customization](#customization) 7 | - [Small Widgets](#small-widgets) 8 | - [Large Widgets](#large-widgets) 9 | - [Development](#development) 10 | 11 | ## Setting Up 12 | 13 | - Copy the script in [calendar.js](./calendar.js) to a new script in Scriptable app. 14 | - Run the script first which should prompt Scriptable to ask for calendar access. 15 | - If it didn't and you haven't given Scriptable calendar access before, try changing the `debug` setting to `true` and trying again. 16 | - **To have the widget open the iOS calendar app, switch `debug` back to `false` afterwards.** 17 | - Add a medium sized Scriptable widget to your homescreen. 18 | - Long press the widget and choose "Edit Widget". 19 | - 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. 20 | - Return to your home screen which should now hopefully show the Scriptable calendar widget. 21 | 22 | ## Customization 23 | 24 | It is recommended to put your settings into another script named `calendar-settings.js` or in script parameter, so that updating the calendar script will not reset your settings. 25 | 26 | The basic syntax of `calendar-settings.js` could be: 27 | ```js 28 | module.exports = { 29 | // Put the settings you want to customize here 30 | calFilter: ['Work'], 31 | locale: 'ja-JP', 32 | startWeekOnSunday: true, 33 | showEventLocation: true, 34 | theme: { 35 | // See below section 36 | } 37 | } 38 | ``` 39 | You can write anything in the settings script as long as the script is exporting an object. 40 | 41 | ### Available Settings 42 | 43 | - `debug` - set to `true` to show the widget in Scriptable, `false` to open a 44 | calendar app. 45 | - `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`. 46 | - `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. 47 | - `markToday` - show a circle around today or not 48 | - `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. 49 | - `eventCircleStyle` - `circle` or `dot` style for indicating event. `circle` will have a circle background. `dot` will show a dot below the day. 50 | - `discountAllDayEvents` - if true, all-day events don't count towards eventCircle intensity value 51 | - `smallerPrevNextMonth` - date size for previous or next month 52 | - `locale` - a Unicode locale identifier string. Default follow device setting. 53 | - `fontSize` - specify font size (`small`, `medium` or `large`) for calendar text. Default is `medium` 54 | - `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)) 55 | - `themeName` - `light`, `dark` or `auto`. `auto` will follow device setting 56 | - `theme` - theme object containing custom theme settings. See [Theme Settings](#theme-settings) for available settings 57 | - `showAllDayEvents` - would either show or hide all day events. 58 | - `showIconForAllDayEvents` - show an icon for all day event. 59 | - `showCalendarBullet` - would show a `●` in front of the event name which matches the calendar color from which the event originates. 60 | - `startWeekOnSunday` - would start the week either on a Sunday or a Monday. 61 | - `showEventsOnlyForToday` - would either limit the events to today or a specified number of future days with `nextNumOfDays` 62 | - `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. 63 | - `showCompleteTitle` - would truncate long event titles so that they can fit onto a single line to fit more events into the view. 64 | - `showEventLocation` - show the location infomation of the event if availabe. 65 | - `showEventTime` - show or hide the event time. 66 | - `clock24Hour` - use 12 or 24 hour clock for displaying time. 67 | - `showPrevMonth` - would show days from the previous month if they fit into the calendar view. 68 | - `showNextMonth` - would show days from the next month if they fit into the calendar view. 69 | - `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). 70 | - `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. 71 | 72 | ### Theme Settings 73 | You can customize the color settings of the widget. To get started, choose one of the `light` or `dark` theme and override the theme base on it: (See [Customization](#customization) if you don't know how to change settings) 74 | ```js 75 | module.exports = { 76 | // ...your other settings 77 | calFilter: ['Work'], 78 | locale: 'ja-JP', 79 | ... 80 | // Customize the theme 81 | theme: { 82 | backgroundImage: 'my-image.jpg', 83 | todayTextColor: '#ff6600', 84 | textColorPrevNextMonth: '#0000ff', 85 | // You can choose change some settings only, or change everything 86 | } 87 | } 88 | ``` 89 | 90 | - `backgroundImage` - Image path to use as the widget background. 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. 91 | - `widgetBackgroundColor` - In case of no background image, what color to use. 92 | - `todayTextColor` - color of today's date 93 | - `todayCircleColor` - if we mark days, then in what color 94 | - `eventCircleColor` - if showing event circles, then in what color 95 | - `weekdayTextColor` - color of weekdays 96 | - `weekendLetterColor` - color of the letters in the top row 97 | - `weekendLettersOpacity` - a value between 0 and 1 to dim the color of the letters 98 | - `weekendDateColor` - color of the weekend days 99 | - `textColorPrevNextMonth` - text color for previous or next month 100 | - `textColor` - color of all the other text 101 | - `eventDateTimeOpacity` - opacity value for event times 102 | - `eventBackgroundOpacity` - opacity value for event item background in event view 103 | 104 | ## Small Widgets 105 | 106 | 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: 107 | 108 | - `{ "bg": "top-left.jpg", "view": "events" }` 109 | - `{ "bg": "top-right.jpg", "view": "cal" }` 110 | 111 | Where `"events"` specifies the events view and `"cal"` the calendar view. (Setting the background is not necessary). 112 | 113 | ## Large Widgets 114 | 115 | The script should detect on its own that it is running in a large widget and will adjust accordingly. 116 | 117 | ## Development 118 | 119 | - `npm install` - install dev dependencies 120 | - `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. 121 | -------------------------------------------------------------------------------- /calendar.js: -------------------------------------------------------------------------------- 1 | var __defProp = Object.defineProperty; 2 | var __defProps = Object.defineProperties; 3 | var __getOwnPropDescs = Object.getOwnPropertyDescriptors; 4 | var __getOwnPropSymbols = Object.getOwnPropertySymbols; 5 | var __hasOwnProp = Object.prototype.hasOwnProperty; 6 | var __propIsEnum = Object.prototype.propertyIsEnumerable; 7 | var __defNormalProp = (obj, key, value) => 8 | key in obj 9 | ? __defProp(obj, key, { 10 | enumerable: true, 11 | configurable: true, 12 | writable: true, 13 | value, 14 | }) 15 | : (obj[key] = value); 16 | var __spreadValues = (a, b) => { 17 | for (var prop in b || (b = {})) 18 | if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); 19 | if (__getOwnPropSymbols) 20 | for (var prop of __getOwnPropSymbols(b)) { 21 | if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); 22 | } 23 | return a; 24 | }; 25 | var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); 26 | 27 | // src/themes.ts 28 | var darkTheme = { 29 | backgroundImage: "transparent.jpg", 30 | widgetBackgroundColor: "#000000", 31 | todayTextColor: "#000000", 32 | todayCircleColor: "#FFB800", 33 | weekdayTextColor: "#ffffff", 34 | eventCircleColor: "#1E5C7B", 35 | weekendLetterColor: "#FFB800", 36 | weekendLetterOpacity: 1, 37 | weekendDateColor: "#FFB800", 38 | textColorPrevNextMonth: "#9e9e9e", 39 | textColor: "#ffffff", 40 | eventDateTimeOpacity: 0.7, 41 | eventBackgroundOpacity: 0.3, 42 | }; 43 | var lightTheme = { 44 | backgroundImage: "transparent.jpg", 45 | widgetBackgroundColor: "#FFFFFF", 46 | todayTextColor: "#000000", 47 | todayCircleColor: "#FFB800", 48 | weekdayTextColor: "#000000", 49 | eventCircleColor: "#a5beca", 50 | weekendLetterColor: "#ff6600", 51 | weekendLetterOpacity: 1, 52 | weekendDateColor: "#ff6600", 53 | textColorPrevNextMonth: "#403e3e", 54 | textColor: "#000000", 55 | eventDateTimeOpacity: 0.7, 56 | eventBackgroundOpacity: 0.3, 57 | }; 58 | var autoTheme = Device.isUsingDarkAppearance() ? darkTheme : lightTheme; 59 | 60 | // src/settings.ts 61 | var params = JSON.parse(args.widgetParameter) || {}; 62 | var importedSettings = {}; 63 | try { 64 | importedSettings = importModule("calendar-settings"); 65 | } catch (e) {} 66 | var defaultSettings = { 67 | debug: false, 68 | calendarApp: "calshow", 69 | calFilter: [], 70 | markToday: true, 71 | showEventCircles: true, 72 | eventCircleStyle: "circle", 73 | discountAllDayEvents: false, 74 | smallerPrevNextMonth: false, 75 | locale: "en-GB", 76 | fontSize: "medium", 77 | widgetType: "cal", 78 | themeName: "auto", 79 | theme: autoTheme, 80 | showAllDayEvents: true, 81 | showIconForAllDayEvents: true, 82 | showCalendarBullet: true, 83 | startWeekOnSunday: false, 84 | showEventsOnlyForToday: false, 85 | nextNumOfDays: 7, 86 | showCompleteTitle: false, 87 | showEventLocation: true, 88 | showEventTime: true, 89 | clock24Hour: false, 90 | showPrevMonth: true, 91 | showNextMonth: true, 92 | individualDateTargets: false, 93 | flipped: false, 94 | }; 95 | var settings = Object.assign(defaultSettings, importedSettings, params); 96 | if (params.bg) settings.theme.backgroundImage = params.bg; 97 | var theme; 98 | switch (settings.themeName) { 99 | case "dark": 100 | theme = darkTheme; 101 | break; 102 | case "light": 103 | theme = lightTheme; 104 | break; 105 | default: 106 | theme = autoTheme; 107 | break; 108 | } 109 | settings.theme = Object.assign(theme, importedSettings.theme, params.theme); 110 | var settings_default = settings; 111 | 112 | // src/setWidgetBackground.ts 113 | function setWidgetBackground(widget, imageName) { 114 | const imageUrl = getImageUrl(imageName); 115 | const image = Image.fromFile(imageUrl); 116 | widget.backgroundImage = image; 117 | } 118 | function getImageUrl(name) { 119 | const fm = FileManager.iCloud(); 120 | const dir = fm.documentsDirectory(); 121 | return fm.joinPath(dir, `${name}`); 122 | } 123 | var setWidgetBackground_default = setWidgetBackground; 124 | 125 | // src/addWidgetTextLine.ts 126 | function addWidgetTextLine( 127 | text, 128 | widget, 129 | { 130 | textColor = "#ffffff", 131 | textSize = 12, 132 | opacity = 1, 133 | align, 134 | font, 135 | lineLimit = 0, 136 | } 137 | ) { 138 | const textLine = widget.addText(text); 139 | textLine.textColor = new Color(textColor, 1); 140 | textLine.lineLimit = lineLimit; 141 | if (typeof font === "string") { 142 | textLine.font = new Font(font, textSize); 143 | } else if (font !== void 0) { 144 | textLine.font = font; 145 | } else if (textSize !== void 0) { 146 | textLine.font = Font.systemFont(textSize); 147 | } 148 | textLine.textOpacity = opacity; 149 | switch (align) { 150 | case "left": 151 | textLine.leftAlignText(); 152 | break; 153 | case "center": 154 | textLine.centerAlignText(); 155 | break; 156 | case "right": 157 | textLine.rightAlignText(); 158 | break; 159 | default: 160 | textLine.leftAlignText(); 161 | break; 162 | } 163 | } 164 | var addWidgetTextLine_default = addWidgetTextLine; 165 | 166 | // src/getMonthBoundaries.ts 167 | function getMonthBoundaries(date) { 168 | const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); 169 | const lastOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); 170 | return { firstOfMonth, lastOfMonth }; 171 | } 172 | var getMonthBoundaries_default = getMonthBoundaries; 173 | 174 | // src/getMonthOffset.ts 175 | function getMonthOffset(date, offset) { 176 | const newDate = new Date(date); 177 | let offsetMonth = date.getMonth() + offset; 178 | if (offsetMonth < 0) { 179 | offsetMonth += 12; 180 | newDate.setFullYear(date.getFullYear() - 1); 181 | } else if (offsetMonth > 11) { 182 | offsetMonth -= 12; 183 | newDate.setFullYear(date.getFullYear() + 1); 184 | } 185 | newDate.setMonth(offsetMonth, 1); 186 | return newDate; 187 | } 188 | var getMonthOffset_default = getMonthOffset; 189 | 190 | // src/getWeekLetters.ts 191 | function getWeekLetters(locale = "en-US", startWeekOnSunday = false) { 192 | let week = []; 193 | for (let i = 1; i <= 7; i += 1) { 194 | const day = new Date(`February 0${i}, 2021`); 195 | week.push(day.toLocaleDateString(locale, { weekday: "narrow" })); 196 | } 197 | week = week.map((day) => [day.slice(0, 1).toUpperCase()]); 198 | if (startWeekOnSunday) { 199 | const sunday = week.pop(); 200 | week.unshift(sunday); 201 | } 202 | return week; 203 | } 204 | var getWeekLetters_default = getWeekLetters; 205 | 206 | // src/buildCalendar.ts 207 | function buildCalendar( 208 | date = new Date(), 209 | { 210 | locale, 211 | showPrevMonth = true, 212 | showNextMonth = true, 213 | startWeekOnSunday = false, 214 | } 215 | ) { 216 | const currentMonth = getMonthBoundaries_default(date); 217 | const prevMonth = getMonthBoundaries_default( 218 | getMonthOffset_default(date, -1) 219 | ); 220 | const calendar = getWeekLetters_default(locale, startWeekOnSunday); 221 | let daysFromPrevMonth = 0; 222 | let daysFromNextMonth = 0; 223 | let index = 1; 224 | let offset = 1; 225 | let firstDay = 226 | currentMonth.firstOfMonth.getDay() !== 0 227 | ? currentMonth.firstOfMonth.getDay() 228 | : 7; 229 | if (startWeekOnSunday) { 230 | index = 0; 231 | offset = 0; 232 | firstDay = firstDay % 7; 233 | } 234 | let dayStackCounter = 0; 235 | for (; index < firstDay; index += 1) { 236 | if (showPrevMonth) { 237 | calendar[index - offset].push( 238 | `${prevMonth.lastOfMonth.getMonth()}/${ 239 | prevMonth.lastOfMonth.getDate() - firstDay + 1 + index 240 | }` 241 | ); 242 | daysFromPrevMonth += 1; 243 | } else { 244 | calendar[index - offset].push(" "); 245 | } 246 | dayStackCounter = (dayStackCounter + 1) % 7; 247 | } 248 | for ( 249 | let indexDate = 1; 250 | indexDate <= currentMonth.lastOfMonth.getDate(); 251 | indexDate += 1 252 | ) { 253 | calendar[dayStackCounter].push(`${date.getMonth()}/${indexDate}`); 254 | dayStackCounter = (dayStackCounter + 1) % 7; 255 | } 256 | let longestColumn = calendar.reduce( 257 | (acc, dayStacks) => (dayStacks.length > acc ? dayStacks.length : acc), 258 | 0 259 | ); 260 | if (showNextMonth && longestColumn < 6) { 261 | longestColumn += 1; 262 | } 263 | const nextMonth = getMonthOffset_default(date, 1); 264 | calendar.forEach((dayStacks, index2) => { 265 | while (dayStacks.length < longestColumn) { 266 | if (showNextMonth) { 267 | daysFromNextMonth += 1; 268 | calendar[index2].push(`${nextMonth.getMonth()}/${daysFromNextMonth}`); 269 | } else { 270 | calendar[index2].push(" "); 271 | } 272 | } 273 | }); 274 | return { calendar, daysFromPrevMonth, daysFromNextMonth }; 275 | } 276 | var buildCalendar_default = buildCalendar; 277 | 278 | // src/countEvents.ts 279 | async function countEvents( 280 | date, 281 | extendToPrev = 0, 282 | extendToNext = 0, 283 | settings2 284 | ) { 285 | const { firstOfMonth } = getMonthBoundaries_default(date); 286 | const { startDate, endDate } = extendBoundaries( 287 | firstOfMonth, 288 | extendToPrev, 289 | extendToNext 290 | ); 291 | let events = await CalendarEvent.between(startDate, endDate); 292 | events = trimEvents(events, settings2); 293 | const eventCounts = /* @__PURE__ */ new Map(); 294 | events.forEach((event) => { 295 | if (event.isAllDay) { 296 | const date2 = event.startDate; 297 | do { 298 | updateEventCounts(date2, eventCounts); 299 | date2.setDate(date2.getDate() + 1); 300 | } while (date2 < event.endDate); 301 | } else { 302 | updateEventCounts(event.startDate, eventCounts); 303 | } 304 | }); 305 | const intensity = calculateIntensity(eventCounts); 306 | return { eventCounts, intensity }; 307 | } 308 | function trimEvents(events, settings2) { 309 | let trimmedEvents = events; 310 | if (settings2.calFilter.length) { 311 | trimmedEvents = events.filter((event) => 312 | settings2.calFilter.includes(event.calendar.title) 313 | ); 314 | } 315 | if (settings2.discountAllDayEvents || !settings2.showAllDayEvents) { 316 | trimmedEvents = trimmedEvents.filter((event) => !event.isAllDay); 317 | } 318 | return trimmedEvents; 319 | } 320 | function extendBoundaries(first, extendToPrev, extendToNext) { 321 | const startDate = new Date( 322 | first.getFullYear(), 323 | first.getMonth(), 324 | first.getDate() - extendToPrev 325 | ); 326 | const endDate = new Date( 327 | first.getFullYear(), 328 | first.getMonth() + 1, 329 | first.getDate() + extendToNext 330 | ); 331 | return { startDate, endDate }; 332 | } 333 | function updateEventCounts(date, eventCounts) { 334 | if (eventCounts.has(`${date.getMonth()}/${date.getDate()}`)) { 335 | eventCounts.set( 336 | `${date.getMonth()}/${date.getDate()}`, 337 | eventCounts.get(`${date.getMonth()}/${date.getDate()}`) + 1 338 | ); 339 | } else { 340 | eventCounts.set(`${date.getMonth()}/${date.getDate()}`, 1); 341 | } 342 | } 343 | function calculateIntensity(eventCounts) { 344 | const counter = eventCounts.values(); 345 | const counts = []; 346 | for (const count of counter) { 347 | counts.push(count); 348 | } 349 | const max = Math.max(...counts); 350 | const min = Math.min(...counts); 351 | let intensity = 1 / (max - min + 1); 352 | intensity = intensity < 0.3 ? 0.3 : intensity; 353 | return intensity; 354 | } 355 | var countEvents_default = countEvents; 356 | 357 | // src/createDateImage.ts 358 | function createDateImage( 359 | text, 360 | { 361 | backgroundColor, 362 | textColor, 363 | intensity, 364 | toFullSize, 365 | textSize = "medium", 366 | style = "circle", 367 | } 368 | ) { 369 | const largeSize = 50; 370 | const smallSize = 35; 371 | const size = toFullSize ? largeSize : smallSize; 372 | const largeTextFactor = 0.65; 373 | const mediumTextFactor = 0.55; 374 | const smallTextFactor = 0.45; 375 | let textSizeFactor = mediumTextFactor; 376 | if (textSize === "small") { 377 | textSizeFactor = smallTextFactor; 378 | } else if (textSize === "medium") { 379 | textSizeFactor = mediumTextFactor; 380 | } else if (textSize === "large") { 381 | textSizeFactor = largeTextFactor; 382 | } 383 | const drawing = new DrawContext(); 384 | drawing.respectScreenScale = true; 385 | const contextSize = largeSize; 386 | drawing.size = new Size(contextSize, contextSize); 387 | drawing.opaque = false; 388 | drawing.setFillColor(new Color(backgroundColor, intensity)); 389 | if (style === "circle") { 390 | drawing.fillEllipse( 391 | new Rect( 392 | (contextSize - (size - 2)) / 2, 393 | (contextSize - (size - 2)) / 2, 394 | size - 2, 395 | size - 2 396 | ) 397 | ); 398 | } else if (style === "dot") { 399 | const dotSize = contextSize / 5; 400 | drawing.fillEllipse( 401 | new Rect( 402 | contextSize / 2 - dotSize / 2, 403 | contextSize - dotSize, 404 | dotSize, 405 | dotSize 406 | ) 407 | ); 408 | } 409 | drawing.setFont(Font.boldSystemFont(size * textSizeFactor)); 410 | drawing.setTextAlignedCenter(); 411 | drawing.setTextColor(new Color(textColor, 1)); 412 | const textBox = new Rect( 413 | (contextSize - size) / 2, 414 | (contextSize - size * textSizeFactor) / 2 - 3, 415 | size, 416 | size * textSizeFactor 417 | ); 418 | drawing.drawTextInRect(text, textBox); 419 | return drawing.getImage(); 420 | } 421 | var createDateImage_default = createDateImage; 422 | 423 | // src/isDateFromBoundingMonth.ts 424 | function isDateFromBoundingMonth(row, column, date, calendar) { 425 | const [month] = calendar[row][column].split("/"); 426 | const currentMonth = date.getMonth().toString(); 427 | return month === currentMonth; 428 | } 429 | var isDateFromBoundingMonth_default = isDateFromBoundingMonth; 430 | 431 | // src/isWeekend.ts 432 | function isWeekend(index, startWeekOnSunday = false) { 433 | if (startWeekOnSunday) { 434 | switch (index) { 435 | case 0: 436 | case 6: 437 | return true; 438 | default: 439 | return false; 440 | } 441 | } 442 | return index > 4; 443 | } 444 | var isWeekend_default = isWeekend; 445 | 446 | // src/createUrl.ts 447 | function createUrl(day, month, date, settings2) { 448 | let url; 449 | let year; 450 | const currentMonth = date.getMonth(); 451 | if (currentMonth === 11 && Number(month) === 1) { 452 | year = date.getFullYear() + 1; 453 | } else if (currentMonth === 0 && Number(month) === 11) { 454 | year = date.getFullYear() - 1; 455 | } else { 456 | year = date.getFullYear(); 457 | } 458 | if (settings2.calendarApp === "calshow") { 459 | const appleDate = new Date("2001/01/01"); 460 | const timestamp = 461 | (new Date(`${year}/${Number(month) + 1}/${day}`).getTime() - 462 | appleDate.getTime()) / 463 | 1e3; 464 | url = `calshow:${timestamp}`; 465 | } else if (settings2.calendarApp === "x-fantastical3") { 466 | url = `${settings2.calendarApp}://show/calendar/${year}-${ 467 | Number(month) + 1 468 | }-${day}`; 469 | } else { 470 | url = ""; 471 | } 472 | return url; 473 | } 474 | var createUrl_default = createUrl; 475 | 476 | // src/buildCalendarView.ts 477 | async function buildCalendarView( 478 | date, 479 | stack, 480 | settings2, 481 | { verticalAlign = "center" } = {} 482 | ) { 483 | const rightStack = stack.addStack(); 484 | rightStack.layoutVertically(); 485 | if (verticalAlign === "center") { 486 | rightStack.addSpacer(); 487 | } 488 | const dateFormatter = new DateFormatter(); 489 | dateFormatter.dateFormat = "MMMM"; 490 | dateFormatter.locale = settings2.locale.split("-")[0]; 491 | const spacing = config.widgetFamily === "small" ? 18 : 19; 492 | const monthLine = rightStack.addStack(); 493 | monthLine.addSpacer(4); 494 | const monthFontSize = 495 | settings2.fontSize === "small" 496 | ? 12 497 | : settings2.fontSize === "medium" 498 | ? 14 499 | : 16; 500 | addWidgetTextLine_default( 501 | dateFormatter.string(date).toUpperCase(), 502 | monthLine, 503 | { 504 | textColor: settings2.theme.textColor, 505 | font: Font.boldSystemFont(monthFontSize), 506 | } 507 | ); 508 | const calendarStack = rightStack.addStack(); 509 | calendarStack.spacing = 2; 510 | const { calendar, daysFromPrevMonth, daysFromNextMonth } = 511 | buildCalendar_default(date, settings2); 512 | const { eventCounts, intensity } = await countEvents_default( 513 | date, 514 | daysFromPrevMonth, 515 | daysFromNextMonth, 516 | settings2 517 | ); 518 | const fontSize = 519 | settings2.fontSize === "small" 520 | ? 10 521 | : settings2.fontSize === "medium" 522 | ? 11 523 | : 12; 524 | for (let i = 0; i < calendar.length; i += 1) { 525 | const weekdayStack = calendarStack.addStack(); 526 | weekdayStack.layoutVertically(); 527 | for (let j = 0; j < calendar[i].length; j += 1) { 528 | const dayStack = weekdayStack.addStack(); 529 | dayStack.size = new Size(spacing, spacing); 530 | dayStack.centerAlignContent(); 531 | const [day, month] = calendar[i][j].split("/").reverse(); 532 | if (settings2.individualDateTargets) { 533 | const callbackUrl = createUrl_default(day, month, date, settings2); 534 | if (j > 0) dayStack.url = callbackUrl; 535 | } 536 | if (calendar[i][j] === `${date.getMonth()}/${date.getDate()}`) { 537 | if (settings2.markToday) { 538 | const highlightedDate = createDateImage_default(day, { 539 | backgroundColor: settings2.theme.todayCircleColor, 540 | textColor: settings2.theme.todayTextColor, 541 | intensity: 1, 542 | toFullSize: true, 543 | textSize: settings2.fontSize, 544 | }); 545 | dayStack.addImage(highlightedDate); 546 | } else { 547 | addWidgetTextLine_default(day, dayStack, { 548 | textColor: settings2.theme.todayTextColor, 549 | font: Font.boldSystemFont(fontSize), 550 | align: "center", 551 | }); 552 | } 553 | } else if (j > 0 && calendar[i][j] !== " ") { 554 | const isCurrentMonth = isDateFromBoundingMonth_default( 555 | i, 556 | j, 557 | date, 558 | calendar 559 | ); 560 | const toFullSize = !settings2.smallerPrevNextMonth || isCurrentMonth; 561 | let textColor = isWeekend_default(i, settings2.startWeekOnSunday) 562 | ? settings2.theme.weekendDateColor 563 | : settings2.theme.weekdayTextColor; 564 | if (!isCurrentMonth) textColor = settings2.theme.textColorPrevNextMonth; 565 | const dateImage = createDateImage_default(day, { 566 | backgroundColor: settings2.theme.eventCircleColor, 567 | textColor, 568 | intensity: settings2.showEventCircles 569 | ? eventCounts.get(calendar[i][j]) * intensity 570 | : 0, 571 | toFullSize, 572 | style: settings2.eventCircleStyle, 573 | textSize: settings2.fontSize, 574 | }); 575 | dayStack.addImage(dateImage); 576 | } else { 577 | addWidgetTextLine_default(day, dayStack, { 578 | textColor: isWeekend_default(i, settings2.startWeekOnSunday) 579 | ? settings2.theme.weekendLetterColor 580 | : settings2.theme.textColor, 581 | opacity: isWeekend_default(i, settings2.startWeekOnSunday) 582 | ? settings2.theme.weekendLetterOpacity 583 | : 1, 584 | font: Font.boldSystemFont(fontSize), 585 | align: "center", 586 | }); 587 | } 588 | } 589 | } 590 | if (verticalAlign === "center") { 591 | rightStack.addSpacer(); 592 | } 593 | } 594 | var buildCalendarView_default = buildCalendarView; 595 | 596 | // src/getEventIcon.ts 597 | function getEventIcon(event) { 598 | if (event.attendees === null) { 599 | return "\u25CF "; 600 | } 601 | const status = event.attendees.filter((attendee) => attendee.isCurrentUser)[0] 602 | .status; 603 | switch (status) { 604 | case "accepted": 605 | return "\u2713 "; 606 | case "tentative": 607 | return "~ "; 608 | case "declined": 609 | return "\u2718 "; 610 | default: 611 | return "\u25CF "; 612 | } 613 | } 614 | var getEventIcon_default = getEventIcon; 615 | 616 | // src/iconAllDay.ts 617 | function iconFullDay() { 618 | return SFSymbol.named("clock.badge").image; 619 | } 620 | var iconAllDay_default = iconFullDay; 621 | 622 | // src/formatDuration.ts 623 | function formatDuration(startDate, endDate, { clock24Hour, locale }) { 624 | if (clock24Hour) { 625 | const formatter = Intl.DateTimeFormat(locale, { 626 | hour: "numeric", 627 | minute: "numeric", 628 | }); 629 | return `${formatter.format(startDate)}-${formatter.format(endDate)}`; 630 | } else { 631 | const formatter = Intl.DateTimeFormat(locale, { 632 | hour: "numeric", 633 | minute: "numeric", 634 | hour12: true, 635 | }); 636 | const startDayParts = formatter.formatToParts(startDate); 637 | const endDayParts = formatter.formatToParts(endDate); 638 | const startPeriod = startDayParts.find((p) => p.type === "dayPeriod").value; 639 | const endPeriod = endDayParts.find((p) => p.type === "dayPeriod").value; 640 | if (startPeriod === endPeriod) { 641 | if (isPeriodFirst(startDayParts)) { 642 | log( 643 | `${joinDateParts(startDayParts)}-${joinDateParts( 644 | endDayParts.filter((p) => p.type !== "dayPeriod") 645 | )}` 646 | ); 647 | return `${joinDateParts(startDayParts)}-${joinDateParts( 648 | endDayParts.filter((p) => p.type !== "dayPeriod") 649 | )}`; 650 | } 651 | } 652 | return `${joinDateParts(startDayParts)}-${joinDateParts(endDayParts)}`; 653 | } 654 | } 655 | function joinDateParts(parts) { 656 | return parts.map((p) => p.value).join(""); 657 | } 658 | function isPeriodFirst(parts) { 659 | for (let part of parts) { 660 | if (part.type === "dayPeriod") return true; 661 | if (part.type === "hour" || part.type === "minute") return false; 662 | } 663 | } 664 | var formatDuration_default = formatDuration; 665 | 666 | // src/formatEvent.ts 667 | function formatEvent( 668 | stack, 669 | event, 670 | { 671 | theme: theme2, 672 | showCalendarBullet, 673 | showCompleteTitle, 674 | showEventLocation, 675 | showEventTime, 676 | showIconForAllDayEvents, 677 | clock24Hour, 678 | locale, 679 | } 680 | ) { 681 | const eventLine = stack.addStack(); 682 | const backgroundColor = new Color( 683 | event.calendar.color.hex, 684 | theme2.eventBackgroundOpacity 685 | ); 686 | eventLine.backgroundColor = backgroundColor; 687 | eventLine.layoutVertically(); 688 | eventLine.cornerRadius = 5; 689 | eventLine.setPadding(3, 3, 3, 3); 690 | eventLine.size = new Size(150, 0); 691 | let lineCount = 0; 692 | const titleStack = eventLine.addStack(); 693 | if (showCalendarBullet) { 694 | const icon = getEventIcon_default(event); 695 | addWidgetTextLine_default(icon, titleStack, { 696 | textColor: event.calendar.color.hex, 697 | font: Font.mediumSystemFont(13), 698 | lineLimit: showCompleteTitle ? 0 : 1, 699 | }); 700 | } 701 | addWidgetTextLine_default(event.title, titleStack, { 702 | textColor: theme2.textColor, 703 | font: Font.mediumSystemFont(13), 704 | lineLimit: showCompleteTitle ? 0 : 1, 705 | }); 706 | if (showIconForAllDayEvents && event.isAllDay) { 707 | titleStack.addSpacer(); 708 | const icon = titleStack.addImage(iconAllDay_default()); 709 | icon.imageSize = new Size(15, 15); 710 | icon.rightAlignImage(); 711 | icon.tintColor = new Color(theme2.textColor); 712 | } 713 | lineCount++; 714 | if (showEventLocation && event.location) { 715 | addWidgetTextLine_default(event.location, eventLine.addStack(), { 716 | textColor: theme2.textColor, 717 | opacity: theme2.eventDateTimeOpacity, 718 | font: Font.mediumSystemFont(12), 719 | lineLimit: showCompleteTitle ? 0 : 1, 720 | }); 721 | lineCount++; 722 | } 723 | if (showEventTime) { 724 | let time = ""; 725 | if (!event.isAllDay) { 726 | time = formatDuration_default(event.startDate, event.endDate, { 727 | clock24Hour, 728 | locale, 729 | }); 730 | } 731 | if (time) { 732 | const timeStack = eventLine.addStack(); 733 | addWidgetTextLine_default(time, timeStack, { 734 | textColor: theme2.textColor, 735 | opacity: theme2.eventDateTimeOpacity, 736 | font: Font.regularSystemFont(12), 737 | }); 738 | lineCount++; 739 | } 740 | } 741 | return lineCount; 742 | } 743 | var formatEvent_default = formatEvent; 744 | 745 | // src/dateToReadableDiff.ts 746 | function dateToReadableDiff(d1, locale = "en-GB") { 747 | const now = new Date(); 748 | now.setHours(0); 749 | now.setMinutes(0); 750 | now.setSeconds(0); 751 | now.setMilliseconds(0); 752 | const diff = d1.valueOf() - now.valueOf(); 753 | const dateDiff = Math.floor(diff / (1e3 * 60 * 60 * 24)); 754 | const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); 755 | if (dateDiff < 0) { 756 | return ""; 757 | } else if (dateDiff <= 3) { 758 | return formatter.format(dateDiff, "day"); 759 | } else { 760 | return d1.toLocaleDateString(locale, { 761 | month: "long", 762 | day: "numeric", 763 | weekday: "short", 764 | }); 765 | } 766 | } 767 | var dateToReadableDiff_default = dateToReadableDiff; 768 | 769 | // src/buildEventsView.ts 770 | async function buildEventsView( 771 | events, 772 | stack, 773 | settings2, 774 | { 775 | horizontalAlign = "left", 776 | verticalAlign = "top", 777 | eventSpacer = 4, 778 | lineSpaceLimit = 8, 779 | showMsg = true, 780 | } = {} 781 | ) { 782 | const leftStack = stack.addStack(); 783 | leftStack.layoutVertically(); 784 | leftStack.setPadding(5, 0, 0, 0); 785 | if (horizontalAlign === "left") { 786 | stack.addSpacer(); 787 | } 788 | if (events.length == 0 && showMsg) { 789 | const noEventStack = leftStack.addStack(); 790 | noEventStack.setPadding(5, 0, 0, 0); 791 | noEventStack.layoutVertically(); 792 | const checkmark = SFSymbol.named("checkmark.circle").image; 793 | const titleStack = noEventStack.addStack(); 794 | titleStack.centerAlignContent(); 795 | const formatter = Intl.DateTimeFormat(settings2.locale, { 796 | day: "numeric", 797 | weekday: "long", 798 | }); 799 | const parts = formatter.formatToParts(new Date()); 800 | addWidgetTextLine_default( 801 | parts.find((v) => v.type === "day").value, 802 | titleStack, 803 | { 804 | textColor: settings2.theme.textColor, 805 | textSize: 30, 806 | } 807 | ); 808 | titleStack.addSpacer(5); 809 | addWidgetTextLine_default( 810 | parts.find((v) => v.type === "weekday").value, 811 | titleStack, 812 | { 813 | textColor: settings2.theme.todayCircleColor, 814 | textSize: 15, 815 | } 816 | ); 817 | noEventStack.addSpacer(); 818 | const img = noEventStack.addImage(checkmark); 819 | img.imageSize = new Size(35, 35); 820 | img.centerAlignImage(); 821 | noEventStack.addSpacer(); 822 | return; 823 | } 824 | if (verticalAlign === "bottom" || verticalAlign === "center") { 825 | leftStack.addSpacer(); 826 | } 827 | if (events.length !== 0) { 828 | const groupStack = /* @__PURE__ */ new Map(); 829 | const numEvents = events.length; 830 | const showLocation = settings2.showEventLocation; 831 | let spaceLeft = lineSpaceLimit; 832 | let i = 0; 833 | while (spaceLeft > 0 && i < numEvents) { 834 | let stack2; 835 | let eventDate = dateToReadableDiff_default( 836 | events[i].startDate, 837 | settings2.locale 838 | ); 839 | if (groupStack.has(eventDate)) { 840 | stack2 = groupStack.get(eventDate); 841 | } else { 842 | if (spaceLeft <= 1) { 843 | break; 844 | } 845 | stack2 = leftStack.addStack(); 846 | stack2.layoutVertically(); 847 | groupStack.set(eventDate, stack2); 848 | addWidgetTextLine_default(eventDate, stack2, { 849 | textColor: settings2.theme.textColorPrevNextMonth, 850 | font: Font.regularSystemFont(13), 851 | }); 852 | spaceLeft--; 853 | stack2.url = createUrl_default( 854 | events[i].startDate.getDate().toString(), 855 | events[i].startDate.getMonth().toString(), 856 | events[i].startDate, 857 | settings2 858 | ); 859 | } 860 | const showTime = settings2.showEventTime; 861 | const spaceUsed = formatEvent_default( 862 | stack2, 863 | events[i], 864 | __spreadProps(__spreadValues({}, settings2), { 865 | showEventLocation: spaceLeft >= 3 ? showLocation : false, 866 | showEventTime: spaceLeft >= 2 ? showTime : false, 867 | }) 868 | ); 869 | spaceLeft -= spaceUsed; 870 | if (spaceLeft > 0 && i < numEvents - 1) { 871 | stack2.addSpacer(eventSpacer); 872 | } 873 | i++; 874 | } 875 | } 876 | if (verticalAlign === "top" || verticalAlign === "center") { 877 | leftStack.addSpacer(); 878 | } 879 | } 880 | var buildEventsView_default = buildEventsView; 881 | 882 | // src/getEvents.ts 883 | async function getEvents(date, settings2) { 884 | let events = []; 885 | if (settings2.showEventsOnlyForToday) { 886 | events = await CalendarEvent.today([]); 887 | } else { 888 | const dateLimit = new Date(); 889 | dateLimit.setDate(dateLimit.getDate() + settings2.nextNumOfDays); 890 | events = await CalendarEvent.between(date, dateLimit); 891 | } 892 | if (settings2.calFilter.length) { 893 | events = events.filter((event) => 894 | settings2.calFilter.includes(event.calendar.title) 895 | ); 896 | } 897 | const futureEvents = []; 898 | for (const event of events) { 899 | if ( 900 | event.isAllDay && 901 | settings2.showAllDayEvents && 902 | event.startDate.getTime() > 903 | new Date(new Date().setDate(new Date().getDate() - 1)).getTime() 904 | ) { 905 | futureEvents.push(event); 906 | } else if ( 907 | !event.isAllDay && 908 | event.endDate.getTime() > date.getTime() && 909 | !event.title.startsWith("Canceled:") 910 | ) { 911 | futureEvents.push(event); 912 | } 913 | } 914 | return futureEvents; 915 | } 916 | var getEvents_default = getEvents; 917 | 918 | // src/buildLargeWidget.ts 919 | async function buildLargeWidget(date, events, stack, settings2) { 920 | const leftSide = stack.addStack(); 921 | stack.addSpacer(); 922 | const rightSide = stack.addStack(); 923 | leftSide.layoutVertically(); 924 | rightSide.layoutVertically(); 925 | rightSide.addSpacer(); 926 | rightSide.centerAlignContent(); 927 | const leftSideEvents = events.slice(0, 8); 928 | const rightSideEvents = events.slice(8, 12); 929 | await buildEventsView_default(leftSideEvents, leftSide, settings2, { 930 | lineSpaceLimit: 16, 931 | eventSpacer: 6, 932 | verticalAlign: "top", 933 | }); 934 | await buildCalendarView_default(date, rightSide, settings2, { 935 | verticalAlign: "top", 936 | }); 937 | rightSide.addSpacer(); 938 | await buildEventsView_default(rightSideEvents, rightSide, settings2, { 939 | lineSpaceLimit: 12, 940 | eventSpacer: 6, 941 | verticalAlign: "top", 942 | showMsg: false, 943 | }); 944 | } 945 | var buildLargeWidget_default = buildLargeWidget; 946 | 947 | // src/buildWidget.ts 948 | async function buildWidget(settings2) { 949 | const widget = new ListWidget(); 950 | widget.backgroundColor = new Color(settings2.theme.widgetBackgroundColor, 1); 951 | setWidgetBackground_default(widget, settings2.theme.backgroundImage); 952 | widget.setPadding(16, 16, 16, 16); 953 | const today = new Date(); 954 | const globalStack = widget.addStack(); 955 | const events = await getEvents_default(today, settings2); 956 | switch (config.widgetFamily) { 957 | case "small": 958 | if (settings2.widgetType === "events") { 959 | await buildEventsView_default(events, globalStack, settings2); 960 | } else { 961 | await buildCalendarView_default(today, globalStack, settings2); 962 | } 963 | break; 964 | case "large": 965 | await buildLargeWidget_default(today, events, globalStack, settings2); 966 | break; 967 | default: 968 | if (settings2.flipped) { 969 | await buildCalendarView_default(today, globalStack, settings2); 970 | globalStack.addSpacer(10); 971 | await buildEventsView_default(events, globalStack, settings2); 972 | } else { 973 | await buildEventsView_default(events, globalStack, settings2); 974 | await buildCalendarView_default(today, globalStack, settings2); 975 | } 976 | break; 977 | } 978 | return widget; 979 | } 980 | var buildWidget_default = buildWidget; 981 | 982 | // src/index.ts 983 | async function main() { 984 | if (config.runsInWidget) { 985 | const widget = await buildWidget_default(settings_default); 986 | Script.setWidget(widget); 987 | Script.complete(); 988 | } else if (settings_default.debug) { 989 | Script.complete(); 990 | const widget = await buildWidget_default(settings_default); 991 | await widget.presentMedium(); 992 | } else { 993 | const appleDate = new Date("2001/01/01"); 994 | const timestamp = (new Date().getTime() - appleDate.getTime()) / 1e3; 995 | const callback = new CallbackURL( 996 | `${settings_default.calendarApp}:` + timestamp 997 | ); 998 | callback.open(); 999 | Script.complete(); 1000 | } 1001 | } 1002 | 1003 | await main(); 1004 | --------------------------------------------------------------------------------