diff --git a/.gitignore b/.gitignore index 50cf087c..27b3702b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ dist # obsidian .obsidian + +**Identifier /.claude/skills/references/obsidian-developer-docs/Assets diff --git a/README-zh.md b/README-zh.md index b19ce288..898833f9 100644 --- a/README-zh.md +++ b/README-zh.md @@ -27,6 +27,7 @@ - 🔍 **事件筛选**:按类型过滤、隐藏空日期 - 🌙 **农历支持**:原生支持中国农历 - 🌏 **多语言**:支持简繁中文和英文语言 +- 🎊 **节假日支持**:内置系统节假日(法定、节气、传统),可持久化用户自定义节假日 ## 截图展示 @@ -82,6 +83,17 @@ ![Event Management](./doc/manager.png) *事件管理界面.* +### 5. 补充法定节假日数据 + +法定节假日由各国政府提前一年制定,内置数据可能不完整,如有缺失可自行补充。 + +导入方法: +1. 在插件设置中找到"法定节假日补充" +2. 输入节假日数据 +3. 点击"导入"按钮 + +导入后的数据会持久化保存,即使插件更新也不会丢失。 + ## 自定义设置 diff --git a/README.md b/README.md index 08439751..01c9de23 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ English | [中文文档](https://github.com/Moyf/yearly-glance/blob/master/READM - 🔍 **Event Filtering**: Filter by event type and hide empty dates for a cleaner view. - 🌙 **Lunar Calendar Support**: Natively supports the traditional Chinese lunar calendar. - 🌏 **Multi-language Support**: English and Chinese (Both simplified and traditional) available. - +- 🎊 **Holiday Support**: Built-in system holidays (statutory, solar terms, traditional), plus persistent user-defined holidays. ## Screenshots @@ -85,6 +85,17 @@ English | [中文文档](https://github.com/Moyf/yearly-glance/blob/master/READM ![Event Management](./doc/manager.png) *Centralized event management interface.* +### 5. Supplement Holiday Data + +Public holidays are determined by governments a year in advance, so built-in data may be incomplete. You can supplement missing holidays yourself. + +How to import: +1. Find "Holiday supplementary data" in plugin settings +2. Input holiday data +3. Click "Import" button + +Imported data will be persisted and won't be lost even after plugin updates. + ## Customization Settings ![display](./doc/display-options.png) diff --git a/package-lock.json b/package-lock.json index e9a14897..b3030ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -685,33 +686,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@commitlint/load/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "optional": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader": { "version": "6.1.0", "resolved": "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.1.0.tgz", @@ -730,21 +704,6 @@ "typescript": ">=5" } }, - "node_modules/@commitlint/load/node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@commitlint/resolve-extends": { "version": "19.8.0", "resolved": "https://registry.npmmirror.com/@commitlint/resolve-extends/-/resolve-extends-19.8.0.tgz", @@ -2297,6 +2256,7 @@ "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.85.tgz", "integrity": "sha512-61sLB7tXUMpkHJagZQAzPV4xGyqzulLvphe0lquRX80rZG24VupRv9p6Qo06V9VBNeGBM8Sv8rRVVLji6pi7QQ==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2313,6 +2273,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2398,6 +2359,7 @@ "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-5.29.0.tgz", "integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.29.0", "@typescript-eslint/types": "5.29.0", @@ -2824,6 +2786,7 @@ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3141,6 +3104,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3686,6 +3650,7 @@ "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "meow": "^13.0.0" }, @@ -4165,6 +4130,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5523,6 +5489,7 @@ "integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.0.4", "@jest/types": "30.0.1", @@ -6448,9 +6415,10 @@ } }, "node_modules/lunar-typescript": { - "version": "1.7.8", - "resolved": "https://registry.npmmirror.com/lunar-typescript/-/lunar-typescript-1.7.8.tgz", - "integrity": "sha512-i1rjc1eDHUF4k8NfpYxar3ZKtxyVHOGnR+yVTMS8jMy74ugq7KeHI+8T1XIvB3uojpBq6asywe7z25PmI3VLPw==" + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/lunar-typescript/-/lunar-typescript-1.8.6.tgz", + "integrity": "sha512-5Eo4T/cnuXfrgO4k5LCpOGHIUOuz5hCF/IfNv0T29WY2shR36Hiz+ecN9WjnUuxUKhql9gbOkPaQoqLFKtPRNA==", + "license": "MIT" }, "node_modules/make-dir": { "version": "4.0.0", @@ -7033,6 +7001,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -7074,6 +7043,7 @@ "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7169,6 +7139,7 @@ "version": "19.1.0", "resolved": "https://registry.npmmirror.com/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7730,8 +7701,7 @@ "version": "4.1.2", "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.2.tgz", "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/style-to-js": { "version": "1.1.16", @@ -7993,6 +7963,7 @@ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8163,8 +8134,7 @@ "version": "2.2.8", "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/walker": { "version": "1.0.8", diff --git a/package.json b/package.json index 66f2e0de..bed77fa6 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dotenv": "^16.4.7", "html-react-parser": "^5.2.5", "lucide-react": "^0.487.0", - "lunar-typescript": "^1.7.8", + "lunar-typescript": "^1.8.6", "react": "^19.1.0", "react-dom": "^19.1.0", "uuid": "^11.1.0" diff --git a/src/components/DataPort/DataExport.tsx b/src/components/DataPort/DataExport.tsx index 140c9093..5772930c 100644 --- a/src/components/DataPort/DataExport.tsx +++ b/src/components/DataPort/DataExport.tsx @@ -7,6 +7,7 @@ import { import { BaseEvent, EVENT_TYPE_DEFAULT, + EVENT_TYPE_LIST, Events, EventType, } from "@/src/type/Events"; @@ -60,6 +61,7 @@ export const DataExport: React.FC = ({ const allEventIds = React.useMemo(() => { const allIds: string[] = []; allIds.push(...currentData.holidays.map((event) => event.id)); + allIds.push(...currentData.systemHolidays.map((event) => event.id)); allIds.push(...currentData.birthdays.map((event) => event.id)); allIds.push(...currentData.customEvents.map((event) => event.id)); return allIds; @@ -134,13 +136,19 @@ export const DataExport: React.FC = ({ }); }; - const groups: Record = { + const groups: Record = { holiday: sortEventsByDate( currentData.holidays.map((event) => ({ ...event, type: "holiday" as EventType, })) ), + systemHoliday: sortEventsByDate( + currentData.systemHolidays.map((event) => ({ + ...event, + type: "holiday" as EventType, + })) + ), birthday: sortEventsByDate( currentData.birthdays.map((event) => ({ ...event, @@ -155,7 +163,7 @@ export const DataExport: React.FC = ({ ), }; return groups; - }, [currentData, config]); + }, [currentData]); // 当数据变化时更新选中状态,保持全选 React.useEffect(() => { @@ -176,7 +184,7 @@ export const DataExport: React.FC = ({ }; // 选择/取消选择某个分组的所有事件 - const handleGroupSelection = (type: EventType) => { + const handleGroupSelection = (type: EventType | "systemHoliday") => { const groupEventIds = eventGroups[type].map((event) => event.id); const newSelected = new Set(selectedEvents); @@ -208,7 +216,7 @@ export const DataExport: React.FC = ({ }; // 检查某个分组是否全选 - const isGroupSelected = (type: EventType) => { + const isGroupSelected = (type: EventType | "systemHoliday") => { const groupEventIds = eventGroups[type].map((event) => event.id); return ( groupEventIds.length > 0 && @@ -235,6 +243,9 @@ export const DataExport: React.FC = ({ holidays: currentData.holidays.filter((event) => selectedEvents.has(event.id) ), + systemHolidays: currentData.systemHolidays.filter((event) => + selectedEvents.has(event.id) + ), birthdays: currentData.birthdays.filter((event) => selectedEvents.has(event.id) ), @@ -255,28 +266,17 @@ export const DataExport: React.FC = ({ const selectedData = getSelectedData(); switch (activeExportFormat) { - case "json": { - const content = JsonService.createJsonEvents(selectedData); - const filename = `${exportFileName}.json`; - const mimeType = "application/json"; - - // 创建下载链接 - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - break; - } + case "json": case "ics": { const content = - iCalendarService.createICalEvents(selectedData); - const filename = `${exportFileName}.ics`; - const mimeType = "text/calendar"; + activeExportFormat === "json" + ? JsonService.createJsonEvents(selectedData) + : iCalendarService.createICalEvents(selectedData); + const filename = `${exportFileName}.${activeExportFormat}`; + const mimeType = + activeExportFormat === "json" + ? "application/json" + : "text/calendar"; // 创建下载链接 const blob = new Blob([content], { type: mimeType }); @@ -288,6 +288,7 @@ export const DataExport: React.FC = ({ link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); + // 不添加导出成功数量提示,因为会和导出路径选择有冲突 break; } case "md": { @@ -332,12 +333,16 @@ export const DataExport: React.FC = ({ }; // 获取分组显示信息 - const getGroupInfo = (type: EventType) => { - const config = { + const getGroupInfo = (type: EventType | "systemHoliday") => { + const config: Record = { holiday: { title: t("view.yearlyGlance.legend.holiday"), icon: , }, + systemHoliday: { + title: t("view.eventManager.holiday.systemHolidays"), + icon: , + }, birthday: { title: t("view.yearlyGlance.legend.birthday"), icon: , @@ -663,7 +668,7 @@ export const DataExport: React.FC = ({ {/* 分类选择控制 */} - {(Object.keys(eventGroups) as EventType[]).map((type) => { + {[...EVENT_TYPE_LIST, "systemHoliday" as const].map((type) => { const events = eventGroups[type]; if (events.length === 0) return null; @@ -706,11 +711,14 @@ export const DataExport: React.FC = ({ {/* 事件分组列表 */}
- {(Object.keys(eventGroups) as EventType[]).map((type) => { + {[...EVENT_TYPE_LIST, "systemHoliday" as const].map((type) => { const events = eventGroups[type]; if (events.length === 0) return null; const groupInfo = getGroupInfo(type); + const defaultEmoji = type === "systemHoliday" + ? EVENT_TYPE_DEFAULT.holiday.emoji + : EVENT_TYPE_DEFAULT[type].emoji; return (
@@ -733,9 +741,7 @@ export const DataExport: React.FC = ({ ) } - onClick={() => - handleGroupSelection(type) - } + onClick={() => handleGroupSelection(type)} > {isGroupSelected(type) ? t( @@ -769,11 +775,7 @@ export const DataExport: React.FC = ({ />
- {!event.emoji - ? EVENT_TYPE_DEFAULT[ - type - ].emoji - : event.emoji}{" "} + {event.emoji || defaultEmoji}{" "} {event.text}
diff --git a/src/components/EventForm/EventFormModal.tsx b/src/components/EventForm/EventFormModal.tsx index 81754abf..25f3ea5e 100644 --- a/src/components/EventForm/EventFormModal.tsx +++ b/src/components/EventForm/EventFormModal.tsx @@ -130,6 +130,7 @@ export class EventFormModal extends Modal { event as Holiday, currentYear ); + // 用户添加的节假日保存在 holidays 数组中 if (this.isEditing) { newEvents.holidays = newEvents.holidays.map((h) => h.id === event.id ? (event as Holiday) : h diff --git a/src/components/EventManager/EventItem.tsx b/src/components/EventManager/EventItem.tsx index c6278766..68226fd9 100644 --- a/src/components/EventManager/EventItem.tsx +++ b/src/components/EventManager/EventItem.tsx @@ -17,6 +17,7 @@ interface EventItemProps { onDelete: () => void; eventType: EventType; gregorianDisplayFormat: string; // 公历显示格式 + readonly?: boolean; // 只读模式,不显示编辑/删除按钮 } // 事件列表项组件 @@ -26,6 +27,7 @@ export const EventItem: React.FC = ({ onDelete, eventType, gregorianDisplayFormat, + readonly = false, }) => { // 获取事件特定信息 const getEventSpecificInfo = () => { @@ -233,20 +235,26 @@ export const EventItem: React.FC = ({
- - + {readonly ? ( + 🔒 + ) : ( + <> + + + + )}
); diff --git a/src/components/EventManager/EventList.tsx b/src/components/EventManager/EventList.tsx index 1e5705fb..1bcb041d 100644 --- a/src/components/EventManager/EventList.tsx +++ b/src/components/EventManager/EventList.tsx @@ -165,6 +165,7 @@ export const EventList: React.FC = ({ ); } + // 普通模式:直接渲染排序后的事件列表 return (
diff --git a/src/components/Settings/SettingsItem.tsx b/src/components/Settings/SettingsItem.tsx index f6ec506f..18a468fa 100644 --- a/src/components/Settings/SettingsItem.tsx +++ b/src/components/Settings/SettingsItem.tsx @@ -3,7 +3,7 @@ import { ChevronDown, ChevronRight } from "lucide-react"; interface SettingsItemProps { name: string; - desc?: string; + desc?: string | React.ReactNode; icon?: React.ReactNode; children?: React.ReactNode; collapsible?: boolean; diff --git a/src/components/Settings/ViewSettings.tsx b/src/components/Settings/ViewSettings.tsx index d6531843..1678f444 100644 --- a/src/components/Settings/ViewSettings.tsx +++ b/src/components/Settings/ViewSettings.tsx @@ -292,6 +292,40 @@ export const ViewSettings: React.FC = ({ plugin }) => { } /> + {/* 节假日补充数据 */} + + {t("setting.general.holidayFixData.desc")} + + {t("setting.general.holidayFixData.link")} + + + } + > +
+ + handleUpdateConfig({ holidayFixData: value }) + } + placeholder={t("setting.general.holidayFixData.placeholder")} + style={{ flex: 1 }} + /> + +
+
{/* 显示生日 */} new Date(), []); @@ -115,6 +115,16 @@ export function useYearlyCalendar(plugin: YearlyGlancePlugin) { // 处理节假日 if (showHolidays) { + // 系统节假日 + systemHolidays.forEach((holiday) => { + if (!holiday.isHidden) { + events.push({ + ...holiday, + eventType: "holiday", + }); + } + }); + // 用户节假日 holidays.forEach((holiday) => { if (!holiday.isHidden) { events.push({ diff --git a/src/i18n/holidayConfig.ts b/src/i18n/holidayConfig.ts new file mode 100644 index 00000000..b657eaf2 --- /dev/null +++ b/src/i18n/holidayConfig.ts @@ -0,0 +1,15 @@ +export type HolidayDisplayType = "public" | "public-work" | "solar-term" | "festival"; + +export const HOLIDAY_EMOJI: Record = { + public: "🎉", + "public-work": "💼", + "solar-term": "🌿", + festival: "🌿", +}; + +export const HOLIDAY_COLOR: Record = { + public: "#ff7875", + "public-work": "#ff4d4f", + "solar-term": "#b8b8b8", + festival: "#b8b8b8", +}; diff --git a/src/i18n/holidays.ts b/src/i18n/holidays.ts new file mode 100644 index 00000000..4806501f --- /dev/null +++ b/src/i18n/holidays.ts @@ -0,0 +1,225 @@ +import { HolidayUtil, Lunar, Solar, SolarUtil } from "lunar-typescript"; +import { Holiday, HolidayDisplayType } from "@/src/type/Events"; +import { HOLIDAY_COLOR, HOLIDAY_EMOJI } from "./holidayConfig"; + +/** + * 节假日服务类 + * 负责从 lunar-typescript 库获取全年节假日信息 + * 包括:法定节假日、节气、公历节日、农历节日 + */ +export class HolidayService { + /** + * 获取指定年份的全部节假日信息 + * @param year 年份 + * @returns 节假日数组 + */ + static getFullYearHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + + const publicHolidays = this.getPublicHolidays(year); + results.push(...publicHolidays); + + const solarTerms = this.getSolarTerms(year); + results.push(...solarTerms); + + const festivals = this.getAllFestivals(year); + results.push(...festivals); + + const final = this.deduplicate(results); + + return final; + } + + /** + * 获取法定节假日 + * 来源:HolidayUtil.getHolidays(year) + * 包含:元旦、春节、清明、劳动节、端午、中秋、国庆等 + * 自动包含调休信息 + * @param year 年份 + * @returns 法定节假日数组 + */ + private static getPublicHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + const holidays = HolidayUtil.getHolidays(year); + + for (const h of holidays) { + const day = h.getDay(); + const name = h.getName(); + const target = h.getTarget(); + const work = h.isWork(); + + const isFirstDay = day === target; + + let text: string; + let displayType: HolidayDisplayType; + + if (isFirstDay) { + text = name; + displayType = "public"; + } else if (work) { + text = "班"; + displayType = "public-work"; + } else { + text = "休"; + displayType = "public"; + } + + const [y, m, d] = day.split("-").map(Number); + Solar.fromYmd(y, m, d); + + results.push({ + id: `holi-public-${day.replace(/-/g, "")}`, + text, + emoji: HOLIDAY_EMOJI[displayType], + color: HOLIDAY_COLOR[displayType], + holidayType: displayType, + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + foundDate: target, + }); + } + + return results; + } + + /** + * 获取24节气 + * 来源:Lunar.getJieQiTable() + * 节气是根据太阳运行规律计算的,传统中国历法 + * @param year 年份 + * @returns 节气数组 + */ + private static getSolarTerms(year: number): Holiday[] { + const results: Holiday[] = []; + + const lunar = Lunar.fromYmd(year, 6, 15); + const jieQiTable = lunar.getJieQiTable(); + + for (const [name, solar] of Object.entries(jieQiTable)) { + // 节气名称是纯中文,不包含常量名(如 DA_XUE) + if (/^[\u4e00-\u9fa5]+$/.test(name)) { + const day = solar.toYmd(); + + results.push({ + id: `holi-solar-term-${day.replace(/-/g, "")}`, + text: name, + emoji: HOLIDAY_EMOJI["solar-term"], + color: HOLIDAY_COLOR["solar-term"], + holidayType: "solar-term", + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + }); + } + } + + return results; + } + + /** + * 遍历全年获取所有节日 + * 包括: + * - 公历节日:情人节、愚人节、母亲节、万圣节、圣诞节等 + * - 公历其他节日/纪念日:建党节,建军节、教师节、护士节等 + * - 农历节日:春节、元宵、端午、中秋等 + * - 农历其他节日:寒食、春社,秋社等 + * @param year 年份 + * @returns 所有节日数组 + */ + private static getAllFestivals(year: number): Holiday[] { + const results: Holiday[] = []; + + for (let month = 1; month <= 12; month++) { + const daysInMonth = SolarUtil.getDaysOfMonth(year, month); + + for (let day = 1; day <= daysInMonth; day++) { + const solar = Solar.fromYmd(year, month, day); + const lunar = solar.getLunar(); + const solarDay = solar.toYmd(); + + for (const name of solar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of solar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + } + } + + return results; + } + + /** + * 创建节日 Holiday 对象 + * @param solarDay 公历日期 + * @param name 节日名称 + * @returns Holiday 对象 + */ + private static createFestivalHoliday( + solarDay: string, + name: string + ): Holiday { + return { + id: `holi-festival-${solarDay.replace(/-/g, "")}-${name}`, + text: name, + emoji: HOLIDAY_EMOJI["festival"], + color: HOLIDAY_COLOR["festival"], + holidayType: "festival", + eventDate: { + isoDate: solarDay, + calendar: "GREGORIAN", + userInput: { + input: solarDay, + calendar: "GREGORIAN", + }, + }, + }; + } + + /** + * 去重处理 + * 规则:同一天 + 前两字相同 → 只保留一个 + * 优先级:法定节假日 > 节日 + * @param holidays 节假日数组 + * @returns 去重后的节假日数组 + */ + private static deduplicate(holidays: Holiday[]): Holiday[] { + const seen = new Map(); + + for (const h of holidays) { + const key = `${h.eventDate.isoDate}-${h.text.substring(0, 2)}`; + + if (!seen.has(key)) { + seen.set(key, h); + } + // 法定节假日优先级最高,替换其他类型 + else if (h.holidayType === "public" || h.holidayType === "public-work") { + seen.set(key, h); + } + } + + return Array.from(seen.values()).sort((a, b) => { + return a.eventDate.isoDate.localeCompare(b.eventDate.isoDate); + }); + } +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e083e04a..7a253601 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -88,6 +88,14 @@ const translations: BaseMessage = { name: "Debug information", desc: "Show debug information for development", }, + holidayFixData: { + name: "Holiday supplementary data", + desc: "Supplement missing public holidays in the library", + link: "Correction data reference", + placeholder: "e.g.: 202601010020260101xxx", + import: "Import", + success: "Holiday data imported successfully", + }, presetColors: { name: "Preset colors", desc: "Color presets for different events", @@ -278,6 +286,8 @@ const translations: BaseMessage = { holiday: { name: "Holiday", foundDate: "Found date", + systemHolidays: "System Holidays", + userHolidays: "User Holidays", }, birthday: { name: "Birthday", diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index b17e0133..29688c89 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -88,6 +88,14 @@ const translations: BaseMessage = { name: "顯示偵錯資訊", desc: "在主控台中顯示偵錯資訊", }, + holidayFixData: { + name: "法定節假日補充", + desc: "補充庫中缺失的法定節假日", + link: "修正資料參考", + placeholder: "例如: 202601010020260101xxx", + import: "匯入", + success: "節假日資料匯入成功", + }, presetColors: { name: "預設顏色", desc: "各事件的配色預設選項", @@ -276,6 +284,8 @@ const translations: BaseMessage = { holiday: { name: "節日", foundDate: "節日起源時間", + systemHolidays: "系統節假日", + userHolidays: "使用者節假日", }, birthday: { name: "生日", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index e29c3c29..8b54f53f 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -88,6 +88,14 @@ const translations: BaseMessage = { name: "显示调试信息", desc: "在控制台中显示调试信息", }, + holidayFixData: { + name: "法定节假日补充", + desc: "补充库中缺失的法定节假日", + link: "修正数据参考", + placeholder: "例如: 202601010020260101xxx", + import: "导入", + success: "节假日数据导入成功", + }, presetColors: { name: "预设颜色", desc: "各事件的配色预设选项", @@ -276,6 +284,8 @@ const translations: BaseMessage = { holiday: { name: "节日", foundDate: "节日起源时间", + systemHolidays: "系统节假日", + userHolidays: "用户节假日", }, birthday: { name: "生日", @@ -372,7 +382,7 @@ const translations: BaseMessage = { nullText: "缺少事件名称", nullDate: "缺少用户输入的日期", duplicateEvent: "该事件可能已存在", - }, + }, }, }, }, diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 4d98ddc3..303412b8 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -50,6 +50,14 @@ export type BaseMessage = { showLunarDay: IBaseSettingsItem; showEmojiBeforeTabName: IBaseSettingsItem; showDebugInfo: IBaseSettingsItem; + holidayFixData: { + name: string; + desc: string; + link: string; + placeholder: string; + import: string; + success: string; + }; presetColors: SettingsItem<{ newColor: string; }>; @@ -190,6 +198,8 @@ export type BaseMessage = { holiday: { name: string; foundDate: string; + systemHolidays: string; + userHolidays: string; }; birthday: { name: string; diff --git a/src/main.ts b/src/main.ts index cf21bd1e..83ba6045 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { Notice, Plugin } from "obsidian"; +import { HolidayUtil } from "lunar-typescript"; import { DEFAULT_CONFIG, YearlyGlanceConfig } from "./type/Config"; import YearlyGlanceSettingsTab from "./components/Settings/SettingsTab"; import { @@ -19,6 +20,7 @@ import { YearlyGlanceBus } from "./hooks/useYearlyGlanceConfig"; import { t } from "./i18n/i18n"; import { MigrateData } from "./utils/migrateData"; import { EventCalculator } from "./utils/eventCalculator"; +import { generateSystemHolidays, updateUserHolidays } from "./utils/holidayUtils"; import { IsoUtils } from "./utils/isoUtils"; import { generateEventId } from "./utils/uniqueEventId"; @@ -53,16 +55,20 @@ export default class YearlyGlancePlugin extends Plugin { // 检查是否为第一次安装,如果是则添加示例事件 await this.addSampleEventOnFirstInstall(savedData); + // 应用保存的节假日补充数据(如果存在) + if (this.settings.config.holidayFixData) { + HolidayUtil.fix(this.settings.config.holidayFixData); + } + // 更新所有事件的dateArr字段 await this.updateAllEventsDateObj(); - // 保存设置,并通知其他组件 - await this.saveSettings(); + // updateAllEventsDateObj 内部会保存用户节假日 } // 确保数据结构符合预期格式,移除未定义的配置 private validateAndMergeSettings(savedData: unknown): YearlyGlanceConfig { // 创建默认配置的深拷贝 - const validatedSettings = structuredClone(DEFAULT_CONFIG); + const validatedSettings = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as YearlyGlanceConfig; try { // 如果savedData存在且是对象 @@ -79,9 +85,12 @@ export default class YearlyGlancePlugin extends Plugin { // 验证并合并data部分 if (data.data && typeof data.data === "object") { + const savedDataObj = data.data as Record; validatedSettings.data = { ...validatedSettings.data, - ...(data.data as Record), + holidays: (savedDataObj.holidays as Holiday[] | undefined) || [], + birthdays: (savedDataObj.birthdays as Birthday[] | undefined) || [], + customEvents: (savedDataObj.customEvents as CustomEvent[] | undefined) || [], }; } } @@ -93,7 +102,17 @@ export default class YearlyGlancePlugin extends Plugin { } async saveSettings() { - await this.saveData(this.settings); + // 只保存需要持久化的数据(用户节假日、生日、自定义事件) + const dataToSave = { + ...this.settings, + data: { + holidays: this.settings.data.holidays, + birthdays: this.settings.data.birthdays, + customEvents: this.settings.data.customEvents, + }, + }; + await this.saveData(dataToSave); + // 通知所有订阅组件刷新数据 YearlyGlanceBus.publish(); } @@ -153,22 +172,49 @@ export default class YearlyGlancePlugin extends Plugin { newConfig: Partial ) { const oldYear = this.settings.config.year; + const oldShowHolidays = this.settings.config.showHolidays; this.settings.config = { ...this.settings.config, ...newConfig, }; - // 检查年份是否变化,如果变化则更新所有事件的dateArr - if (newConfig.year && newConfig.year !== oldYear) { + // 检查是否需要重新加载节假日 + const yearChanged = newConfig.year && newConfig.year !== oldYear; + const showHolidaysChanged = newConfig.showHolidays !== undefined && newConfig.showHolidays !== oldShowHolidays; + + if (yearChanged || showHolidaysChanged) { await this.updateAllEventsDateObj(); } await this.saveSettings(); } + // 导入节假日补充数据(用于修正法定节假日) + public async importHolidayFixData(fixData: string) { + if (!fixData) { + return; + } + + // 调用 lunar-typescript 库的 fix 方法修正节假日 + HolidayUtil.fix(fixData); + // 保存到配置中(持久化) + // lunar 组件没有开放对比方法,所以,无法和组件内部配置对比去重 + this.settings.config.holidayFixData = fixData; + // 重新生成节假日(使用修正后的数据) + await this.updateAllEventsDateObj(); + await this.saveSettings(); + new Notice(t("setting.general.holidayFixData.success")); + } + + // 返回运行时数据(包含 systemHolidays),供 UI 组件使用 public getData(): YearlyGlanceConfig["data"] { - return this.settings.data; + return { + holidays: [...this.settings.data.holidays], + systemHolidays: [...this.settings.data.systemHolidays], + birthdays: [...this.settings.data.birthdays], + customEvents: [...this.settings.data.customEvents], + }; } public async updateData(newData: Partial) { @@ -180,6 +226,10 @@ export default class YearlyGlancePlugin extends Plugin { // 确保所有事件都有id await this.ensureEventsHaveIds(); + // 重新生成系统节假日 + await this.updateAllEventsDateObj(); + + // 持久化数据到 data.json await this.saveSettings(); } @@ -294,35 +344,32 @@ export default class YearlyGlancePlugin extends Plugin { customEvent.id = generateEventId("customEvent"); } }); - - await this.saveData(this.settings); } /** - * 更新所有事件的dateArr字段 + * 更新所有事件的 dateArr 字段 + * 触发时机:插件加载、切换年份、开关节假日显示、导入节假日数据、增删改事件 */ public async updateAllEventsDateObj() { const year = this.settings.config.year; + const { showHolidays } = this.settings.config; const events = this.settings.data; - // 更新节日和自定义事件的dateArr - events.holidays = EventCalculator.updateHolidaysInfo( - events.holidays, - year - ); - events.customEvents = EventCalculator.updateCustomEventsInfo( - events.customEvents, - year - ); + // 更新用户节假日 dateArr + events.holidays = updateUserHolidays(events.holidays, year); - // 更新生日的完整信息(包含dateArr、nextBirthday、age、animal、zodiac等) - events.birthdays = EventCalculator.updateBirthdaysInfo( - events.birthdays, - year - ); + // 更新自定义事件和生日的 dateArr + events.customEvents = EventCalculator.updateCustomEventsInfo(events.customEvents, year); + events.birthdays = EventCalculator.updateBirthdaysInfo(events.birthdays, year); - // 不触发保存的通知,因为这是内部计算,不需要通知用户 - await this.saveData(this.settings); + // 生成系统节假日 + if (showHolidays) { + events.systemHolidays = generateSystemHolidays(year); + } else { + events.systemHolidays = []; + } + + YearlyGlanceBus.publish(); } /** diff --git a/src/service/JsonService.ts b/src/service/JsonService.ts index 7a6f4792..81d4d954 100644 --- a/src/service/JsonService.ts +++ b/src/service/JsonService.ts @@ -45,6 +45,9 @@ export class JsonService { holidays: eventsData.holidays?.map((h) => this.converterToJsonEvent(h) ), + systemHolidays: eventsData.systemHolidays?.map((h) => + this.converterToJsonEvent(h) + ), birthdays: eventsData.birthdays?.map((b) => this.converterToJsonEvent(b) ), @@ -134,6 +137,7 @@ export class JsonService { } if (importData.holidays) { + // 导入JSON中的节假日(都是用户节假日,系统节假日通过代码生成) this.parseEventsProcess( importData.holidays, "holiday", diff --git a/src/service/MarkdownService.ts b/src/service/MarkdownService.ts index 70706a7d..2aecb33e 100644 --- a/src/service/MarkdownService.ts +++ b/src/service/MarkdownService.ts @@ -40,10 +40,11 @@ export class MarkdownService { errors: [] as string[], }; - // 导出节假日 - if (eventsData.holidays.length > 0) { + // 导出节假日(用户 + 系统) + const allHolidays = [...eventsData.holidays, ...eventsData.systemHolidays]; + if (allHolidays.length > 0) { const holidayResult = await this.exportEventType( - eventsData.holidays, + allHolidays, "holiday", config.holidayFolder, config.holidayFields diff --git a/src/service/iCalendarService.ts b/src/service/iCalendarService.ts index 101a0010..91e99600 100644 --- a/src/service/iCalendarService.ts +++ b/src/service/iCalendarService.ts @@ -17,6 +17,10 @@ export class iCalendarService { type: "holiday", event: h, })), + ...eventsData.systemHolidays.map((h) => ({ + type: "holiday", + event: h, + })), ...eventsData.birthdays.map((b) => ({ type: "birthday", event: b, diff --git a/src/type/DataPort.ts b/src/type/DataPort.ts index 68696986..3b7d5904 100644 --- a/src/type/DataPort.ts +++ b/src/type/DataPort.ts @@ -20,7 +20,10 @@ export interface JsonEvent { } export interface ImportJsonEvents { + /** 用户节假日 */ holidays?: JsonEvent[]; + /** 系统节假日(仅供查看,导入时忽略) */ + systemHolidays?: JsonEvent[]; birthdays?: JsonEvent[]; customEvents?: JsonEvent[]; } diff --git a/src/type/Events.ts b/src/type/Events.ts index 178f6acc..2593e0ff 100644 --- a/src/type/Events.ts +++ b/src/type/Events.ts @@ -1,7 +1,10 @@ import { EventDate } from "./Date"; export interface Events { + /** 节假日(持久化) */ holidays: Holiday[]; + /** 系统节假日(内存生成,不持久化) */ + systemHolidays: Holiday[]; birthdays: Birthday[]; customEvents: CustomEvent[]; } @@ -22,13 +25,27 @@ export interface BaseEvent { isHidden?: boolean; } +/** + * 节假日类型 + * - public: 法定节假日(放假) + * - public-work: 法定节假日(调休上班) + * - solar-term: 节气 + * - festival: 节日(公历节日、农历节日、纪念日等) + */ +export type HolidayDisplayType = + | "public" + | "public-work" + | "solar-term" + | "festival"; + /** * 节日接口 - * type: 节日类型, 内置节日或自定义添加的节日 * foundDate?: 节日起源日期, 年月日,年月,年,一般用于计算周年 + * holidayType?: 节假日显示类型,用于区分颜色和图标 */ export interface Holiday extends BaseEvent { foundDate?: string; + holidayType?: HolidayDisplayType; } /** @@ -70,7 +87,8 @@ export const EVENT_TYPE_DEFAULT: Record< }; export const DEFAULT_EVENTS: Events = { - holidays: [], // 内置节日将通过验证和合并机制添加 + holidays: [], + systemHolidays: [], birthdays: [], customEvents: [], }; diff --git a/src/type/Settings.ts b/src/type/Settings.ts index 4d69568a..e115b779 100644 --- a/src/type/Settings.ts +++ b/src/type/Settings.ts @@ -114,6 +114,8 @@ export interface YearlyGlanceSettings { emojiOnTop: boolean; // 是否在事件上方显示emoji(仅日历视图) wrapEventText: boolean; // 是否换行显示事件文本 gregorianDisplayFormat: (typeof GREGORIAN_DISPLAY_FORMAT_OPTIONS)[number]["value"]; // 公历显示格式 + // 节假日补充数据(用户手动输入的节假日数据,用于补充库中缺失的节假日) + holidayFixData?: string; } export const DEFAULT_SETTINGS: YearlyGlanceSettings = { @@ -141,4 +143,5 @@ export const DEFAULT_SETTINGS: YearlyGlanceSettings = { emojiOnTop: false, // 默认在左侧显示emoji wrapEventText: false, gregorianDisplayFormat: "YYYY-MM-DD", // 默认使用ISO格式 + holidayFixData: "", // 节假日补充数据 }; diff --git a/src/utils/eventCalculator.ts b/src/utils/eventCalculator.ts index a4ba353e..4b330200 100644 --- a/src/utils/eventCalculator.ts +++ b/src/utils/eventCalculator.ts @@ -3,7 +3,6 @@ import { Birthday, CustomEvent, EventType, Holiday } from "@/src/type/Events"; import { getBirthdayTranslation } from "@/src/i18n/birthday"; import { IsoUtils } from "./isoUtils"; import { LunarLibrary } from "./lunarLibrary"; -import { SpecialHoliday } from "./specialHoliday"; import { CalendarType } from "@/src/type/Date"; export class EventCalculator { @@ -15,7 +14,7 @@ export class EventCalculator { * @param yearSelected 当前选择的年份 * @param isRepeat 是否为重复事件,针对customEvent * @returns 日期数组 - * + * * 优化逻辑: * 1. 对于不重复的自定义事件且有年份:不随yearSelected变动,直接计算出公历日期 * 2. 对于生日:当yearSelected小于出生日期公历的年份时不计算 @@ -111,40 +110,20 @@ export class EventCalculator { /** * 更新单个节假日信息 + * + * 节假日直接使用原始 isoDate 作为 dateArr,因为: + * - 系统节假日由 HolidayService 根据年份动态生成,已经是正确日期 + * - 用户节假日是用户输入的特定日期,直接使用原始 isoDate + * * @param holiday 节假日对象 - * @param yearSelected 当前选择的年份 - * @returns 更新后的节假日对象 + * @param yearSelected 当前选择的年份(未使用,保持接口一致) + * @returns 更新后的节假日对象(添加 dateArr 字段) */ static updateHolidayInfo(holiday: Holiday, yearSelected: number) { - const { id } = holiday; - let isoDate: string = holiday.eventDate.isoDate; - const calendar = holiday.eventDate.calendar; - - // TODO: 完善节气节日的处理 - if (id === "holi-wblqm") { - // 清明节 - const qingMing = SpecialHoliday.solarTerm(yearSelected, "清明"); - isoDate = qingMing; - } else if (id === "holi-wbldz") { - // 冬至 - const dongZhi = SpecialHoliday.solarTerm(yearSelected, "冬至"); - isoDate = dongZhi; - } - - const dateArr = this.calculateDateArr( - "holiday", - isoDate, - calendar, - yearSelected - ); - + const isoDate = holiday.eventDate.isoDate; return { ...holiday, - dateArr, - eventDate: { - ...holiday.eventDate, - isoDate, - }, + dateArr: [isoDate], }; } diff --git a/src/utils/holidayUtils.ts b/src/utils/holidayUtils.ts new file mode 100644 index 00000000..6ccb1d20 --- /dev/null +++ b/src/utils/holidayUtils.ts @@ -0,0 +1,32 @@ +import { Holiday } from "../type/Events"; +import { HolidayService } from "../i18n/holidays"; +import { EventCalculator } from "../utils/eventCalculator"; + +/** + * 生成系统节假日(法定节假日、二十四节气、传统节日) + * + * 流程: + * 1. HolidayService.getFullYearHolidays(year) - 根据年份从 lunar-typescript 库获取系统节假日的原始数据 + * 2. EventCalculator.updateHolidaysInfo() - 计算每个节假日的 dateArr(公历日期数组)等信息 + * + * @param year 年份 + * @returns 包含完整信息(dateArr 等)的系统节假日数组 + */ +export function generateSystemHolidays(year: number): Holiday[] { + const systemHolidays = HolidayService.getFullYearHolidays(year); + return EventCalculator.updateHolidaysInfo(systemHolidays, year); +} + +/** + * 更新用户节假日的 dateArr 字段 + * + * 用户节假日存储时只有原始日期(如 2026-01-01),需要根据年份计算公历日期数组 + * 这个函数与 generateSystemHolidays 流程相同,只是数据来源不同 + * + * @param holidays 用户节假日数组 + * @param year 年份 + * @returns 更新后的用户节假日数组(包含 dateArr) + */ +export function updateUserHolidays(holidays: Holiday[], year: number): Holiday[] { + return EventCalculator.updateHolidaysInfo(holidays, year); +} diff --git a/src/utils/specialHoliday.ts b/src/utils/specialHoliday.ts deleted file mode 100644 index 1a58697c..00000000 --- a/src/utils/specialHoliday.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Lunar } from "lunar-typescript"; - -export class SpecialHoliday { - /** - * 获取指定年份的节气日期 - * @param year 年份 - * @param term 节气名称 - * DA_XUE, 冬至, 小寒, 大寒, - * 立春, 雨水, 惊蛰, 春分, 清明, 谷雨, - * 立夏, 小满, 芒种, 夏至, 小暑, 大暑, - * 立秋, 处暑, 白露, 秋分, 寒露, 霜降, - * 立冬, 小雪, 大雪, DONG_ZHI, XIAO_HAN, DA_HAN, - * LI_CHUN, YU_SHUI, JING_ZHE - * @returns 节气日期 ISO格式 - */ - static solarTerm(year: number, term: string) { - const date = new Date(year, 0, 1); // 以目标年份1月1日为基础日期 - const lunar = Lunar.fromDate(date); - const jieQiTable = lunar.getJieQiTable(); - - return jieQiTable[term].toYmd(); - } -} diff --git a/test/holidayService.test.ts b/test/holidayService.test.ts new file mode 100644 index 00000000..5b687ecf --- /dev/null +++ b/test/holidayService.test.ts @@ -0,0 +1,304 @@ +import { HolidayUtil, Lunar, Solar, SolarUtil } from "lunar-typescript"; +import { Holiday } from "../src/type/Events"; +import { HOLIDAY_COLOR, HOLIDAY_EMOJI } from "../src/i18n/holidayConfig"; + +/** + * 节假日服务类(测试版) + */ +class HolidayServiceTest { + private static getPublicHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + const holidays = HolidayUtil.getHolidays(year); + + console.log(`[getPublicHolidays] ${year}年:`, holidays.length, "条"); + + for (const h of holidays) { + const day = h.getDay(); + const name = h.getName(); + const target = h.getTarget(); + const work = h.isWork(); + + const isFirstDay = day === target; + + let text: string; + let displayType: "public" | "public-work"; + + if (isFirstDay) { + text = name; + displayType = "public"; + } else if (work) { + text = "班"; + displayType = "public-work"; + } else { + text = "休"; + displayType = "public"; + } + + results.push({ + id: `holi-public-${day.replace(/-/g, "")}`, + text, + emoji: HOLIDAY_EMOJI[displayType], + color: HOLIDAY_COLOR[displayType], + holidayType: displayType, + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + foundDate: target, + }); + } + + return results; + } + + private static getSolarTerms(year: number): Holiday[] { + const results: Holiday[] = []; + + const lunar = Lunar.fromYmd(year, 6, 15); + const jieQiTable = lunar.getJieQiTable(); + + for (const [name, solar] of Object.entries(jieQiTable)) { + if (/^[\u4e00-\u9fa5]+$/.test(name)) { + const day = solar.toYmd(); + + results.push({ + id: `holi-solar-term-${day.replace(/-/g, "")}`, + text: name, + emoji: HOLIDAY_EMOJI["solar-term"], + color: HOLIDAY_COLOR["solar-term"], + holidayType: "solar-term", + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + }); + } + } + + return results; + } + + private static getAllFestivals(year: number): Holiday[] { + const results: Holiday[] = []; + + for (let month = 1; month <= 12; month++) { + const daysInMonth = SolarUtil.getDaysOfMonth(year, month); + + for (let day = 1; day <= daysInMonth; day++) { + const solar = Solar.fromYmd(year, month, day); + const lunar = solar.getLunar(); + const solarDay = solar.toYmd(); + + for (const name of solar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of solar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + } + } + + return results; + } + + private static createFestivalHoliday(solarDay: string, name: string): Holiday { + return { + id: `holi-festival-${solarDay.replace(/-/g, "")}-${name}`, + text: name, + emoji: HOLIDAY_EMOJI["festival"], + color: HOLIDAY_COLOR["festival"], + holidayType: "festival", + eventDate: { + isoDate: solarDay, + calendar: "GREGORIAN", + userInput: { + input: solarDay, + calendar: "GREGORIAN", + }, + }, + }; + } + + private static deduplicate(holidays: Holiday[]): Holiday[] { + const seen = new Map(); + + for (const h of holidays) { + const key = `${h.eventDate.isoDate}-${h.text.substring(0, 2)}`; + + if (!seen.has(key)) { + seen.set(key, h); + } else if (h.holidayType === "public" || h.holidayType === "public-work") { + seen.set(key, h); + } + } + + return Array.from(seen.values()).sort((a, b) => { + return a.eventDate.isoDate.localeCompare(b.eventDate.isoDate); + }); + } + + static getFullYearHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + + const publicHolidays = this.getPublicHolidays(year); + results.push(...publicHolidays); + + const solarTerms = this.getSolarTerms(year); + results.push(...solarTerms); + + const festivals = this.getAllFestivals(year); + results.push(...festivals); + + const final = this.deduplicate(results); + + console.log(`[HolidayService] ${year}年:`); + console.log(` 法定节假日: ${publicHolidays.length}条`); + console.log(` 节气: ${solarTerms.length}条`); + console.log(` 节日: ${festivals.length}条`); + console.log(` 去重后: ${final.length}条`); + + return final; + } + + /** + * 解析用户输入的节假日数据 + */ + static parseFixData(fixData: string, year: number): Holiday[] { + const results: Holiday[] = []; + const SIZE = 18; + const NAMES = [ + "元旦节", "春节", "清明节", "劳动节", + "端午节", "中秋节", "国庆节", "国庆中秋", "抗战胜利日" + ]; + + console.log(`[parseFixData] 解析数据,长度: ${fixData.length},年份: ${year}`); + + for (let i = 0; i + SIZE <= fixData.length; i += SIZE) { + const segment = fixData.substring(i, i + SIZE); + const dateStr = segment.substring(0, 8); + const nameIndex = parseInt(segment.charAt(8), 10); + const isWork = segment.charAt(9) === "0"; + const targetDate = segment.substring(10, 18); + + const holidayYear = parseInt(dateStr.substring(0, 4), 10); + if (holidayYear !== year) { + continue; + } + + const day = `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`; + const target = `${targetDate.substring(0, 4)}-${targetDate.substring(4, 6)}-${targetDate.substring(6, 8)}`; + const name = NAMES[nameIndex] || "节假日"; + + const isFirstDay = day === target; + let text: string; + let displayType: "public" | "public-work"; + + if (isFirstDay) { + text = name; + displayType = "public"; + } else if (isWork) { + text = "班"; + displayType = "public-work"; + } else { + text = "休"; + displayType = "public"; + } + + console.log(` 解析: ${day} ${name} (${nameIndex}) ${isWork ? "班" : "休"} -> ${displayType}`); + + results.push({ + id: `holi-fix-${day.replace(/-/g, "")}`, + text, + emoji: HOLIDAY_EMOJI[displayType], + color: HOLIDAY_COLOR[displayType], + holidayType: displayType, + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + foundDate: target, + }); + } + + console.log(`[parseFixData] 解析完成,共 ${results.length} 条`); + return results; + } +} + +describe("节假日获取测试", () => { + it("测试1: 获取2025年节假日(库内置数据)", () => { + const holidays = HolidayServiceTest.getFullYearHolidays(2025); + expect(holidays.length).toBeGreaterThan(0); + + // 打印法定节假日 + const publicHolidays = holidays.filter( + h => h.holidayType === "public" || h.holidayType === "public-work" + ); + console.log("\n2025年法定节假日:"); + publicHolidays.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} emoji=${h.emoji} color=${h.color}`); + }); + }); + + it("测试2: 导入2026年数据后获取节假日", () => { + const fixData = + "202601010120260101" + + "202601020120260101" + + "202601030120260101" + + "202601040020260101"; + + console.log("\n=== 导入2026年数据 ==="); + HolidayUtil.fix(fixData); + + const holidays = HolidayServiceTest.getFullYearHolidays(2026); + + // 打印法定节假日 + const publicHolidays = holidays.filter( + h => h.holidayType === "public" || h.holidayType === "public-work" + ); + console.log("\n2026年法定节假日:"); + publicHolidays.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} emoji=${h.emoji} color=${h.color}`); + }); + + expect(publicHolidays.length).toBeGreaterThan(0); + }); + + it("测试3: 使用parseFixData解析用户数据", () => { + const fixData = + "202601010120260101" + + "202601020120260101" + + "202601030120260101" + + "202601040020260101"; + + console.log("\n=== parseFixData 测试 ==="); + const holidays = HolidayServiceTest.parseFixData(fixData, 2026); + + expect(holidays.length).toBe(4); + + holidays.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} emoji=${h.emoji} color=${h.color}`); + }); + }); +}); diff --git a/test/holidayServiceIssue.test.ts b/test/holidayServiceIssue.test.ts new file mode 100644 index 00000000..584ace8a --- /dev/null +++ b/test/holidayServiceIssue.test.ts @@ -0,0 +1,310 @@ +import { HolidayUtil, Lunar, Solar, SolarUtil } from "lunar-typescript"; +import { Holiday } from "../src/type/Events"; +import { HOLIDAY_COLOR, HOLIDAY_EMOJI } from "../src/i18n/holidayConfig"; + +/** + * 节假日服务类 + * 负责从 lunar-typescript 库获取全年节假日信息 + * 包括:法定节假日、节气、公历节日、农历节日 + */ +class HolidayServiceTest { + private static getPublicHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + const holidays = HolidayUtil.getHolidays(year); + + console.log(`[HolidayService] 获取 ${year} 年法定节假日:`, holidays.length, "条"); + + for (const h of holidays) { + const day = h.getDay(); + const name = h.getName(); + const target = h.getTarget(); + const work = h.isWork(); + + const isFirstDay = day === target; + + let text: string; + let displayType: "public" | "public-work"; + + if (isFirstDay) { + text = name; + displayType = "public"; + } else if (work) { + text = "班"; + displayType = "public-work"; + } else { + text = "休"; + displayType = "public"; + } + + results.push({ + id: `holi-public-${day.replace(/-/g, "")}`, + text, + emoji: HOLIDAY_EMOJI[displayType], + color: HOLIDAY_COLOR[displayType], + holidayType: displayType, + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + foundDate: target, + }); + } + + return results; + } + + private static getSolarTerms(year: number): Holiday[] { + const results: Holiday[] = []; + + const lunar = Lunar.fromYmd(year, 6, 15); + const jieQiTable = lunar.getJieQiTable(); + + for (const [name, solar] of Object.entries(jieQiTable)) { + if (/^[\u4e00-\u9fa5]+$/.test(name)) { + const day = solar.toYmd(); + + results.push({ + id: `holi-solar-term-${day.replace(/-/g, "")}`, + text: name, + emoji: HOLIDAY_EMOJI["solar-term"], + color: HOLIDAY_COLOR["solar-term"], + holidayType: "solar-term", + eventDate: { + isoDate: day, + calendar: "GREGORIAN", + userInput: { + input: day, + calendar: "GREGORIAN", + }, + }, + }); + } + } + + return results; + } + + private static getAllFestivals(year: number): Holiday[] { + const results: Holiday[] = []; + + for (let month = 1; month <= 12; month++) { + const daysInMonth = SolarUtil.getDaysOfMonth(year, month); + + for (let day = 1; day <= daysInMonth; day++) { + const solar = Solar.fromYmd(year, month, day); + const lunar = solar.getLunar(); + const solarDay = solar.toYmd(); + + for (const name of solar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of solar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + + for (const name of lunar.getOtherFestivals()) { + results.push(this.createFestivalHoliday(solarDay, name)); + } + } + } + + return results; + } + + private static createFestivalHoliday(solarDay: string, name: string): Holiday { + return { + id: `holi-festival-${solarDay.replace(/-/g, "")}-${name}`, + text: name, + emoji: HOLIDAY_EMOJI["festival"], + color: HOLIDAY_COLOR["festival"], + holidayType: "festival", + eventDate: { + isoDate: solarDay, + calendar: "GREGORIAN", + userInput: { + input: solarDay, + calendar: "GREGORIAN", + }, + }, + }; + } + + private static deduplicate(holidays: Holiday[]): Holiday[] { + const seen = new Map(); + + for (const h of holidays) { + const key = `${h.eventDate.isoDate}-${h.text.substring(0, 2)}`; + + if (!seen.has(key)) { + seen.set(key, h); + } else if (h.holidayType === "public" || h.holidayType === "public-work") { + seen.set(key, h); + } + } + + const stats = { + public: 0, + "public-work": 0, + "solar-term": 0, + festival: 0, + }; + for (const h of seen.values()) { + if (h.holidayType && stats.hasOwnProperty(h.holidayType)) { + stats[h.holidayType]++; + } + } + console.log(`[HolidayService] ${holidays.length} 条数据去重后:`, seen.size, "条", stats); + + return Array.from(seen.values()).sort((a, b) => { + return a.eventDate.isoDate.localeCompare(b.eventDate.isoDate); + }); + } + + static getFullYearHolidays(year: number): Holiday[] { + const results: Holiday[] = []; + + results.push(...this.getPublicHolidays(year)); + results.push(...this.getSolarTerms(year)); + results.push(...this.getAllFestivals(year)); + + return this.deduplicate(results); + } +} + +describe("HolidayService 问题复现测试", () => { + beforeAll(() => { + console.log("===== HolidayService 问题复现测试开始 ====="); + }); + + it("测试1: 导入2026年节假日数据前,获取各年份节假日", () => { + console.log("\n=== 导入前 ==="); + + const holidays2024 = HolidayServiceTest.getFullYearHolidays(2024); + const holidays2025 = HolidayServiceTest.getFullYearHolidays(2025); + const holidays2026 = HolidayServiceTest.getFullYearHolidays(2026); + const holidays2027 = HolidayServiceTest.getFullYearHolidays(2027); + + console.log("2024年节假日总数:", holidays2024.length); + console.log("2025年节假日总数:", holidays2025.length); + console.log("2026年节假日总数:", holidays2026.length); + console.log("2027年节假日总数:", holidays2027.length); + + // 检查是否有法定节假日 + const public2024 = holidays2024.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2025 = holidays2025.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2026 = holidays2026.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2027 = holidays2027.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + + console.log("2024年法定节假日:", public2024.length, "条"); + console.log("2025年法定节假日:", public2025.length, "条"); + console.log("2026年法定节假日:", public2026.length, "条"); + console.log("2027年法定节假日:", public2027.length, "条"); + + // 打印法定节假日详情 + if (public2026.length > 0) { + console.log("2026年法定节假日详情:"); + public2026.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} ${h.holidayType}`); + }); + } + }); + + it("测试2: 导入2026年节假日数据", () => { + const fixData = + "202601010120260101" + + "202601020120260101" + + "202601030120260101" + + "202601040020260101" + + "202602141020260217" + + "202602151120260217" + + "202602161120260217" + + "202602171120260217" + + "202602181120260217" + + "202602191120260217" + + "202602201120260217" + + "202602211120260217" + + "202602221120260217" + + "202602231120260217" + + "202602281020260217" + + "202604042120260405" + + "202604052120260405" + + "202604062120260405" + + "202605013120260501" + + "202605023120260501" + + "202605033120260501" + + "202605043120260501" + + "202605053120260501" + + "202605093020260501" + + "202606194120260619" + + "202606204120260619" + + "202606214120260619" + + "202609206020261001" + + "202609255120260925" + + "202609265120260925" + + "202609275120260925" + + "202610016120261001" + + "202610026120261001" + + "202610036120261001" + + "202610046120261001" + + "202610056120261001" + + "202610066120261001" + + "202610076120261001" + + "202610106020261001"; + + console.log("\n=== 调用 HolidayUtil.fix() ==="); + console.log("数据长度:", fixData.length); + + HolidayUtil.fix(fixData); + + console.log("fix() 调用完成"); + }); + + it("测试3: 导入2026年节假日数据后,获取各年份节假日", () => { + console.log("\n=== 导入后 ==="); + + const holidays2024 = HolidayServiceTest.getFullYearHolidays(2024); + const holidays2025 = HolidayServiceTest.getFullYearHolidays(2025); + const holidays2026 = HolidayServiceTest.getFullYearHolidays(2026); + const holidays2027 = HolidayServiceTest.getFullYearHolidays(2027); + + console.log("2024年节假日总数:", holidays2024.length); + console.log("2025年节假日总数:", holidays2025.length); + console.log("2026年节假日总数:", holidays2026.length); + console.log("2027年节假日总数:", holidays2027.length); + + // 检查是否有法定节假日 + const public2024 = holidays2024.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2025 = holidays2025.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2026 = holidays2026.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + const public2027 = holidays2027.filter(h => h.holidayType === "public" || h.holidayType === "public-work"); + + console.log("2024年法定节假日:", public2024.length, "条"); + console.log("2025年法定节假日:", public2025.length, "条"); + console.log("2026年法定节假日:", public2026.length, "条"); + console.log("2027年法定节假日:", public2027.length, "条"); + + // 打印法定节假日详情 + console.log("\n2026年法定节假日详情:"); + public2026.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} ${h.holidayType}`); + }); + + console.log("\n2027年法定节假日详情:"); + public2027.forEach(h => { + console.log(` ${h.eventDate.isoDate} ${h.text} ${h.holidayType}`); + }); + }); + + afterAll(() => { + console.log("\n===== 测试完成 ====="); + }); +}); diff --git a/test/holidayUtil.test.ts b/test/holidayUtil.test.ts new file mode 100644 index 00000000..426650ab --- /dev/null +++ b/test/holidayUtil.test.ts @@ -0,0 +1,71 @@ +import { HolidayUtil } from "lunar-typescript"; + +describe("HolidayUtil.fix() 测试", () => { + beforeAll(() => { + console.log("===== HolidayUtil.fix() 测试开始 ====="); + }); + + it("测试 1: 获取修复前各年份节假日数量", () => { + const holidays2024 = HolidayUtil.getHolidays(2024); + const holidays2025 = HolidayUtil.getHolidays(2025); + const holidays2026 = HolidayUtil.getHolidays(2026); + + console.log("修复前:"); + console.log(" 2024 年节假日数量:", holidays2024.length); + console.log(" 2025 年节假日数量:", holidays2025.length); + console.log(" 2026 年节假日数量:", holidays2026.length, "(库不支持,应为 0)"); + + expect(holidays2024.length).toBeGreaterThan(0); + expect(holidays2025.length).toBeGreaterThan(0); + expect(holidays2026.length).toBe(0); // 库不支持 2026 年 + }); + + it("测试 2: 调用 fix() 追加 2026 年数据", () => { + const fixData2026 = + "202601010120260101" + + "202601020120260101" + + "202601030120260101" + + "202601040020260101"; + + console.log("\n调用 HolidayUtil.fix() 追加 2026 年数据..."); + console.log("数据:", fixData2026); + console.log("长度:", fixData2026.length, "(应为 72,即 4 条 * 18)"); + + HolidayUtil.fix(fixData2026); + + console.log("fix() 调用完成"); + }); + + it("测试 3: 获取修复后各年份节假日数量", () => { + const holidays2024 = HolidayUtil.getHolidays(2024); + const holidays2025 = HolidayUtil.getHolidays(2025); + const holidays2026 = HolidayUtil.getHolidays(2026); + + console.log("\n修复后:"); + console.log(" 2024 年节假日数量:", holidays2024.length); + console.log(" 2025 年节假日数量:", holidays2025.length); + console.log(" 2026 年节假日数量:", holidays2026.length); + + holidays2026.forEach((h) => { + console.log(` ${h.getDay()} ${h.getName()} ${h.isWork() ? "班" : "休"}`); + }); + + expect(holidays2026.length).toBeGreaterThan(0); + }); + + it("测试 4: 验证 fix() 是否影响其他年份", () => { + const holidays2024 = HolidayUtil.getHolidays(2024); + const holidays2025 = HolidayUtil.getHolidays(2025); + + console.log("\n验证是否影响其他年份:"); + console.log(" 2024 年节假日数量:", holidays2024.length, "(应该不变)"); + console.log(" 2025 年节假日数量:", holidays2025.length, "(应该不变)"); + + expect(holidays2024.length).toBeGreaterThan(0); + expect(holidays2025.length).toBeGreaterThan(0); + }); + + afterAll(() => { + console.log("\n===== 测试完成 ====="); + }); +});