├── scripts ├── .gitignore ├── elevate.ps1 ├── make_install.js ├── make_dev_link.js ├── make_dev_copy.js ├── update_version.js └── utils.js ├── icon.png ├── donate.webp ├── preview.png ├── src ├── libs │ ├── components │ │ ├── b3-typography.svelte │ │ ├── Form │ │ │ ├── index.ts │ │ │ ├── form-wrap.svelte │ │ │ └── form-input.svelte │ │ └── setting-panel.svelte │ ├── index.d.ts │ ├── promise-pool.ts │ ├── const.ts │ ├── dialog.ts │ └── setting-utils.ts ├── components │ ├── LoadingDialog.svelte │ ├── 0README.md │ ├── ProjectColorDialog.ts │ ├── HabitGroupManageDialog.ts │ └── ProjectDialog.ts ├── types │ ├── reminder.ts │ ├── api.d.ts │ └── index.d.ts ├── utils │ ├── i18n.ts │ ├── dateUtils.ts │ ├── sortConfig.ts │ ├── pomodoroManager.ts │ ├── calendarConfigManager.ts │ ├── habitGroupManager.ts │ ├── statusManager.ts │ ├── categoryManager.ts │ ├── lunarUtils.ts │ ├── projectManager.ts │ └── repeatUtils.ts ├── styles │ ├── reminder-dialog.css │ ├── habit-calendar.scss │ ├── doc-reminder.scss │ └── project-reminder.scss └── hello.svelte ├── assets └── donate.png ├── audios ├── notify.mp3 ├── work_end.mp3 ├── end_music.mp3 ├── random_end.mp3 ├── random_start.mp3 ├── background_music.mp3 └── relax_background.mp3 ├── .gitignore ├── .claude └── settings.local.json ├── tsconfig.node.json ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md ├── workflows │ └── release.yml └── copilot-instructions.md ├── release.sh ├── svelte.config.js ├── i18n └── README.md ├── plugin.json ├── tsconfig.json ├── package.json ├── yaml-plugin.js └── vite.config.ts /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | build 3 | dist 4 | *.exe 5 | *.spec 6 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/icon.png -------------------------------------------------------------------------------- /donate.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/donate.webp -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/preview.png -------------------------------------------------------------------------------- /src/libs/components/b3-typography.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /assets/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/assets/donate.png -------------------------------------------------------------------------------- /audios/notify.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/notify.mp3 -------------------------------------------------------------------------------- /audios/work_end.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/work_end.mp3 -------------------------------------------------------------------------------- /audios/end_music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/end_music.mp3 -------------------------------------------------------------------------------- /audios/random_end.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/random_end.mp3 -------------------------------------------------------------------------------- /audios/random_start.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/random_start.mp3 -------------------------------------------------------------------------------- /audios/background_music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/background_music.mp3 -------------------------------------------------------------------------------- /audios/relax_background.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Achuan-2/siyuan-plugin-task-note-management/HEAD/audios/relax_background.mp3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | pnpm-lock.yaml 5 | package-lock.json 6 | package.zip 7 | node_modules 8 | dev 9 | dist 10 | build 11 | tmp 12 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(grep:*)", 5 | "Bash(rg:*)" 6 | ], 7 | "deny": [], 8 | "ask": [] 9 | } 10 | } -------------------------------------------------------------------------------- /src/libs/components/Form/index.ts: -------------------------------------------------------------------------------- 1 | import FormInput from './form-input.svelte'; 2 | import FormWrap from './form-wrap.svelte'; 3 | 4 | const Form = { Wrap: FormWrap, Input: FormInput }; 5 | export default Form; 6 | export { FormInput, FormWrap }; 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 开源只是分享,不等于我要浪费我的时间免费帮用户实现ta需要的功能, 11 | 12 | 我需要的功能我会慢慢改进(打赏可以催更),有些我觉得可以改进、但是现阶段不必要的功能需要打赏才改进(会标注打赏标签和需要打赏金额),而不需要的功能、实现很麻烦的功能会直接关闭issue不考虑实现 13 | 14 | 15 | 已赞赏用户请备注赞赏截图,否则视为未赞赏用户,会直接关闭特性提议issue 16 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | # Change directory to the script's directory 2 | cd "$(dirname $0)" 3 | 4 | # Get version from theme.json 5 | version=v$(grep -oP '(?<="version": ")[^"]+' plugin.json) 6 | 7 | # Commit changes 8 | git add . 9 | git commit -m "🔖 $version" 10 | git push 11 | 12 | # 判断 tag 是否存在 13 | if git rev-parse --quiet --verify $version >/dev/null; then 14 | # 删除本地仓库中的 tag 15 | git tag -d $version 16 | # 删除远程仓库中的 tag 17 | git push origin ":refs/tags/$version" 18 | 19 | fi 20 | 21 | # 创建新的 tag 22 | git tag $version # Create a tag 23 | 24 | # 推送新的 tag 到远程仓库 25 | git push origin --tags 26 | # git archive --format zip --output ../package-$version.zip HEAD # Create a zip archive -------------------------------------------------------------------------------- /src/components/LoadingDialog.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 |
9 |
{message}
10 |
11 | 12 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-05-19 19:49:13 5 | * @FilePath : /svelte.config.js 6 | * @LastEditTime : 2024-04-19 19:01:55 7 | * @Description : 8 | */ 9 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" 10 | 11 | const NoWarns = new Set([ 12 | "a11y-click-events-have-key-events", 13 | "a11y-no-static-element-interactions", 14 | "a11y-no-noninteractive-element-interactions" 15 | ]); 16 | 17 | export default { 18 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 19 | // for more information about preprocessors 20 | preprocess: vitePreprocess(), 21 | onwarn: (warning, handler) => { 22 | // suppress warnings on `vite dev` and `vite build`; but even without this, things still work 23 | if (NoWarns.has(warning.code)) return; 24 | handler(warning); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/elevate.ps1: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 by frostime. All Rights Reserved. 2 | # @Author : frostime 3 | # @Date : 2024-09-06 19:15:53 4 | # @FilePath : /scripts/elevate.ps1 5 | # @LastEditTime : 2024-09-06 19:39:13 6 | # @Description : Force to elevate the script to admin privilege. 7 | 8 | param ( 9 | [string]$scriptPath 10 | ) 11 | 12 | $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition 13 | $projectDir = Split-Path -Parent $scriptDir 14 | 15 | if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 16 | $args = "-NoProfile -ExecutionPolicy Bypass -File `"" + $MyInvocation.MyCommand.Path + "`" -scriptPath `"" + $scriptPath + "`"" 17 | Start-Process powershell.exe -Verb RunAs -ArgumentList $args -WorkingDirectory $projectDir 18 | exit 19 | } 20 | 21 | Set-Location -Path $projectDir 22 | & node $scriptPath 23 | 24 | pause 25 | -------------------------------------------------------------------------------- /src/types/reminder.ts: -------------------------------------------------------------------------------- 1 | export interface ReminderTime { 2 | time: string; 3 | note?: string; 4 | } 5 | 6 | export interface ReminderItem { 7 | id: string; // 块 ID 8 | title: string; // 笔记标题 9 | date: string; // 提醒日期 YYYY-MM-DD 10 | time?: string; // 提醒时间 HH:MM,可选 11 | reminderTimes?: (string | ReminderTime)[]; // 多个提醒时间 HH:MM 或 {time, note} 12 | notifiedTimes?: { [time: string]: boolean }; // 记录每个时间的提醒状态 13 | note?: string; // 新增备注字段 14 | completed: boolean; // 是否已完成 15 | createdAt: string; // 创建时间 16 | notified?: boolean; 17 | termType?: 'long_term' | 'short_term' | 'doing'; // 任务类型:长期、短期或进行中 18 | customReminderTime?: string; // 自定义提醒时间 HH:MM,可选 19 | url?: string; // 网页链接,可选 20 | } 21 | 22 | export interface ReminderData { 23 | [blockId: string]: ReminderItem; 24 | } 25 | 26 | export type ViewMode = 'today' | 'overdue' | 'upcoming' | 'all'; 27 | 28 | export interface BatchReminderOptions { 29 | date: string; 30 | time?: string; 31 | blockIds: string[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/0README.md: -------------------------------------------------------------------------------- 1 | # 组件说明 2 | 3 | ## 核心视图组件 4 | - **CalendarView.ts**:日历视图组件,提供月历、周历等多种日历显示模式,支持任务的可视化管理 5 | - **ProjectPanel.ts**:项目面板组件,提供项目列表展示、筛选、排序和管理功能 6 | - **ProjectKanbanView.ts**:项目看板视图组件,以看板形式展示项目中的任务状态(待办、进行中、已完成) 7 | - **ReminderPanel.ts**:提醒面板组件,展示和管理各种提醒任务,支持多种过滤和排序方式 8 | - **EisenhowerMatrixView.ts**:艾森豪威尔四象限矩阵视图,按重要性和紧急性对任务进行分类管理 9 | 10 | ## 对话框组件 11 | - **AddToProjectDialog.ts**:添加任务到项目的对话框,用于将现有任务关联到指定项目 12 | - **BatchReminderDialog.ts**:批量提醒设置对话框,支持批量创建和编辑多个提醒任务 13 | - **CategoryManageDialog.ts**:分类管理对话框,用于创建、编辑和删除任务分类 14 | - **DocumentReminderDialog.ts**:文档提醒对话框,为特定文档设置提醒功能 15 | - **NotificationDialog.ts**:通知对话框组件,显示系统提醒通知,支持单个和批量通知 16 | - **ProjectColorDialog.ts**:项目颜色设置对话框,为不同项目配置标识颜色 17 | - **ProjectDialog.ts**:项目创建/编辑对话框,用于项目的基本信息设置 18 | - **QuickReminderDialog.ts**:快速提醒创建对话框,支持自然语言输入和快速任务创建 19 | - **ReminderDialog.ts**:提醒任务创建/编辑对话框,提供完整的任务设置功能 20 | - **ReminderEditDialog.ts**:提醒任务编辑对话框,专门用于修改现有提醒任务 21 | - **RepeatSettingsDialog.ts**:重复设置对话框,配置任务的重复规则(日、周、月、年等) 22 | - **StatusManageDialog.ts**:状态管理对话框,管理任务的自定义状态类型 23 | - **TaskSummaryDialog.ts**:任务摘要对话框,生成和显示任务统计摘要信息 24 | 25 | ## 专用功能组件 26 | - **LoadingDialog.svelte**:加载中对话框组件(Svelte),显示操作进度和加载状态 27 | - **PomodoroStatsView.ts**:番茄工作法统计视图,展示专注时间统计、趋势分析和工作记录 28 | - **PomodoroTimer.ts**:番茄工作法计时器,提供专注时间管理和工作/休息提醒功能 -------------------------------------------------------------------------------- /i18n/README.md: -------------------------------------------------------------------------------- 1 | 思源支持的 i18n 文件范围,可以在控制台 `siyuan.config.langs` 中查看。以下是目前(2024-10-24)支持的语言方案: 2 | 3 | The range of i18n files supported by SiYuan can be viewed in the console under `siyuan.config.langs`. Below are the language schemes currently supported as of now (October 24, 2024) : 4 | 5 | ```js 6 | >>> siyuan.config.langs.map( lang => lang.name) 7 | ['de_DE', 'en_US', 'es_ES', 'fr_FR', 'he_IL', 'it_IT', 'ja_JP', 'pl_PL', 'ru_RU', 'zh_CHT', 'zh_CN'] 8 | ``` 9 | 10 | 在插件开发中,默认使用 JSON 格式作为国际化(i18n)的载体文件。如果您更喜欢使用 YAML 语法,可以将 JSON 文件替换为 YAML 文件(例如 `en_US.yaml`),并在其中编写 i18n 文本。本模板提供了相关的 Vite 插件,可以在编译时自动将 YAML 文件转换为 JSON 文件(请参见 `/yaml-plugin.js`)。本 MD 文件 和 YAML 文件会在 `npm run build` 时自动从 `dist` 目录下删除,仅保留必要的 JSON 文件共插件系统使用。 11 | 12 | In plugin development, JSON format is used by default as the carrier file for internationalization (i18n). If you prefer to use YAML syntax, you can replace the JSON file with a YAML file (e.g., `en_US.yaml`) and write the i18n text within it. This template provides a related Vite plugin that can automatically convert YAML files to JSON files during the compilation process (see `/yaml-plugin.js`). This markdown file and YAML files will be automatically removed from the `dist` directory during `npm run build`, leaving only the necessary JSON files for plugin system to use. 13 | -------------------------------------------------------------------------------- /src/libs/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-04-19 18:30:12 5 | * @FilePath : /src/libs/index.d.ts 6 | * @LastEditTime : 2024-04-30 16:39:54 7 | * @Description : 8 | */ 9 | type TSettingItemType = "checkbox" | "select" | "textinput" | "textarea" | "number" | "slider" | "button" | "hint" | "custom"; 10 | 11 | interface ISettingItemCore { 12 | type: TSettingItemType; 13 | key: string; 14 | value: any; 15 | placeholder?: string; 16 | slider?: { 17 | min: number; 18 | max: number; 19 | step: number; 20 | }; 21 | options?: { [key: string | number]: string }; 22 | button?: { 23 | label: string; 24 | callback: () => void; 25 | } 26 | } 27 | 28 | interface ISettingItem extends ISettingItemCore { 29 | title: string; 30 | description: string; 31 | direction?: "row" | "column"; 32 | } 33 | 34 | 35 | //Interface for setting-utils 36 | interface ISettingUtilsItem extends ISettingItem { 37 | action?: { 38 | callback: () => void; 39 | } 40 | createElement?: (currentVal: any) => HTMLElement; 41 | getEleVal?: (ele: HTMLElement) => any; 42 | setEleVal?: (ele: HTMLElement, val: any) => void; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | let pluginInstance: any = null; 2 | 3 | // 设置插件实例的引用 4 | export function setPluginInstance(plugin: any) { 5 | pluginInstance = plugin; 6 | } 7 | 8 | export function t(key: string, params?: { [key: string]: string }): string { 9 | // 首先尝试从插件实例获取i18n数据 10 | let i18nData = null; 11 | 12 | if (pluginInstance && pluginInstance.i18n) { 13 | i18nData = pluginInstance.i18n; 14 | } 15 | 16 | // 如果插件实例不可用,尝试从全局获取 17 | if (!i18nData) { 18 | try { 19 | const { i18n } = require("siyuan"); 20 | i18nData = i18n; 21 | } catch (error) { 22 | console.warn('无法获取i18n对象:', error); 23 | } 24 | } 25 | 26 | // 如果仍然没有i18n数据,使用key作为后备 27 | if (!i18nData || typeof i18nData !== 'object') { 28 | console.warn('i18n数据不可用,使用key作为后备:', key); 29 | return key; 30 | } 31 | 32 | let text = i18nData[key]; 33 | 34 | // 如果没有找到对应的翻译文本,使用key作为后备 35 | if (typeof text !== 'string') { 36 | console.warn('未找到i18n键:', key); 37 | text = key; 38 | } 39 | 40 | // 处理参数替换 41 | if (params && typeof text === 'string') { 42 | Object.keys(params).forEach(param => { 43 | text = text.replace(new RegExp(`\\$\\{${param}\\}`, 'g'), params[param]); 44 | }); 45 | } 46 | 47 | return text; 48 | } 49 | -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | interface IResGetNotebookConf { 2 | box: string; 3 | conf: NotebookConf; 4 | name: string; 5 | } 6 | 7 | interface IReslsNotebooks { 8 | notebooks: Notebook[]; 9 | } 10 | 11 | interface IResUpload { 12 | errFiles: string[]; 13 | succMap: { [key: string]: string }; 14 | } 15 | 16 | interface IResdoOperations { 17 | doOperations: doOperation[]; 18 | undoOperations: doOperation[] | null; 19 | } 20 | 21 | interface IResGetBlockKramdown { 22 | id: BlockId; 23 | kramdown: string; 24 | } 25 | 26 | interface IResGetChildBlock { 27 | id: BlockId; 28 | type: BlockType; 29 | subtype?: BlockSubType; 30 | } 31 | 32 | interface IResGetTemplates { 33 | content: string; 34 | path: string; 35 | } 36 | 37 | interface IResReadDir { 38 | isDir: boolean; 39 | isSymlink: boolean; 40 | name: string; 41 | } 42 | 43 | interface IResExportMdContent { 44 | hPath: string; 45 | content: string; 46 | } 47 | 48 | interface IResBootProgress { 49 | progress: number; 50 | details: string; 51 | } 52 | 53 | interface IResForwardProxy { 54 | body: string; 55 | contentType: string; 56 | elapsed: number; 57 | headers: { [key: string]: string }; 58 | status: number; 59 | url: string; 60 | } 61 | 62 | interface IResExportResources { 63 | path: string; 64 | } 65 | 66 | -------------------------------------------------------------------------------- /src/styles/reminder-dialog.css: -------------------------------------------------------------------------------- 1 | /* ...existing code... */ 2 | 3 | .reminder-date-container { 4 | display: flex; 5 | align-items: center; 6 | gap: 8px; 7 | width: 100%; 8 | } 9 | 10 | .reminder-date-container input[type="date"] { 11 | flex: 1; 12 | min-width: 0; 13 | } 14 | 15 | .reminder-date-container input[type="date"]:first-child { 16 | flex: 1; 17 | } 18 | 19 | .reminder-date-container input[type="date"]:last-child { 20 | flex: 1; 21 | opacity: 0.8; 22 | } 23 | 24 | .reminder-date-container input[type="date"]:last-child:focus { 25 | opacity: 1; 26 | } 27 | 28 | .reminder-arrow { 29 | color: var(--b3-theme-on-surface-light); 30 | font-size: 14px; 31 | font-weight: 500; 32 | flex-shrink: 0; 33 | user-select: none; 34 | } 35 | 36 | .reminder-date-container input[type="date"]:last-child::placeholder { 37 | color: var(--b3-theme-on-surface-light); 38 | font-size: 12px; 39 | } 40 | 41 | /* 空值时的样式 */ 42 | .reminder-date-container input[type="date"]:last-child:placeholder-shown { 43 | opacity: 0.6; 44 | border-style: dashed; 45 | } 46 | 47 | /* 响应式布局 */ 48 | @media (max-width: 480px) { 49 | .reminder-date-container { 50 | flex-direction: column; 51 | align-items: stretch; 52 | gap: 6px; 53 | } 54 | 55 | .reminder-arrow { 56 | align-self: center; 57 | transform: rotate(90deg); 58 | } 59 | } 60 | 61 | /* ...existing code... */ -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "siyuan-plugin-task-note-management", 3 | "author": "Achuan-2", 4 | "url": "https://github.com/Achuan-2/siyuan-plugin-task-note-management", 5 | "version": "3.6.2", 6 | "minAppVersion": "3.0.12", 7 | "backends": [ 8 | "windows", 9 | "linux", 10 | "darwin", 11 | "docker", 12 | "ios", 13 | "android", 14 | "harmony" 15 | ], 16 | "frontends": [ 17 | "desktop", 18 | "mobile", 19 | "browser-desktop", 20 | "browser-mobile", 21 | "desktop-window" 22 | ], 23 | "disabledInPublish": true, 24 | "displayName": { 25 | "default": "Task Note Management", 26 | "zh_CN": "任务笔记管理" 27 | }, 28 | "description": { 29 | "default": "A plugin for SiYuan Note that implements task management features, developed to practice the Bullet Journal method. It supports document and block reminders, calendar view for scheduling, Pomodoro focus timer, and more.", 30 | "zh_CN": "一款在思源笔记里实现任务笔记管理功能的插件,为了践行防弹笔记法而开发。支持文档和块提醒、日历视图查看行事历、番茄钟专注和数据统计、项目看板、四象限面板、习惯打卡等功能" 31 | }, 32 | "readme": { 33 | "default": "README.md", 34 | "zh_CN": "README_zh_CN.md" 35 | }, 36 | "funding": { 37 | "custom": [ 38 | "https://www.yuque.com/achuan-2" 39 | ] 40 | }, 41 | "keywords": [ 42 | "reminder", 43 | "time", 44 | "calendar", 45 | "notification", 46 | "提醒", 47 | "时间", 48 | "日历", 49 | "通知", 50 | "番茄钟", 51 | "四象限", 52 | "任务管理", 53 | "项目管理", 54 | "习惯打卡" 55 | ] 56 | } -------------------------------------------------------------------------------- /src/libs/promise-pool.ts: -------------------------------------------------------------------------------- 1 | export default class PromiseLimitPool { 2 | private maxConcurrent: number; 3 | private currentRunning = 0; 4 | private queue: (() => void)[] = []; 5 | private promises: Promise[] = []; 6 | 7 | constructor(maxConcurrent: number) { 8 | this.maxConcurrent = maxConcurrent; 9 | } 10 | 11 | add(fn: () => Promise): void { 12 | const promise = new Promise((resolve, reject) => { 13 | const run = async () => { 14 | try { 15 | this.currentRunning++; 16 | const result = await fn(); 17 | resolve(result); 18 | } catch (error) { 19 | reject(error); 20 | } finally { 21 | this.currentRunning--; 22 | this.next(); 23 | } 24 | }; 25 | 26 | if (this.currentRunning < this.maxConcurrent) { 27 | run(); 28 | } else { 29 | this.queue.push(run); 30 | } 31 | }); 32 | this.promises.push(promise); 33 | } 34 | 35 | async awaitAll(): Promise { 36 | return Promise.all(this.promises); 37 | } 38 | 39 | /** 40 | * Handles the next task in the queue. 41 | */ 42 | private next(): void { 43 | if (this.queue.length > 0 && this.currentRunning < this.maxConcurrent) { 44 | const nextRun = this.queue.shift()!; 45 | nextRun(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/libs/components/setting-panel.svelte: -------------------------------------------------------------------------------- 1 | 9 | 29 | 30 |
31 | 32 | {#each settingItems as item (item.key)} 33 | 38 | 49 | 50 | {/each} 51 |
-------------------------------------------------------------------------------- /src/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取本地日期字符串(YYYY-MM-DD格式) 3 | * 解决时区问题,确保在东八区正确显示日期 4 | */ 5 | export function getLocalDateString(date?: Date): string { 6 | const d = date || new Date(); 7 | const year = d.getFullYear(); 8 | const month = String(d.getMonth() + 1).padStart(2, '0'); 9 | const day = String(d.getDate()).padStart(2, '0'); 10 | return `${year}-${month}-${day}`; 11 | } 12 | 13 | /** 14 | * 获取本地时间字符串(HH:MM格式) 15 | */ 16 | export function getLocalTimeString(date?: Date): string { 17 | const d = date || new Date(); 18 | const hours = String(d.getHours()).padStart(2, '0'); 19 | const minutes = String(d.getMinutes()).padStart(2, '0'); 20 | return `${hours}:${minutes}`; 21 | } 22 | 23 | export function getLocalDateTimeString(date?: Date): string { 24 | const d = date || new Date(); 25 | 26 | const year = d.getFullYear(); 27 | const month = String(d.getMonth() + 1).padStart(2, '0'); 28 | const day = String(d.getDate()).padStart(2, '0'); 29 | 30 | const hours = String(d.getHours()).padStart(2, '0'); 31 | const minutes = String(d.getMinutes()).padStart(2, '0'); 32 | return `${year}-${month}-${day} ${hours}:${minutes}`; 33 | } 34 | 35 | /** 36 | * 从Date对象获取本地日期时间 37 | */ 38 | export function getLocalDateTime(date: Date): { dateStr: string; timeStr: string } { 39 | return { 40 | dateStr: getLocalDateString(date), 41 | timeStr: getLocalTimeString(date) 42 | }; 43 | } 44 | 45 | /** 46 | * 比较两个日期字符串(YYYY-MM-DD格式) 47 | * 返回值:-1表示date1早于date2,0表示相等,1表示date1晚于date2 48 | */ 49 | export function compareDateStrings(date1: string, date2: string): number { 50 | if (date1 < date2) return -1; 51 | if (date1 > date2) return 1; 52 | return 0; 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "Node", 14 | // "allowImportingTsExtensions": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | /* Linting */ 21 | "strict": false, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | /* Svelte */ 26 | /** 27 | * Typecheck JS in `.svelte` and `.js` files by default. 28 | * Disable checkJs if you'd like to use dynamic types in JS. 29 | * Note that setting allowJs false does not prevent the use 30 | * of JS in `.svelte` files. 31 | */ 32 | "allowJs": true, 33 | "checkJs": true, 34 | "types": [ 35 | "node", 36 | "vite/client", 37 | "svelte" 38 | ], 39 | // "baseUrl": "./src", 40 | "paths": { 41 | "@/*": ["./src/*"], 42 | "@/libs/*": ["./src/libs/*"], 43 | } 44 | }, 45 | "include": [ 46 | "tools/**/*.ts", 47 | "src/**/*.ts", 48 | "src/**/*.d.ts", 49 | "src/**/*.tsx", 50 | "src/**/*.vue", 51 | "src/**/*.svelte" 52 | ], 53 | "references": [ 54 | { 55 | "path": "./tsconfig.node.json" 56 | } 57 | ], 58 | "root": "." 59 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | # Install Node.js 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 20 21 | registry-url: "https://registry.npmjs.org" 22 | 23 | # Install pnpm 24 | - name: Install pnpm 25 | uses: pnpm/action-setup@v4 26 | id: pnpm-install 27 | with: 28 | version: 8 29 | run_install: false 30 | 31 | # Get pnpm store directory 32 | - name: Get pnpm store directory 33 | id: pnpm-cache 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 37 | 38 | # Setup pnpm cache 39 | - name: Setup pnpm cache 40 | uses: actions/cache@v3 41 | with: 42 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 43 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 44 | restore-keys: | 45 | ${{ runner.os }}-pnpm-store- 46 | 47 | # Install dependencies 48 | - name: Install dependencies 49 | run: pnpm install 50 | 51 | # Build for production, 这一步会生成一个 package.zip 52 | - name: Build for production 53 | run: pnpm build 54 | 55 | - name: Release 56 | uses: ncipollo/release-action@v1 57 | with: 58 | allowUpdates: true 59 | artifactErrorsFailBuild: true 60 | artifacts: "package.zip" 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | prerelease: false 63 | -------------------------------------------------------------------------------- /src/libs/components/Form/form-wrap.svelte: -------------------------------------------------------------------------------- 1 | 9 | 14 | 15 | {#if direction === "row"} 16 |
17 |
18 | {title} 19 |
{@html description}
20 |
21 |
22 | 23 |
24 |
25 |
26 | {:else} 27 |
28 |
29 | {title} 30 |
31 | {@html description} 32 |
33 |
34 | 35 | 36 |
37 | {/if} 38 | 39 | 54 | -------------------------------------------------------------------------------- /scripts/make_install.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-03-28 20:03:59 5 | * @FilePath : /scripts/make_install.js 6 | * @LastEditTime : 2024-09-06 18:08:19 7 | * @Description : 8 | */ 9 | // make_install.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, copyDirectory, getThisPluginName } from './utils.js'; 12 | 13 | let targetDir = ''; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_install.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (res === null || res === undefined || res.length === 0) { 24 | error('>>> Can not get SiYuan directory automatically'); 25 | process.exit(1); 26 | } else { 27 | targetDir = await chooseTarget(res); 28 | } 29 | log(`>>> Successfully got target directory: ${targetDir}`); 30 | } 31 | if (!fs.existsSync(targetDir)) { 32 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 33 | error('Please set the plugin directory in scripts/make_install.js'); 34 | process.exit(1); 35 | } 36 | 37 | /** 38 | * 2. The dist directory, which contains the compiled plugin code 39 | */ 40 | const distDir = `${process.cwd()}/dist`; 41 | if (!fs.existsSync(distDir)) { 42 | fs.mkdirSync(distDir); 43 | } 44 | 45 | /** 46 | * 3. The target directory to install the plugin 47 | */ 48 | const name = getThisPluginName(); 49 | if (name === null) { 50 | process.exit(1); 51 | } 52 | const targetPath = `${targetDir}/${name}`; 53 | 54 | /** 55 | * 4. Copy the compiled plugin code to the target directory 56 | */ 57 | copyDirectory(distDir, targetPath); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-sample-vite-svelte", 3 | "version": "0.3.6", 4 | "type": "module", 5 | "description": "This is a sample plugin based on vite and svelte for Siyuan (https://b3log.org/siyuan)", 6 | "repository": "", 7 | "homepage": "", 8 | "author": "frostime", 9 | "license": "MIT", 10 | "scripts": { 11 | "dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch", 12 | "build": "cross-env NODE_ENV=production vite build", 13 | "make-link": "node --no-warnings ./scripts/make_dev_link.js", 14 | "make-link-win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./scripts/elevate.ps1 -scriptPath ./scripts/make_dev_link.js", 15 | "update-version": "node --no-warnings ./scripts/update_version.js", 16 | "make-install": "vite build && node --no-warnings ./scripts/make_install.js" 17 | }, 18 | "devDependencies": { 19 | "@fullcalendar/core": "^6.1.10", 20 | "@fullcalendar/daygrid": "^6.1.10", 21 | "@fullcalendar/interaction": "^6.1.10", 22 | "@fullcalendar/timegrid": "^6.1.10", 23 | "@sveltejs/vite-plugin-svelte": "^3.1.0", 24 | "@tsconfig/svelte": "^4.0.1", 25 | "@types/node": "^20.3.0", 26 | "cross-env": "^7.0.3", 27 | "fast-glob": "^3.2.12", 28 | "glob": "^10.0.0", 29 | "js-yaml": "^4.1.0", 30 | "minimist": "^1.2.8", 31 | "rollup-plugin-livereload": "^2.0.5", 32 | "sass": "^1.89.2", 33 | "sass-loader": "^16.0.5", 34 | "siyuan": "1.1.1", 35 | "svelte": "^4.2.19", 36 | "ts-node": "^10.9.1", 37 | "typescript": "^5.1.3", 38 | "vite": "^5.2.9", 39 | "vite-plugin-static-copy": "^1.0.2", 40 | "vite-plugin-zip-pack": "^1.0.5" 41 | }, 42 | "dependencies": { 43 | "@types/node-notifier": "^8.0.5", 44 | "chrono-node": "^2.8.2", 45 | "echarts": "^5.6.0", 46 | "emoji-picker-element": "^1.28.0", 47 | "lunar-typescript": "^1.8.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/hello.svelte: -------------------------------------------------------------------------------- 1 | 9 | 45 | 46 |
47 |
appId:
48 |
49 |
${app?.appId}
50 |
51 |
52 |
API demo:
53 |
54 |
55 | System current time: {time} 56 |
57 |
58 |
59 |
Protyle demo: id = {blockID}
60 |
61 |
62 |
63 | 64 | -------------------------------------------------------------------------------- /scripts/make_dev_link.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-07-15 15:31:31 5 | * @FilePath : /scripts/make_dev_link.js 6 | * @LastEditTime : 2024-09-06 18:13:53 7 | * @Description : 8 | */ 9 | // make_dev_link.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, getThisPluginName, makeSymbolicLink } from './utils.js'; 12 | 13 | let targetDir = ''; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_dev_link.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (!res || res.length === 0) { 24 | log('>>> Can not get SiYuan directory automatically, try to visit environment variable "SIYUAN_PLUGIN_DIR"....'); 25 | let env = process.env?.SIYUAN_PLUGIN_DIR; 26 | if (env) { 27 | targetDir = env; 28 | log(`\tGot target directory from environment variable "SIYUAN_PLUGIN_DIR": ${targetDir}`); 29 | } else { 30 | error('\tCan not get SiYuan directory from environment variable "SIYUAN_PLUGIN_DIR", failed!'); 31 | process.exit(1); 32 | } 33 | } else { 34 | targetDir = await chooseTarget(res); 35 | } 36 | 37 | log(`>>> Successfully got target directory: ${targetDir}`); 38 | } 39 | if (!fs.existsSync(targetDir)) { 40 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 41 | error('Please set the plugin directory in scripts/make_dev_link.js'); 42 | process.exit(1); 43 | } 44 | 45 | /** 46 | * 2. The dev directory, which contains the compiled plugin code 47 | */ 48 | const devDir = `${process.cwd()}/dev`; 49 | if (!fs.existsSync(devDir)) { 50 | fs.mkdirSync(devDir); 51 | } 52 | 53 | 54 | /** 55 | * 3. The target directory to make symbolic link to dev directory 56 | */ 57 | const name = getThisPluginName(); 58 | if (name === null) { 59 | process.exit(1); 60 | } 61 | const targetPath = `${targetDir}/${name}`; 62 | 63 | /** 64 | * 4. Make symbolic link 65 | */ 66 | makeSymbolicLink(devDir, targetPath); 67 | -------------------------------------------------------------------------------- /yaml-plugin.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-04-05 21:27:55 5 | * @FilePath : /yaml-plugin.js 6 | * @LastEditTime : 2024-04-05 22:53:34 7 | * @Description : 去妮玛的 json 格式,我就是要用 yaml 写 i18n 8 | */ 9 | // plugins/vite-plugin-parse-yaml.js 10 | import fs from 'fs'; 11 | import yaml from 'js-yaml'; 12 | import { resolve } from 'path'; 13 | 14 | export default function vitePluginYamlI18n(options = {}) { 15 | // Default options with a fallback 16 | const DefaultOptions = { 17 | inDir: 'src/i18n', 18 | outDir: 'dist/i18n', 19 | }; 20 | 21 | const finalOptions = { ...DefaultOptions, ...options }; 22 | 23 | return { 24 | name: 'vite-plugin-yaml-i18n', 25 | buildStart() { 26 | console.log('🌈 Parse I18n: YAML to JSON..'); 27 | const inDir = finalOptions.inDir; 28 | const outDir = finalOptions.outDir 29 | 30 | if (!fs.existsSync(outDir)) { 31 | fs.mkdirSync(outDir, { recursive: true }); 32 | } 33 | 34 | //Parse yaml file, output to json 35 | const files = fs.readdirSync(inDir); 36 | for (const file of files) { 37 | if (file.endsWith('.yaml') || file.endsWith('.yml')) { 38 | console.log(`-- Parsing ${file}`) 39 | //检查是否有同名的json文件 40 | const jsonFile = file.replace(/\.(yaml|yml)$/, '.json'); 41 | if (files.includes(jsonFile)) { 42 | console.log(`---- File ${jsonFile} already exists, skipping...`); 43 | continue; 44 | } 45 | try { 46 | const filePath = resolve(inDir, file); 47 | const fileContents = fs.readFileSync(filePath, 'utf8'); 48 | const parsed = yaml.load(fileContents); 49 | const jsonContent = JSON.stringify(parsed, null, 2); 50 | const outputFilePath = resolve(outDir, file.replace(/\.(yaml|yml)$/, '.json')); 51 | console.log(`---- Writing to ${outputFilePath}`); 52 | fs.writeFileSync(outputFilePath, jsonContent); 53 | } catch (error) { 54 | this.error(`---- Error parsing YAML file ${file}: ${error.message}`); 55 | } 56 | } 57 | } 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/libs/const.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-06-08 20:36:30 5 | * @FilePath : /src/libs/const.ts 6 | * @LastEditTime : 2024-06-08 20:48:06 7 | * @Description : 8 | */ 9 | 10 | 11 | export const BlockType2NodeType: {[key in BlockType]: string} = { 12 | d: 'NodeDocument', 13 | p: 'NodeParagraph', 14 | query_embed: 'NodeBlockQueryEmbed', 15 | l: 'NodeList', 16 | i: 'NodeListItem', 17 | h: 'NodeHeading', 18 | iframe: 'NodeIFrame', 19 | tb: 'NodeThematicBreak', 20 | b: 'NodeBlockquote', 21 | s: 'NodeSuperBlock', 22 | c: 'NodeCodeBlock', 23 | widget: 'NodeWidget', 24 | t: 'NodeTable', 25 | html: 'NodeHTMLBlock', 26 | m: 'NodeMathBlock', 27 | av: 'NodeAttributeView', 28 | audio: 'NodeAudio' 29 | } 30 | 31 | 32 | export const NodeIcons = { 33 | NodeAttributeView: { 34 | icon: "iconDatabase" 35 | }, 36 | NodeAudio: { 37 | icon: "iconRecord" 38 | }, 39 | NodeBlockQueryEmbed: { 40 | icon: "iconSQL" 41 | }, 42 | NodeBlockquote: { 43 | icon: "iconQuote" 44 | }, 45 | NodeCodeBlock: { 46 | icon: "iconCode" 47 | }, 48 | NodeDocument: { 49 | icon: "iconFile" 50 | }, 51 | NodeHTMLBlock: { 52 | icon: "iconHTML5" 53 | }, 54 | NodeHeading: { 55 | icon: "iconHeadings", 56 | subtypes: { 57 | h1: { icon: "iconH1" }, 58 | h2: { icon: "iconH2" }, 59 | h3: { icon: "iconH3" }, 60 | h4: { icon: "iconH4" }, 61 | h5: { icon: "iconH5" }, 62 | h6: { icon: "iconH6" } 63 | } 64 | }, 65 | NodeIFrame: { 66 | icon: "iconLanguage" 67 | }, 68 | NodeList: { 69 | subtypes: { 70 | o: { icon: "iconOrderedList" }, 71 | t: { icon: "iconCheck" }, 72 | u: { icon: "iconList" } 73 | } 74 | }, 75 | NodeListItem: { 76 | icon: "iconListItem" 77 | }, 78 | NodeMathBlock: { 79 | icon: "iconMath" 80 | }, 81 | NodeParagraph: { 82 | icon: "iconParagraph" 83 | }, 84 | NodeSuperBlock: { 85 | icon: "iconSuper" 86 | }, 87 | NodeTable: { 88 | icon: "iconTable" 89 | }, 90 | NodeThematicBreak: { 91 | icon: "iconLine" 92 | }, 93 | NodeVideo: { 94 | icon: "iconVideo" 95 | }, 96 | NodeWidget: { 97 | icon: "iconBoth" 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-08-15 10:28:10 5 | * @FilePath : /src/types/index.d.ts 6 | * @LastEditTime : 2024-06-08 20:50:53 7 | * @Description : Frequently used data structures in SiYuan 8 | */ 9 | 10 | 11 | type DocumentId = string; 12 | type BlockId = string; 13 | type NotebookId = string; 14 | type PreviousID = BlockId; 15 | type ParentID = BlockId | DocumentId; 16 | 17 | type Notebook = { 18 | id: NotebookId; 19 | name: string; 20 | icon: string; 21 | sort: number; 22 | closed: boolean; 23 | } 24 | 25 | type NotebookConf = { 26 | name: string; 27 | closed: boolean; 28 | refCreateSavePath: string; 29 | createDocNameTemplate: string; 30 | dailyNoteSavePath: string; 31 | dailyNoteTemplatePath: string; 32 | } 33 | 34 | type BlockType = 35 | | 'd' 36 | | 'p' 37 | | 'query_embed' 38 | | 'l' 39 | | 'i' 40 | | 'h' 41 | | 'iframe' 42 | | 'tb' 43 | | 'b' 44 | | 's' 45 | | 'c' 46 | | 'widget' 47 | | 't' 48 | | 'html' 49 | | 'm' 50 | | 'av' 51 | | 'audio'; 52 | 53 | 54 | type BlockSubType = "d1" | "d2" | "s1" | "s2" | "s3" | "t1" | "t2" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table" | "task" | "toggle" | "latex" | "quote" | "html" | "code" | "footnote" | "cite" | "collection" | "bookmark" | "attachment" | "comment" | "mindmap" | "spreadsheet" | "calendar" | "image" | "audio" | "video" | "other"; 55 | 56 | type Block = { 57 | id: BlockId; 58 | parent_id?: BlockId; 59 | root_id: DocumentId; 60 | hash: string; 61 | box: string; 62 | path: string; 63 | hpath: string; 64 | name: string; 65 | alias: string; 66 | memo: string; 67 | tag: string; 68 | content: string; 69 | fcontent?: string; 70 | markdown: string; 71 | length: number; 72 | type: BlockType; 73 | subtype: BlockSubType; 74 | /** string of { [key: string]: string } 75 | * For instance: "{: custom-type=\"query-code\" id=\"20230613234017-zkw3pr0\" updated=\"20230613234509\"}" 76 | */ 77 | ial?: string; 78 | sort: number; 79 | created: string; 80 | updated: string; 81 | } 82 | 83 | type doOperation = { 84 | action: string; 85 | data: string; 86 | id: BlockId; 87 | parentID: BlockId | DocumentId; 88 | previousID: BlockId; 89 | retData: null; 90 | } 91 | 92 | interface Window { 93 | siyuan: { 94 | config: any; 95 | notebooks: any; 96 | menus: any; 97 | dialogs: any; 98 | blockPanels: any; 99 | storage: any; 100 | user: any; 101 | ws: any; 102 | languages: any; 103 | emojis: any; 104 | }; 105 | Lute: any; 106 | } 107 | -------------------------------------------------------------------------------- /scripts/make_dev_copy.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2025-07-13 5 | * @FilePath : /scripts/make_dev_copy.js 6 | * @LastEditTime : 2025-07-13 7 | * @Description : Copy plugin files to SiYuan plugins directory instead of creating symbolic links 8 | */ 9 | import fs from 'fs'; 10 | import path from 'path'; 11 | import { log, error, getSiYuanDir, chooseTarget, getThisPluginName, copyDirectory } from './utils.js'; 12 | 13 | let targetDir = `D:\\Notes\\Siyuan\\Achuan-2\\data\\plugins`; 14 | // let targetDir =`C:\\Users\\wangmin\\Documents\\siyuan_plugins_test\\data\\plugins`; 15 | // let targetDir =`C:\\Users\\wangmin\\Documents\\Project Code\\notebook\\data\\plugins`; 16 | 17 | /** 18 | * 1. Get the parent directory to install the plugin 19 | */ 20 | log('>>> Try to visit constant "targetDir" in make_dev_copy.js...'); 21 | if (targetDir === '') { 22 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 23 | let res = await getSiYuanDir(); 24 | 25 | if (!res || res.length === 0) { 26 | log('>>> Can not get SiYuan directory automatically, try to visit environment variable "SIYUAN_PLUGIN_DIR"....'); 27 | let env = process.env?.SIYUAN_PLUGIN_DIR; 28 | if (env) { 29 | targetDir = env; 30 | log(`\tGot target directory from environment variable "SIYUAN_PLUGIN_DIR": ${targetDir}`); 31 | } else { 32 | error('\tCan not get SiYuan directory from environment variable "SIYUAN_PLUGIN_DIR", failed!'); 33 | process.exit(1); 34 | } 35 | } else { 36 | targetDir = await chooseTarget(res); 37 | } 38 | 39 | log(`>>> Successfully got target directory: ${targetDir}`); 40 | } 41 | if (!fs.existsSync(targetDir)) { 42 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 43 | error('Please set the plugin directory in scripts/make_dev_copy.js'); 44 | process.exit(1); 45 | } 46 | 47 | /** 48 | * 2. The dev directory, which contains the compiled plugin code 49 | */ 50 | const devDir = `${process.cwd()}/dev`; 51 | if (!fs.existsSync(devDir)) { 52 | error(`Failed! Dev directory not exists: "${devDir}"`); 53 | error('Please run "pnpm run build" or "pnpm run dev" first to generate the dev directory'); 54 | process.exit(1); 55 | } 56 | 57 | /** 58 | * 3. The target directory to copy dev directory contents 59 | */ 60 | const name = getThisPluginName(); 61 | if (name === null) { 62 | process.exit(1); 63 | } 64 | const targetPath = `${targetDir}/${name}`; 65 | 66 | /** 67 | * 4. Create target directory if it doesn't exist 68 | */ 69 | log(`>>> Ensuring target directory exists: ${targetPath}`); 70 | if (!fs.existsSync(targetPath)) { 71 | fs.mkdirSync(targetPath, { recursive: true }); 72 | log(`Created directory: ${targetPath}`); 73 | } else { 74 | log(`Target directory already exists, will update files incrementally`); 75 | } 76 | 77 | /** 78 | * 5. Copy/update all contents from dev directory to target directory 79 | * This will only update changed files instead of deleting everything 80 | */ 81 | copyDirectory(devDir, targetPath); 82 | log(`>>> Successfully synchronized all files to SiYuan plugins directory!`); 83 | -------------------------------------------------------------------------------- /src/utils/sortConfig.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "siyuan"; 2 | import { t } from "./i18n"; 3 | import { getFile, removeFile } from "../api"; 4 | import { SETTINGS_FILE } from "../index"; 5 | 6 | const SORT_CONFIG_FILE = 'data/storage/petal/siyuan-plugin-task-note-management/sort_config.json'; 7 | 8 | export interface SortConfig { 9 | method: string; 10 | order: 'asc' | 'desc'; 11 | } 12 | 13 | export async function loadSortConfig(plugin: Plugin): Promise { 14 | try { 15 | const settings = await plugin.loadData(SETTINGS_FILE) || {}; 16 | 17 | // 检查是否存在旧的 sort_config.json 文件,如果存在则导入并删除 18 | try { 19 | const oldSortContent = await getFile(SORT_CONFIG_FILE); 20 | if (oldSortContent && oldSortContent.code !== 404) { 21 | const oldSort = typeof oldSortContent === 'string' ? JSON.parse(oldSortContent) : oldSortContent; 22 | if (oldSort && typeof oldSort === 'object') { 23 | // 合并旧排序配置到新的 settings 24 | if (oldSort.method) settings.sortMethod = oldSort.method; 25 | if (oldSort.order) settings.sortOrder = oldSort.order; 26 | await plugin.saveData('reminder-settings.json', settings); 27 | // 删除旧文件 28 | await removeFile(SORT_CONFIG_FILE); 29 | console.log('成功导入并删除旧的 sort_config.json 文件'); 30 | } 31 | } 32 | } catch (error) { 33 | // 如果文件不存在或其他错误,忽略 34 | console.log('旧的 sort_config.json 文件不存在或已处理'); 35 | } 36 | 37 | return { 38 | method: settings.sortMethod || 'time', 39 | order: settings.sortOrder || 'asc' 40 | }; 41 | } catch (error) { 42 | console.log('加载排序配置失败,使用默认配置:', error); 43 | return { method: 'time', order: 'asc' }; 44 | } 45 | } 46 | 47 | export async function saveSortConfig(plugin: Plugin, method: string, order: 'asc' | 'desc' = 'asc'): Promise { 48 | try { 49 | const settings = await plugin.loadData(SETTINGS_FILE) || {}; 50 | settings.sortMethod = method; 51 | settings.sortOrder = order; 52 | await plugin.saveData(SETTINGS_FILE, settings); 53 | 54 | console.log('排序配置保存成功:', { method, order }); 55 | 56 | // 触发排序配置更新事件 57 | window.dispatchEvent(new CustomEvent('sortConfigUpdated', { 58 | detail: { method, order } 59 | })); 60 | } catch (error) { 61 | console.error('保存排序配置失败:', error); 62 | // 即使保存失败,仍然触发事件以保持界面同步 63 | window.dispatchEvent(new CustomEvent('sortConfigUpdated', { 64 | detail: { method, order } 65 | })); 66 | } 67 | } 68 | 69 | export function getSortMethodName(method: string, order: 'asc' | 'desc' = 'asc'): string { 70 | const methodNames = { 71 | 'time': t("sortByTime"), 72 | 'priority': t("sortByPriority"), 73 | 'title': t("sortByTitle"), 74 | 'created': t("sortByCreated") 75 | }; 76 | 77 | const orderNames = { 78 | 'asc': t("ascending"), 79 | 'desc': t("descending") 80 | }; 81 | 82 | const methodName = methodNames[method] || t("sortByTime"); 83 | const orderName = orderNames[order] || t("ascending"); 84 | 85 | return `${methodName}(${orderName})`; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/ProjectColorDialog.ts: -------------------------------------------------------------------------------- 1 | import { Dialog, showMessage } from "siyuan"; 2 | import { t } from "../utils/i18n"; 3 | import { Project, ProjectManager } from "../utils/projectManager"; 4 | import { StatusManager } from "../utils/statusManager"; 5 | 6 | export class ProjectColorDialog { 7 | private dialog: Dialog; 8 | private projectManager: ProjectManager; 9 | private statusManager: StatusManager; 10 | private onSave: () => void; 11 | 12 | constructor(onSave: () => void, plugin?: any) { 13 | this.projectManager = ProjectManager.getInstance(plugin); 14 | this.statusManager = StatusManager.getInstance(plugin); 15 | this.onSave = onSave; 16 | } 17 | 18 | public show() { 19 | this.dialog = new Dialog({ 20 | title: t("setProjectColors"), 21 | content: `
`, 22 | width: "520px", 23 | height: "600px", 24 | }); 25 | 26 | const contentEl = this.dialog.element.querySelector("#project-color-dialog-content"); 27 | this.renderContent(contentEl); 28 | } 29 | 30 | private async renderContent(container: Element) { 31 | await this.projectManager.initialize(); 32 | const projectsByStatus = this.projectManager.getProjectsGroupedByStatus(); 33 | 34 | let content = ''; 35 | for (const statusId in projectsByStatus) { 36 | const projects = projectsByStatus[statusId]; 37 | if (projects.length > 0) { 38 | const status = this.statusManager.getStatusById(statusId); 39 | const statusName = status ? status.name : t("uncategorized"); 40 | content += ` 41 |
42 |
43 | ${statusName} (${projects.length}) 44 |
45 | ${projects.map(p => this.renderProjectItem(p)).join('')} 46 |
47 |
48 |
49 | `; 50 | } 51 | } 52 | container.innerHTML = content; 53 | this.addEventListeners(container); 54 | } 55 | 56 | private renderProjectItem(project: Project): string { 57 | const color = this.projectManager.getProjectColor(project.id); 58 | return ` 59 |
60 | ${project.name} 61 |
62 | 63 |
64 |
65 | `; 66 | } 67 | 68 | private addEventListeners(container: Element) { 69 | container.querySelectorAll('input[type="color"]').forEach(input => { 70 | input.addEventListener('change', async (e) => { 71 | const target = e.target as HTMLInputElement; 72 | const projectId = (target.closest('.project-item') as HTMLElement).dataset.projectId; 73 | await this.projectManager.setProjectColor(projectId, target.value); 74 | showMessage(t("colorSetSuccess")); 75 | this.onSave(); 76 | }); 77 | }); 78 | } 79 | } -------------------------------------------------------------------------------- /src/libs/components/Form/form-input.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | {#if type === "checkbox"} 35 | 36 | 44 | {:else if type === "textinput"} 45 | 46 | 56 | {:else if type === "textarea"} 57 |
21 |
22 |
23 |
24 | 25 |
`, 26 | width: args.width ?? "520px", 27 | height: args.height 28 | }); 29 | const target: HTMLTextAreaElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword>textarea"); 30 | const btnsElement = dialog.element.querySelectorAll(".b3-button"); 31 | btnsElement[0].addEventListener("click", () => { 32 | if (args?.cancel) { 33 | args.cancel(); 34 | } 35 | dialog.destroy(); 36 | }); 37 | btnsElement[1].addEventListener("click", () => { 38 | if (args?.confirm) { 39 | args.confirm(target.value); 40 | } 41 | dialog.destroy(); 42 | }); 43 | }; 44 | 45 | export const inputDialogSync = async (args: { 46 | title: string, placeholder?: string, defaultText?: string, 47 | width?: string, height?: string 48 | }) => { 49 | return new Promise((resolve) => { 50 | let newargs = { 51 | ...args, confirm: (text) => { 52 | resolve(text); 53 | }, cancel: () => { 54 | resolve(null); 55 | } 56 | }; 57 | inputDialog(newargs); 58 | }); 59 | } 60 | 61 | 62 | interface IConfirmDialogArgs { 63 | title: string; 64 | content: string | HTMLElement; 65 | confirm?: (ele?: HTMLElement) => void; 66 | cancel?: (ele?: HTMLElement) => void; 67 | width?: string; 68 | height?: string; 69 | } 70 | 71 | export const confirmDialog = (args: IConfirmDialogArgs) => { 72 | const { title, content, confirm, cancel, width, height } = args; 73 | 74 | const dialog = new Dialog({ 75 | title, 76 | content: `
77 |
78 |
79 |
80 |
81 |
82 | 83 |
`, 84 | width: width, 85 | height: height 86 | }); 87 | 88 | const target: HTMLElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword"); 89 | if (typeof content === "string") { 90 | target.innerHTML = content; 91 | } else { 92 | target.appendChild(content); 93 | } 94 | 95 | const btnsElement = dialog.element.querySelectorAll(".b3-button"); 96 | btnsElement[0].addEventListener("click", () => { 97 | if (cancel) { 98 | cancel(target); 99 | } 100 | dialog.destroy(); 101 | }); 102 | btnsElement[1].addEventListener("click", () => { 103 | if (confirm) { 104 | confirm(target); 105 | } 106 | dialog.destroy(); 107 | }); 108 | }; 109 | 110 | 111 | export const confirmDialogSync = async (args: IConfirmDialogArgs) => { 112 | return new Promise((resolve) => { 113 | let newargs = { 114 | ...args, confirm: (ele: HTMLElement) => { 115 | resolve(ele); 116 | }, cancel: (ele: HTMLElement) => { 117 | resolve(ele); 118 | } 119 | }; 120 | confirmDialog(newargs); 121 | }); 122 | }; 123 | 124 | 125 | export const simpleDialog = (args: { 126 | title: string, ele: HTMLElement | DocumentFragment, 127 | width?: string, height?: string, 128 | callback?: () => void; 129 | }) => { 130 | const dialog = new Dialog({ 131 | title: args.title, 132 | content: `
`, 133 | width: args.width, 134 | height: args.height, 135 | destroyCallback: args.callback 136 | }); 137 | dialog.element.querySelector(".dialog-content").appendChild(args.ele); 138 | return { 139 | dialog, 140 | close: dialog.destroy.bind(dialog) 141 | }; 142 | } 143 | 144 | 145 | export const svelteDialog = (args: { 146 | title: string, constructor: (container: HTMLElement) => SvelteComponent, 147 | width?: string, height?: string, 148 | callback?: () => void; 149 | }) => { 150 | let container = document.createElement('div') 151 | container.style.display = 'contents'; 152 | let component = args.constructor(container); 153 | const { dialog, close } = simpleDialog({ 154 | ...args, ele: container, callback: () => { 155 | component.$destroy(); 156 | if (args.callback) args.callback(); 157 | } 158 | }); 159 | return { 160 | component, 161 | dialog, 162 | close 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-09-06 17:42:57 5 | * @FilePath : /scripts/utils.js 6 | * @LastEditTime : 2024-09-06 19:23:12 7 | * @Description : 8 | */ 9 | // common.js 10 | import fs from 'fs'; 11 | import path from 'node:path'; 12 | import http from 'node:http'; 13 | import readline from 'node:readline'; 14 | 15 | // Logging functions 16 | export const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 17 | export const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 18 | 19 | // HTTP POST headers 20 | export const POST_HEADER = { 21 | "Content-Type": "application/json", 22 | }; 23 | 24 | // Fetch function compatible with older Node.js versions 25 | export async function myfetch(url, options) { 26 | return new Promise((resolve, reject) => { 27 | let req = http.request(url, options, (res) => { 28 | let data = ''; 29 | res.on('data', (chunk) => { 30 | data += chunk; 31 | }); 32 | res.on('end', () => { 33 | resolve({ 34 | ok: true, 35 | status: res.statusCode, 36 | json: () => JSON.parse(data) 37 | }); 38 | }); 39 | }); 40 | req.on('error', (e) => { 41 | reject(e); 42 | }); 43 | req.end(); 44 | }); 45 | } 46 | 47 | /** 48 | * Fetch SiYuan workspaces from port 6806 49 | * @returns {Promise} 50 | */ 51 | export async function getSiYuanDir() { 52 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 53 | let conf = {}; 54 | try { 55 | let response = await myfetch(url, { 56 | method: 'POST', 57 | headers: POST_HEADER 58 | }); 59 | if (response.ok) { 60 | conf = await response.json(); 61 | } else { 62 | error(`\tHTTP-Error: ${response.status}`); 63 | return null; 64 | } 65 | } catch (e) { 66 | error(`\tError: ${e}`); 67 | error("\tPlease make sure SiYuan is running!!!"); 68 | return null; 69 | } 70 | return conf?.data; // 保持原始返回值 71 | } 72 | 73 | /** 74 | * Choose target workspace 75 | * @param {{path: string}[]} workspaces 76 | * @returns {string} The path of the selected workspace 77 | */ 78 | export async function chooseTarget(workspaces) { 79 | let count = workspaces.length; 80 | log(`>>> Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`); 81 | workspaces.forEach((workspace, i) => { 82 | log(`\t[${i}] ${workspace.path}`); 83 | }); 84 | 85 | if (count === 1) { 86 | return `${workspaces[0].path}/data/plugins`; 87 | } else { 88 | const rl = readline.createInterface({ 89 | input: process.stdin, 90 | output: process.stdout 91 | }); 92 | let index = await new Promise((resolve) => { 93 | rl.question(`\tPlease select a workspace[0-${count - 1}]: `, (answer) => { 94 | resolve(answer); 95 | }); 96 | }); 97 | rl.close(); 98 | return `${workspaces[index].path}/data/plugins`; 99 | } 100 | } 101 | 102 | /** 103 | * Check if two paths are the same 104 | * @param {string} path1 105 | * @param {string} path2 106 | * @returns {boolean} 107 | */ 108 | export function cmpPath(path1, path2) { 109 | path1 = path1.replace(/\\/g, '/'); 110 | path2 = path2.replace(/\\/g, '/'); 111 | if (path1[path1.length - 1] !== '/') { 112 | path1 += '/'; 113 | } 114 | if (path2[path2.length - 1] !== '/') { 115 | path2 += '/'; 116 | } 117 | return path1 === path2; 118 | } 119 | 120 | export function getThisPluginName() { 121 | if (!fs.existsSync('./plugin.json')) { 122 | process.chdir('../'); 123 | if (!fs.existsSync('./plugin.json')) { 124 | error('Failed! plugin.json not found'); 125 | return null; 126 | } 127 | } 128 | 129 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 130 | const name = plugin?.name; 131 | if (!name) { 132 | error('Failed! Please set plugin name in plugin.json'); 133 | return null; 134 | } 135 | 136 | return name; 137 | } 138 | 139 | export function copyDirectory(srcDir, dstDir) { 140 | if (!fs.existsSync(dstDir)) { 141 | fs.mkdirSync(dstDir); 142 | log(`Created directory ${dstDir}`); 143 | } 144 | 145 | fs.readdirSync(srcDir, { withFileTypes: true }).forEach((file) => { 146 | const src = path.join(srcDir, file.name); 147 | const dst = path.join(dstDir, file.name); 148 | 149 | if (file.isDirectory()) { 150 | copyDirectory(src, dst); 151 | } else { 152 | fs.copyFileSync(src, dst); 153 | } 154 | }); 155 | log(`All files copied!`); 156 | } 157 | 158 | 159 | export function makeSymbolicLink(srcPath, targetPath) { 160 | if (!fs.existsSync(targetPath)) { 161 | // fs.symlinkSync(srcPath, targetPath, 'junction'); 162 | //Go 1.23 no longer supports junctions as symlinks 163 | //Please refer to https://github.com/siyuan-note/siyuan/issues/12399 164 | fs.symlinkSync(srcPath, targetPath, 'dir'); 165 | log(`Done! Created symlink ${targetPath}`); 166 | return; 167 | } 168 | 169 | //Check the existed target path 170 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 171 | if (!isSymbol) { 172 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 173 | return; 174 | } 175 | let existedPath = fs.readlinkSync(targetPath); 176 | if (cmpPath(existedPath, srcPath)) { 177 | log(`Good! ${targetPath} is already linked to ${srcPath}`); 178 | } else { 179 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${existedPath}`); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig, loadEnv } from "vite" 3 | import { viteStaticCopy } from "vite-plugin-static-copy" 4 | import livereload from "rollup-plugin-livereload" 5 | import { svelte } from "@sveltejs/vite-plugin-svelte" 6 | import zipPack from "vite-plugin-zip-pack"; 7 | import fg from 'fast-glob'; 8 | import fs from 'fs'; 9 | import { execSync } from 'child_process'; 10 | 11 | 12 | const env = process.env; 13 | const isSrcmap = env.VITE_SOURCEMAP === 'inline'; 14 | const isDev = env.NODE_ENV === 'development'; 15 | 16 | const outputDir = isDev ? "dev" : "dist"; 17 | 18 | console.log("isDev=>", isDev); 19 | console.log("isSrcmap=>", isSrcmap); 20 | console.log("outputDir=>", outputDir); 21 | 22 | export default defineConfig({ 23 | resolve: { 24 | alias: { 25 | "@": resolve(__dirname, "src"), 26 | } 27 | }, 28 | 29 | plugins: [ 30 | svelte(), 31 | 32 | viteStaticCopy({ 33 | targets: [ 34 | { src: "./README*.md", dest: "./" }, 35 | { src: "./plugin.json", dest: "./" }, 36 | { src: "./preview.png", dest: "./" }, 37 | { src: "./icon.png", dest: "./" }, 38 | { src: "./audios/*", dest: "./audios/" }, 39 | { src: "./assets/*", dest: "./assets/" }, 40 | { src: "./i18n/*", dest: "./i18n/" }, 41 | ], 42 | }), 43 | 44 | // Auto copy to SiYuan plugins directory in dev mode 45 | ...(isDev ? [ 46 | { 47 | name: 'auto-copy-to-siyuan', 48 | writeBundle() { 49 | try { 50 | // Run the copy script after build 51 | execSync('node --no-warnings ./scripts/make_dev_copy.js', { 52 | stdio: 'inherit', 53 | cwd: process.cwd() 54 | }); 55 | } catch (error) { 56 | console.warn('Auto copy to SiYuan failed:', error.message); 57 | console.warn('You can manually run: pnpm run make-link-win'); 58 | } 59 | } 60 | } 61 | ] : []), 62 | 63 | ], 64 | 65 | define: { 66 | "process.env.DEV_MODE": JSON.stringify(isDev), 67 | "process.env.NODE_ENV": JSON.stringify(env.NODE_ENV) 68 | }, 69 | 70 | build: { 71 | outDir: outputDir, 72 | // Keep existing files in output directory for incremental builds 73 | emptyOutDir: false, 74 | minify: true, 75 | sourcemap: isSrcmap ? 'inline' : false, 76 | 77 | lib: { 78 | entry: resolve(__dirname, "src/index.ts"), 79 | fileName: "index", 80 | formats: ["cjs"], 81 | }, 82 | rollupOptions: { 83 | plugins: [ 84 | ...(isDev ? [ 85 | { 86 | name: 'watch-external', 87 | async buildStart() { 88 | const files = await fg([ 89 | './i18n/**', 90 | './README*.md', 91 | './plugin.json' 92 | ]); 93 | for (let file of files) { 94 | this.addWatchFile(file); 95 | } 96 | } 97 | } 98 | ] : [ 99 | // Clean up unnecessary files under dist dir 100 | cleanupDistFiles({ 101 | patterns: ['i18n/*.yaml', 'i18n/*.md'], 102 | distDir: outputDir 103 | }), 104 | zipPack({ 105 | inDir: './dist', 106 | outDir: './', 107 | outFileName: 'package.zip' 108 | }) 109 | ]) 110 | ], 111 | 112 | external: ["siyuan", "process"], 113 | 114 | output: { 115 | entryFileNames: "[name].js", 116 | assetFileNames: (assetInfo) => { 117 | if (assetInfo.name === "style.css") { 118 | return "index.css" 119 | } 120 | return assetInfo.name 121 | }, 122 | }, 123 | }, 124 | } 125 | }); 126 | 127 | 128 | /** 129 | * Clean up some dist files after compiled 130 | * @author frostime 131 | * @param options: 132 | * @returns 133 | */ 134 | function cleanupDistFiles(options: { patterns: string[], distDir: string }) { 135 | const { 136 | patterns, 137 | distDir 138 | } = options; 139 | 140 | return { 141 | name: 'rollup-plugin-cleanup', 142 | enforce: 'post', 143 | writeBundle: { 144 | sequential: true, 145 | order: 'post' as 'post', 146 | async handler() { 147 | const fg = await import('fast-glob'); 148 | const fs = await import('fs'); 149 | // const path = await import('path'); 150 | 151 | // 使用 glob 语法,确保能匹配到文件 152 | const distPatterns = patterns.map(pat => `${distDir}/${pat}`); 153 | console.debug('Cleanup searching patterns:', distPatterns); 154 | 155 | const files = await fg.default(distPatterns, { 156 | dot: true, 157 | absolute: true, 158 | onlyFiles: false 159 | }); 160 | 161 | // console.info('Files to be cleaned up:', files); 162 | 163 | for (const file of files) { 164 | try { 165 | if (fs.default.existsSync(file)) { 166 | const stat = fs.default.statSync(file); 167 | if (stat.isDirectory()) { 168 | fs.default.rmSync(file, { recursive: true }); 169 | } else { 170 | fs.default.unlinkSync(file); 171 | } 172 | console.log(`Cleaned up: ${file}`); 173 | } 174 | } catch (error) { 175 | console.error(`Failed to clean up ${file}:`, error); 176 | } 177 | } 178 | } 179 | } 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /src/utils/categoryManager.ts: -------------------------------------------------------------------------------- 1 | import { t } from "./i18n"; 2 | import { CATEGORIES_DATA_FILE } from "../index"; 3 | 4 | export interface Category { 5 | id: string; 6 | name: string; 7 | color: string; 8 | icon?: string; 9 | } 10 | 11 | const DEFAULT_CATEGORIES: Category[] = [ 12 | { id: 'work', name: '工作', color: '#e74c3c', icon: '🎯' }, 13 | { id: 'study', name: '学习', color: '#3498db', icon: '📖' }, 14 | { id: 'life', name: '娱乐', color: '#27ae60', icon: '☘️' } 15 | ]; 16 | 17 | export class CategoryManager { 18 | private static instance: CategoryManager; 19 | private categories: Category[] = []; 20 | private plugin: any; 21 | 22 | private constructor(plugin: any) { 23 | this.plugin = plugin; 24 | } 25 | 26 | public static getInstance(plugin?: any): CategoryManager { 27 | if (!CategoryManager.instance) { 28 | CategoryManager.instance = new CategoryManager(plugin); 29 | } 30 | return CategoryManager.instance; 31 | } 32 | 33 | /** 34 | * 初始化分类数据 35 | */ 36 | public async initialize(): Promise { 37 | try { 38 | await this.loadCategories(); 39 | } catch (error) { 40 | console.error('初始化分类失败:', error); 41 | // 如果加载失败,使用默认分类 42 | this.categories = [...DEFAULT_CATEGORIES]; 43 | await this.saveCategories(); 44 | } 45 | } 46 | 47 | /** 48 | * 加载分类数据 49 | */ 50 | public async loadCategories(): Promise { 51 | try { 52 | const content = await this.plugin.loadData(CATEGORIES_DATA_FILE); 53 | if (!content) { 54 | console.log('分类文件不存在,创建默认分类'); 55 | this.categories = [...DEFAULT_CATEGORIES]; 56 | await this.saveCategories(); 57 | return this.categories; 58 | } 59 | 60 | const categoriesData = content; 61 | 62 | // 验证加载的数据是否为有效的分类数组 63 | if (Array.isArray(categoriesData) && categoriesData.length > 0) { 64 | this.categories = categoriesData; 65 | } else { 66 | console.log('分类数据无效,使用默认分类'); 67 | this.categories = [...DEFAULT_CATEGORIES]; 68 | await this.saveCategories(); 69 | } 70 | } catch (error) { 71 | console.warn('加载分类文件失败,使用默认分类:', error); 72 | this.categories = [...DEFAULT_CATEGORIES]; 73 | await this.saveCategories(); 74 | } 75 | 76 | return this.categories; 77 | } 78 | 79 | /** 80 | * 保存分类数据 81 | */ 82 | public async saveCategories(): Promise { 83 | try { 84 | await this.plugin.saveData(CATEGORIES_DATA_FILE, this.categories); 85 | } catch (error) { 86 | console.error('保存分类失败:', error); 87 | throw error; 88 | } 89 | } 90 | 91 | /** 92 | * 获取所有分类 93 | */ 94 | public getCategories(): Category[] { 95 | return [...this.categories]; 96 | } 97 | 98 | /** 99 | * 根据ID获取分类 100 | */ 101 | public getCategoryById(id: string): Category | undefined { 102 | return this.categories.find(cat => cat.id === id); 103 | } 104 | 105 | /** 106 | * 添加新分类 107 | */ 108 | public async addCategory(category: Omit): Promise { 109 | const newCategory: Category = { 110 | ...category, 111 | id: `category_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` 112 | }; 113 | 114 | this.categories.push(newCategory); 115 | await this.saveCategories(); 116 | return newCategory; 117 | } 118 | 119 | /** 120 | * 更新分类 121 | */ 122 | public async updateCategory(id: string, updates: Partial>): Promise { 123 | const index = this.categories.findIndex(cat => cat.id === id); 124 | if (index === -1) { 125 | return false; 126 | } 127 | 128 | this.categories[index] = { ...this.categories[index], ...updates }; 129 | await this.saveCategories(); 130 | return true; 131 | } 132 | 133 | /** 134 | * 删除分类 135 | */ 136 | public async deleteCategory(id: string): Promise { 137 | const index = this.categories.findIndex(cat => cat.id === id); 138 | if (index === -1) { 139 | return false; 140 | } 141 | 142 | this.categories.splice(index, 1); 143 | await this.saveCategories(); 144 | return true; 145 | } 146 | 147 | /** 148 | * 重置为默认分类 149 | */ 150 | public async resetToDefault(): Promise { 151 | this.categories = [...DEFAULT_CATEGORIES]; 152 | await this.saveCategories(); 153 | } 154 | 155 | /** 156 | * 获取分类的样式 157 | */ 158 | public getCategoryStyle(categoryId: string): { backgroundColor: string; borderColor: string } { 159 | const category = this.getCategoryById(categoryId); 160 | if (!category) { 161 | return { backgroundColor: '#95a5a6', borderColor: '#7f8c8d' }; 162 | } 163 | 164 | return { 165 | backgroundColor: category.color, 166 | borderColor: this.darkenColor(category.color, 10) 167 | }; 168 | } 169 | 170 | /** 171 | * 加深颜色 172 | */ 173 | private darkenColor(color: string, percent: number): string { 174 | const num = parseInt(color.replace("#", ""), 16); 175 | const amt = Math.round(2.55 * percent); 176 | const R = (num >> 16) - amt; 177 | const G = (num >> 8 & 0x00FF) - amt; 178 | const B = (num & 0x0000FF) - amt; 179 | return "#" + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + 180 | (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + 181 | (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1); 182 | } 183 | 184 | /** 185 | * 重新排序分类 186 | */ 187 | public async reorderCategories(reorderedCategories: Category[]): Promise { 188 | // 验证传入的分类数组 189 | if (!Array.isArray(reorderedCategories)) { 190 | throw new Error('重排序的分类必须是数组'); 191 | } 192 | 193 | // 验证分类数量是否匹配 194 | if (reorderedCategories.length !== this.categories.length) { 195 | throw new Error('重排序的分类数量不匹配'); 196 | } 197 | 198 | // 验证所有分类ID都存在 199 | const currentIds = new Set(this.categories.map(cat => cat.id)); 200 | const reorderedIds = new Set(reorderedCategories.map(cat => cat.id)); 201 | 202 | if (currentIds.size !== reorderedIds.size || 203 | ![...currentIds].every(id => reorderedIds.has(id))) { 204 | throw new Error('重排序的分类ID不匹配'); 205 | } 206 | 207 | // 更新分类顺序 208 | this.categories = [...reorderedCategories]; 209 | await this.saveCategories(); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/styles/habit-calendar.scss: -------------------------------------------------------------------------------- 1 | /* 习惯打卡日历样式 */ 2 | 3 | /* 视图切换按钮样式 */ 4 | .view-toggle-btn { 5 | padding: 8px 16px; 6 | border: none; 7 | background: transparent; 8 | cursor: pointer; 9 | font-size: 14px; 10 | transition: background-color 0.2s; 11 | border-radius: 4px; 12 | } 13 | 14 | .view-toggle-btn.active { 15 | background: var(--b3-theme-primary); 16 | color: var(--b3-theme-on-primary); 17 | } 18 | 19 | .view-toggle-btn:hover { 20 | background: var(--b3-theme-surface-lighter); 21 | } 22 | 23 | .view-toggle-btn.active:hover { 24 | background: var(--b3-theme-primary); 25 | } 26 | 27 | /* 日期圆形徽章样式 */ 28 | .date-badge { 29 | display: flex; 30 | flex-wrap: wrap; 31 | align-items: center; 32 | justify-content: center; 33 | align-content: center; 34 | width: 100%; 35 | max-width: 100%; 36 | border-radius: 8px; 37 | background: transparent; 38 | box-shadow: none; 39 | transition: all 0.2s ease; 40 | user-select: none; 41 | cursor: default; 42 | -webkit-user-select: none; 43 | -moz-user-select: none; 44 | -ms-user-select: none; 45 | gap: 2px; 46 | padding: 4px; 47 | box-sizing: border-box; 48 | } 49 | 50 | /* 当emoji数量少时,保持正方形 */ 51 | .date-badge.count-2, 52 | .date-badge.count-3, 53 | .date-badge:not(.count-4):not(.count-5):not(.count-6):not(.count-many):not(.multi) { 54 | aspect-ratio: 1 / 1; 55 | } 56 | 57 | .date-badge:hover { 58 | transform: scale(1.05); 59 | /* 减小缩放以避免多行时过度放大 */ 60 | box-shadow: var(--b3-elevation-3, 0 4px 8px rgba(0, 0, 0, 0.15)); 61 | } 62 | 63 | .date-badge .emoji { 64 | display: inline-block; 65 | line-height: 1; 66 | font-size: 20px; 67 | flex-shrink: 0; 68 | } 69 | 70 | /* 2-3个emoji时缩小 */ 71 | .date-badge.count-2 .emoji, 72 | .date-badge.count-3 .emoji { 73 | font-size: 16px; 74 | } 75 | 76 | /* 4-6个emoji时更小 */ 77 | .date-badge.count-4 .emoji, 78 | .date-badge.count-5 .emoji, 79 | .date-badge.count-6 .emoji { 80 | font-size: 12px; 81 | } 82 | 83 | /* 7个或更多emoji时最小 */ 84 | .date-badge.count-many .emoji { 85 | font-size: 10px; 86 | } 87 | 88 | /* 兼容旧的 multi 类 */ 89 | .date-badge.multi .emoji { 90 | font-size: 12px; 91 | } 92 | 93 | /* 空状态样式 - 灰色圆圈 */ 94 | .date-badge.empty-badge { 95 | background: var(--b3-theme-surface-lighter); 96 | opacity: 0.3; 97 | box-shadow: none; 98 | } 99 | 100 | .date-badge.empty-badge:hover { 101 | opacity: 0.8; 102 | background: var(--b3-theme-surface-light); 103 | } 104 | 105 | /* Grid 布局样式 */ 106 | .habit-calendar-grid { 107 | padding-bottom: 20px; 108 | grid-auto-rows: minmax(auto, max-content); 109 | /* 确保行高度自适应内容 */ 110 | position: relative; 111 | /* 为sticky定位提供参考 */ 112 | min-width: min-content; 113 | /* 确保grid不会被压缩,允许水平滚动 */ 114 | } 115 | 116 | .grid-header { 117 | padding: 12px 8px; 118 | text-align: center; 119 | font-weight: bold; 120 | color: var(--b3-theme-on-surface); 121 | background: var(--b3-theme-background); 122 | position: sticky; 123 | top: 0; 124 | z-index: 10; 125 | border-bottom: 1px solid var(--b3-theme-surface-lighter); 126 | } 127 | 128 | .grid-header.habit-header { 129 | text-align: left; 130 | left: 0; 131 | z-index: 30; 132 | /* 比普通表头和普通单元格更高,防止滚动时被遮挡 */ 133 | padding-left: 16px; 134 | } 135 | 136 | .grid-header.date-header { 137 | font-size: 12px; 138 | color: var(--b3-theme-on-surface-light); 139 | } 140 | 141 | .grid-cell { 142 | padding: 8px; 143 | display: flex; 144 | align-items: center; 145 | justify-content: center; 146 | user-select: none; 147 | cursor: default; 148 | -webkit-user-select: none; 149 | -moz-user-select: none; 150 | -ms-user-select: none; 151 | } 152 | 153 | .grid-cell.habit-name-cell { 154 | justify-content: flex-start; 155 | font-weight: 500; 156 | position: sticky; 157 | left: 0; 158 | background: var(--b3-theme-background); 159 | z-index: 15; 160 | /* 比普通表头高,但比习惯表头低 */ 161 | padding-left: 16px; 162 | border-right: 1px solid var(--b3-theme-surface-lighter); 163 | /* 分隔线 */ 164 | } 165 | 166 | .grid-cell.date-cell { 167 | min-width: 52px; 168 | /* 移除固定高度,让内容决定高度 */ 169 | } 170 | 171 | /* 月视图需要更大的最小宽度 */ 172 | .month-view .grid-cell.date-cell { 173 | min-width: 60px; 174 | } 175 | 176 | 177 | /* 日历容器样式 */ 178 | .habit-calendar-container { 179 | padding: 20px; 180 | height: 100%; 181 | display: flex; 182 | flex-direction: column; 183 | background: var(--b3-theme-background); 184 | overflow: hidden; 185 | /* 防止容器滚动,让内容区域滚动 */ 186 | } 187 | 188 | .habit-calendar-toolbar { 189 | display: flex; 190 | justify-content: space-between; 191 | align-items: center; 192 | margin-bottom: 24px; 193 | flex-shrink: 0; 194 | position: sticky; 195 | /* 固定工具栏在顶部 */ 196 | top: 0; 197 | z-index: 10; 198 | background: var(--b3-theme-background); 199 | /* 确保背景不透明 */ 200 | padding: 10px 0; 201 | /* 添加内边距避免内容重叠 */ 202 | } 203 | 204 | .habit-calendar-view-toggle { 205 | display: flex; 206 | background: var(--b3-theme-surface); 207 | padding: 4px; 208 | border-radius: 6px; 209 | gap: 4px; 210 | } 211 | 212 | .habit-calendar-navigation { 213 | display: flex; 214 | gap: 12px; 215 | align-items: center; 216 | } 217 | 218 | .habit-calendar-date-label { 219 | font-weight: bold; 220 | min-width: 180px; 221 | text-align: center; 222 | font-size: 16px; 223 | } 224 | 225 | .habit-calendar-content { 226 | flex: 1; 227 | overflow: auto; 228 | /* 同时支持横向和纵向滚动 */ 229 | border: 1px solid var(--b3-theme-surface-lighter); 230 | border-radius: 8px; 231 | background: var(--b3-theme-background); 232 | position: relative; 233 | /* 为内部sticky元素提供滚动上下文 */ 234 | } 235 | 236 | /* 拖拽相关样式 */ 237 | .habit-name-cell { 238 | cursor: move; 239 | transition: all 0.2s ease; 240 | position: relative; 241 | } 242 | 243 | .habit-name-cell:hover { 244 | background: var(--b3-theme-surface-lighter); 245 | } 246 | 247 | .habit-name-cell.dragging { 248 | opacity: 0.5; 249 | background: var(--b3-theme-surface-lighter); 250 | } 251 | 252 | /* 拖拽到上方的指示 */ 253 | .habit-name-cell.drop-target-top { 254 | border-top: 3px solid var(--b3-theme-primary); 255 | background: var(--b3-theme-primary-lighter, rgba(64, 150, 255, 0.08)); 256 | } 257 | 258 | .habit-name-cell.drop-target-top::before { 259 | content: ''; 260 | position: absolute; 261 | top: -6px; 262 | left: 0; 263 | right: 0; 264 | height: 6px; 265 | background: linear-gradient(to bottom, var(--b3-theme-primary), transparent); 266 | opacity: 0.3; 267 | } 268 | 269 | /* 拖拽到下方的指示 */ 270 | .habit-name-cell.drop-target-bottom { 271 | border-bottom: 3px solid var(--b3-theme-primary); 272 | background: var(--b3-theme-primary-lighter, rgba(64, 150, 255, 0.08)); 273 | } 274 | 275 | .habit-name-cell.drop-target-bottom::after { 276 | content: ''; 277 | position: absolute; 278 | bottom: -6px; 279 | left: 0; 280 | right: 0; 281 | height: 6px; 282 | background: linear-gradient(to top, var(--b3-theme-primary), transparent); 283 | opacity: 0.3; 284 | } -------------------------------------------------------------------------------- /src/utils/lunarUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 农历工具类 3 | * 使用 lunar-typescript 库进行农历与公历转换 4 | */ 5 | import { Lunar, Solar } from 'lunar-typescript'; 6 | 7 | /** 8 | * 将公历日期转换为农历 9 | * @param solarDate 公历日期 (YYYY-MM-DD) 10 | * @returns 农历月份和日期 {month: number, day: number} 11 | */ 12 | export function solarToLunar(solarDate: string): { month: number; day: number } { 13 | const [year, month, day] = solarDate.split('-').map(Number); 14 | const solar = Solar.fromYmd(year, month, day); 15 | const lunar = solar.getLunar(); 16 | 17 | return { 18 | month: lunar.getMonth(), 19 | day: lunar.getDay() 20 | }; 21 | } 22 | 23 | /** 24 | * 将农历日期转换为公历 25 | * @param year 公历年份 26 | * @param lunarMonth 农历月份 (1-12) 27 | * @param lunarDay 农历日 (1-30) 28 | * @param isLeapMonth 是否闰月,默认false 29 | * @returns 公历日期字符串 (YYYY-MM-DD) 或 null(如果日期无效) 30 | */ 31 | export function lunarToSolar(year: number, lunarMonth: number, lunarDay: number, isLeapMonth: boolean = false): string | null { 32 | try { 33 | // 创建农历对象,如果是闰月需要特殊处理 34 | let lunar; 35 | if (isLeapMonth) { 36 | // 闰月用负数表示,例如闰四月为-4 37 | lunar = Lunar.fromYmd(year, -Math.abs(lunarMonth), lunarDay); 38 | } else { 39 | lunar = Lunar.fromYmd(year, lunarMonth, lunarDay); 40 | } 41 | 42 | const solar = lunar.getSolar(); 43 | 44 | const solarYear = solar.getYear(); 45 | const solarMonth = solar.getMonth().toString().padStart(2, '0'); 46 | const solarDay = solar.getDay().toString().padStart(2, '0'); 47 | 48 | return `${solarYear}-${solarMonth}-${solarDay}`; 49 | } catch (error) { 50 | console.error('Invalid lunar date:', error); 51 | return null; 52 | } 53 | } 54 | 55 | /** 56 | * 获取下一个农历月的公历日期 57 | * @param currentDate 当前公历日期 (YYYY-MM-DD) 58 | * @param lunarDay 农历日 (1-30) 59 | * @returns 下一个对应农历日的公历日期 (YYYY-MM-DD) 60 | */ 61 | export function getNextLunarMonthlyDate(currentDate: string, lunarDay: number): string | null { 62 | const [year, month, day] = currentDate.split('-').map(Number); 63 | const solar = Solar.fromYmd(year, month, day); 64 | const lunar = solar.getLunar(); 65 | 66 | // 先尝试当月的农历日期 67 | let nextLunarMonth = lunar.getMonth(); 68 | let nextLunarYear = lunar.getYear(); 69 | 70 | // 如果当前农历日期还没到指定日期,使用当月 71 | if (lunar.getDay() < lunarDay) { 72 | const solarDate = lunarToSolar(nextLunarYear, nextLunarMonth, lunarDay); 73 | if (solarDate && solarDate > currentDate) { 74 | return solarDate; 75 | } 76 | } 77 | 78 | // 否则,使用下个月 79 | nextLunarMonth += 1; 80 | if (nextLunarMonth > 12) { 81 | nextLunarMonth = 1; 82 | nextLunarYear += 1; 83 | } 84 | 85 | return lunarToSolar(nextLunarYear, nextLunarMonth, lunarDay); 86 | } 87 | 88 | /** 89 | * 获取下一个农历年的公历日期 90 | * @param currentDate 当前公历日期 (YYYY-MM-DD) 91 | * @param lunarMonth 农历月份 (1-12) 92 | * @param lunarDay 农历日 (1-30) 93 | * @returns 下一个对应农历日期的公历日期 (YYYY-MM-DD) 94 | */ 95 | export function getNextLunarYearlyDate(currentDate: string, lunarMonth: number, lunarDay: number): string | null { 96 | const [year, month, day] = currentDate.split('-').map(Number); 97 | const solar = Solar.fromYmd(year, month, day); 98 | const lunar = solar.getLunar(); 99 | 100 | let nextLunarYear = lunar.getYear(); 101 | 102 | // 先尝试今年的农历日期 103 | const thisYearDate = lunarToSolar(nextLunarYear, lunarMonth, lunarDay); 104 | if (thisYearDate && thisYearDate > currentDate) { 105 | return thisYearDate; 106 | } 107 | 108 | // 否则,使用明年 109 | nextLunarYear += 1; 110 | return lunarToSolar(nextLunarYear, lunarMonth, lunarDay); 111 | } 112 | 113 | /** 114 | * 解析农历日期文本,例如 "八月廿一"、"正月初一"、"农历七月十三" 115 | * @param text 农历日期文本 116 | * @returns {month: number, day: number} 或 null 117 | */ 118 | export function parseLunarDateText(text: string): { month: number; day: number } | null { 119 | // 预处理:移除出现的所有"农历"关键字(不局限于开头),并去除多余空白 120 | let processedText = text.replace(/农历/g, '').trim(); 121 | 122 | // 农历月份映射 123 | const lunarMonthMap: { [key: string]: number } = { 124 | '正月': 1, '一月': 1, 125 | '二月': 2, 126 | '三月': 3, 127 | '四月': 4, 128 | '五月': 5, 129 | '六月': 6, 130 | '七月': 7, 131 | '八月': 8, 132 | '九月': 9, 133 | '十月': 10, 134 | '冬月': 11, '十一月': 11, 135 | '腊月': 12, '十二月': 12 136 | }; 137 | 138 | // 农历日期映射 139 | const lunarDayMap: { [key: string]: number } = { 140 | '初一': 1, '初二': 2, '初三': 3, '初四': 4, '初五': 5, 141 | '初六': 6, '初七': 7, '初八': 8, '初九': 9, '初十': 10, 142 | '十一': 11, '十二': 12, '十三': 13, '十四': 14, '十五': 15, 143 | '十六': 16, '十七': 17, '十八': 18, '十九': 19, '二十': 20, 144 | '廿一': 21, '廿二': 22, '廿三': 23, '廿四': 24, '廿五': 25, 145 | '廿六': 26, '廿七': 27, '廿八': 28, '廿九': 29, '三十': 30 146 | }; 147 | 148 | // 先尝试匹配数字月份 + 数字日期/中文日期(例如:"7月13"、"7月13日"、"7月十三"、"7月 廿一") 149 | const numericMonthDayRegex = /(\d{1,2})月\s*([\d]{1,2}|[初十廿一二三四五六七八九零十百]+)(?:日|号)?/; 150 | const numericMonthDayMatch = processedText.match(numericMonthDayRegex); 151 | if (numericMonthDayMatch) { 152 | const month = parseInt(numericMonthDayMatch[1], 10); 153 | let dayText = numericMonthDayMatch[2]; 154 | let day = parseInt(dayText, 10); 155 | if (isNaN(day)) { 156 | // 不是纯数字,尝试从映射表中解析中文日期(如 十三、廿一 等) 157 | dayText = dayText.trim(); 158 | day = lunarDayMap[dayText] || NaN; 159 | } 160 | 161 | if (!isNaN(month) && !isNaN(day)) { 162 | return { month, day }; 163 | } 164 | } 165 | 166 | // 再尝试匹配中文月份 + 中文日期(例如:"八月廿一"、"正月初一") 167 | const monthDayPattern = /^(.+月)(.+)$/; 168 | const match = processedText.match(monthDayPattern); 169 | 170 | if (match) { 171 | const monthText = match[1]; 172 | const dayText = match[2]; 173 | 174 | const month = lunarMonthMap[monthText]; 175 | let day = lunarDayMap[dayText]; 176 | 177 | // 如果日部分是阿拉伯数字(例如 "13"),将其解析为数字 178 | if ((!day || day === undefined) && /^\d{1,2}$/.test(dayText.trim())) { 179 | const dayNum = parseInt(dayText.trim(), 10); 180 | if (dayNum >= 1 && dayNum <= 30) { 181 | day = dayNum; 182 | } 183 | } 184 | 185 | if (month && day) { 186 | return { month, day }; 187 | } 188 | } 189 | 190 | // 只匹配日期 "廿一"、"初一" 等(没有提到月份的情况) 191 | const day = lunarDayMap[processedText]; 192 | if (day) { 193 | return { month: 0, day }; // month 为 0 表示只有日期 194 | } 195 | 196 | // 也尝试纯数字日期(如:"13日"、"13号"、"13")但不指定月份 197 | const numericDayMatch = processedText.match(/^(?:第)?(\d{1,2})(?:日|号)?$/); 198 | if (numericDayMatch) { 199 | const dayNum = parseInt(numericDayMatch[1], 10); 200 | if (!isNaN(dayNum) && dayNum >= 1 && dayNum <= 30) { 201 | return { month: 0, day: dayNum }; 202 | } 203 | } 204 | 205 | return null; 206 | } 207 | 208 | /** 209 | * 将当前年份的农历日期转换为公历日期字符串 210 | * @param lunarMonth 农历月份 211 | * @param lunarDay 农历日 212 | * @returns 公历日期字符串 (YYYY-MM-DD) 213 | */ 214 | export function getCurrentYearLunarToSolar(lunarMonth: number, lunarDay: number): string | null { 215 | const currentYear = new Date().getFullYear(); 216 | return lunarToSolar(currentYear, lunarMonth, lunarDay); 217 | } 218 | 219 | /** 220 | * 将农历月份数字转换为中文 221 | * @param month 农历月份 (1-12) 222 | * @returns 中文月份名称 223 | */ 224 | export function formatLunarMonth(month: number): string { 225 | const lunarMonths = ['', '正月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '冬月', '腊月']; 226 | return lunarMonths[month] || `${month}月`; 227 | } 228 | 229 | /** 230 | * 将农历日期数字转换为中文 231 | * @param day 农历日期 (1-30) 232 | * @returns 中文日期 233 | */ 234 | export function formatLunarDay(day: number): string { 235 | const lunarDays = [ 236 | '', '初一', '初二', '初三', '初四', '初五', 237 | '初六', '初七', '初八', '初九', '初十', 238 | '十一', '十二', '十三', '十四', '十五', 239 | '十六', '十七', '十八', '十九', '二十', 240 | '廿一', '廿二', '廿三', '廿四', '廿五', 241 | '廿六', '廿七', '廿八', '廿九', '三十' 242 | ]; 243 | return lunarDays[day] || `${day}日`; 244 | } 245 | 246 | /** 247 | * 格式化完整的农历日期为中文 248 | * @param month 农历月份 (1-12) 249 | * @param day 农历日期 (1-30) 250 | * @returns 格式化的农历日期,如 "八月十五" 251 | */ 252 | export function formatLunarDate(month: number, day: number): string { 253 | return `${formatLunarMonth(month)}${formatLunarDay(day)}`; 254 | } 255 | 256 | /** 257 | * 获取公历日期对应的农历日期字符串 258 | * @param solarDate 公历日期 (YYYY-MM-DD) 259 | * @returns 农历日期字符串,如 "八月十五" 260 | */ 261 | export function getSolarDateLunarString(solarDate: string): string { 262 | try { 263 | const lunar = solarToLunar(solarDate); 264 | return formatLunarDate(lunar.month, lunar.day); 265 | } catch (error) { 266 | console.error('Failed to get lunar string:', error); 267 | return ''; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/styles/doc-reminder.scss: -------------------------------------------------------------------------------- 1 | // 文档提醒对话框样式 2 | .document-reminder-dialog { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | padding: 12px; 7 | background: var(--b3-theme-background); 8 | } 9 | 10 | .doc-reminder-header { 11 | margin-bottom: 16px; 12 | padding-bottom: 12px; 13 | border-bottom: 1px solid var(--b3-border-color); 14 | } 15 | 16 | .doc-reminder-toolbar { 17 | display: flex; 18 | justify-content: space-between; 19 | align-items: center; 20 | gap: 12px; 21 | margin-bottom: 12px; 22 | 23 | @media (max-width: 600px) { 24 | flex-direction: column; 25 | gap: 8px; 26 | } 27 | } 28 | 29 | .doc-reminder-filters { 30 | display: flex; 31 | gap: 8px; 32 | align-items: center; 33 | 34 | .b3-select { 35 | min-width: 100px; 36 | } 37 | 38 | @media (max-width: 600px) { 39 | flex-wrap: wrap; 40 | } 41 | } 42 | 43 | .doc-sort-order-btn { 44 | display: flex; 45 | align-items: center; 46 | gap: 4px; 47 | white-space: nowrap; 48 | 49 | .b3-button__icon { 50 | width: 14px; 51 | height: 14px; 52 | } 53 | 54 | span { 55 | font-size: 12px; 56 | } 57 | } 58 | 59 | .doc-reminder-search { 60 | .doc-search-input { 61 | min-width: 200px; 62 | 63 | @media (max-width: 600px) { 64 | min-width: 150px; 65 | } 66 | } 67 | } 68 | 69 | .doc-reminder-stats { 70 | .doc-reminder-count { 71 | font-size: 13px; 72 | color: var(--b3-theme-on-surface-light); 73 | display: flex; 74 | align-items: center; 75 | gap: 4px; 76 | 77 | &::before { 78 | content: "📊"; 79 | font-size: 14px; 80 | } 81 | } 82 | } 83 | 84 | .doc-reminder-content { 85 | flex: 1; 86 | overflow: hidden; 87 | display: flex; 88 | flex-direction: column; 89 | } 90 | 91 | .doc-reminders-container { 92 | flex: 1; 93 | overflow-y: auto; 94 | padding: 8px 0; 95 | } 96 | 97 | .doc-reminder-loading, 98 | .doc-reminder-empty, 99 | .doc-reminder-error { 100 | text-align: center; 101 | color: var(--b3-theme-on-surface-light); 102 | padding: 40px 20px; 103 | font-style: italic; 104 | } 105 | 106 | .doc-reminder-error { 107 | color: var(--b3-theme-error); 108 | } 109 | 110 | // 文档提醒项样式 111 | .doc-reminder-item { 112 | display: flex; 113 | align-items: flex-start; 114 | gap: 12px; 115 | padding: 12px 16px; 116 | margin-bottom: 8px; 117 | border: 1px solid var(--b3-theme-surface-lighter); 118 | border-radius: 8px; 119 | background: var(--b3-theme-surface); 120 | transition: all 0.3s ease; 121 | position: relative; 122 | overflow: hidden; 123 | 124 | &:hover { 125 | background: var(--b3-theme-surface-light); 126 | transform: translateY(-1px); 127 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 128 | 129 | &::before { 130 | transform: scaleY(1); 131 | } 132 | } 133 | 134 | // 过期状态 135 | &--overdue { 136 | border-color: var(--b3-theme-error-light); 137 | background: var(--b3-theme-error-lighter); 138 | } 139 | 140 | // 跨天事件状态 141 | &--spanning { 142 | border-left: 3px solid var(--b3-theme-primary); 143 | } 144 | 145 | // 复选框 146 | input[type="checkbox"] { 147 | margin: 4px 0 0 0; 148 | flex-shrink: 0; 149 | } 150 | 151 | // 手机端样式优化 152 | @media (max-width: 768px) { 153 | padding: 8px 12px; 154 | gap: 8px; 155 | margin-bottom: 6px; 156 | border-radius: 6px; 157 | 158 | input[type="checkbox"] { 159 | margin: 2px 0 0 0; 160 | transform: scale(0.9); 161 | } 162 | } 163 | } 164 | 165 | .doc-reminder-item__info { 166 | flex: 1; 167 | min-width: 0; 168 | } 169 | 170 | .doc-reminder-item__title { 171 | font-size: 14px; 172 | font-weight: 500; 173 | color: var(--b3-theme-on-background); 174 | margin-bottom: 6px; 175 | display: block; 176 | text-decoration: none; 177 | overflow: hidden; 178 | text-overflow: ellipsis; 179 | white-space: nowrap; 180 | line-height: 1.3; 181 | cursor: pointer; 182 | transition: color 0.2s ease; 183 | 184 | &:hover { 185 | color: var(--b3-theme-primary); 186 | text-decoration: underline; 187 | } 188 | 189 | @media (max-width: 768px) { 190 | font-size: 13px; 191 | margin-bottom: 4px; 192 | line-height: 1.2; 193 | } 194 | } 195 | 196 | .doc-reminder-item__time { 197 | font-size: 12px; 198 | color: var(--b3-theme-on-surface-light); 199 | margin-bottom: 4px; 200 | display: flex; 201 | align-items: center; 202 | gap: 8px; 203 | flex-wrap: wrap; 204 | 205 | @media (max-width: 768px) { 206 | font-size: 11px; 207 | margin-bottom: 3px; 208 | gap: 6px; 209 | } 210 | } 211 | 212 | .doc-reminder-repeat-icon { 213 | font-size: 11px; 214 | opacity: 0.7; 215 | cursor: help; 216 | } 217 | 218 | .doc-reminder-priority-label { 219 | display: inline-flex; 220 | align-items: center; 221 | gap: 2px; 222 | font-size: 10px; 223 | padding: 1px 4px; 224 | border-radius: 2px; 225 | font-weight: 500; 226 | 227 | &.high { 228 | background-color: rgba(231, 76, 60, 0.1); 229 | color: #e74c3c; 230 | } 231 | 232 | &.medium { 233 | background-color: rgba(243, 156, 18, 0.1); 234 | color: #f39c12; 235 | } 236 | 237 | &.low { 238 | background-color: rgba(52, 152, 219, 0.1); 239 | color: #3498db; 240 | } 241 | 242 | .priority-dot { 243 | width: 6px; 244 | height: 6px; 245 | border-radius: 50%; 246 | flex-shrink: 0; 247 | 248 | &.high { 249 | background-color: #e74c3c; 250 | } 251 | 252 | &.medium { 253 | background-color: #f39c12; 254 | } 255 | 256 | &.low { 257 | background-color: #3498db; 258 | } 259 | } 260 | } 261 | 262 | .doc-reminder-overdue-label { 263 | color: var(--b3-theme-error); 264 | font-weight: 500; 265 | font-size: 10px; 266 | padding: 1px 4px; 267 | background: var(--b3-theme-error-lighter); 268 | border-radius: 2px; 269 | } 270 | 271 | .doc-reminder-category-tag { 272 | // 样式已在创建元素时内联定义 273 | } 274 | 275 | .doc-reminder-item__note { 276 | font-size: 12px; 277 | color: var(--b3-card-success-color); 278 | margin-top: 4px; 279 | padding: 4px 8px; 280 | background: var(--b3-card-success-background); 281 | border-radius: 4px; 282 | white-space: pre-wrap; 283 | word-break: break-word; 284 | border: 1px solid var(--b3-card-success-color); 285 | 286 | @media (max-width: 768px) { 287 | font-size: 11px; 288 | margin-top: 3px; 289 | padding: 3px 6px; 290 | border-radius: 3px; 291 | } 292 | } 293 | 294 | .doc-reminder-completed-time { 295 | font-size: 11px; 296 | color: var(--b3-theme-on-surface); 297 | opacity: 0.7; 298 | margin-top: 4px; 299 | display: flex; 300 | align-items: center; 301 | gap: 4px; 302 | 303 | @media (max-width: 768px) { 304 | font-size: 10px; 305 | margin-top: 3px; 306 | gap: 3px; 307 | } 308 | } 309 | 310 | .doc-reminder-item__actions { 311 | flex-shrink: 0; 312 | margin-left: 8px; 313 | 314 | .b3-button--small { 315 | padding: 4px 8px; 316 | font-size: 11px; 317 | border-radius: 4px; 318 | transition: all 0.2s ease; 319 | 320 | &:hover { 321 | transform: translateY(-1px); 322 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 323 | } 324 | } 325 | 326 | @media (max-width: 768px) { 327 | margin-left: 4px; 328 | 329 | .b3-button--small { 330 | padding: 3px 6px; 331 | font-size: 10px; 332 | border-radius: 3px; 333 | } 334 | } 335 | } 336 | 337 | // 优先级颜色样式 - 文档提醒项 338 | .doc-reminder-priority-high { 339 | border-left: 4px solid var(--b3-card-error-color) !important; 340 | background-color: var(--b3-card-error-background) !important; 341 | } 342 | 343 | .doc-reminder-priority-high .doc-reminder-item__note { 344 | border: 2px solid var(--b3-card-error-color) !important; 345 | color: var(--b3-card-error-color) !important; 346 | background-color: transparent !important; 347 | } 348 | 349 | .doc-reminder-priority-medium { 350 | border-left: 4px solid var(--b3-card-warning-color) !important; 351 | background-color: var(--b3-card-warning-background) !important; 352 | } 353 | 354 | .doc-reminder-priority-medium .doc-reminder-item__note { 355 | color: var(--b3-card-warning-color) !important; 356 | background-color: var(--b3-card-warning-background) !important; 357 | border: 2px solid var(--b3-card-warning-color) !important; 358 | } 359 | 360 | .doc-reminder-priority-low { 361 | border-left: 4px solid var(--b3-card-info-color) !important; 362 | background-color: var(--b3-card-info-background) !important; 363 | } 364 | 365 | .doc-reminder-priority-low .doc-reminder-item__note { 366 | color: var(--b3-card-info-color) !important; 367 | background-color: var(--b3-card-info-background) !important; 368 | border: 2px solid var(--b3-card-info-color) !important; 369 | } 370 | 371 | .doc-reminder-priority-none .doc-reminder-item__note { 372 | color: var(--b3-theme-on-background) !important; 373 | background-color: transparent !important; 374 | border: 2px solid var(--b3-border-color) !important; 375 | } 376 | 377 | // 响应式设计 378 | @media (max-width: 480px) { 379 | .doc-reminder-item { 380 | padding: 6px 10px; 381 | gap: 6px; 382 | margin-bottom: 4px; 383 | 384 | input[type="checkbox"] { 385 | align-self: flex-start; 386 | transform: scale(0.85); 387 | } 388 | } 389 | 390 | .doc-reminder-item__title { 391 | font-size: 12px; 392 | margin-bottom: 3px; 393 | } 394 | 395 | .doc-reminder-item__time { 396 | font-size: 10px; 397 | margin-bottom: 2px; 398 | gap: 4px; 399 | } 400 | 401 | .doc-reminder-item__note { 402 | font-size: 10px; 403 | margin-top: 2px; 404 | padding: 2px 4px; 405 | } 406 | 407 | .doc-reminder-completed-time { 408 | font-size: 9px; 409 | margin-top: 2px; 410 | gap: 2px; 411 | } 412 | 413 | .doc-reminder-item__actions { 414 | margin-left: 2px; 415 | 416 | .b3-button--small { 417 | padding: 2px 4px; 418 | font-size: 9px; 419 | } 420 | } 421 | 422 | .doc-reminder-priority-label { 423 | font-size: 9px; 424 | padding: 1px 3px; 425 | } 426 | 427 | .doc-reminder-overdue-label { 428 | font-size: 9px; 429 | padding: 1px 3px; 430 | } 431 | } -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for SiYuan Task Note Management Plugin 2 | 3 | ## Project Overview 4 | 5 | This is a task management plugin for SiYuan Notes, developed to practice the Bullet Journal method. The plugin supports document and block reminders, calendar view for scheduling, Pomodoro timer for focus, project kanban boards, Eisenhower Matrix view, and more. 6 | 7 | **Key Philosophy**: This plugin is built around the "Bullet Journal Method" (防弹笔记法), focusing on task-centered note-taking rather than just material organization. Each note should revolve around a specific task with clear action purposes and steps. 8 | 9 | ## Technology Stack 10 | 11 | - **Language**: TypeScript 12 | - **UI Framework**: Svelte 13 | - **Build Tool**: Vite 14 | - **Plugin API**: SiYuan Plugin API 15 | - **Date/Time Parsing**: chrono-node 16 | - **Charts**: ECharts 17 | - **Internationalization**: Custom YAML-based i18n system 18 | 19 | ## Project Structure 20 | 21 | ``` 22 | src/ 23 | ├── index.ts # Main plugin entry point 24 | ├── api.ts # API functions for SiYuan integration 25 | ├── components/ # UI components (dialogs, panels, views) 26 | ├── libs/ # Utility libraries 27 | ├── types/ # TypeScript type definitions 28 | ├── utils/ # Helper utilities 29 | ├── SettingPanel.svelte # Settings UI component 30 | └── index.scss # Main styles 31 | 32 | public/i18n/ # Internationalization files 33 | ├── en_US.json 34 | ├── zh_CN.json 35 | └── README.md 36 | 37 | scripts/ # Development and build scripts 38 | ├── make_dev_copy.js # Copy plugin to SiYuan for development 39 | ├── make_dev_link.js # Create symbolic links for development 40 | ├── make_install.js # Install plugin to SiYuan 41 | └── utils.js # Script utilities 42 | ``` 43 | 44 | ## Coding Conventions 45 | 46 | ### TypeScript/JavaScript 47 | 48 | 1. **File Headers**: Include copyright headers with author, date, and description: 49 | ```typescript 50 | /* 51 | * Copyright (c) 2024 by [author]. All Rights Reserved. 52 | * @Author : [author] 53 | * @Date : [date] 54 | * @FilePath : /path/to/file 55 | * @LastEditTime : [date] 56 | * @Description : [description] 57 | */ 58 | ``` 59 | 60 | 2. **Naming Conventions**: 61 | - Classes: PascalCase (e.g., `ReminderDialog`, `ProjectPanel`) 62 | - Functions/Methods: camelCase (e.g., `loadSettings`, `createDocWithMd`) 63 | - Constants: UPPER_SNAKE_CASE (e.g., `STORAGE_NAME`, `TAB_TYPE`) 64 | - Private class members: prefix with underscore or use TypeScript private modifier 65 | 66 | 3. **Code Style**: 67 | - Use async/await for asynchronous operations 68 | - Prefer const over let when variables won't be reassigned 69 | - Use descriptive variable names, especially for Chinese contexts (可以使用中文注释) 70 | 71 | 4. **Error Handling**: 72 | - Always use try-catch for async operations 73 | - Show user-friendly error messages using `showMessage()` 74 | - Log detailed errors to console for debugging 75 | 76 | ### Svelte Components 77 | 78 | 1. Use TypeScript in Svelte files with `