// Anniversary Cards for Scriptable // Copy this file into Scriptable, run it once to create cards, then use each // card id as the home screen widget parameter. const STORAGE_KEY = "anniversary_widget_profiles_v4" const LEGACY_STORAGE_KEY = "anniversary_widget_config_v3" const LEGACY_BG_IMAGE_FILE = "anniversary_widget_bg.jpg" const BG_IMAGE_PREFIX = "anniversary_widget_bg_" async function main() { const store = loadConfigStore() if (config.runsInApp) { try { const selectedId = await promptForProfileSelection(store) if (selectedId === null) { Script.complete() return } const selectedAction = store.profiles[selectedId] ? await promptForProfileAction(store, selectedId) : "edit" if (selectedAction === "cancel") { Script.complete() return } if (selectedAction === "copy") { copyProfileIdToClipboard(selectedId) await showSetupHint(selectedId) Script.complete() return } if (selectedAction === "delete") { const summary = buildDeleteSummary(store, selectedId) if (summary) { const confirm = new Alert() confirm.title = "确认删除" confirm.message = `将删除「${summary.title}」这条纪念日数据。\n背景图片只会删除 Scriptable 本地拷贝。` confirm.addDestructiveAction("删除") confirm.addCancelAction("取消") const confirmIndex = await confirm.presentAlert() if (confirmIndex === 0) { deleteProfile(store, selectedId) } } Script.complete() return } const oldData = store.profiles[selectedId] || null const saved = await promptForConfig(oldData, selectedId) if (!saved) { Script.complete() return } store.profiles[selectedId] = saved store.defaultId = selectedId saveConfigStore(store) copyProfileIdToClipboard(selectedId) await showSetupHint(selectedId) } catch (e) { await showErrorWidget("设置失败", String(e)) return } } const requestedId = normalizeProfileId(args.widgetParameter) const profileId = config.runsInApp ? getPreferredProfileId(store, requestedId) : getWidgetProfileId(store, requestedId) const saved = profileId ? store.profiles[profileId] : null if (!saved) { const message = getMissingProfileMessage(store, requestedId) const title = !config.runsInApp && listProfileIds(store).length > 1 ? "请设置小组件参数" : "还没有设置纪念日" await showErrorWidget(title, message) return } const widget = buildWidget(saved) Script.setWidget(widget) if (config.runsInApp) { await widget.presentSmall() } Script.complete() } function loadConfigStore() { try { if (Keychain.contains(STORAGE_KEY)) { const raw = Keychain.get(STORAGE_KEY) return normalizeStore(JSON.parse(raw)) } } catch (e) {} const migrated = loadLegacyStore() if (migrated) { saveConfigStore(migrated) return migrated } return emptyConfigStore() } function loadLegacyStore() { try { if (!Keychain.contains(LEGACY_STORAGE_KEY)) return null const raw = Keychain.get(LEGACY_STORAGE_KEY) const legacy = normalizeProfile(JSON.parse(raw)) if (!legacy) return null return { defaultId: "default", profiles: { default: legacy } } } catch (e) { return null } } function saveConfigStore(store) { Keychain.set(STORAGE_KEY, JSON.stringify(store)) } function emptyConfigStore() { return { defaultId: null, profiles: {} } } function normalizeStore(raw) { const store = emptyConfigStore() if (!raw || typeof raw !== "object") { return store } const sourceProfiles = raw.profiles && typeof raw.profiles === "object" ? raw.profiles : {} for (const [id, profile] of Object.entries(sourceProfiles)) { const normalizedId = normalizeProfileId(id) const normalizedProfile = normalizeProfile(profile) if (isValidProfileId(normalizedId) && normalizedProfile) { store.profiles[normalizedId] = normalizedProfile } } const profileIds = listProfileIds(store) const defaultId = normalizeProfileId(raw.defaultId) store.defaultId = isValidProfileId(defaultId) && store.profiles[defaultId] ? defaultId : profileIds[0] || null return store } function normalizeProfile(profile) { if (!profile || typeof profile !== "object") { return null } const title = typeof profile.title === "string" ? profile.title.trim() : "" const year = Number(profile.year) const month = Number(profile.month) const day = Number(profile.day) if (!title) { return null } if (!isValidDateParts(year, month, day)) { return null } return { title, year, month, day, bg: normalizeBackground(profile.bg) } } function normalizeBackground(bg) { const type = bg?.type if (type === "photo") { const file = typeof bg?.file === "string" && bg.file ? bg.file : LEGACY_BG_IMAGE_FILE return { type: "photo", file } } if (type === "dark" || type === "light" || type === "transparent") { return { type } } return { type: "transparent" } } function normalizeProfileId(value) { if (typeof value !== "string") { return "" } return value.trim().toLowerCase() } function isValidProfileId(value) { return typeof value === "string" && value.length > 0 && !/\s/.test(value) } function getProfileIdValidationMessage(value) { if (!value) { return "标识不能为空" } if (/\s/.test(value)) { return "标识不能包含空格" } return "" } function getPreferredProfileId(store, requestedId) { if (requestedId && store.profiles[requestedId]) { return requestedId } if (store.defaultId && store.profiles[store.defaultId]) { return store.defaultId } const ids = listProfileIds(store) return ids[0] || null } function getWidgetProfileId(store, requestedId) { const ids = listProfileIds(store) if (requestedId) { return store.profiles[requestedId] ? requestedId : null } return ids.length <= 1 ? ids[0] || null : null } function listProfileIds(store) { return Object.keys(store.profiles) } function isManagedBackgroundFileName(fileName) { return ( typeof fileName === "string" && fileName.startsWith(BG_IMAGE_PREFIX) && fileName.endsWith(".jpg") ) } function collectManagedBackgroundFiles(store, profileId) { const profile = store.profiles[profileId] const file = profile?.bg?.file return isManagedBackgroundFileName(file) ? [file] : [] } function buildDeleteSummary(store, profileId) { const profile = store.profiles[profileId] if (!profile) { return null } const ids = listProfileIds(store).filter((id) => id !== profileId) const nextDefaultId = store.defaultId === profileId ? ids[0] || null : store.defaultId return { profileId, title: profile.title, removeBackgroundFiles: collectManagedBackgroundFiles(store, profileId), nextDefaultId } } function deleteProfile(store, profileId) { const summary = buildDeleteSummary(store, profileId) if (!summary) { return null } const fm = FileManager.local() for (const file of summary.removeBackgroundFiles) { const path = fm.joinPath(fm.documentsDirectory(), file) if (fm.fileExists(path)) { fm.remove(path) } } delete store.profiles[profileId] store.defaultId = summary.nextDefaultId saveConfigStore(store) return summary } function copyProfileIdToClipboard(profileId) { Pasteboard.copyString(profileId) } function getSetupHintMessage(profileId) { return `已复制标识「${profileId}」\n去桌面长按小组件,编辑参数后填入这个标识` } async function showSetupHint(profileId) { const alert = new Alert() alert.title = "标识已复制" alert.message = getSetupHintMessage(profileId) alert.addAction("知道了") await alert.presentAlert() } function getMissingProfileMessage(store, requestedId) { const ids = listProfileIds(store) if (requestedId) { return `未找到标识「${requestedId}」\n请确认小组件参数和标识完全一致` } if (ids.length > 1) { const examples = ids.slice(0, 3).join(", ") return `已保存 ${ids.length} 条纪念日\n请在桌面编辑小组件,参数填写对应标识${examples ? `\n例如:${examples}` : ""}` } return "先在 Scriptable 里运行一次脚本,新建纪念日" } function suggestProfileId(store) { if (!store.profiles.wedding) { return "wedding" } let index = 1 while (store.profiles[`date${index}`]) { index += 1 } return `date${index}` } async function promptForProfileSelection(store) { const alert = new Alert() alert.title = "纪念日列表" alert.message = "同一脚本现在可以保存多条纪念日。\n桌面组件参数填写标识,就能显示不同日期。" alert.addAction("新建纪念日") const ids = listProfileIds(store) for (const id of ids) { const profile = store.profiles[id] alert.addAction(`${profile.title} (${id})`) } alert.addCancelAction("取消") const index = await alert.presentAlert() if (index === -1) { return null } if (index === 0) { return await promptForNewProfileId(store) } return ids[index - 1] || null } async function promptForProfileAction(store, profileId) { const profile = store.profiles[profileId] const alert = new Alert() alert.title = profile.title alert.message = `标识:${profileId}\n请选择操作` alert.addAction("编辑") alert.addAction("复制标识") alert.addDestructiveAction("删除") alert.addCancelAction("取消") const index = await alert.presentAlert() if (index === -1) { return "cancel" } if (index === 0) { return "edit" } if (index === 1) { return "copy" } if (index === 2) { return "delete" } return "cancel" } async function promptForNewProfileId(store) { const alert = new Alert() alert.title = "新建纪念日" alert.message = "先设置一个标识。\n以后在桌面编辑小组件时,把这个标识填进参数即可。" alert.addTextField("标识", suggestProfileId(store)) alert.addAction("下一步") alert.addCancelAction("取消") const index = await alert.presentAlert() if (index === -1) { return null } const profileId = normalizeProfileId(alert.textFieldValue(0)) const validationMessage = getProfileIdValidationMessage(profileId) if (validationMessage) { throw new Error(validationMessage) } if (store.profiles[profileId]) { throw new Error(`标识「${profileId}」已经存在`) } return profileId } async function promptForConfig(oldData, profileId) { const alert = new Alert() alert.title = "设置纪念日" alert.message = `标识:${profileId}\n请输入标题、年月日,并选择背景` alert.addTextField("标题", oldData?.title || "结婚纪念日") alert.addTextField("年", oldData?.year ? String(oldData.year) : "2020") alert.addTextField("月", oldData?.month ? String(oldData.month) : "6") alert.addTextField("日", oldData?.day ? String(oldData.day) : "1") alert.addAction("下一步") if (oldData) { alert.addAction("继续使用当前设置") } alert.addCancelAction("取消") const index = await alert.presentAlert() if (index === -1) { return oldData || null } if (oldData && index === 1) { return oldData } const title = alert.textFieldValue(0).trim() const year = Number(alert.textFieldValue(1).trim()) const month = Number(alert.textFieldValue(2).trim()) const day = Number(alert.textFieldValue(3).trim()) if (!title) { throw new Error("标题不能为空") } if (!Number.isInteger(year) || year < 1900 || year > 2999) { throw new Error("年份不正确") } if (!Number.isInteger(month) || month < 1 || month > 12) { throw new Error("月份必须是 1 到 12") } if (!Number.isInteger(day) || day < 1 || day > 31) { throw new Error("日期必须是 1 到 31") } if (!isValidDateParts(year, month, day)) { throw new Error("日期不存在,请重新输入") } const bg = await promptForBackground(oldData?.bg, profileId) return { title, year, month, day, bg } } async function promptForBackground(oldBg, profileId) { const bgAlert = new Alert() bgAlert.title = "选择背景" bgAlert.message = "默认推荐:透明感" bgAlert.addAction("透明感") bgAlert.addAction("深色") bgAlert.addAction("浅色") bgAlert.addAction("相册图片") if (oldBg) { bgAlert.addAction("沿用当前背景") } bgAlert.addCancelAction("取消") const choice = await bgAlert.presentAlert() if (choice === -1) { return oldBg || { type: "transparent" } } if (oldBg && choice === 4) { return oldBg } if (choice === 0) { return { type: "transparent" } } if (choice === 1) { return { type: "dark" } } if (choice === 2) { return { type: "light" } } if (choice === 3) { const img = await Photos.fromLibrary() const fm = FileManager.local() const file = createBgImageFileName(profileId) const path = fm.joinPath(fm.documentsDirectory(), file) fm.writeImage(path, img) return { type: "photo", file } } return { type: "transparent" } } function createBgImageFileName(profileId) { const token = profileId .replace(/[^a-z0-9_-]/gi, "_") .replace(/_+/g, "_") .slice(0, 24) || "profile" return `${BG_IMAGE_PREFIX}${token}_${Date.now()}.jpg` } function buildWidget(saved) { const widget = new ListWidget() widget.setPadding(16, 16, 16, 16) applyBackground(widget, saved.bg) const targetDate = new Date(saved.year, saved.month - 1, saved.day) const today = startOfDay(new Date()) const target = startOfDay(targetDate) const diffDays = Math.floor((today - target) / 86400000) const titleColor = textColorForBackground(saved.bg, "title") const subColor = textColorForBackground(saved.bg, "sub") const titleText = widget.addText(saved.title) titleText.font = Font.boldSystemFont(16) titleText.lineLimit = 1 titleText.textColor = titleColor widget.addSpacer(10) const mainText = widget.addText( diffDays >= 0 ? `第 ${diffDays} 天` : `还有 ${Math.abs(diffDays)} 天` ) mainText.font = Font.boldSystemFont(28) mainText.lineLimit = 1 mainText.minimumScaleFactor = 0.6 mainText.textColor = titleColor widget.addSpacer(8) const dateText = widget.addText( `${saved.year}-${pad2(saved.month)}-${pad2(saved.day)}` ) dateText.font = Font.systemFont(12) dateText.textColor = subColor widget.addSpacer() const todayText = widget.addText(`今天 ${formatDate(today)}`) todayText.font = Font.systemFont(11) todayText.textColor = subColor const now = new Date() widget.refreshAfterDate = new Date( now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0 ) return widget } function applyBackground(widget, bg) { const type = bg?.type || "transparent" if (type === "photo") { const fm = FileManager.local() const file = typeof bg?.file === "string" && bg.file ? bg.file : LEGACY_BG_IMAGE_FILE const path = fm.joinPath(fm.documentsDirectory(), file) if (fm.fileExists(path)) { widget.backgroundImage = fm.readImage(path) const gradient = new LinearGradient() gradient.colors = [ new Color("#000000", 0.12), new Color("#000000", 0.35) ] gradient.locations = [0, 1] widget.backgroundGradient = gradient return } } if (type === "dark") { const gradient = new LinearGradient() gradient.colors = [ new Color("#1C1C1E", 0.95), new Color("#2C2C2E", 0.88) ] gradient.locations = [0, 1] widget.backgroundGradient = gradient return } if (type === "light") { const gradient = new LinearGradient() gradient.colors = [ new Color("#F2F2F7", 0.92), new Color("#E5E5EA", 0.88) ] gradient.locations = [0, 1] widget.backgroundGradient = gradient return } const gradient = new LinearGradient() gradient.colors = [ new Color("#1C1C1E", 0.35), new Color("#2C2C2E", 0.22) ] gradient.locations = [0, 1] widget.backgroundGradient = gradient } function textColorForBackground(bg, kind) { const type = bg?.type || "transparent" if (type === "light") { return kind === "title" ? new Color("#111111") : new Color("#111111", 0.65) } return kind === "title" ? Color.white() : new Color("#FFFFFF", 0.72) } function isValidDateParts(year, month, day) { if (!Number.isInteger(year) || year < 1900 || year > 2999) { return false } if (!Number.isInteger(month) || month < 1 || month > 12) { return false } if (!Number.isInteger(day) || day < 1 || day > 31) { return false } const testDate = new Date(year, month - 1, day) return ( testDate.getFullYear() === year && testDate.getMonth() === month - 1 && testDate.getDate() === day ) } function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()) } function pad2(n) { return String(n).padStart(2, "0") } function formatDate(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` } async function showErrorWidget(title, message) { const widget = new ListWidget() widget.setPadding(16, 16, 16, 16) const gradient = new LinearGradient() gradient.colors = [ new Color("#1C1C1E", 0.95), new Color("#2C2C2E", 0.9) ] gradient.locations = [0, 1] widget.backgroundGradient = gradient const t1 = widget.addText(title) t1.font = Font.boldSystemFont(16) t1.textColor = Color.white() widget.addSpacer(8) const t2 = widget.addText(message) t2.font = Font.systemFont(12) t2.textColor = new Color("#FFFFFF", 0.8) Script.setWidget(widget) if (config.runsInApp) { await widget.presentSmall() } Script.complete() } await main()