} render
* @property {string} [head] 顶部插入 HTML
* @property {FormItem[]} [formItems]
* @property {(item: FormItem) => void} [onItemClick]
* @property {string} [homePage] 右上角分享菜单地址
* @property {(data: any) => void} [onWebEvent]
*/
/**
* @template T
* @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand
*/
const previewsHTML =
`
`;
const copyrightHTML =
``;
/**
* @param {Expand} options
* @param {boolean} [isFirstPage]
* @param {object} [others]
* @param {Settings} [others.settings]
* @returns {Promise} 仅在 Widget 中运行时返回 ListWidget
*/
const present = async (options, isFirstPage, others = {}) => {
const {
formItems = [],
onItemClick,
render,
head,
homePage = 'https://www.imarkr.com',
onWebEvent
} = options;
const cache = useCache();
const settings = others.settings || await readSettings() || {};
/**
* @param {Parameters[0]} param
*/
const getWidget = async (param) => {
const widget = await render(param);
const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings;
if (backgroundImage && fm.fileExists(backgroundImage)) {
widget.backgroundImage = fm.readImage(backgroundImage);
}
if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) {
widget.backgroundColor = Color.dynamic(
new Color(backgroundColorLight || '#ffffff'),
new Color(backgroundColorDark || '#242426')
);
}
return widget
};
if (config.runsInWidget) {
const widget = await getWidget({ settings });
Script.setWidget(widget);
return widget
}
// ====== web start =======
const style =
`:root {
--color-primary: #007aff;
--divider-color: rgba(60,60,67,0.36);
--card-background: #fff;
--card-radius: 10px;
--list-header-color: rgba(60,60,67,0.6);
}
* {
-webkit-user-select: none;
user-select: none;
}
body {
margin: 10px 0;
-webkit-font-smoothing: antialiased;
font-family: "SF Pro Display","SF Pro Icons","Helvetica Neue","Helvetica","Arial",sans-serif;
accent-color: var(--color-primary);
}
input {
-webkit-user-select: auto;
user-select: auto;
}
body {
background: #f2f2f7;
}
button {
font-size: 16px;
background: var(--color-primary);
color: #fff;
border-radius: 8px;
border: none;
padding: 0.24em 0.5em;
}
button .iconfont {
margin-right: 6px;
}
.list {
margin: 15px;
}
.list__header {
margin: 0 20px;
color: var(--list-header-color);
font-size: 13px;
}
.list__body {
margin-top: 10px;
background: var(--card-background);
border-radius: var(--card-radius);
border-radius: 12px;
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 1em;
font-size: 16px;
min-height: 2em;
padding: 0.5em 20px;
position: relative;
}
.form-item[media*="prefers-color-scheme"] {
display: none;
}
.form-item--link .icon-arrow_right {
color: #86868b;
}
.form-item + .form-item::before {
content: "";
position: absolute;
top: 0;
left: 20px;
right: 0;
border-top: 0.5px solid var(--divider-color);
}
.form-item__input-wrapper {
flex: 1;
overflow: hidden;
text-align: right;
}
.form-item__input {
max-width: 100%;
}
.form-item .iconfont {
margin-right: 4px;
}
.form-item input,
.form-item select {
font-size: 14px;
text-align: right;
}
.form-item input[type="checkbox"] {
width: 1.25em;
height: 1.25em;
}
input[type="number"] {
width: 4em;
}
input[type="date"] {
min-width: 6.4em;
}
input[type='checkbox'][role='switch'] {
position: relative;
display: inline-block;
appearance: none;
width: 40px;
height: 24px;
border-radius: 24px;
background: #ccc;
transition: 0.3s ease-in-out;
}
input[type='checkbox'][role='switch']::before {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: 0.3s ease-in-out;
}
input[type='checkbox'][role='switch']:checked {
background: var(--color-primary);
}
input[type='checkbox'][role='switch']:checked::before {
transform: translateX(16px);
}
.actions {
margin: 15px;
}
.copyright {
margin: 15px;
margin-inline: 18px;
font-size: 12px;
color: #86868b;
}
.copyright a {
color: #515154;
text-decoration: none;
}
.preview.loading {
pointer-events: none;
}
.icon-loading {
display: inline-block;
animation: 1s linear infinite spin;
}
@keyframes spin {
0% {
transform: rotate(0);
}
100% {
transform: rotate(1turn);
}
}
@media (prefers-color-scheme: light) {
.form-item[media="(prefers-color-scheme: light)"] {
display: flex;
}
}
@media (prefers-color-scheme: dark) {
:root {
--divider-color: rgba(84,84,88,0.65);
--card-background: #1c1c1e;
--list-header-color: rgba(235,235,245,0.6);
}
body {
background: #000;
color: #fff;
}
input {
background-color: rgb(58, 57, 57);
color: var(--color-primary);
}
input[type='checkbox'][role='switch'] {
background-color: rgb(56, 56, 60);
}
input[type='checkbox'][role='switch']::before {
background-color: rgb(206, 206, 206);
}
select {
background-color: rgb(82, 82, 82);
border: none;
}
.form-item[media="(prefers-color-scheme: dark)"] {
display: flex;
}
}
`;
const js =
`(() => {
const settings = ${JSON.stringify({
...settings,
useICloud: isUseICloud()
})}
const formItems = ${JSON.stringify(formItems)}
window.invoke = (code, data, cb) => {
ScriptableBridge.invoke(code, data, cb)
}
const formData = {}
const createFormItem = (item) => {
const value = settings[item.name] ?? item.default ?? null
formData[item.name] = value;
const label = document.createElement("label");
label.className = "form-item";
if (item.media) {
label.setAttribute('media', item.media)
}
const div = document.createElement("div");
div.innerText = item.label;
label.appendChild(div);
if (/^(select|multi-select)$/.test(item.type)) {
const wrapper = document.createElement('div')
wrapper.className = 'form-item__input-wrapper'
const select = document.createElement('select')
select.className = 'form-item__input'
select.name = item.name
select.multiple = item.type === 'multi-select'
const map = (options, parent) => {
for (const opt of (options || [])) {
if (opt.children?.length) {
const elGroup = document.createElement('optgroup')
elGroup.label = opt.label
map(opt.children, elGroup)
parent.appendChild(elGroup)
} else {
const option = document.createElement('option')
option.value = opt.value
option.innerText = opt.label
option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value)
parent.appendChild(option)
}
}
}
map(item.options || [], select)
select.addEventListener('change', ({ target }) => {
let { value } = target
if (item.type === 'multi-select') {
value = Array.from(target.selectedOptions).map(({ value }) => value)
}
formData[item.name] = value
invoke('changeSettings', formData)
})
wrapper.appendChild(select)
label.appendChild(wrapper)
} else if (
item.type === 'cell' ||
item.type === 'page'
) {
label.classList.add('form-item--link')
const icon = document.createElement('i')
icon.className = 'iconfont icon-arrow_right'
label.appendChild(icon)
label.addEventListener('click', () => {
const { name } = item
switch (name) {
case 'backgroundImage':
invoke('chooseBgImg')
break
case 'clearBackgroundImage':
invoke('clearBgImg')
break
case 'reset':
reset()
break
default:
invoke('itemClick', item)
}
})
} else {
const input = document.createElement("input")
input.className = 'form-item__input'
input.name = item.name
input.type = item.type || "text";
input.enterKeyHint = 'done'
input.value = value
// Switch
if (item.type === 'switch') {
input.type = 'checkbox'
input.role = 'switch'
input.checked = value
if (item.name === 'useICloud') {
input.addEventListener('change', (e) => {
invoke('moveSettings', e.target.checked)
})
}
}
if (item.type === 'number') {
input.inputMode = 'decimal'
}
if (input.type === 'text') {
input.size = 12
}
input.addEventListener("change", (e) => {
formData[item.name] =
item.type === 'switch'
? e.target.checked
: item.type === 'number'
? Number(e.target.value)
: e.target.value;
invoke('changeSettings', formData)
});
label.appendChild(input);
}
return label
}
const createList = (list, title) => {
const fragment = document.createDocumentFragment()
let elBody;
for (const item of list) {
if (item.type === 'group') {
const grouped = createList(item.items, item.label)
fragment.appendChild(grouped)
} else {
if (!elBody) {
const groupDiv = fragment.appendChild(document.createElement('div'))
groupDiv.className = 'list'
if (title) {
const elTitle = groupDiv.appendChild(document.createElement('div'))
elTitle.className = 'list__header'
elTitle.textContent = title
}
elBody = groupDiv.appendChild(document.createElement('div'))
elBody.className = 'list__body'
}
const label = createFormItem(item)
elBody.appendChild(label)
}
}
return fragment
}
const fragment = createList(formItems)
document.getElementById('settings').appendChild(fragment)
for (const btn of document.querySelectorAll('.preview')) {
btn.addEventListener('click', (e) => {
const target = e.currentTarget
target.classList.add('loading')
const icon = e.currentTarget.querySelector('.iconfont')
const className = icon.className
icon.className = 'iconfont icon-loading'
invoke(
'preview',
e.currentTarget.dataset.size,
() => {
target.classList.remove('loading')
icon.className = className
}
)
})
}
const setFieldValue = (name, value) => {
const input = document.querySelector(\`.form-item__input[name="\${name}"]\`)
if (!input) return
if (input.type === 'checkbox') {
input.checked = value
} else {
input.value = value
}
}
const reset = (items = formItems) => {
for (const item of items) {
if (item.type === 'group') {
reset(item.items)
} else if (item.type === 'page') {
continue;
} else {
setFieldValue(item.name, item.default)
}
}
invoke('removeSettings', formData)
}
})()`;
const html =
`
${head || ''}
${isFirstPage ? (previewsHTML + copyrightHTML) : ''}
`;
const webView = new WebView();
const methods = {
async preview (data) {
const widget = await getWidget({ settings, family: data });
widget[`present${data.replace(data[0], data[0].toUpperCase())}`]();
},
safari (data) {
Safari.openInApp(data, true);
},
changeSettings (data) {
Object.assign(settings, data);
writeSettings(settings, { useICloud: settings.useICloud });
},
moveSettings (data) {
settings.useICloud = data;
moveSettings(data, settings);
},
removeSettings (data) {
Object.assign(settings, data);
clearBgImg();
removeSettings(settings);
},
chooseBgImg (data) {
chooseBgImg();
},
clearBgImg () {
clearBgImg();
},
async itemClick (data) {
if (data.type === 'page') {
// `data` 经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找
const item = (() => {
const find = (items) => {
for (const el of items) {
if (el.name === data.name) return el
if (el.type === 'group') {
const r = find(el.items);
if (r) return r
}
}
return null
};
return find(formItems)
})();
await present(item, false, { settings });
} else {
await onItemClick?.(data, { settings });
}
},
native (data) {
onWebEvent?.(data);
}
};
await loadHTML(
webView,
{ html, baseURL: homePage },
{ methods }
);
const clearBgImg = () => {
const { backgroundImage } = settings;
delete settings.backgroundImage;
if (backgroundImage && fm.fileExists(backgroundImage)) {
fm.remove(backgroundImage);
}
writeSettings(settings, { useICloud: settings.useICloud });
toast(i18n(['Cleared success!', '背景已清除']));
};
const chooseBgImg = async () => {
try {
const image = await Photos.fromLibrary();
cache.writeImage('bg.png', image);
const imgPath = fm.joinPath(cache.cacheDirectory, 'bg.png');
settings.backgroundImage = imgPath;
writeSettings(settings, { useICloud: settings.useICloud });
} catch (e) {
console.log('[info] 用户取消选择图片');
}
};
webView.present();
// ======= web end =========
};
/**
* @param {Options} options
*/
const withSettings = async (options) => {
const { formItems, onItemClick, ...restOptions } = options;
return present({
formItems: [
{
label: i18n(['Common', '通用']),
type: 'group',
items: [
{
label: i18n(['Sync with iCloud', 'iCloud 同步']),
type: 'switch',
name: 'useICloud',
default: false
},
{
label: i18n(['Background', '背景']),
type: 'page',
name: 'background',
formItems: [
{
label: i18n(['Background', '背景']),
type: 'group',
items: [
{
name: 'backgroundColorLight',
type: 'color',
label: i18n(['Background color', '背景色']),
media: '(prefers-color-scheme: light)',
default: '#ffffff'
},
{
name: 'backgroundColorDark',
type: 'color',
label: i18n(['Background color', '背景色']),
media: '(prefers-color-scheme: dark)',
default: '#242426'
},
{
label: i18n(['Background image', '背景图']),
type: 'cell',
name: 'backgroundImage'
}
]
},
{
type: 'group',
items: [
{
label: i18n(['Clear background image', '清除背景图']),
type: 'cell',
name: 'clearBackgroundImage'
}
]
}
]
},
{
label: i18n(['Reset', '重置']),
type: 'cell',
name: 'reset'
}
]
},
{
label: i18n(['Settings', '设置']),
type: 'group',
items: formItems
}
],
onItemClick: (item, ...args) => {
onItemClick?.(item, ...args);
},
...restOptions
}, true)
};
/**
* @param {string} hex
*/
const hexToRGBA = (hex) => {
const red = Number.parseInt(hex.substr(-6, 2), 16);
const green = Number.parseInt(hex.substr(-4, 2), 16);
const blue = Number.parseInt(hex.substr(-2, 2), 16);
let alpha = 1;
if (hex.length >= 8) {
Number.parseInt(hex.substr(-8, 2), 16);
Number.parseInt(hex.substr(-6, 2), 16);
Number.parseInt(hex.substr(-4), 2);
const number = Number.parseInt(hex.substr(-2, 2), 16);
alpha = Number.parseFloat((number / 255).toFixed(3));
}
return { red, green, blue, alpha }
};
const _RGBToHex = (r, g, b) => {
r = r.toString(16);
g = g.toString(16);
b = b.toString(16);
if (r.length === 1) { r = '0' + r; }
if (g.length === 1) { g = '0' + g; }
if (b.length === 1) { b = '0' + b; }
return '#' + r + g + b
};
const RGBToHSL = (r, g, b) => {
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
let h = 0;
let s = 0;
let l = 0;
if (delta === 0) {
h = 0;
} else if (cmax === r) {
h = ((g - b) / delta) % 6;
} else if (cmax === g) {
h = (b - r) / delta + 2;
} else {
h = (r - g) / delta + 4;
}
h = Math.round(h * 60);
if (h < 0) {
h += 360;
}
l = (cmax + cmin) / 2;
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
return { h, s, l }
};
const _HSLToRGB = (h, s, l) => {
// Must be fractions of 1
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return { r, g, b }
};
const lightenDarkenColor = (hsl, amount) => {
const rgb = _HSLToRGB(hsl.h, hsl.s, hsl.l + amount);
const hex = _RGBToHex(rgb.r, rgb.g, rgb.b);
return hex
};
const preference = {
themeColor: '#ff0000',
textColor: '#222222',
textColorDark: '#ffffff',
weekendColor: '#8e8e93',
weekendColorDark: '#8e8e93',
symbolName: 'flag.fill',
eventMax: 3,
eventFontSize: 13,
includesReminder: false,
eventDays: 7,
/** @type {'calendar_events'|'events_calendar'} */
layout: 'calendar_events'
};
const $12Animals = {
子: '鼠',
丑: '牛',
寅: '虎',
卯: '兔',
辰: '龙',
巳: '蛇',
午: '马',
未: '羊',
申: '猴',
酉: '鸡',
戌: '狗',
亥: '猪'
};
const today = new Date();
const firstDay = (() => {
const date = new Date(today);
date.setDate(1);
return date
})();
const lastDay = (() => {
const date = new Date(today);
date.setMonth(date.getMonth() + 1, 0);
return date
})();
let dates = [];
let calendar;
const [calendarTitle, theme] = (args.widgetParameter || '').split(',').map((text) => text.trim());
if (calendarTitle) {
calendar = await Calendar.forEventsByTitle(calendarTitle);
const events = await CalendarEvent.between(firstDay, lastDay, [calendar]);
dates = events.map((item) => item.startDate);
}
const titleSize = 12;
const columnGap = 2;
const rowGap = 2;
/**
* @param {ListWidget|WidgetStack} container
* @param {object} options
* @param {(
* stack: WidgetStack,
* options: {
* date: Date;
* width: number;
* addItem: (stack: WidgetStack, data: { text: string; color: Color }) => WidgetStack
* }
* ) => void} [options.addDay] 自定义添加日期
*/
const addCalendar = async (container, options = {}) => {
const {
itemWidth = 18,
fontSize = 10,
gap = [columnGap, rowGap],
addWeek,
addDay
} = options;
const { textColor, textColorDark, weekendColor, weekendColorDark } = preference;
const family = config.widgetFamily;
const stack = container.addStack();
const { add } = await useGrid(stack, {
column: 7,
gap
});
/**
* @param {WidgetStack} stack
* @param {object} param1
* @param {string} param1.text
* @param {Color} param1.color
*/
const _addItem = (stack, { text, color } = {}) => {
const item = stack.addStack();
item.size = new Size(itemWidth, itemWidth);
item.centerAlignContent();
if (text) {
const content = item.addStack();
content.layoutVertically();
const textInner = content.addText(text);
textInner.rightAlignText();
textInner.font = Font.semiboldSystemFont(fontSize);
textInner.lineLimit = 1;
textInner.minimumScaleFactor = 0.2;
textInner.textColor = theme === 'light'
? new Color(textColor)
: theme === 'dark'
? new Color(textColorDark)
: Color.dynamic(new Color(textColor), new Color(textColorDark));
if (color) {
textInner.textColor = color;
}
item.$content = content;
item.$text = textInner;
}
return item
};
const _addWeek = (stack, { day }) => {
const sunday = new Date('1970/01/04');
const weekFormat = new Intl.DateTimeFormat([], { weekday: family === 'large' ? 'short' : 'narrow' }).format;
return _addItem(stack, {
text: weekFormat(new Date(sunday.getTime() + day * 86400000)),
color: (day === 0 || day === 6) &&
Color.dynamic(new Color(weekendColor), new Color(weekendColorDark))
})
};
const _addDay = (stack, { date }) => {
const color = (() => {
const week = date.getDay();
if (isToday(date)) {
return Color.white()
}
return (week === 0 || week === 6) && Color.gray()
})();
const item = _addItem(stack, {
text: `${date.getDate()}`,
color
});
if (isToday(date)) {
item.cornerRadius = itemWidth / 2;
item.backgroundColor = Color.red();
}
return item
};
for (let i = 0; i < 7; i++) {
await add((stack) => _addWeek(stack, { day: i }));
}
for (let i = 0; i < firstDay.getDay(); i++) {
await add((stack) => _addItem(stack));
}
for (let i = 1; i <= lastDay.getDate(); i++) {
const date = new Date(lastDay);
date.setDate(i);
await add(
async (stack) => addDay
? await addDay(stack, {
date,
width: itemWidth,
addItem: _addItem
})
: _addDay(stack, { date })
);
}
return stack
};
/**
* @param {ListWidget} widget
*/
const addTitle = (widget) => {
const { themeColor } = preference;
const family = config.widgetFamily;
const head = widget.addStack();
head.setPadding(0, 4, 0, 4);
const title = head.addText(
new Date().toLocaleString('default', {
month: family !== 'small' ? 'long' : 'short'
}).toUpperCase()
);
title.font = Font.semiboldSystemFont(11);
title.textColor = new Color(themeColor);
head.addSpacer();
const lunarDate = sloarToLunar(
today.getFullYear(),
today.getMonth() + 1,
today.getDate()
);
let lunarString = `${lunarDate.lunarMonth}月${lunarDate.lunarDay}`;
if (family !== 'small') {
lunarString = `${lunarDate.lunarYear}${$12Animals[lunarDate.lunarYear[1]]}年${lunarString}`;
}
const lunar = head.addText(lunarString);
lunar.font = Font.semiboldSystemFont(11);
lunar.textColor = new Color(themeColor);
};
/**
* @type {Parameters[1]['addDay']}
*/
const addDay = async (
stack,
{ date, width, addItem } = {}
) => {
const { themeColor, textColor, textColorDark, weekendColor, weekendColorDark, symbolName } = preference;
const family = config.widgetFamily;
const text = `${date.getDate()}`;
const i = dates.findIndex((item) => isSameDay(item, date));
const _dateColor = theme === 'light'
? new Color(textColor)
: theme === 'dark'
? new Color(textColorDark)
: Color.dynamic(new Color(textColor), new Color(textColorDark));
const _weekendColor = theme === 'light'
? new Color(weekendColor)
: theme === 'dark'
? new Color(weekendColorDark)
: Color.dynamic(new Color(weekendColor), new Color(weekendColorDark));
let color = (() => {
const week = date.getDay();
return (week === 0 || week === 6) ? _weekendColor : _dateColor
})();
if (isToday(date) || i > -1) {
color = Color.white();
}
const item = addItem(stack, { text, color });
if (family === 'large') {
const lunar = sloarToLunar(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
const lunarText = item.$content.addText(
lunar.lunarDay === '初一' ? `${lunar.lunarMonth}月` : lunar.lunarDay
);
lunarText.font = Font.systemFont(10);
lunarText.textColor = color;
}
if (isToday(date)) {
if (family !== 'large') {
item.cornerRadius = width / 2;
item.backgroundColor = new Color(themeColor);
} else {
const cw = Math.min(12 * Math.sqrt(2) * 2, width);
const cp = cw / 2 - 10;
item.$content.size = new Size(cw, cw);
item.$content.setPadding(0, cp, 0, 0);
item.$content.cornerRadius = cw / 2;
item.$content.backgroundColor = new Color(themeColor);
}
} else if (i > -1) {
dates.splice(i, 1);
const sfs = SFSymbol.named(symbolName);
sfs.applyFont(Font.systemFont(18));
const image = sfs.image;
item.backgroundImage = await tintedImage(image, calendar.color);
item.$text.shadowColor = calendar.color;
item.$text.shadowOffset = new Point(0.5, 0.5);
item.$text.shadowRadius = 0.5;
}
};
/**
* @param {WidgetStack} stack
* @param {CalendarEvent | Reminder} event
*/
const addEvent = (stack, event) => {
const { eventFontSize } = preference;
const { color } = event.calendar;
const row = stack.addStack();
row.layoutHorizontally();
row.centerAlignContent();
row.size = new Size(-1, 28);
const line = row.addStack();
line.layoutVertically();
line.size = new Size(2.4, -1);
line.cornerRadius = 1.2;
line.backgroundColor = color;
line.addSpacer();
row.addSpacer(6);
const content = row.addStack();
content.layoutVertically();
const title = content.addText(event.title);
title.font = Font.boldSystemFont(eventFontSize);
const rgba = hexToRGBA(color.hex);
const hsl = RGBToHSL(rgba.red, rgba.green, rgba.blue);
const lightColor = hsl.l > 30 ? new Color(lightenDarkenColor(hsl, 30 - hsl.l)) : color;
const darkColor = hsl.l < 60 ? new Color(lightenDarkenColor(hsl, 60 - hsl.l)) : color;
title.textColor = Color.dynamic(lightColor, darkColor);
const dateFormat = new Intl.DateTimeFormat([], {
month: '2-digit',
day: '2-digit'
}).format;
const timeFormat = new Intl.DateTimeFormat([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format;
const items = [];
const eventDate = event.startDate || event.dueDate;
if (isToday(eventDate)) {
items.push(i18n(['Today', '今天']));
} else {
items.push(dateFormat(eventDate));
}
// Don't use `!isAllDay`, Reminder does not have `isAllDay` attribute
if (event.isAllDay === false || event.dueDateIncludesTime) items.push(timeFormat(eventDate));
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDayDate = new Date(eventDate);
startDayDate.setHours(0, 0, 0, 0);
const diff = (startDayDate - today) / (24 * 3600000);
if (diff > 0) items.push(`T+${Math.round(diff)}`);
const date = content.addText(items.join(' '));
date.font = Font.systemFont(eventFontSize * 12 / 13);
date.textColor = Color.gray();
row.addSpacer();
};
const getReminders = async () => {
const { eventDays } = preference;
const calendars = await Calendar.forReminders();
const today = new Date();
today.setHours(0, 0, 0, 0);
const later7Date = new Date(today.getTime() + eventDays * 24 * 3600000);
today.setHours(0, 0, 0, -1);
const reminders = await Reminder.incompleteDueBetween(today, later7Date, calendars);
return reminders
};
const getEvents = async () => {
const { eventDays } = preference;
const calendars = await Calendar.forEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const later7Date = new Date(today.getTime() + eventDays * 24 * 3600000);
const events = await CalendarEvent.between(today, later7Date, calendars);
return events
};
/**
* @param {WidgetStack} stack
*/
const addEvents = async (stack) => {
const { eventMax, includesReminder } = preference;
const promises = [getEvents()];
if (includesReminder) {
promises.push(getReminders());
}
const eventsList = await Promise.all(promises);
const _events = eventsList.flat().sort(
(a, b) => (a.startDate || a.dueDate) - (b.startDate || b.dueDate)
);
const list = stack.addStack();
const holder = list.addStack();
holder.layoutHorizontally();
holder.addSpacer();
list.layoutVertically();
for (const event of _events.slice(0, eventMax)) {
list.addSpacer(4);
addEvent(list, event);
}
return list
};
const createWidget = async () => {
const { layout } = preference;
const phone = phoneSize();
const scale = Device.screenScale();
const family = config.widgetFamily;
const widgetWidth = phone[family === 'large' ? 'medium' : family] / scale;
const widgetHeight = phone[family === 'medium' ? 'small' : family] / scale;
const is7Rows = (firstDay.getDay() + lastDay.getDate()) > 35;
let itemWidth = (widgetHeight - titleSize - 12 * 2 + rowGap) / (is7Rows ? 7 : 6) - rowGap;
const w = (widgetWidth - 15 * 2 + columnGap) / 7 - columnGap;
itemWidth = Math.min(itemWidth, w);
const widget = new ListWidget();
widget.url = 'calshow://';
const lightColor = new Color('#fff');
const darkColor = new Color('#242426');
widget.backgroundColor = theme === 'light'
? lightColor
: theme === 'dark'
? darkColor
: Color.dynamic(lightColor, darkColor);
widget.setPadding(12, 15, 12, 15);
addTitle(widget);
const row = widget.addStack();
const actions = [
() =>
addCalendar(row, {
itemWidth,
gap: is7Rows ? [columnGap, rowGap - 1] : [columnGap, rowGap],
addDay
})
];
if (family === 'medium') {
if (layout === 'calendar_events') {
actions.push(() => addEvents(row));
} else {
actions.unshift(() => addEvents(row));
}
}
for (const [i, action] of actions.entries()) {
if (layout === 'calendar_events' && i > 0) {
row.addSpacer(10);
}
await action();
}
return widget
};
const {
themeColor,
textColor,
textColorDark,
weekendColor,
weekendColorDark,
symbolName
} = preference;
const eventSettings = {
name: 'event',
type: 'group',
label: i18n(['Events', '事件']),
items: [
{
name: 'eventFontSize',
type: 'number',
label: i18n(['Text size', '字体大小']),
default: preference.eventFontSize
},
{
name: 'eventMax',
type: 'number',
label: i18n(['Max count', '最大显示数量']),
default: preference.eventMax
},
{
name: 'includesReminder',
type: 'switch',
label: i18n(['Show reminders', '显示提醒事项']),
default: preference.includesReminder
},
{
name: 'eventDays',
type: 'number',
label: i18n(['Days limit', '天数限制']),
default: preference.eventDays
},
{
name: 'layout',
type: 'select',
label: i18n(['Content placement', '排列方式']),
options: [
{ label: i18n(['Calendar-Events', '日历-事件']), value: 'calendar_events' },
{ label: i18n(['Events-Calendar', '事件-日历']), value: 'events_calendar' }
],
default: preference.layout
}
]
};
const widget = await withSettings({
formItems: [
{
name: 'themeColor',
type: 'color',
label: i18n(['Theme color', '主题色']),
default: themeColor
},
{
name: 'textColor',
type: 'color',
label: i18n(['Text color', '文字颜色']),
media: '(prefers-color-scheme: light)',
default: textColor
},
{
name: 'textColorDark',
type: 'color',
label: i18n(['Text color', '文字颜色']),
media: '(prefers-color-scheme: dark)',
default: textColorDark
},
{
name: 'weekendColor',
type: 'color',
label: i18n(['Weekend color', '周末文字颜色']),
media: '(prefers-color-scheme: light)',
default: weekendColor
},
{
name: 'weekendColorDark',
type: 'color',
label: i18n(['Weekend color', '周末文字颜色']),
media: '(prefers-color-scheme: dark)',
default: weekendColorDark
},
{
name: 'symbolName',
label: i18n(['Calendar SFSymbol icon', '事件 SFSymbol 图标']),
default: symbolName
},
eventSettings
],
render: async ({ family, settings }) => {
if (family) {
config.widgetFamily = family;
}
Object.assign(preference, settings);
const widget = await createWidget();
return widget
}
});
if (config.runsInWidget) {
Script.setWidget(widget);
}