├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierignore ├── README.md ├── design ├── focus.md ├── habit.md ├── progressbar.md ├── reward.md ├── side-handler.md ├── task-view-calendar-gantt.md ├── task-view.md ├── tasks-filter.md └── workflow.md ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── media ├── Forecast.png ├── Table.png ├── example.gif ├── example.webp ├── task-genius-view.jpg └── task-genius.svg ├── package.json ├── pnpm-lock.yaml ├── src ├── __mocks__ │ ├── codemirror-language.ts │ ├── codemirror-search.ts │ ├── codemirror-state.ts │ ├── codemirror-view.ts │ ├── obsidian.ts │ └── styleMock.js ├── __tests__ │ ├── autoCompleteParent.test.ts │ ├── cycleCompleteStatus.test.ts │ ├── findContinuousTaskBlocks.test.ts │ ├── mockUtils.ts │ ├── sortTasks.test.ts │ └── types.d.ts ├── commands │ ├── completedTaskMover.ts │ ├── sortTaskCommands.ts │ ├── taskCycleCommands.ts │ └── taskMover.ts ├── common │ ├── default-symbol.ts │ ├── regex-define.ts │ ├── setting-definition.ts │ └── task-status │ │ ├── AnuPpuccinThemeCollection.ts │ │ ├── AuraThemeCollection.ts │ │ ├── BorderThemeCollection.ts │ │ ├── EbullientworksThemeCollection.ts │ │ ├── ITSThemeCollection.ts │ │ ├── LYTModeThemeCollection.ts │ │ ├── MinimalThemeCollection.ts │ │ ├── StatusCollections.d.ts │ │ ├── ThingsThemeCollection.ts │ │ ├── index.ts │ │ └── readme.md ├── components │ ├── AutoComplete.ts │ ├── ConfirmModal.ts │ ├── DragManager.ts │ ├── HabitEditDialog.ts │ ├── HabitSettingList.ts │ ├── IconMenu.ts │ ├── MarkdownRenderer.ts │ ├── QuickCaptureModal.ts │ ├── RewardModal.ts │ ├── StageEditModal.ts │ ├── StatusComponent.ts │ ├── ViewComponentManager.ts │ ├── ViewConfigModal.ts │ ├── WorkflowDefinitionModal.ts │ ├── calendar │ │ ├── algorithm.ts │ │ ├── index.ts │ │ ├── rendering │ │ │ └── event-renderer.ts │ │ └── views │ │ │ ├── agenda-view.ts │ │ │ ├── base-view.ts │ │ │ ├── day-view.ts │ │ │ ├── month-view.ts │ │ │ ├── week-view.ts │ │ │ └── year-view.ts │ ├── date-picker │ │ ├── DatePickerComponent.ts │ │ ├── DatePickerModal.ts │ │ ├── DatePickerPopover.ts │ │ └── index.ts │ ├── gantt │ │ ├── gantt.ts │ │ ├── grid-background.ts │ │ ├── task-renderer.ts │ │ └── timeline-header.ts │ ├── habit │ │ ├── habit.ts │ │ └── habitcard │ │ │ ├── counthabitcard.ts │ │ │ ├── dailyhabitcard.ts │ │ │ ├── habitcard.ts │ │ │ ├── index.ts │ │ │ ├── mappinghabitcard.ts │ │ │ └── scheduledhabitcard.ts │ ├── inview-filter │ │ ├── custom │ │ │ └── scroll-to-date-button.ts │ │ ├── filter-dropdown.ts │ │ ├── filter-pill.ts │ │ ├── filter-type.ts │ │ ├── filter.css │ │ └── filter.ts │ ├── kanban │ │ ├── kanban-card.ts │ │ ├── kanban-column.ts │ │ └── kanban.ts │ ├── readModeProgressbarWidget.ts │ ├── readModeTextMark.ts │ ├── table │ │ ├── TableEditor.ts │ │ ├── TableHeader.ts │ │ ├── TableRenderer.ts │ │ ├── TableTypes.ts │ │ ├── TableView.ts │ │ ├── TableViewAdapter.ts │ │ ├── TreeManager.ts │ │ ├── VirtualScrollManager.ts │ │ └── index.ts │ ├── task-edit │ │ ├── MetadataEditor.ts │ │ ├── TaskDetailsModal.ts │ │ └── TaskDetailsPopover.ts │ ├── task-filter │ │ ├── FilterConfigModal.ts │ │ ├── ViewTaskFilter.ts │ │ ├── ViewTaskFilterModal.ts │ │ ├── ViewTaskFilterPopover.ts │ │ └── index.ts │ └── task-view │ │ ├── InlineEditor.ts │ │ ├── InlineEditorManager.ts │ │ ├── ProjectViewComponent.ts │ │ ├── TagViewComponent.ts │ │ ├── TaskList.ts │ │ ├── TaskPropertyTwoColumnView.ts │ │ ├── TwoColumnViewBase.ts │ │ ├── calendar.ts │ │ ├── content.ts │ │ ├── details.ts │ │ ├── forecast.ts │ │ ├── listItem.ts │ │ ├── projects.ts │ │ ├── review.ts │ │ ├── sidebar.ts │ │ ├── tags.ts │ │ └── treeItem.ts ├── editor-ext │ ├── TaskGutterHandler.ts │ ├── autoCompleteParent.ts │ ├── autoDateManager.ts │ ├── cycleCompleteStatus.ts │ ├── datePicker.ts │ ├── filterTasks.ts │ ├── markdownEditor.ts │ ├── monitorTaskCompleted.ts │ ├── patchedGutter.ts │ ├── priorityPicker.ts │ ├── progressBarWidget.ts │ ├── quickCapture.ts │ ├── regexp-cursor.ts │ ├── taskStatusSwitcher.ts │ └── workflow.ts ├── icon.ts ├── index.ts ├── pages │ ├── FileTaskView.ts │ ├── TaskSpecificView.ts │ ├── TaskView.ts │ └── ViewManager.ts ├── setting.ts ├── styles │ ├── base-view.css │ ├── beta-warning.css │ ├── calendar.css │ ├── calendar │ │ ├── event.css │ │ └── view.css │ ├── date-picker.css │ ├── forecast.css │ ├── gantt │ │ └── gantt.css │ ├── global-filter.css │ ├── global.css │ ├── habit-edit-dialog.css │ ├── habit-list.css │ ├── habit.css │ ├── index.css │ ├── inline-editor.css │ ├── kanban │ │ └── kanban.css │ ├── modal.css │ ├── progressbar.css │ ├── project-view.css │ ├── property-view.css │ ├── quick-capture.css │ ├── review-view.css │ ├── reward.css │ ├── setting.css │ ├── table.css │ ├── tag-view.css │ ├── task-details.css │ ├── task-filter.css │ ├── task-gutter.css │ ├── task-list.css │ ├── task-status.css │ ├── tree-view.css │ ├── view-config.css │ ├── view-two-column-base.css │ └── view.css ├── translations │ ├── helper.ts │ ├── locale │ │ ├── ar.ts │ │ ├── cz.ts │ │ ├── da.ts │ │ ├── de.ts │ │ ├── en-gb.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── fr.ts │ │ ├── hi.ts │ │ ├── id.ts │ │ ├── it.ts │ │ ├── ja.ts │ │ ├── ko.ts │ │ ├── nl.ts │ │ ├── no.ts │ │ ├── pl.ts │ │ ├── pt-br.ts │ │ ├── pt.ts │ │ ├── ro.ts │ │ ├── ru.ts │ │ ├── tr.ts │ │ ├── uk.ts │ │ ├── zh-cn.ts │ │ └── zh-tw.ts │ ├── manager.ts │ └── types.ts ├── types │ ├── bases.d.ts │ ├── file-task.d.ts │ ├── habit-card.d.ts │ ├── obsidian-ex.d.ts │ └── task.d.ts ├── utils.ts └── utils │ ├── DateHelper.ts │ ├── FileTaskManager.ts │ ├── HabitManager.ts │ ├── RewardManager.ts │ ├── TaskFilterUtils.ts │ ├── TaskManager.ts │ ├── dateUtil.ts │ ├── fileUtils.ts │ ├── filterUtils.ts │ ├── goal │ ├── editMode.ts │ ├── readMode.ts │ └── regexGoal.ts │ ├── import │ ├── TaskIndexer.ts │ └── TaskParser.ts │ ├── persister.ts │ ├── taskUtil.ts │ ├── treeViewUtil.ts │ ├── types │ └── worker.d.ts │ └── workers │ ├── TaskIndex.worker.ts │ ├── TaskIndexWorkerMessage.ts │ ├── TaskWorkerManager.ts │ └── deferred.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | PLUGIN_NAME: obsidian-task-genius 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 22 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: 9 23 | - name: Build 24 | id: build 25 | run: | 26 | pnpm install 27 | pnpm run build 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Upload zip file 34 | id: upload-zip 35 | uses: actions/upload-release-asset@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | upload_url: ${{ github.event.release.upload_url }} 40 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 41 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 42 | asset_content_type: application/zip 43 | 44 | - name: Upload main.js 45 | id: upload-main 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ github.event.release.upload_url }} 51 | asset_path: ./main.js 52 | asset_name: main.js 53 | asset_content_type: text/javascript 54 | 55 | - name: Upload manifest.json 56 | id: upload-manifest 57 | uses: actions/upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | upload_url: ${{ github.event.release.upload_url }} 62 | asset_path: ./manifest.json 63 | asset_name: manifest.json 64 | asset_content_type: application/json 65 | 66 | - name: Upload styles.css 67 | id: upload-css 68 | uses: actions/upload-release-asset@v1 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | with: 72 | upload_url: ${{ github.event.release.upload_url }} 73 | asset_path: ./styles.css 74 | asset_name: styles.css 75 | asset_content_type: text/css 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # Zip 9 | *.zip 10 | *.rar 11 | 12 | # npm 13 | node_modules 14 | 15 | # Don't include the compiled main.js file in the repo. 16 | # They should be uploaded to GitHub releases instead. 17 | main.js 18 | 19 | # Exclude sourcemaps 20 | *.map 21 | 22 | # obsidian 23 | data.json 24 | 25 | # Exclude macOS Finder (System Explorer) View States 26 | .DS_Store 27 | 28 | styles.css 29 | 30 | package-lock.json 31 | 32 | # cursorrules 33 | .cursorrules 34 | 35 | # env 36 | .env 37 | 38 | # translations 39 | scripts 40 | translation-templates 41 | ._data.json 42 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Task Genius Logo 3 |

4 | 5 | Full documentation is available at [Docs](https://taskgenius.md/docs/getting-started). 6 | 7 | --- 8 | 9 | Task Genius is a comprehensive plugin for Obsidian designed to enhance your task and project management workflow. 10 | 11 | | Forecast | Inbox | 12 | | --- | --- | 13 | | ![Task Genius Forecast](./media/Forecast.png) | ![Task Genius Inbox](./media/Table.png) | 14 | 15 | ## Key Features 16 | 17 | | Feature | Description | 18 | | --- | --- | 19 | | **Task Progress Bars** | Visualize parent task completion with customizable graphical or text-based progress bars based on sub-task status. Supports headings and non-task list items. | 20 | | **Advanced Task Statuses & Cycling** | Define custom task statuses beyond `- [ ]` and `- [x]` (e.g., In Progress `[/]`, Planned `[?]`, Abandoned `[-]`). Easily cycle through statuses with clicks or commands. | 21 | | **Date & Priority Management** | Quickly add and modify due dates via a calendar picker (`📅 2023-12-25`) and assign priorities (🔺 Highest, ⏫ High, ..., [#A], [#B], [#C]) through context menus, commands, or clickable icons. | 22 | | **In-Editor Task Filtering** | Dynamically filter tasks within a note based on status, content, tags, and relationships (parent/child/sibling) using a toggleable panel. Save and reuse common filters as presets. | 23 | | **Task Mover** | Archive completed or specific sets of sub-tasks to a designated file using commands, keeping your active notes clean. | 24 | | **Quick Capture** | Rapidly capture tasks or notes to a specified file via an inline panel (`Alt+C`), a global command, or a full-featured modal for adding metadata. | 25 | | **Workflow Management** | Define multi-stage workflows (e.g., Todo -> Doing -> Review -> Done) and track tasks through them. Includes options for automatic timestamping, duration tracking, and next-task creation. | 26 | | **Dedicated Task View** | A powerful, unified interface (`Task Genius: Open Task Genius view`) to see, sort, filter, and manage all tasks across your vault. Includes modes like Inbox, Forecast, Tags, Projects, and Review. | 27 | | **Customizable Settings** | Extensive options to configure the appearance and behavior of all features. | 28 | | **Rewards** | Earn rewards for completing tasks. | 29 | | **Habit Tracking** | Track your habits with a dedicated view. | 30 | 31 | ## Installation 32 | 33 | Visit the [Installation](https://taskgenius.md/docs/installation) page for instructions. 34 | 35 | ## Support Me 36 | 37 | If you enjoy Task Genius and find it useful, please consider supporting my work by buying me a coffee! It helps me dedicate time to maintaining and improving the plugin. 38 | 39 | Buy Me A Coffee 40 | -------------------------------------------------------------------------------- /design/side-handler.md: -------------------------------------------------------------------------------- 1 | # Side Handler 功能设计文档 2 | 3 | ## 1. 概述 4 | 5 | Side Handler (侧边栏处理器或行号栏交互器) 是 Task Genius 插件中的一个增强交互功能。它利用编辑器 (CodeMirror) 的行号栏 (gutter) 区域,在用户点击特定任务相关的标记时,提供一个包含该任务详细信息的弹出层 (Popover 或 Modal)。用户可以直接在此弹出层中查看和快速修改任务的某些属性,旨在提升任务管理的便捷性和效率。 6 | 7 | ## 2. 核心功能 8 | 9 | - **Gutter 标记**: 在编辑器的行号栏为识别出的任务行显示一个可交互的标记。 10 | - **任务信息展示**: 点击 Gutter 标记后,根据平台类型(桌面或移动端)弹出相应的界面(Popover 或 Modal)。 11 | - **桌面端**: 默认显示一个紧凑的 Popover 菜单。 12 | - **移动端**: 默认显示一个功能更全面的 Modal 弹窗。 13 | - **快速信息概览**: 弹出层清晰展示任务的核心信息,例如:内容、状态、截止日期、优先级、标签等。 14 | - **便捷信息编辑**: 允许用户在弹出层内直接修改任务的多个属性,如状态、优先级、日期等。编辑功能参考 `TaskDetailsComponent` (`details.ts`) 的实现。 15 | - **动态界面切换**: 根据 `Platform.isDesktop` 自动判断并切换 Popover 与 Modal 的显示。 16 | - **上下文操作**: 17 | - 提供 "在文件中编辑" 的快捷入口,跳转到任务所在行。 18 | - 提供 "标记完成/未完成" 的快捷操作。 19 | 20 | ## 3. 交互设计 21 | 22 | ### 3.1 Gutter 交互 23 | 24 | - 当鼠标悬停在 Gutter 中的任务标记上时,标记高亮,并可显示 Tooltip 提示 (例如 "查看/编辑任务")。 25 | - 单击 Gutter 中的任务标记,触发弹出层(Popover 或 Modal)。 26 | 27 | ### 3.2 桌面端: Popover 菜单 28 | 29 | - 在桌面环境下 (`Platform.isDesktop === true`),点击 Gutter 标记后,在标记附近弹出一个非模态的 Popover。 30 | - Popover 内容区域将集成 `TaskDetailsComponent` 的核心展示和编辑能力。 31 | - Popover 应包含以下元素: 32 | - 任务内容预览 (只读或截断显示)。 33 | - 任务状态切换器 (使用 `StatusComponent`)。 34 | - 关键元数据展示与编辑 (例如:优先级、截止日期)。可参考 `details.ts` 中的 `showEditForm` 方法提供的字段。 35 | - 操作按钮: 36 | - "编辑详细信息" (可选,如果 Popover 只提供部分编辑,此按钮可打开一个更全面的 Modal 或跳转至任务详情视图)。 37 | - "在文件中编辑"。 38 | - "切换完成状态"。 39 | - 点击 Popover 外部区域或按下 `Esc` 键可关闭 Popover。 40 | 41 | ### 3.3 移动端: Modal 弹窗 42 | 43 | - 在非桌面环境(如移动端,`Platform.isDesktop === false`)下,点击 Gutter 标记后,屏幕中央弹出一个模态对话框 (Modal)。 44 | - Modal 的设计和实现可以参考 `QuickCaptureModal.ts` 的结构和交互模式,但内容主要用于展示和编辑现有任务,而非创建新任务。 45 | - Modal 内容将更全面地集成 `TaskDetailsComponent` 的功能,提供比 Popover 更丰富的编辑选项。 46 | - Modal 应包含: 47 | - 清晰的标题,如 "编辑任务"。 48 | - 完整的任务内容展示 (可编辑,参考 `details.ts` 的 `contentInput`)。 49 | - 任务状态选择 (参考 `StatusComponent`)。 50 | - 各项可编辑的任务元数据字段(如项目、标签、上下文、优先级、各项日期、重复规则等),布局和控件参考 `details.ts` 的 `showEditForm`。 51 | - 底部操作按钮: 52 | - "保存" 或 "应用更改"。 53 | - "取消"。 54 | - "在文件中编辑" (打开对应文件并定位)。 55 | - "切换完成状态"。 56 | 57 | ## 4. 数据展示与编辑 58 | 59 | 弹出层 (Popover/Modal) 中展示和允许编辑的任务信息主要基于 `Task`对象的属性,其实现逻辑参考 `src/components/task-view/details.ts` 中的 `TaskDetailsComponent`。 60 | 61 | ### 4.1 展示信息 62 | 63 | - 任务原始内容 ( `task.content` ) 64 | - 任务状态 ( `task.status`, 通过 `getStatus` 或 `StatusComponent` 展示) 65 | - 项目 ( `task.project` ) 66 | - 截止日期 ( `task.dueDate` ) 67 | - 开始日期 ( `task.startDate` ) 68 | - 计划日期 ( `task.scheduledDate` ) 69 | - 完成日期 ( `task.completedDate` ) 70 | - 优先级 ( `task.priority` ) 71 | - 标签 ( `task.tags` ) 72 | - 上下文 ( `task.context` ) 73 | - 重复规则 ( `task.recurrence` ) 74 | - 文件路径 ( `task.filePath` ) 75 | 76 | ### 4.2 可编辑信息 77 | 78 | 以下字段应允许用户在 Popover 或 Modal 中直接修改,修改逻辑和UI组件参考 `TaskDetailsComponent` 的 `showEditForm` 方法: 79 | 80 | - 任务内容 (`contentInput`) 81 | - 项目 (`projectInput` 与 `ProjectSuggest`) 82 | - 标签 (`tagsInput` 与 `TagSuggest`) 83 | - 上下文 (`contextInput` 与 `ContextSuggest`) 84 | - 优先级 (`priorityDropdown`) 85 | - 截止日期 (`dueDateInput`) 86 | - 开始日期 (`startDateInput`) 87 | - 计划日期 (`scheduledDateInput`) 88 | - 重复规则 (`recurrenceInput`) 89 | - 状态 (通过 `StatusComponent` 或类似的机制) 90 | 91 | 保存更新后的任务数据将调用 `onTaskUpdate` 回调,与 `TaskDetailsComponent` 中的保存逻辑类似,可能包含防抖处理。 92 | 93 | ## 5. UI 设计 94 | 95 | ### 5.1 Gutter Marker (行号栏标记) 96 | 97 | - 在任务行的行号栏显示一个简洁、直观的图标 (例如:一个小圆点、任务勾选框图标的变体、或者插件特有的图标)。 98 | - 标记的颜色或形态可以根据任务状态(例如,未完成、已完成)有细微变化。 99 | - 鼠标悬停时标记有视觉反馈(如放大、改变颜色)。 100 | 101 | ### 5.2 Popover (桌面端) 102 | 103 | - 设计应紧凑,避免遮挡过多编辑器内容。 104 | - 风格与 Obsidian 主题保持一致。 105 | - 包含任务核心信息和常用编辑字段。 106 | - 字段布局参考 `TaskDetailsComponent` 中非编辑状态下的信息排布,但控件为编辑形态。 107 | 108 | ### 5.3 Modal (移动端 / 详细编辑) 109 | 110 | - Modal 弹窗的设计参考 `QuickCaptureModal.ts` 的全功能模式 (`createFullFeaturedModal`),但侧重于编辑而非捕获。 111 | - 移除或调整文件目标选择器等不适用于编辑现有任务的元素。 112 | - 表单布局清晰,易于在小屏幕上操作。 113 | - 包含 `TaskDetailsComponent` `showEditForm` 中几乎所有的可编辑字段。 114 | - 提供明确的 "保存" 和 "取消" 按钮。 115 | 116 | ## 6. 实现要点 117 | 118 | 1. **CodeMirror Gutter API**: 119 | * 使用 CodeMirror 6 的 Gutter API (`gutter`, `lineMarker` 等) 来添加和管理行号栏标记。 120 | * 需要监听 Gutter 标记的点击事件。 121 | 2. **任务识别**: 122 | * 需要一种机制来确定哪些行是任务行,以便在这些行旁边显示 Gutter 标记。这可能依赖插件已有的任务解析逻辑。 123 | 3. **动态 UI 加载**: 124 | * 根据 `Platform.isDesktop` 的值,在点击事件回调中动态创建和显示 Popover (可能使用 Obsidian 的 `Menu` 或自定义浮动元素) 或 Modal (继承 Obsidian `Modal` 类)。 125 | 4. **组件复用**: 126 | * 尽可能复用 `TaskDetailsComponent` (`details.ts`) 中的任务信息展示逻辑、表单字段创建逻辑 (`createFormField`) 以及数据更新逻辑 (`onTaskUpdate`, `saveTask`)。 127 | * 对于 Modal 的基础框架,可以借鉴 `QuickCaptureModal.ts` 的结构,特别是其参数化配置和内容组织方式。 128 | 5. **状态管理**: 129 | * 确保 Popover/Modal 中的任务数据与原始任务数据同步。 130 | * 修改后正确更新任务对象,并通过事件或回调通知其他组件(如任务列表视图)刷新。 131 | 6. **性能考虑**: 132 | * Gutter 标记的渲染不应对编辑器性能产生显著影响,尤其是在处理大量任务时。 133 | 134 | ## 7. 开放问题与未来考虑 135 | 136 | - **Gutter 标记自定义**: 是否允许用户自定义 Gutter 标记的图标或行为? 137 | - **Popover/Modal 内容可配置性**: 是否允许用户选择在 Popover/Modal 中显示或编辑哪些字段? 138 | - **键盘可访问性**: 如何确保通过键盘也能方便地触发 Gutter 标记和操作弹出层? 139 | - **与其他视图的交互**: 编辑后,如何更流畅地更新其他打开的任务视图或日历视图? 140 | - **右键菜单集成**: 除了左键点击,是否考虑在 Gutter 标记上支持右键菜单,提供更多上下文操作(如复制任务链接、快速设置提醒等)? 141 | -------------------------------------------------------------------------------- /design/tasks-filter.md: -------------------------------------------------------------------------------- 1 | # Test Tasks for Advanced Filtering 2 | 3 | ## Basic Tasks 4 | 5 | - [ ] A simple task 6 | - [x] A completed task 7 | - [>] An in-progress task 8 | - [-] An abandoned task 9 | - [?] A planned task 10 | 11 | ## Tasks with Tags 12 | 13 | - [ ] Task with #tag1 14 | - [ ] Task with #tag2 and #tag3 15 | - [x] Completed task with #tag1 and #tag2 16 | - [ ] Task with #important #work 17 | 18 | ## Tasks with Priorities 19 | 20 | - [ ] [#A] High priority task 21 | - [ ] [#B] Medium priority task 22 | - [ ] [#C] Low priority task 23 | - [x] [#A] Completed high priority task 24 | - [ ] 🔺 Highest priority task (priorityPicker.ts标准) 25 | - [ ] ⏫ High priority task (priorityPicker.ts标准) 26 | - [ ] 🔼 Medium priority task (priorityPicker.ts标准) 27 | - [ ] 🔽 Low priority task (priorityPicker.ts标准) 28 | - [ ] ⏬️ Lowest priority task (priorityPicker.ts标准) 29 | - [ ] 🔴 High priority task (颜色优先级) 30 | - [ ] 🟠 Medium priority task (颜色优先级) 31 | - [ ] 🟡 Medium-low priority task (颜色优先级) 32 | - [ ] 🟢 Low priority task (颜色优先级) 33 | - [ ] 🔵 Low-lowest priority task (颜色优先级) 34 | - [ ] ⚪️ Lowest priority task (颜色优先级) 35 | - [ ] ⚫️ Below lowest priority task (颜色优先级) 36 | 37 | ## Tasks with Dates 38 | 39 | - [ ] Task due on 2023-05-15 40 | - [ ] Task due on 2023-08-22 41 | - [x] Completed task from 2022-01-10 42 | - [ ] Task planned for 2024-01-01 43 | - [ ] Meeting on 2023-07-15 with John #meeting 44 | 45 | ## Complex Tasks 46 | 47 | - [ ] [#A] Important task with #project1 due on 2023-06-30 48 | - [x] [#B] Completed task with #project1 and #project2 from 2023-04-15 49 | - [>] ⏫ In-progress high priority task with #urgent due tomorrow 2023-05-10 50 | - [ ] 🔽 Low priority task with #waiting #followup for 2023-09-01 51 | - [-] 🔼 Abandoned medium priority task from 2023-02-28 #cancelled 52 | 53 | ## Nested Tasks 54 | 55 | - [ ] Parent task 1 56 | - [ ] Child task 1.1 57 | - [x] Child task 1.2 58 | - [ ] Child task 1.3 59 | - [ ] Grandchild task 1.3.1 60 | - [>] Grandchild task 1.3.2 #inprogress 61 | - [ ] Parent task 2 [#A] with #important tag 62 | - [ ] Child task 2.1 due on 2023-07-20 63 | - [x] Child task 2.2 completed on 2023-06-15 64 | - [ ] Parent task 3 65 | - [-] Abandoned child task 3.1 66 | - [?] Planned child task 3.2 for 2023-10-01 67 | 68 | ## Advanced Filter Examples 69 | 70 | Here are some example filters you can try: 71 | 72 | 1. Find all highest priority tasks: `PRIORITY:🔺` 73 | 2. Find all high priority tasks: `PRIORITY:#A` or `PRIORITY:⏫` or `PRIORITY:🔴` 74 | 3. Find all tasks with medium priority or higher: `PRIORITY:<=#B` or `PRIORITY:<=🔼` 75 | 4. Find all tasks not with low priority: `PRIORITY:!=🔽` or `PRIORITY:!=🟢` 76 | 5. Find tasks due before August 2023: `DATE:<2023-08-01` 77 | 6. Find tasks due on or after January 1, 2024: `DATE:>=2024-01-01` 78 | 7. Find high priority tasks about projects: `(PRIORITY:⏫ OR PRIORITY:🔴) AND project` 79 | 8. Find tasks with tag1 that aren't completed: `#tag1 AND NOT [x]` 80 | 9. Find all high priority tasks that contain "important" or have the #urgent tag: `(PRIORITY:#A OR PRIORITY:⏫ OR PRIORITY:🔴) AND (important OR #urgent)` 81 | 10. Complex filter: `(#project1 OR #project2) AND (PRIORITY:<=🔼 OR PRIORITY:<=#B) AND DATE:>=2023-01-01 AND NOT (abandoned OR cancelled)` 82 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import fs from "fs"; 5 | import path from "path"; 6 | 7 | import inlineWorkerPlugin from "esbuild-plugin-inline-worker"; 8 | 9 | const banner = `/* 10 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 11 | if you want to view the source, please visit the github repository of this plugin 12 | */ 13 | `; 14 | 15 | // Custom plugin to add CSS settings comment at the top of the CSS file 16 | const cssSettingsPlugin = { 17 | name: "css-settings-plugin", 18 | setup(build) { 19 | build.onEnd(async (result) => { 20 | // Path to the output CSS file 21 | const cssOutfile = "styles.css"; 22 | 23 | // The settings comment to prepend 24 | const settingsComment = 25 | fs.readFileSync("src/styles/index.css", "utf8").split("*/")[0] + 26 | "*/\n\n"; 27 | 28 | if (fs.existsSync(cssOutfile)) { 29 | // Read the current content 30 | const cssContent = fs.readFileSync(cssOutfile, "utf8"); 31 | 32 | // Check if the settings comment is already there 33 | if (!cssContent.includes("/* @settings")) { 34 | // Prepend the settings comment 35 | fs.writeFileSync(cssOutfile, settingsComment + cssContent); 36 | } 37 | } 38 | }); 39 | }, 40 | }; 41 | 42 | const renamePlugin = { 43 | name: "rename-styles", 44 | setup(build) { 45 | build.onEnd(() => { 46 | const { outfile } = build.initialOptions; 47 | const outcss = outfile.replace(/\.js$/, ".css"); 48 | const fixcss = outfile.replace(/main\.js$/, "styles.css"); 49 | if (fs.existsSync(outcss)) { 50 | console.log("Renaming", outcss, "to", fixcss); 51 | fs.renameSync(outcss, fixcss); 52 | } 53 | }); 54 | }, 55 | }; 56 | 57 | const prod = process.argv[2] === "production"; 58 | 59 | esbuild 60 | .build({ 61 | banner: { 62 | js: banner, 63 | }, 64 | minify: prod ? true : false, 65 | entryPoints: ["src/index.ts"], 66 | plugins: [ 67 | inlineWorkerPlugin({ workerName: "Task Genius Indexer" }), 68 | 69 | renamePlugin, 70 | cssSettingsPlugin, 71 | ], 72 | bundle: true, 73 | external: [ 74 | "obsidian", 75 | "electron", 76 | "codemirror", 77 | "@codemirror/autocomplete", 78 | "@codemirror/closebrackets", 79 | "@codemirror/collab", 80 | "@codemirror/commands", 81 | "@codemirror/comment", 82 | "@codemirror/fold", 83 | "@codemirror/gutter", 84 | "@codemirror/highlight", 85 | "@codemirror/history", 86 | "@codemirror/language", 87 | "@codemirror/lint", 88 | "@codemirror/matchbrackets", 89 | "@codemirror/panel", 90 | "@codemirror/rangeset", 91 | "@codemirror/rectangular-selection", 92 | "@codemirror/search", 93 | "@codemirror/state", 94 | "@codemirror/stream-parser", 95 | "@codemirror/text", 96 | "@codemirror/tooltip", 97 | "@codemirror/view", 98 | "@lezer/common", 99 | "@lezer/lr", 100 | "@lezer/highlight", 101 | "obsidian-typings", 102 | ...builtins, 103 | ], 104 | format: "cjs", 105 | watch: !prod, 106 | target: "es2018", 107 | logLevel: "info", 108 | sourcemap: prod ? false : "inline", 109 | treeShaking: true, 110 | outfile: "main.js", 111 | pure: prod ? ["console.log"] : [], 112 | }) 113 | .catch(() => process.exit(1)); 114 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testMatch: ["**/__tests__/**/*.test.ts"], 5 | moduleNameMapper: { 6 | "^obsidian$": "/src/__mocks__/obsidian.ts", 7 | "^@codemirror/state$": "/src/__mocks__/codemirror-state.ts", 8 | "^@codemirror/view$": "/src/__mocks__/codemirror-view.ts", 9 | "^@codemirror/language$": 10 | "/src/__mocks__/codemirror-language.ts", 11 | "^@codemirror/search$": "/src/__mocks__/codemirror-search.ts", 12 | "\\.(css|less|scss|sass)$": "/src/__mocks__/styleMock.js", 13 | }, 14 | transform: { 15 | "^.+\\.tsx?$": [ 16 | "ts-jest", 17 | { 18 | tsconfig: "tsconfig.json", 19 | }, 20 | ], 21 | }, 22 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 23 | }; 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-task-progress-bar", 3 | "name": "Task Genius", 4 | "version": "8.10.1", 5 | "minAppVersion": "0.15.2", 6 | "description": "Comprehensive task management that includes progress bars, task status cycling, and advanced task tracking features.", 7 | "author": "Boninall", 8 | "authorUrl": "https://github.com/Quorafind", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /media/Forecast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-Task-Genius/5542fd8e4eb388d5f0d04fbe590a4781580f1399/media/Forecast.png -------------------------------------------------------------------------------- /media/Table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-Task-Genius/5542fd8e4eb388d5f0d04fbe590a4781580f1399/media/Table.png -------------------------------------------------------------------------------- /media/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-Task-Genius/5542fd8e4eb388d5f0d04fbe590a4781580f1399/media/example.gif -------------------------------------------------------------------------------- /media/example.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-Task-Genius/5542fd8e4eb388d5f0d04fbe590a4781580f1399/media/example.webp -------------------------------------------------------------------------------- /media/task-genius-view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-Task-Genius/5542fd8e4eb388d5f0d04fbe590a4781580f1399/media/task-genius-view.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-task-progress-bar", 3 | "version": "8.10.1", 4 | "description": "Comprehensive task management plugin for Obsidian with progress bars, task status cycling, and advanced task tracking features.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "e-t": "cross-env node scripts/extract-translations.cjs", 11 | "g-l": "cross-env node scripts/generate-locale-files.cjs", 12 | "test": "jest", 13 | "test:watch": "jest --watch" 14 | }, 15 | "keywords": [ 16 | "obsidian", 17 | "task", 18 | "progress", 19 | "bar", 20 | "task management", 21 | "task tracking", 22 | "task progress", 23 | "task status", 24 | "task cycle", 25 | "task marks" 26 | ], 27 | "author": "Boninall", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "@codemirror/language": "https://github.com/lishid/cm-language", 31 | "@codemirror/search": "^6.0.0", 32 | "@codemirror/state": "^6.5.2", 33 | "@codemirror/view": "^6.36.7", 34 | "@datastructures-js/queue": "^4.2.3", 35 | "@types/jest": "^29.5.0", 36 | "@types/node": "^16.11.6", 37 | "@typescript-eslint/eslint-plugin": "^5.2.0", 38 | "@typescript-eslint/parser": "^5.2.0", 39 | "builtin-modules": "^3.2.0", 40 | "codemirror": "^6.0.0", 41 | "cross-env": "^7.0.3", 42 | "esbuild": "0.13.12", 43 | "esbuild-plugin-inline-worker": "https://github.com/mitschabaude/esbuild-plugin-inline-worker", 44 | "jest": "^29.5.0", 45 | "jest-environment-jsdom": "^29.5.0", 46 | "monkey-around": "^3.0.0", 47 | "obsidian": "^1.8.7", 48 | "regexp-match-indices": "^1.0.2", 49 | "rrule": "^2.8.1", 50 | "ts-jest": "^29.1.0", 51 | "tslib": "2.4.0", 52 | "typescript": "4.7.3" 53 | }, 54 | "dependencies": { 55 | "@popperjs/core": "^2.11.8", 56 | "@types/sortablejs": "^1.15.8", 57 | "date-fns": "^4.1.0", 58 | "localforage": "^1.10.0", 59 | "obsidian-daily-notes-interface": "^0.9.4", 60 | "sortablejs": "^1.15.6" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/__mocks__/codemirror-language.ts: -------------------------------------------------------------------------------- 1 | // Mock for @codemirror/language 2 | 3 | export function foldable(state: any, from: number, to: number) { 4 | // Mock implementation that returns a foldable range 5 | return { from, to: to + 10 }; 6 | } -------------------------------------------------------------------------------- /src/__mocks__/codemirror-search.ts: -------------------------------------------------------------------------------- 1 | // Mock for @codemirror/search 2 | 3 | export const search = { 4 | findNext: () => null, 5 | findPrevious: () => null, 6 | openSearchPanel: () => null, 7 | closeSearchPanel: () => null 8 | }; -------------------------------------------------------------------------------- /src/__mocks__/codemirror-view.ts: -------------------------------------------------------------------------------- 1 | // Mock for @codemirror/view 2 | 3 | export class EditorView { 4 | state: any; 5 | 6 | constructor(config: any = {}) { 7 | this.state = config.state || null; 8 | } 9 | 10 | dispatch(transaction: any) { 11 | // Mock implementation 12 | } 13 | } 14 | 15 | export class WidgetType { 16 | eq(other: any): boolean { 17 | return false; 18 | } 19 | 20 | toDOM(): HTMLElement { 21 | return document.createElement("div"); 22 | } 23 | 24 | ignoreEvent(event: Event): boolean { 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | // Mock for Obsidian API 2 | 3 | // Simple mock function implementation 4 | function mockFn() { 5 | const fn = function () { 6 | return fn; 7 | }; 8 | return fn; 9 | } 10 | 11 | export class App { 12 | vault = { 13 | getMarkdownFiles: function () { 14 | return []; 15 | }, 16 | read: function () { 17 | return Promise.resolve(""); 18 | }, 19 | create: function () { 20 | return Promise.resolve({}); 21 | }, 22 | modify: function () { 23 | return Promise.resolve({}); 24 | }, 25 | getConfig: function (key: string) { 26 | if (key === "tabSize") return 4; 27 | if (key === "useTab") return false; 28 | return null; 29 | }, 30 | }; 31 | 32 | workspace = { 33 | getLeaf: function () { 34 | return { 35 | openFile: function () {}, 36 | }; 37 | }, 38 | 39 | getActiveFile: function () { 40 | return { 41 | path: "mockFile.md", 42 | // Add other TFile properties if necessary for the tests 43 | name: "mockFile.md", 44 | basename: "mockFile", 45 | extension: "md", 46 | }; 47 | }, 48 | }; 49 | 50 | fileManager = { 51 | generateMarkdownLink: function () { 52 | return "[[link]]"; 53 | }, 54 | }; 55 | 56 | metadataCache = { 57 | getFileCache: function () { 58 | return { 59 | headings: [], 60 | }; 61 | }, 62 | }; 63 | 64 | plugins = { 65 | enabledPlugins: new Set(["obsidian-tasks-plugin"]), 66 | plugins: { 67 | "obsidian-tasks-plugin": { 68 | api: { 69 | getTasksFromFile: () => [], 70 | getTaskAtLine: () => null, 71 | updateTask: () => {}, 72 | }, 73 | }, 74 | }, 75 | }; 76 | } 77 | 78 | export class Editor { 79 | getValue = function () { 80 | return ""; 81 | }; 82 | setValue = function () {}; 83 | replaceRange = function () {}; 84 | getLine = function () { 85 | return ""; 86 | }; 87 | lineCount = function () { 88 | return 0; 89 | }; 90 | getCursor = function () { 91 | return { line: 0, ch: 0 }; 92 | }; 93 | setCursor = function () {}; 94 | getSelection = function () { 95 | return ""; 96 | }; 97 | } 98 | 99 | export class TFile { 100 | path: string; 101 | name: string; 102 | parent: any; 103 | 104 | constructor(path = "", name = "", parent = null) { 105 | this.path = path; 106 | this.name = name; 107 | this.parent = parent; 108 | } 109 | } 110 | 111 | export class Notice { 112 | constructor(message: string) { 113 | // Mock implementation 114 | } 115 | } 116 | 117 | export class MarkdownView { 118 | editor: Editor; 119 | file: TFile; 120 | 121 | constructor() { 122 | this.editor = new Editor(); 123 | this.file = new TFile(); 124 | } 125 | } 126 | 127 | export class MarkdownFileInfo { 128 | file: TFile; 129 | 130 | constructor() { 131 | this.file = new TFile(); 132 | } 133 | } 134 | 135 | export class FuzzySuggestModal { 136 | app: App; 137 | 138 | constructor(app: App) { 139 | this.app = app; 140 | } 141 | 142 | open() {} 143 | close() {} 144 | setPlaceholder() {} 145 | getItems() { 146 | return []; 147 | } 148 | getItemText() { 149 | return ""; 150 | } 151 | renderSuggestion() {} 152 | onChooseItem() {} 153 | getSuggestions() { 154 | return []; 155 | } 156 | } 157 | 158 | export class SuggestModal { 159 | app: App; 160 | 161 | constructor(app: App) { 162 | this.app = app; 163 | } 164 | 165 | open() {} 166 | close() {} 167 | setPlaceholder() {} 168 | getSuggestions() { 169 | return Promise.resolve([]); 170 | } 171 | renderSuggestion() {} 172 | onChooseSuggestion() {} 173 | } 174 | 175 | export class MetadataCache { 176 | getFileCache() { 177 | return null; 178 | } 179 | } 180 | 181 | export class FuzzyMatch { 182 | item: T; 183 | match: { score: number; matches: any[] }; 184 | 185 | constructor(item: T) { 186 | this.item = item; 187 | this.match = { score: 0, matches: [] }; 188 | } 189 | } 190 | 191 | // Mock moment function and its methods 192 | function momentFn() { 193 | return { 194 | format: function () { 195 | return "2023-01-01 00:00:00"; 196 | }, 197 | diff: function () { 198 | return 0; 199 | }, 200 | }; 201 | } 202 | 203 | // Add static methods to momentFn 204 | (momentFn as any).utc = function () { 205 | return { 206 | format: function () { 207 | return "00:00:00"; 208 | }, 209 | }; 210 | }; 211 | 212 | (momentFn as any).duration = function () { 213 | return { 214 | asMilliseconds: function () { 215 | return 0; 216 | }, 217 | }; 218 | }; 219 | 220 | (momentFn as any).locale = function (locale?: string) { 221 | return locale || "en"; 222 | }; 223 | 224 | export const moment = momentFn as any; 225 | 226 | // Add any other Obsidian classes or functions needed for tests 227 | -------------------------------------------------------------------------------- /src/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Style mock for Jest 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /src/__tests__/findContinuousTaskBlocks.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findContinuousTaskBlocks, 3 | SortableTask, 4 | SortableTaskStatus, 5 | } from "../commands/sortTaskCommands"; 6 | 7 | // 创建SortableTask的辅助函数 8 | function createMockTask( 9 | lineNumber: number, 10 | indentation: number = 0, 11 | children: SortableTask[] = [], 12 | completed: boolean = false 13 | ): SortableTask { 14 | const status = completed ? "x" : " "; 15 | return { 16 | id: `test-${lineNumber}`, 17 | lineNumber, 18 | indentation, 19 | children, 20 | parent: undefined, 21 | calculatedStatus: completed 22 | ? SortableTaskStatus.Completed 23 | : SortableTaskStatus.Incomplete, 24 | originalMarkdown: `${" ".repeat( 25 | indentation 26 | )}- [${status}] Task at line ${lineNumber}`, 27 | status, 28 | completed, 29 | content: `Task at line ${lineNumber}`, 30 | tags: [], 31 | } as SortableTask; 32 | } 33 | 34 | describe("findContinuousTaskBlocks", () => { 35 | it("应该能够识别连续的任务块", () => { 36 | // 创建模拟任务数据 37 | const mockTasks: SortableTask[] = [ 38 | // 第一个任务块(连续行号:0,1,2) 39 | createMockTask(0), 40 | createMockTask(1), 41 | createMockTask(2), 42 | 43 | // 第二个任务块(连续行号:5,6)- 与第一个块之间有空行 44 | createMockTask(5), 45 | createMockTask(6), 46 | ]; 47 | 48 | // 执行函数 49 | const blocks = findContinuousTaskBlocks(mockTasks); 50 | 51 | // 验证结果 52 | expect(blocks.length).toBe(2); // 应该识别出两个不连续的任务块 53 | expect(blocks[0].length).toBe(3); // 第一个块有3个任务 54 | expect(blocks[1].length).toBe(2); // 第二个块有2个任务 55 | 56 | // 验证块中任务的行号 57 | expect(blocks[0].map((t) => t.lineNumber)).toEqual([0, 1, 2]); 58 | expect(blocks[1].map((t) => t.lineNumber)).toEqual([5, 6]); 59 | }); 60 | 61 | it("应该正确处理带有子任务的任务块", () => { 62 | // 创建子任务 63 | const childTask1 = createMockTask(1, 2); 64 | const childTask2 = createMockTask(2, 2); 65 | 66 | // 创建带有子任务的模拟数据 67 | const parentTask = createMockTask(0, 0, [childTask1, childTask2]); 68 | childTask1.parent = parentTask; 69 | childTask2.parent = parentTask; 70 | 71 | const mockTasks: SortableTask[] = [ 72 | // 任务块1:父任务(包含子任务) 73 | parentTask, 74 | 75 | // 任务块2:独立任务 76 | createMockTask(5), 77 | ]; 78 | 79 | // 执行函数 80 | const blocks = findContinuousTaskBlocks(mockTasks); 81 | 82 | // 验证结果 83 | expect(blocks.length).toBe(2); // 应该识别出两个不连续的任务块 84 | 85 | // 验证第一个块包含父任务 86 | expect(blocks[0].length).toBe(1); 87 | expect(blocks[0][0].lineNumber).toBe(0); 88 | expect(blocks[0][0].children.length).toBe(2); 89 | expect(blocks[0][0].children[0].lineNumber).toBe(1); 90 | expect(blocks[0][0].children[1].lineNumber).toBe(2); 91 | 92 | // 验证第二个块包含独立任务 93 | expect(blocks[1].length).toBe(1); 94 | expect(blocks[1][0].lineNumber).toBe(5); 95 | }); 96 | 97 | it("应该将任务及其子任务视为一个连续块", () => { 98 | // 创建一个带有子任务的任务,子任务在不连续的行 99 | const child1 = createMockTask(2, 2); 100 | const child2 = createMockTask(4, 2); 101 | 102 | const parent1 = createMockTask(0, 0, [child1]); 103 | child1.parent = parent1; 104 | 105 | const parent2 = createMockTask(3, 0, [child2]); 106 | child2.parent = parent2; 107 | 108 | const mockTasks: SortableTask[] = [parent1, parent2]; 109 | 110 | // 执行函数 - 行号是 0, 2, 3, 4 111 | const blocks = findContinuousTaskBlocks(mockTasks); 112 | 113 | // 验证结果 - 应该是一个连续块,因为父任务的最大行号 + 1 >= 下一个任务的行号 114 | expect(blocks.length).toBe(1); // 应该识别为一个连续块 115 | expect(blocks[0].length).toBe(2); // 包含两个父任务 116 | }); 117 | 118 | it("在没有任务的情况下应返回空数组", () => { 119 | const emptyTasks: SortableTask[] = []; 120 | const blocks = findContinuousTaskBlocks(emptyTasks); 121 | expect(blocks).toEqual([]); 122 | }); 123 | 124 | it("对于乱序输入的任务应正确排序并分组", () => { 125 | // 创建乱序排列的任务 126 | const mockTasks: SortableTask[] = [ 127 | createMockTask(5), // 第二个块 128 | createMockTask(1), // 第一个块 129 | createMockTask(6), // 第二个块 130 | createMockTask(0), // 第一个块 131 | createMockTask(2), // 第一个块 132 | ]; 133 | 134 | // 执行函数 135 | const blocks = findContinuousTaskBlocks(mockTasks); 136 | 137 | // 验证结果 - 应该先排序,然后分成两个块 138 | expect(blocks.length).toBe(2); 139 | 140 | // 第一个块(0,1,2) 141 | expect(blocks[0].length).toBe(3); 142 | expect(blocks[0].map((t) => t.lineNumber).sort()).toEqual([0, 1, 2]); 143 | 144 | // 第二个块(5,6) 145 | expect(blocks[1].length).toBe(2); 146 | expect(blocks[1].map((t) => t.lineNumber).sort()).toEqual([5, 6]); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/__tests__/types.d.ts: -------------------------------------------------------------------------------- 1 | // Jest type definitions 2 | declare const jest: any; 3 | declare const describe: (name: string, fn: () => void) => void; 4 | declare const it: (name: string, fn: () => void | Promise) => void; 5 | declare const expect: any; 6 | declare const beforeEach: (fn: () => void | Promise) => void; 7 | declare const afterEach: (fn: () => void | Promise) => void; 8 | declare const beforeAll: (fn: () => void | Promise) => void; 9 | declare const afterAll: (fn: () => void | Promise) => void; -------------------------------------------------------------------------------- /src/commands/taskCycleCommands.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownFileInfo, MarkdownView } from "obsidian"; 2 | import TaskProgressBarPlugin from "../index"; 3 | 4 | /** 5 | * Cycles the task status on the current line forward 6 | * @param checking Whether this is a check or an execution 7 | * @param editor The editor instance 8 | * @param ctx The markdown view or file info context 9 | * @param plugin The plugin instance 10 | * @returns Boolean indicating whether the command can be executed 11 | */ 12 | export function cycleTaskStatusForward( 13 | checking: boolean, 14 | editor: Editor, 15 | ctx: MarkdownView | MarkdownFileInfo, 16 | plugin: TaskProgressBarPlugin 17 | ): boolean { 18 | return cycleTaskStatus(checking, editor, plugin, "forward"); 19 | } 20 | 21 | /** 22 | * Cycles the task status on the current line backward 23 | * @param checking Whether this is a check or an execution 24 | * @param editor The editor instance 25 | * @param ctx The markdown view or file info context 26 | * @param plugin The plugin instance 27 | * @returns Boolean indicating whether the command can be executed 28 | */ 29 | export function cycleTaskStatusBackward( 30 | checking: boolean, 31 | editor: Editor, 32 | ctx: MarkdownView | MarkdownFileInfo, 33 | plugin: TaskProgressBarPlugin 34 | ): boolean { 35 | return cycleTaskStatus(checking, editor, plugin, "backward"); 36 | } 37 | 38 | /** 39 | * Cycles the task status on the current line in the specified direction 40 | * @param checking Whether this is a check or an execution 41 | * @param editor The editor instance 42 | * @param plugin The plugin instance 43 | * @param direction The direction to cycle: "forward" or "backward" 44 | * @returns Boolean indicating whether the command can be executed 45 | */ 46 | function cycleTaskStatus( 47 | checking: boolean, 48 | editor: Editor, 49 | plugin: TaskProgressBarPlugin, 50 | direction: "forward" | "backward" 51 | ): boolean { 52 | // Get the current cursor position 53 | const cursor = editor.getCursor(); 54 | 55 | // Get the text from the current line 56 | const line = editor.getLine(cursor.line); 57 | 58 | // Check if this line contains a task 59 | const taskRegex = /^[\s|\t]*([-*+]|\d+\.)\s+\[(.)]/; 60 | const match = line.match(taskRegex); 61 | 62 | if (!match) { 63 | // Not a task line 64 | return false; 65 | } 66 | 67 | // If just checking if the command is valid 68 | if (checking) { 69 | return true; 70 | } 71 | 72 | // Get the task cycle and marks from plugin settings 73 | const { cycle, marks, excludeMarksFromCycle } = getTaskStatusConfig(plugin); 74 | const remainingCycle = cycle.filter( 75 | (state) => !excludeMarksFromCycle.includes(state) 76 | ); 77 | 78 | // If no cycle is defined, don't do anything 79 | if (remainingCycle.length === 0) { 80 | return false; 81 | } 82 | 83 | // Get the current mark 84 | const currentMark = match[2]; 85 | 86 | // Find the current status in the cycle 87 | let currentStatusIndex = -1; 88 | for (let i = 0; i < remainingCycle.length; i++) { 89 | const state = remainingCycle[i]; 90 | if (marks[state] === currentMark) { 91 | currentStatusIndex = i; 92 | break; 93 | } 94 | } 95 | 96 | // If we couldn't find the current status in the cycle, start from the first one 97 | if (currentStatusIndex === -1) { 98 | currentStatusIndex = 0; 99 | } 100 | 101 | // Calculate the next status based on direction 102 | let nextStatusIndex; 103 | if (direction === "forward") { 104 | nextStatusIndex = (currentStatusIndex + 1) % remainingCycle.length; 105 | } else { 106 | nextStatusIndex = 107 | (currentStatusIndex - 1 + remainingCycle.length) % 108 | remainingCycle.length; 109 | } 110 | 111 | const nextStatus = remainingCycle[nextStatusIndex]; 112 | const nextMark = marks[nextStatus] || " "; 113 | 114 | // Find the positions of the mark in the line 115 | const startPos = line.indexOf("[") + 1; 116 | 117 | // Replace the mark 118 | editor.replaceRange( 119 | nextMark, 120 | { line: cursor.line, ch: startPos }, 121 | { line: cursor.line, ch: startPos + 1 } 122 | ); 123 | 124 | return true; 125 | } 126 | 127 | /** 128 | * Gets the task status configuration from the plugin settings 129 | * @param plugin The plugin instance 130 | * @returns Object containing the task cycle and marks 131 | */ 132 | function getTaskStatusConfig(plugin: TaskProgressBarPlugin) { 133 | return { 134 | cycle: plugin.settings.taskStatusCycle, 135 | excludeMarksFromCycle: plugin.settings.excludeMarksFromCycle || [], 136 | marks: plugin.settings.taskStatusMarks, 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/common/default-symbol.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Regular expressions for parsing task components 3 | */ 4 | export const TASK_REGEX = /^([\s>]*- \[(.)\])\s*(.*)$/m; 5 | export const TAG_REGEX = 6 | /#[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g; 7 | export const CONTEXT_REGEX = /@[\w-]+/g; 8 | 9 | /** 10 | * Task symbols and formatting 11 | */ 12 | export const DEFAULT_SYMBOLS = { 13 | prioritySymbols: { 14 | Highest: "🔺", 15 | High: "⏫", 16 | Medium: "🔼", 17 | Low: "🔽", 18 | Lowest: "⏬", 19 | None: "", 20 | }, 21 | startDateSymbol: "🛫", 22 | createdDateSymbol: "➕", 23 | scheduledDateSymbol: "⏳", 24 | dueDateSymbol: "📅", 25 | doneDateSymbol: "✅", 26 | cancelledDateSymbol: "❌", 27 | recurrenceSymbol: "🔁", 28 | onCompletionSymbol: "🏁", 29 | dependsOnSymbol: "⛔", 30 | idSymbol: "🆔", 31 | }; 32 | 33 | // --- Priority Mapping --- (Combine from TaskParser) 34 | export const PRIORITY_MAP: Record = { 35 | "🔺": 5, 36 | "⏫": 4, 37 | "🔼": 3, 38 | "🔽": 2, 39 | "⏬️": 1, 40 | "⏬": 1, 41 | "[#A]": 5, 42 | "[#B]": 4, 43 | "[#C]": 3, // Keep Taskpaper style? Maybe remove later 44 | "[#D]": 2, 45 | "[#E]": 1, 46 | highest: 5, 47 | high: 4, 48 | medium: 3, 49 | low: 2, 50 | lowest: 1, 51 | }; 52 | -------------------------------------------------------------------------------- /src/common/regex-define.ts: -------------------------------------------------------------------------------- 1 | // Task identification 2 | const TASK_REGEX = /^(([\s>]*)?(-|\d+\.|\*|\+)\s\[(.)\])\s*(.*)$/m; 3 | 4 | // --- Emoji/Tasks Style Regexes --- 5 | const EMOJI_START_DATE_REGEX = /🛫\s*(\d{4}-\d{2}-\d{2})/; 6 | const EMOJI_COMPLETED_DATE_REGEX = /✅\s*(\d{4}-\d{2}-\d{2})/; 7 | const EMOJI_DUE_DATE_REGEX = /📅\s*(\d{4}-\d{2}-\d{2})/; 8 | const EMOJI_SCHEDULED_DATE_REGEX = /⏳\s*(\d{4}-\d{2}-\d{2})/; 9 | const EMOJI_CREATED_DATE_REGEX = /➕\s*(\d{4}-\d{2}-\d{2})/; 10 | const EMOJI_RECURRENCE_REGEX = /🔁\s*(.*?)(?=\s(?:🗓️|🛫|⏳|✅|➕|🔁|@|#)|$)/u; 11 | const EMOJI_PRIORITY_REGEX = /(([🔺⏫🔼🔽⏬️⏬])|(\[#[A-E]\]))/u; // Using the corrected variant selector 12 | const EMOJI_CONTEXT_REGEX = /@([\w-]+)/g; 13 | const EMOJI_TAG_REGEX = 14 | /#[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g; // Includes #project/ tags 15 | const EMOJI_PROJECT_PREFIX = "#project/"; 16 | 17 | // --- Dataview Style Regexes --- 18 | const DV_START_DATE_REGEX = /\[(?:start|🛫)::\s*(\d{4}-\d{2}-\d{2})\]/i; 19 | const DV_COMPLETED_DATE_REGEX = 20 | /\[(?:completion|✅)::\s*(\d{4}-\d{2}-\d{2})\]/i; 21 | const DV_DUE_DATE_REGEX = /\[(?:due|🗓️)::\s*(\d{4}-\d{2}-\d{2})\]/i; 22 | const DV_SCHEDULED_DATE_REGEX = /\[(?:scheduled|⏳)::\s*(\d{4}-\d{2}-\d{2})\]/i; 23 | const DV_CREATED_DATE_REGEX = /\[(?:created|➕)::\s*(\d{4}-\d{2}-\d{2})\]/i; 24 | const DV_RECURRENCE_REGEX = /\[(?:repeat|recurrence|🔁)::\s*([^\]]+)\]/i; 25 | const DV_PRIORITY_REGEX = /\[priority::\s*([^\]]+)\]/i; 26 | const DV_PROJECT_REGEX = /\[project::\s*([^\]]+)\]/i; 27 | const DV_CONTEXT_REGEX = /\[context::\s*([^\]]+)\]/i; 28 | // Dataview Tag Regex is the same, applied after DV field removal 29 | const ANY_DATAVIEW_FIELD_REGEX = /\[\w+(?:|🗓️|✅|➕|🛫|⏳|🔁)::\s*[^\]]+\]/gi; 30 | 31 | export { 32 | TASK_REGEX, 33 | EMOJI_START_DATE_REGEX, 34 | EMOJI_COMPLETED_DATE_REGEX, 35 | EMOJI_DUE_DATE_REGEX, 36 | EMOJI_SCHEDULED_DATE_REGEX, 37 | EMOJI_CREATED_DATE_REGEX, 38 | EMOJI_RECURRENCE_REGEX, 39 | EMOJI_PRIORITY_REGEX, 40 | EMOJI_CONTEXT_REGEX, 41 | EMOJI_TAG_REGEX, 42 | EMOJI_PROJECT_PREFIX, 43 | DV_START_DATE_REGEX, 44 | DV_COMPLETED_DATE_REGEX, 45 | DV_DUE_DATE_REGEX, 46 | DV_SCHEDULED_DATE_REGEX, 47 | DV_CREATED_DATE_REGEX, 48 | DV_RECURRENCE_REGEX, 49 | DV_PRIORITY_REGEX, 50 | DV_PROJECT_REGEX, 51 | DV_CONTEXT_REGEX, 52 | ANY_DATAVIEW_FIELD_REGEX, 53 | }; 54 | -------------------------------------------------------------------------------- /src/common/task-status/AnuPpuccinThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from "./StatusCollections"; 5 | 6 | /** 7 | * Status supported by the AnuPpuccin theme. {@link https://github.com/AnubisNekhet/AnuPpuccin} 8 | * @see {@link StatusSettings.bulkAddStatusCollection} 9 | */ 10 | export function anuppuccinSupportedStatuses() { 11 | const zzz: StatusCollection = [ 12 | [" ", "Unchecked", "notStarted"], 13 | ["x", "Checked", "completed"], 14 | [">", "Rescheduled", "planned"], 15 | ["<", "Scheduled", "planned"], 16 | ["!", "Important", "notStarted"], 17 | ["-", "Cancelled", "abandoned"], 18 | ["/", "In Progress", "inProgress"], 19 | ["?", "Question", "notStarted"], 20 | ["*", "Star", "notStarted"], 21 | ["n", "Note", "notStarted"], 22 | ["l", "Location", "notStarted"], 23 | ["i", "Information", "notStarted"], 24 | ["I", "Idea", "notStarted"], 25 | ["S", "Amount", "notStarted"], 26 | ["p", "Pro", "notStarted"], 27 | ["c", "Con", "notStarted"], 28 | ["b", "Bookmark", "notStarted"], 29 | ['"', "Quote", "notStarted"], 30 | ["0", "Speech bubble 0", "notStarted"], 31 | ["1", "Speech bubble 1", "notStarted"], 32 | ["2", "Speech bubble 2", "notStarted"], 33 | ["3", "Speech bubble 3", "notStarted"], 34 | ["4", "Speech bubble 4", "notStarted"], 35 | ["5", "Speech bubble 5", "notStarted"], 36 | ["6", "Speech bubble 6", "notStarted"], 37 | ["7", "Speech bubble 7", "notStarted"], 38 | ["8", "Speech bubble 8", "notStarted"], 39 | ["9", "Speech bubble 9", "notStarted"], 40 | ]; 41 | return zzz; 42 | } 43 | -------------------------------------------------------------------------------- /src/common/task-status/AuraThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from "./StatusCollections"; 5 | 6 | /** 7 | * Status supported by the Aura theme. {@link https://github.com/ashwinjadhav818/obsidian-aura} 8 | * @see {@link StatusSettings.bulkAddStatusCollection} 9 | */ 10 | export function auraSupportedStatuses() { 11 | const zzz: StatusCollection = [ 12 | [" ", "incomplete", "notStarted"], 13 | ["x", "complete / done", "completed"], 14 | ["-", "cancelled", "abandoned"], 15 | [">", "deferred", "planned"], 16 | ["/", "in progress, or half-done", "inProgress"], 17 | ["!", "Important", "notStarted"], 18 | ["?", "question", "notStarted"], 19 | ["R", "review", "notStarted"], 20 | ["+", "Inbox / task that should be processed later", "notStarted"], 21 | ["b", "bookmark", "notStarted"], 22 | ["B", "brainstorm", "notStarted"], 23 | ["D", "deferred or scheduled", "planned"], 24 | ["I", "Info", "notStarted"], 25 | ["i", "idea", "notStarted"], 26 | ["N", "note", "notStarted"], 27 | ["Q", "quote", "notStarted"], 28 | ["W", "win / success / reward", "notStarted"], 29 | ["P", "pro", "notStarted"], 30 | ["C", "con", "notStarted"], 31 | ]; 32 | return zzz; 33 | } 34 | -------------------------------------------------------------------------------- /src/common/task-status/BorderThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | import type { StatusCollection } from "./StatusCollections"; 4 | 5 | /** 6 | * Statuses supported by the Border theme. {@link https://github.com/Akifyss/obsidian-border?tab=readme-ov-file#alternate-checkboxes} 7 | * @see {@link StatusSettings.bulkAddStatusCollection} 8 | */ 9 | export function borderSupportedStatuses() { 10 | const zzz: StatusCollection = [ 11 | [" ", "To Do", "notStarted"], 12 | ["/", "In Progress", "inProgress"], 13 | ["x", "Done", "completed"], 14 | ["-", "Cancelled", "abandoned"], 15 | [">", "Rescheduled", "planned"], 16 | ["<", "Scheduled", "planned"], 17 | ["!", "Important", "notStarted"], 18 | ["?", "Question", "notStarted"], 19 | ["i", "Infomation", "notStarted"], 20 | ["S", "Amount", "notStarted"], 21 | ["*", "Star", "notStarted"], 22 | ["b", "Bookmark", "notStarted"], 23 | ["“", "Quote", "notStarted"], 24 | ["n", "Note", "notStarted"], 25 | ["l", "Location", "notStarted"], 26 | ["I", "Idea", "notStarted"], 27 | ["p", "Pro", "notStarted"], 28 | ["c", "Con", "notStarted"], 29 | ["u", "Up", "notStarted"], 30 | ["d", "Down", "notStarted"], 31 | ]; 32 | return zzz; 33 | } 34 | -------------------------------------------------------------------------------- /src/common/task-status/EbullientworksThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | import type { StatusCollection } from "./StatusCollections"; 4 | 5 | /** 6 | * Status supported by the Ebullientworks theme. {@link https://github.com/ebullient/obsidian-theme-ebullientworks} 7 | * @see {@link StatusSettings.bulkAddStatusCollection} 8 | */ 9 | export function ebullientworksSupportedStatuses() { 10 | const zzz: StatusCollection = [ 11 | [" ", "Unchecked", "notStarted"], 12 | ["x", "Checked", "completed"], 13 | ["-", "Cancelled", "abandoned"], 14 | ["/", "In Progress", "inProgress"], 15 | [">", "Deferred", "planned"], 16 | ["!", "Important", "notStarted"], 17 | ["?", "Question", "planned"], 18 | ["r", "Review", "notStarted"], 19 | ]; 20 | return zzz; 21 | } 22 | -------------------------------------------------------------------------------- /src/common/task-status/ITSThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from "./StatusCollections"; 5 | 6 | /** 7 | * Status supported by the ITS theme. {@link https://github.com/SlRvb/Obsidian--ITS-Theme} 8 | * Values recognised by Tasks are excluded. 9 | * @see {@link StatusSettings.bulkAddStatusCollection} 10 | */ 11 | export function itsSupportedStatuses() { 12 | const zzz: StatusCollection = [ 13 | [" ", "Unchecked", "notStarted"], 14 | ["x", "Regular", "completed"], 15 | ["X", "Checked", "completed"], 16 | ["-", "Dropped", "abandoned"], 17 | [">", "Forward", "planned"], 18 | ["D", "Date", "notStarted"], 19 | ["?", "Question", "planned"], 20 | ["/", "Half Done", "inProgress"], 21 | ["+", "Add", "notStarted"], 22 | ["R", "Research", "notStarted"], 23 | ["!", "Important", "notStarted"], 24 | ["i", "Idea", "notStarted"], 25 | ["B", "Brainstorm", "notStarted"], 26 | ["P", "Pro", "notStarted"], 27 | ["C", "Con", "notStarted"], 28 | ["Q", "Quote", "notStarted"], 29 | ["N", "Note", "notStarted"], 30 | ["b", "Bookmark", "notStarted"], 31 | ["I", "Information", "notStarted"], 32 | ["p", "Paraphrase", "notStarted"], 33 | ["L", "Location", "notStarted"], 34 | ["E", "Example", "notStarted"], 35 | ["A", "Answer", "notStarted"], 36 | ["r", "Reward", "notStarted"], 37 | ["c", "Choice", "notStarted"], 38 | ["d", "Doing", "inProgress"], 39 | ["T", "Time", "notStarted"], 40 | ["@", "Character / Person", "notStarted"], 41 | ["t", "Talk", "notStarted"], 42 | ["O", "Outline / Plot", "notStarted"], 43 | ["~", "Conflict", "notStarted"], 44 | ["W", "World", "notStarted"], 45 | ["f", "Clue / Find", "notStarted"], 46 | ["F", "Foreshadow", "notStarted"], 47 | ["H", "Favorite / Health", "notStarted"], 48 | ["&", "Symbolism", "notStarted"], 49 | ["s", "Secret", "notStarted"], 50 | ]; 51 | return zzz; 52 | } 53 | -------------------------------------------------------------------------------- /src/common/task-status/LYTModeThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from './StatusCollections'; 5 | 6 | /** 7 | * Status supported by the LYT Mode theme. {@link https://github.com/nickmilo/LYT-Mode} 8 | * @see {@link StatusSettings.bulkAddStatusCollection} 9 | */ 10 | export function lytModeSupportedStatuses() { 11 | const zzz: StatusCollection = [ 12 | [' ', 'Unchecked', 'notStarted'], 13 | ['x', 'Checked', 'completed'], 14 | ['>', 'Rescheduled', 'planned'], 15 | ['<', 'Scheduled', 'planned'], 16 | ['!', 'Important', 'notStarted'], 17 | ['-', 'Cancelled', 'abandoned'], 18 | ['/', 'In Progress', 'inProgress'], 19 | ['?', 'Question', 'notStarted'], 20 | ['*', 'Star', 'notStarted'], 21 | ['n', 'Note', 'notStarted'], 22 | ['l', 'Location', 'notStarted'], 23 | ['i', 'Information', 'notStarted'], 24 | ['I', 'Idea', 'notStarted'], 25 | ['S', 'Amount', 'notStarted'], 26 | ['p', 'Pro', 'notStarted'], 27 | ['c', 'Con', 'notStarted'], 28 | ['b', 'Bookmark', 'notStarted'], 29 | ['f', 'Fire', 'notStarted'], 30 | ['k', 'Key', 'notStarted'], 31 | ['w', 'Win', 'notStarted'], 32 | ['u', 'Up', 'notStarted'], 33 | ['d', 'Down', 'notStarted'], 34 | ]; 35 | return zzz; 36 | } 37 | -------------------------------------------------------------------------------- /src/common/task-status/MinimalThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from './StatusCollections'; 5 | 6 | /** 7 | * Status supported by the Minimal theme. {@link https://github.com/kepano/obsidian-minimal} 8 | * Values recognised by Tasks are excluded. 9 | * @see {@link StatusSettings.bulkAddStatusCollection} 10 | */ 11 | export function minimalSupportedStatuses() { 12 | const zzz: StatusCollection = [ 13 | [' ', 'to-do', 'notStarted'], 14 | ['/', 'incomplete', 'inProgress'], 15 | ['x', 'done', 'completed'], 16 | ['-', 'canceled', 'abandoned'], 17 | ['>', 'forwarded', 'planned'], 18 | ['<', 'scheduling', 'planned'], 19 | ['?', 'question', 'notStarted'], 20 | ['!', 'important', 'notStarted'], 21 | ['*', 'star', 'notStarted'], 22 | ['"', 'quote', 'notStarted'], 23 | ['l', 'location', 'notStarted'], 24 | ['b', 'bookmark', 'notStarted'], 25 | ['i', 'information', 'notStarted'], 26 | ['S', 'savings', 'notStarted'], 27 | ['I', 'idea', 'notStarted'], 28 | ['p', 'pros', 'notStarted'], 29 | ['c', 'cons', 'notStarted'], 30 | ['f', 'fire', 'notStarted'], 31 | ['k', 'key', 'notStarted'], 32 | ['w', 'win', 'notStarted'], 33 | ['u', 'up', 'notStarted'], 34 | ['d', 'down', 'notStarted'], 35 | ]; 36 | return zzz; 37 | } 38 | -------------------------------------------------------------------------------- /src/common/task-status/StatusCollections.d.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Statuses 2 | // Original code is licensed under the MIT License. 3 | 4 | /** 5 | * The type used for a single entry in bulk imports of pre-created sets of statuses, such as for Themes or CSS Snippets. 6 | * The values are: symbol, name, status type (must be one of the values in {@link StatusType} 7 | */ 8 | export type StatusCollectionEntry = [string, string, string]; 9 | 10 | /** 11 | * The type used for bulk imports of pre-created sets of statuses, such as for Themes or CSS Snippets. 12 | * See {@link Status.createFromImportedValue} 13 | */ 14 | export type StatusCollection = Array; 15 | -------------------------------------------------------------------------------- /src/common/task-status/ThingsThemeCollection.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | import type { StatusCollection } from './StatusCollections'; 5 | 6 | /** 7 | * Status supported by the Things theme. {@link https://github.com/colineckert/obsidian-things} 8 | * @see {@link StatusSettings.bulkAddStatusCollection} 9 | */ 10 | export function thingsSupportedStatuses() { 11 | const zzz: StatusCollection = [ 12 | // Basic 13 | [' ', 'to-do', 'notStarted'], 14 | ['/', 'incomplete', 'inProgress'], 15 | ['x', 'done', 'completed'], 16 | ['-', 'canceled', 'abandoned'], 17 | ['>', 'forwarded', 'planned'], 18 | ['<', 'scheduling', 'planned'], 19 | // Extras 20 | ['?', 'question', 'notStarted'], 21 | ['!', 'important', 'notStarted'], 22 | ['*', 'star', 'notStarted'], 23 | ['"', 'quote', 'notStarted'], 24 | ['l', 'location', 'notStarted'], 25 | ['b', 'bookmark', 'notStarted'], 26 | ['i', 'information', 'notStarted'], 27 | ['S', 'savings', 'notStarted'], 28 | ['I', 'idea', 'notStarted'], 29 | ['p', 'pros', 'notStarted'], 30 | ['c', 'cons', 'notStarted'], 31 | ['f', 'fire', 'notStarted'], 32 | ['k', 'key', 'notStarted'], 33 | ['w', 'win', 'notStarted'], 34 | ['u', 'up', 'notStarted'], 35 | ['d', 'down', 'notStarted'], 36 | ]; 37 | return zzz; 38 | } 39 | -------------------------------------------------------------------------------- /src/common/task-status/index.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/src/Config/Themes 2 | // Original code is licensed under the MIT License. 3 | 4 | export * from "./AnuPpuccinThemeCollection"; 5 | export * from "./AuraThemeCollection"; 6 | export * from "./BorderThemeCollection"; 7 | export * from "./EbullientworksThemeCollection"; 8 | export * from "./ITSThemeCollection"; 9 | export * from "./LYTModeThemeCollection"; 10 | export * from "./MinimalThemeCollection"; 11 | export * from "./ThingsThemeCollection"; 12 | 13 | export const allStatusCollections: string[] = [ 14 | "AnuPpuccin", 15 | "Aura", 16 | "Border", 17 | "Ebullientworks", 18 | "ITS", 19 | "LYTMode", 20 | "Minimal", 21 | "Things", 22 | ]; 23 | -------------------------------------------------------------------------------- /src/common/task-status/readme.md: -------------------------------------------------------------------------------- 1 | # Task Status 2 | 3 | This folder contains the code for the task statuses that are supported by the plugin. 4 | 5 | ## Claim 6 | 7 | This code is originally from [obsidian-tasks](https://github.com/obsidian-tasks-group/obsidian-tasks) plugin. 8 | 9 | ## License 10 | 11 | This code is licensed under the MIT License. 12 | -------------------------------------------------------------------------------- /src/components/ConfirmModal.ts: -------------------------------------------------------------------------------- 1 | import { App, ButtonComponent, Modal } from "obsidian"; 2 | import TaskProgressBarPlugin from "../index"; 3 | import "../styles/modal.css"; 4 | 5 | export class ConfirmModal extends Modal { 6 | constructor( 7 | plugin: TaskProgressBarPlugin, 8 | public params: { 9 | title: string; 10 | message: string; 11 | confirmText: string; 12 | cancelText: string; 13 | onConfirm: (confirmed: boolean) => void; 14 | } 15 | ) { 16 | super(plugin.app); 17 | } 18 | 19 | onOpen() { 20 | this.titleEl.setText(this.params.title); 21 | this.contentEl.setText(this.params.message); 22 | 23 | const buttonsContainer = this.contentEl.createEl("div", { 24 | cls: "confirm-modal-buttons", 25 | }); 26 | 27 | new ButtonComponent(buttonsContainer) 28 | .setButtonText(this.params.confirmText) 29 | .setCta() 30 | .onClick(() => { 31 | this.params.onConfirm(true); 32 | this.close(); 33 | }); 34 | 35 | new ButtonComponent(buttonsContainer) 36 | .setButtonText(this.params.cancelText) 37 | .setCta() 38 | .onClick(() => { 39 | this.params.onConfirm(false); 40 | this.close(); 41 | }); 42 | } 43 | 44 | onClose() { 45 | this.contentEl.empty(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/RewardModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | import { RewardItem } from "../common/setting-definition"; 3 | import { t } from "../translations/helper"; 4 | import "../styles/reward.css"; 5 | 6 | export class RewardModal extends Modal { 7 | private reward: RewardItem; 8 | private onChoose: (accepted: boolean) => void; // Callback function 9 | 10 | constructor( 11 | app: App, 12 | reward: RewardItem, 13 | onChoose: (accepted: boolean) => void 14 | ) { 15 | super(app); 16 | this.reward = reward; 17 | this.onChoose = onChoose; 18 | } 19 | 20 | onOpen() { 21 | const { contentEl } = this; 22 | contentEl.empty(); // Clear previous content 23 | 24 | this.modalEl.toggleClass("reward-modal", true); 25 | 26 | contentEl.addClass("reward-modal-content"); 27 | 28 | // Add a title 29 | this.setTitle("🎉 " + t("You've Earned a Reward!") + " 🎉"); 30 | 31 | // Display reward name 32 | contentEl.createEl("p", { 33 | text: t("Your reward:") + " " + this.reward.name, 34 | cls: "reward-name", 35 | }); 36 | 37 | // Display reward image if available 38 | if (this.reward.imageUrl) { 39 | const imgContainer = contentEl.createDiv({ 40 | cls: "reward-image-container", 41 | }); 42 | // Basic check for local vs web URL (can be improved) 43 | if (this.reward.imageUrl.startsWith("http")) { 44 | imgContainer.createEl("img", { 45 | attr: { src: this.reward.imageUrl }, // Use attr for attributes like src 46 | cls: "reward-image", 47 | }); 48 | } else { 49 | // Assume it might be a vault path - needs resolving 50 | const imageFile = this.app.vault.getFileByPath( 51 | this.reward.imageUrl 52 | ); 53 | if (imageFile) { 54 | imgContainer.createEl("img", { 55 | attr: { 56 | src: this.app.vault.getResourcePath(imageFile), 57 | }, // Use TFile reference if possible 58 | cls: "reward-image", 59 | }); 60 | } else { 61 | imgContainer.createEl("p", { 62 | text: `(${t("Image not found:")} ${ 63 | this.reward.imageUrl 64 | })`, 65 | cls: "reward-image-error", 66 | }); 67 | } 68 | } 69 | } 70 | 71 | // Add spacing before buttons 72 | contentEl.createEl("div", { cls: "reward-spacer" }); 73 | 74 | // Add buttons 75 | new Setting(contentEl) 76 | .addButton((button) => 77 | button 78 | .setButtonText(t("Claim Reward")) 79 | .setCta() // Makes the button more prominent 80 | .onClick(() => { 81 | this.onChoose(true); // Call callback with true (accepted) 82 | this.close(); 83 | }) 84 | ) 85 | .addButton((button) => 86 | button.setButtonText(t("Skip")).onClick(() => { 87 | this.onChoose(false); // Call callback with false (skipped) 88 | this.close(); 89 | }) 90 | ); 91 | } 92 | 93 | onClose() { 94 | const { contentEl } = this; 95 | contentEl.empty(); // Clean up the modal content 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/components/StatusComponent.ts: -------------------------------------------------------------------------------- 1 | import { ExtraButtonComponent, Menu } from "obsidian"; 2 | import { Component } from "obsidian"; 3 | import TaskProgressBarPlugin from "../index"; 4 | import { Task } from "../types/task"; 5 | import { createTaskCheckbox } from "./task-view/details"; 6 | import { getStatusText } from "./task-view/details"; 7 | import { t } from "../translations/helper"; 8 | 9 | export class StatusComponent extends Component { 10 | constructor( 11 | private plugin: TaskProgressBarPlugin, 12 | private containerEl: HTMLElement, 13 | private task: Task, 14 | private params: { 15 | type?: "task-view" | "quick-capture"; 16 | onTaskUpdate?: (task: Task, updatedTask: Task) => Promise; 17 | onTaskStatusSelected?: (status: string) => void; 18 | } 19 | ) { 20 | super(); 21 | } 22 | 23 | onload(): void { 24 | this.containerEl.createDiv({ cls: "details-status-selector" }, (el) => { 25 | let containerEl = el; 26 | if (this.params.type === "quick-capture") { 27 | el.createEl("div", { 28 | cls: "quick-capture-status-selector-label", 29 | text: t("Status"), 30 | }); 31 | 32 | containerEl = el.createDiv({ 33 | cls: "quick-capture-status-selector", 34 | }); 35 | } 36 | 37 | const allStatuses = Object.keys( 38 | this.plugin.settings.taskStatuses 39 | ).map((status) => { 40 | return { 41 | status: status, 42 | text: this.plugin.settings.taskStatuses[ 43 | status as keyof typeof this.plugin.settings.taskStatuses 44 | ].split("|")[0], 45 | }; // Get the first status from each group 46 | }); 47 | 48 | // Create five side-by-side status elements 49 | allStatuses.forEach((status) => { 50 | const statusEl = containerEl.createEl("div", { 51 | cls: 52 | "status-option" + 53 | (status.text === this.task.status 54 | ? " current-status" 55 | : ""), 56 | attr: { 57 | "aria-label": getStatusText( 58 | status.status, 59 | this.plugin.settings 60 | ), 61 | }, 62 | }); 63 | 64 | // Create checkbox-like element for the status 65 | const checkbox = createTaskCheckbox( 66 | status.text, 67 | this.task, 68 | statusEl 69 | ); 70 | this.registerDomEvent(checkbox, "click", (evt) => { 71 | evt.stopPropagation(); 72 | evt.preventDefault(); 73 | if (status.text === this.getTaskStatus()) { 74 | return; 75 | } 76 | 77 | const options = { 78 | ...this.task, 79 | status: status.text, 80 | }; 81 | 82 | if (status.text === "x" && !this.task.completed) { 83 | options.completed = true; 84 | options.completedDate = new Date().getTime(); 85 | } 86 | 87 | this.params.onTaskUpdate?.(this.task, options); 88 | this.params.onTaskStatusSelected?.(status.text); 89 | }); 90 | }); 91 | 92 | const moreStatus = el.createEl("div", { 93 | cls: "more-status", 94 | }); 95 | const moreStatusBtn = new ExtraButtonComponent(moreStatus) 96 | .setIcon("ellipsis") 97 | .onClick(() => { 98 | const menu = new Menu(); 99 | 100 | // Get unique statuses from taskStatusMarks 101 | const statusMarks = this.plugin.settings.taskStatusMarks; 102 | const uniqueStatuses = new Map(); 103 | 104 | // Build a map of unique mark -> status name to avoid duplicates 105 | for (const status of Object.keys(statusMarks)) { 106 | const mark = 107 | statusMarks[status as keyof typeof statusMarks]; 108 | // If this mark is not already in the map, add it 109 | // This ensures each mark appears only once in the menu 110 | if ( 111 | !Array.from(uniqueStatuses.values()).includes(mark) 112 | ) { 113 | uniqueStatuses.set(status, mark); 114 | } 115 | } 116 | 117 | // Create menu items from unique statuses 118 | for (const [status, mark] of uniqueStatuses) { 119 | menu.addItem((item) => { 120 | item.titleEl.createEl( 121 | "span", 122 | { 123 | cls: "status-option-checkbox", 124 | }, 125 | (el) => { 126 | createTaskCheckbox(mark, this.task, el); 127 | } 128 | ); 129 | item.titleEl.createEl("span", { 130 | cls: "status-option", 131 | text: status, 132 | }); 133 | item.onClick(() => { 134 | this.params.onTaskUpdate?.(this.task, { 135 | ...this.task, 136 | status: mark, 137 | }); 138 | this.params.onTaskStatusSelected?.(mark); 139 | }); 140 | }); 141 | } 142 | const rect = 143 | moreStatusBtn.extraSettingsEl?.getBoundingClientRect(); 144 | if (rect) { 145 | menu.showAtPosition({ 146 | x: rect.left, 147 | y: rect.bottom + 10, 148 | }); 149 | } 150 | }); 151 | }); 152 | } 153 | 154 | private getTaskStatus() { 155 | return this.task.status || ""; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/components/calendar/algorithm.ts: -------------------------------------------------------------------------------- 1 | import { CalendarEvent } from "."; 2 | 3 | /** 4 | * Placeholder for event positioning algorithms. 5 | * This might involve calculating overlapping events, assigning vertical positions, 6 | * handling multi-day spans across different views, etc. 7 | */ 8 | 9 | export interface EventLayout { 10 | id: string; // Event ID 11 | top: number; // Vertical position (e.g., percentage or pixel offset) 12 | left: number; // Horizontal position (e.g., percentage or pixel offset) 13 | width: number; // Width (e.g., percentage) 14 | height: number; // Height (e.g., pixel offset for timed events) 15 | zIndex: number; // Stacking order 16 | } 17 | 18 | /** 19 | * Calculates layout for events within a specific day or time slot. 20 | * This is a complex task, especially for overlapping timed events. 21 | * @param events Events occurring on a specific day or within a time range. 22 | * @param timeRangeStart Start time of the viewable range (optional, for day/week views). 23 | * @param timeRangeEnd End time of the viewable range (optional). 24 | * @returns An array of layout properties for each event. 25 | */ 26 | export function calculateEventLayout( 27 | events: CalendarEvent[], 28 | timeRangeStart?: Date, 29 | timeRangeEnd?: Date 30 | ): EventLayout[] { 31 | console.log("Calculating event layout (stub)", events); 32 | // Basic Stub: Return simple layout (no overlap calculation yet) 33 | return events.map((event, index) => ({ 34 | id: event.id, 35 | top: index * 10, // Simple stacking for now 36 | left: 0, 37 | width: 100, 38 | height: 20, 39 | zIndex: index, 40 | })); 41 | } 42 | 43 | /** 44 | * Placeholder for a function to determine visual properties like color based on task data. 45 | * @param event The calendar event. 46 | * @returns A color string (e.g., CSS color name, hex code). 47 | */ 48 | export function determineEventColor(event: CalendarEvent): string | undefined { 49 | if (event.completed) return "grey"; 50 | // TODO: Add more complex logic based on project, tags, priority etc. 51 | // Example: if (event.project === 'Work') return 'blue'; 52 | return undefined; // Default color will be applied via CSS 53 | } 54 | -------------------------------------------------------------------------------- /src/components/calendar/views/agenda-view.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, moment } from "obsidian"; 2 | import { CalendarEvent } from "../index"; 3 | import { renderCalendarEvent } from "../rendering/event-renderer"; // Use new renderer 4 | import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; // Import base class 5 | import TaskProgressBarPlugin from "../../../index"; // Import plugin type 6 | 7 | export class AgendaView extends CalendarViewComponent { 8 | // Extend base class 9 | // private containerEl: HTMLElement; // Inherited 10 | private currentDate: moment.Moment; 11 | // private events: CalendarEvent[]; // Inherited 12 | private app: App; // Keep app reference 13 | private plugin: TaskProgressBarPlugin; // Added for base constructor 14 | 15 | constructor( 16 | app: App, 17 | plugin: TaskProgressBarPlugin, // Added plugin dependency 18 | containerEl: HTMLElement, 19 | currentDate: moment.Moment, 20 | events: CalendarEvent[], 21 | options: CalendarViewOptions = {} // Use base options, default to empty 22 | ) { 23 | super(plugin, app, containerEl, events, options); // Call base constructor 24 | this.app = app; 25 | this.plugin = plugin; 26 | this.currentDate = currentDate; 27 | } 28 | 29 | render(): void { 30 | this.containerEl.empty(); 31 | this.containerEl.addClass("view-agenda"); 32 | 33 | // 1. Define date range (e.g., next 7 days starting from currentDate) 34 | const rangeStart = this.currentDate.clone().startOf("day"); 35 | const rangeEnd = this.currentDate.clone().add(6, "days").endOf("day"); // 7 days total 36 | 37 | // 2. Filter and Sort Events: Only include events whose START date is within the range 38 | const agendaEvents = this.events 39 | .filter((event) => { 40 | const eventStart = moment(event.start); 41 | // Only consider the start date for inclusion in the agenda range 42 | return eventStart.isBetween( 43 | rangeStart, 44 | rangeEnd, 45 | undefined, 46 | "[]" 47 | ); 48 | }) 49 | .sort( 50 | (a, b) => moment(a.start).valueOf() - moment(b.start).valueOf() 51 | ); // Ensure sorting by start time 52 | 53 | // 3. Group events by their start day 54 | const eventsByDay: { [key: string]: CalendarEvent[] } = {}; 55 | agendaEvents.forEach((event) => { 56 | // Get the start date string 57 | const dateStr = moment(event.start).format("YYYY-MM-DD"); 58 | 59 | if (!eventsByDay[dateStr]) { 60 | eventsByDay[dateStr] = []; 61 | } 62 | // Add the event to its start date list 63 | eventsByDay[dateStr].push(event); 64 | }); 65 | 66 | // 4. Render the list 67 | if (Object.keys(eventsByDay).length === 0) { 68 | this.containerEl.setText( 69 | `No upcoming events from ${rangeStart.format( 70 | "MMM D" 71 | )} to ${rangeEnd.format("MMM D, YYYY")}.` 72 | ); 73 | return; 74 | } 75 | 76 | let currentDayIter = rangeStart.clone(); 77 | while (currentDayIter.isSameOrBefore(rangeEnd, "day")) { 78 | const dateStr = currentDayIter.format("YYYY-MM-DD"); 79 | if (eventsByDay[dateStr] && eventsByDay[dateStr].length > 0) { 80 | // Create a container for the two-column layout for the day 81 | const daySection = 82 | this.containerEl.createDiv("agenda-day-section"); 83 | 84 | // Left column for the date 85 | const dateColumn = daySection.createDiv( 86 | "agenda-day-date-column" 87 | ); 88 | const dayHeader = dateColumn.createDiv("agenda-day-header"); 89 | dayHeader.textContent = currentDayIter.format("dddd, MMMM D"); 90 | if (currentDayIter.isSame(moment(), "day")) { 91 | dayHeader.addClass("is-today"); 92 | } 93 | 94 | // Right column for the events 95 | const eventsColumn = daySection.createDiv( 96 | "agenda-day-events-column" 97 | ); 98 | const eventsList = eventsColumn.createDiv("agenda-events-list"); // Keep the original list class if needed 99 | 100 | eventsByDay[dateStr] 101 | .sort((a, b) => { 102 | const timeA = a.start ? moment(a.start).valueOf() : 0; 103 | const timeB = b.start ? moment(b.start).valueOf() : 0; 104 | return timeA - timeB; 105 | }) 106 | .forEach((event) => { 107 | const eventItem = 108 | eventsList.createDiv("agenda-event-item"); 109 | const { eventEl, component } = renderCalendarEvent({ 110 | event: event, 111 | viewType: "agenda", 112 | app: this.app, 113 | onEventClick: this.options.onEventClick, 114 | onEventHover: this.options.onEventHover, 115 | onEventContextMenu: this.options.onEventContextMenu, 116 | onEventComplete: this.options.onEventComplete, 117 | }); 118 | this.addChild(component); 119 | eventItem.appendChild(eventEl); 120 | }); 121 | } 122 | currentDayIter.add(1, "day"); 123 | } 124 | 125 | console.log( 126 | `Rendered Agenda View component from ${rangeStart.format( 127 | "YYYY-MM-DD" 128 | )} to ${rangeEnd.format("YYYY-MM-DD")}` 129 | ); 130 | } 131 | 132 | // Update methods to allow changing data after initial render 133 | updateEvents(events: CalendarEvent[]): void { 134 | this.events = events; 135 | this.render(); 136 | } 137 | 138 | updateCurrentDate(date: moment.Moment): void { 139 | this.currentDate = date; 140 | this.render(); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/components/calendar/views/base-view.ts: -------------------------------------------------------------------------------- 1 | import { App, Component } from "obsidian"; 2 | import { CalendarEvent } from "../index"; 3 | import TaskProgressBarPlugin from "../../../index"; 4 | 5 | interface EventMap { 6 | onEventClick: (ev: MouseEvent, event: CalendarEvent) => void; 7 | onEventHover: (ev: MouseEvent, event: CalendarEvent) => void; 8 | onDayClick: ( 9 | ev: MouseEvent, 10 | day: number, 11 | options: { 12 | behavior: "open-quick-capture" | "open-task-view"; 13 | } 14 | ) => void; 15 | onDayHover: (ev: MouseEvent, day: number) => void; 16 | onMonthClick: (ev: MouseEvent, month: number) => void; 17 | onMonthHover: (ev: MouseEvent, month: number) => void; 18 | onYearClick: (ev: MouseEvent, year: number) => void; 19 | onYearHover: (ev: MouseEvent, year: number) => void; 20 | onEventContextMenu: (ev: MouseEvent, event: CalendarEvent) => void; 21 | onEventComplete: (ev: MouseEvent, event: CalendarEvent) => void; 22 | } 23 | 24 | // Combine event handlers into a single options object, making them optional 25 | export interface CalendarViewOptions extends Partial { 26 | // Add other common view options here if needed 27 | } 28 | 29 | export abstract class CalendarViewComponent extends Component { 30 | protected containerEl: HTMLElement; 31 | protected events: CalendarEvent[]; 32 | protected options: CalendarViewOptions; 33 | 34 | constructor( 35 | plugin: TaskProgressBarPlugin, 36 | app: App, 37 | containerEl: HTMLElement, 38 | events: CalendarEvent[], 39 | options: CalendarViewOptions = {} // Provide default empty options 40 | ) { 41 | super(); // Call the base class constructor 42 | this.containerEl = containerEl; 43 | this.events = events; 44 | this.options = options; 45 | } 46 | 47 | // Abstract method for rendering the specific view content 48 | // Subclasses (MonthView, WeekView, DayView) must implement this 49 | abstract render(): void; 50 | 51 | // Example common method (can be implemented here or left abstract) 52 | protected handleEventClick(ev: MouseEvent, event: CalendarEvent): void { 53 | if (this.options.onEventClick) { 54 | this.options.onEventClick(ev, event); 55 | } 56 | } 57 | 58 | // Lifecycle methods from Component might be overridden here or in subclasses 59 | onload(): void { 60 | super.onload(); 61 | this.render(); // Initial render on load 62 | } 63 | 64 | onunload(): void { 65 | // Clean up resources, remove event listeners, etc. 66 | this.containerEl.empty(); 67 | super.onunload(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/calendar/views/day-view.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, moment } from "obsidian"; 2 | import { CalendarEvent } from "../index"; 3 | import { renderCalendarEvent } from "../rendering/event-renderer"; 4 | import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; 5 | import TaskProgressBarPlugin from "../../../index"; 6 | 7 | export class DayView extends CalendarViewComponent { 8 | private currentDate: moment.Moment; 9 | private app: App; 10 | private plugin: TaskProgressBarPlugin; 11 | 12 | constructor( 13 | app: App, 14 | plugin: TaskProgressBarPlugin, 15 | containerEl: HTMLElement, 16 | currentDate: moment.Moment, 17 | events: CalendarEvent[], 18 | options: CalendarViewOptions = {} 19 | ) { 20 | super(plugin, app, containerEl, events, options); 21 | this.app = app; 22 | this.plugin = plugin; 23 | this.currentDate = currentDate; 24 | } 25 | 26 | render(): void { 27 | this.containerEl.empty(); 28 | this.containerEl.addClass("view-day"); 29 | 30 | // 1. Filter events for the current day 31 | const todayStart = this.currentDate.clone().startOf("day"); 32 | const todayEnd = this.currentDate.clone().endOf("day"); 33 | 34 | const dayEvents = this.events 35 | .filter((event) => { 36 | // Check if event occurs today (handles multi-day) 37 | const eventStart = moment(event.start); 38 | // Treat events without end date as starting today if they start before today ends 39 | const eventEnd = event.end 40 | ? moment(event.end) 41 | : eventStart.clone().endOf("day"); // Assume end of day if no end time 42 | // Event overlaps if its start is before today ends AND its end is after today starts 43 | return ( 44 | eventStart.isBefore(todayEnd) && 45 | eventEnd.isAfter(todayStart) 46 | ); 47 | }) 48 | .sort((a, b) => { 49 | // Sort events by ID 50 | if (a.id < b.id) return -1; 51 | if (a.id > b.id) return 1; 52 | return 0; 53 | }); 54 | 55 | // 2. Render Timeline Section (Combined List) 56 | const timelineSection = this.containerEl.createDiv( 57 | "calendar-timeline-section" // Keep this class for general styling? Or rename? 58 | ); 59 | const timelineEventsContainer = timelineSection.createDiv( 60 | "calendar-timeline-events-container" // Renamed? maybe calendar-day-events-list 61 | ); 62 | 63 | // 3. Render events in a simple list 64 | if (dayEvents.length === 0) { 65 | timelineEventsContainer.addClass("is-empty"); 66 | timelineEventsContainer.setText("(No events for this day)"); 67 | } else { 68 | dayEvents.forEach((event) => { 69 | // Remove layout finding logic 70 | /* 71 | const layout = eventLayouts.find((l) => l.id === event.id); 72 | if (!layout) { 73 | console.warn("Layout not found for event:", event); 74 | // Optionally render it somewhere as a fallback? 75 | return; 76 | } 77 | */ 78 | 79 | // Use the renderer, adjust viewType if needed, remove layout 80 | const { eventEl, component } = renderCalendarEvent({ 81 | event: event, 82 | // Use a generic type or reuse 'timed' but styles will handle layout 83 | viewType: "day-timed", // Changed back to day-timed, CSS will handle layout 84 | // layout: layout, // Removed layout 85 | app: this.app, 86 | onEventClick: this.options.onEventClick, 87 | onEventHover: this.options.onEventHover, 88 | onEventContextMenu: this.options.onEventContextMenu, 89 | onEventComplete: this.options.onEventComplete, 90 | }); 91 | this.addChild(component); 92 | timelineEventsContainer.appendChild(eventEl); // Append directly to the container 93 | 94 | // Add event listeners using the options from the base class 95 | if (this.options.onEventClick) { 96 | this.registerDomEvent(eventEl, "click", (ev) => { 97 | this.options.onEventClick!(ev, event); 98 | }); 99 | } 100 | if (this.options.onEventHover) { 101 | this.registerDomEvent(eventEl, "mouseenter", (ev) => { 102 | this.options.onEventHover!(ev, event); 103 | }); 104 | // Optionally add mouseleave if needed 105 | } 106 | }); 107 | } 108 | } 109 | 110 | // Update methods to allow changing data after initial render 111 | updateEvents(events: CalendarEvent[]): void { 112 | this.events = events; 113 | this.render(); 114 | } 115 | 116 | updateCurrentDate(date: moment.Moment): void { 117 | this.currentDate = date; 118 | this.render(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/components/date-picker/DatePickerModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | import { DatePickerComponent, DatePickerState } from "./DatePickerComponent"; 3 | import type TaskProgressBarPlugin from "../../index"; 4 | 5 | export class DatePickerModal extends Modal { 6 | public datePickerComponent: DatePickerComponent; 7 | public onDateSelected: ((date: string | null) => void) | null = null; 8 | private plugin?: TaskProgressBarPlugin; 9 | private initialDate?: string; 10 | private dateMark: string; 11 | 12 | constructor( 13 | app: App, 14 | plugin?: TaskProgressBarPlugin, 15 | initialDate?: string, 16 | dateMark: string = "📅" 17 | ) { 18 | super(app); 19 | this.plugin = plugin; 20 | this.initialDate = initialDate; 21 | this.dateMark = dateMark; 22 | } 23 | 24 | onOpen() { 25 | const { contentEl } = this; 26 | contentEl.empty(); 27 | 28 | this.datePickerComponent = new DatePickerComponent( 29 | this.contentEl, 30 | this.app, 31 | this.plugin, 32 | this.initialDate, 33 | this.dateMark 34 | ); 35 | 36 | this.datePickerComponent.onload(); 37 | 38 | // Set up date change callback 39 | this.datePickerComponent.setOnDateChange((date: string) => { 40 | if (this.onDateSelected) { 41 | this.onDateSelected(date); 42 | } 43 | this.close(); 44 | }); 45 | } 46 | 47 | onClose() { 48 | const { contentEl } = this; 49 | 50 | if (this.datePickerComponent) { 51 | this.datePickerComponent.onunload(); 52 | } 53 | 54 | contentEl.empty(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/date-picker/DatePickerPopover.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, CloseableComponent } from "obsidian"; 2 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 3 | import { DatePickerComponent, DatePickerState } from "./DatePickerComponent"; 4 | import type TaskProgressBarPlugin from "../../index"; 5 | 6 | export class DatePickerPopover extends Component implements CloseableComponent { 7 | private app: App; 8 | public popoverRef: HTMLDivElement | null = null; 9 | public datePickerComponent: DatePickerComponent; 10 | private win: Window; 11 | private scrollParent: HTMLElement | Window; 12 | private popperInstance: PopperInstance | null = null; 13 | public onDateSelected: ((date: string | null) => void) | null = null; 14 | private plugin?: TaskProgressBarPlugin; 15 | private initialDate?: string; 16 | private dateMark: string; 17 | 18 | constructor( 19 | app: App, 20 | plugin?: TaskProgressBarPlugin, 21 | initialDate?: string, 22 | dateMark: string = "📅" 23 | ) { 24 | super(); 25 | this.app = app; 26 | this.plugin = plugin; 27 | this.initialDate = initialDate; 28 | this.dateMark = dateMark; 29 | this.win = app.workspace.containerEl.win || window; 30 | this.scrollParent = this.win; 31 | } 32 | 33 | /** 34 | * Shows the date picker popover at the given position. 35 | */ 36 | showAtPosition(position: { x: number; y: number }) { 37 | if (this.popoverRef) { 38 | this.close(); 39 | } 40 | 41 | // Create content container 42 | const contentEl = createDiv({ cls: "date-picker-popover-content" }); 43 | 44 | // Prevent clicks inside the popover from bubbling up 45 | this.registerDomEvent(contentEl, "click", (e) => { 46 | e.stopPropagation(); 47 | }); 48 | 49 | // Create date picker component 50 | this.datePickerComponent = new DatePickerComponent( 51 | contentEl, 52 | this.app, 53 | this.plugin, 54 | this.initialDate, 55 | this.dateMark 56 | ); 57 | 58 | // Initialize component 59 | this.datePickerComponent.onload(); 60 | 61 | // Set up date change callback 62 | this.datePickerComponent.setOnDateChange((date: string) => { 63 | if (this.onDateSelected) { 64 | this.onDateSelected(date); 65 | } 66 | this.close(); 67 | }); 68 | 69 | // Create the popover 70 | this.popoverRef = this.app.workspace.containerEl.createDiv({ 71 | cls: "date-picker-popover tg-menu bm-menu", 72 | }); 73 | this.popoverRef.appendChild(contentEl); 74 | 75 | document.body.appendChild(this.popoverRef); 76 | 77 | // Create a virtual element for Popper.js 78 | const virtualElement = { 79 | getBoundingClientRect: () => ({ 80 | width: 0, 81 | height: 0, 82 | top: position.y, 83 | right: position.x, 84 | bottom: position.y, 85 | left: position.x, 86 | x: position.x, 87 | y: position.y, 88 | toJSON: function () { 89 | return this; 90 | }, 91 | }), 92 | }; 93 | 94 | if (this.popoverRef) { 95 | this.popperInstance = createPopper( 96 | virtualElement, 97 | this.popoverRef, 98 | { 99 | placement: "bottom-start", 100 | modifiers: [ 101 | { 102 | name: "offset", 103 | options: { 104 | offset: [0, 8], // Offset the popover slightly from the reference 105 | }, 106 | }, 107 | { 108 | name: "preventOverflow", 109 | options: { 110 | padding: 10, // Padding from viewport edges 111 | }, 112 | }, 113 | { 114 | name: "flip", 115 | options: { 116 | fallbackPlacements: [ 117 | "top-start", 118 | "right-start", 119 | "left-start", 120 | ], 121 | padding: 10, 122 | }, 123 | }, 124 | ], 125 | } 126 | ); 127 | } 128 | 129 | // Use timeout to ensure popover is rendered before adding listeners 130 | this.win.setTimeout(() => { 131 | this.win.addEventListener("click", this.clickOutside); 132 | this.scrollParent.addEventListener( 133 | "scroll", 134 | this.scrollHandler, 135 | true 136 | ); // Use capture for scroll 137 | }, 10); 138 | } 139 | 140 | private clickOutside = (e: MouseEvent) => { 141 | if (this.popoverRef && !this.popoverRef.contains(e.target as Node)) { 142 | this.close(); 143 | } 144 | }; 145 | 146 | private scrollHandler = (e: Event) => { 147 | if (this.popoverRef) { 148 | if ( 149 | e.target instanceof Node && 150 | this.popoverRef.contains(e.target) 151 | ) { 152 | const targetElement = e.target as HTMLElement; 153 | if ( 154 | targetElement.scrollHeight > targetElement.clientHeight || 155 | targetElement.scrollWidth > targetElement.clientWidth 156 | ) { 157 | return; 158 | } 159 | } 160 | this.close(); 161 | } 162 | }; 163 | 164 | /** 165 | * Closes the popover. 166 | */ 167 | close() { 168 | if (this.popperInstance) { 169 | this.popperInstance.destroy(); 170 | this.popperInstance = null; 171 | } 172 | 173 | if (this.popoverRef) { 174 | this.popoverRef.remove(); 175 | this.popoverRef = null; 176 | } 177 | 178 | this.win.removeEventListener("click", this.clickOutside); 179 | this.scrollParent.removeEventListener( 180 | "scroll", 181 | this.scrollHandler, 182 | true 183 | ); 184 | 185 | if (this.datePickerComponent) { 186 | this.datePickerComponent.onunload(); 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/components/date-picker/index.ts: -------------------------------------------------------------------------------- 1 | import { DatePickerComponent } from "./DatePickerComponent"; 2 | import { DatePickerModal } from "./DatePickerModal"; 3 | import { DatePickerPopover } from "./DatePickerPopover"; 4 | 5 | export { DatePickerComponent, DatePickerModal, DatePickerPopover }; 6 | -------------------------------------------------------------------------------- /src/components/gantt/grid-background.ts: -------------------------------------------------------------------------------- 1 | import { Component, App } from "obsidian"; 2 | import { GanttTaskItem, Timescale, PlacedGanttTaskItem } from "./gantt"; // Correctly imports PlacedGanttTaskItem now 3 | import { DateHelper } from "../../utils/DateHelper"; // Corrected import path again 4 | 5 | // Interface for parameters needed by the grid component 6 | interface GridBackgroundParams { 7 | startDate: Date; 8 | endDate: Date; 9 | visibleStartDate: Date; // Need visible range for optimization 10 | visibleEndDate: Date; // Need visible range for optimization 11 | totalWidth: number; 12 | totalHeight: number; 13 | visibleTasks: PlacedGanttTaskItem[]; // Use filtered tasks 14 | timescale: Timescale; 15 | dayWidth: number; 16 | rowHeight: number; 17 | dateHelper: DateHelper; // Pass helper functions 18 | shouldDrawMajorTick: (date: Date) => boolean; 19 | shouldDrawMinorTick: (date: Date) => boolean; 20 | } 21 | 22 | export class GridBackgroundComponent extends Component { 23 | private app: App; 24 | private svgGroupEl: SVGGElement; // The element to draw into 25 | private params: GridBackgroundParams | null = null; 26 | 27 | // Use DateHelper for date calculations 28 | private dateHelper = new DateHelper(); 29 | 30 | constructor(app: App, svgGroupEl: SVGGElement) { 31 | super(); 32 | this.app = app; 33 | this.svgGroupEl = svgGroupEl; 34 | } 35 | 36 | onload() { 37 | console.log("GridBackgroundComponent loaded."); 38 | // Initial render happens when updateParams is called 39 | } 40 | 41 | onunload() { 42 | console.log("GridBackgroundComponent unloaded."); 43 | this.svgGroupEl.empty(); // Clear the grid group 44 | } 45 | 46 | updateParams(newParams: GridBackgroundParams) { 47 | this.params = newParams; 48 | this.render(); 49 | } 50 | 51 | private render() { 52 | if (!this.params) { 53 | console.warn( 54 | "GridBackgroundComponent: Cannot render, params not set." 55 | ); 56 | return; 57 | } 58 | 59 | this.svgGroupEl.empty(); // Clear previous grid 60 | 61 | const { 62 | startDate, // Overall start for coordinate calculations 63 | endDate, // Overall end for today marker check 64 | visibleStartDate, // Use these for rendering loops 65 | visibleEndDate, 66 | totalWidth, // Still needed for horizontal line width 67 | totalHeight, 68 | visibleTasks, // Use filtered tasks 69 | timescale, 70 | rowHeight, 71 | dateHelper, // Use passed dateHelper 72 | shouldDrawMajorTick, 73 | shouldDrawMinorTick, 74 | } = this.params; 75 | 76 | // --- Vertical Lines (Optimized) --- 77 | // Determine the date range to render vertical lines for 78 | const renderBufferDays = 30; // Match header buffer or adjust as needed 79 | let renderStartDate = dateHelper.addDays( 80 | visibleStartDate, 81 | -renderBufferDays 82 | ); 83 | let renderEndDate = dateHelper.addDays( 84 | visibleEndDate, 85 | renderBufferDays 86 | ); 87 | 88 | // Clamp render range to the overall gantt chart bounds 89 | renderStartDate = new Date( 90 | Math.max(renderStartDate.getTime(), startDate.getTime()) 91 | ); 92 | renderEndDate = new Date( 93 | Math.min(renderEndDate.getTime(), endDate.getTime()) 94 | ); 95 | 96 | // Start iteration from the beginning of the renderStartDate's day 97 | let currentDate = dateHelper.startOfDay(renderStartDate); 98 | 99 | while (currentDate <= renderEndDate) { 100 | // Iterate only over render range 101 | const x = dateHelper.dateToX( 102 | currentDate, 103 | startDate, // Base calculation still uses overall startDate 104 | this.params.dayWidth 105 | ); 106 | if (shouldDrawMajorTick(currentDate)) { 107 | this.svgGroupEl.createSvg("line", { 108 | attr: { 109 | x1: x, 110 | y1: 0, 111 | x2: x, 112 | y2: totalHeight, 113 | class: "gantt-grid-line-major", 114 | }, 115 | }); 116 | } else if ( 117 | shouldDrawMinorTick(currentDate) || 118 | timescale === "Day" 119 | ) { 120 | // Draw day lines in Day view 121 | this.svgGroupEl.createSvg("line", { 122 | attr: { 123 | x1: x, 124 | y1: 0, 125 | x2: x, 126 | y2: totalHeight, 127 | class: "gantt-grid-line-minor", 128 | }, 129 | }); 130 | } 131 | 132 | // Stop iterating if we've passed the render end date 133 | if (currentDate > renderEndDate) { 134 | break; 135 | } 136 | 137 | currentDate = dateHelper.addDays(currentDate, 1); 138 | } 139 | 140 | // --- Horizontal Lines (Simplified) --- 141 | // Draw a line every rowHeight up to totalHeight 142 | for (let y = rowHeight; y <= totalHeight; y += rowHeight) { 143 | this.svgGroupEl.createSvg("line", { 144 | attr: { 145 | x1: 0, 146 | y1: y, 147 | x2: totalWidth, 148 | y2: y, 149 | class: "gantt-grid-line-horizontal", 150 | }, 151 | }); 152 | } 153 | 154 | // --- Today Marker Line in Grid (No change needed, already checks bounds) --- 155 | const today = dateHelper.startOfDay(new Date()); 156 | if (today >= startDate && today <= endDate) { 157 | const todayX = dateHelper.dateToX( 158 | today, 159 | startDate, 160 | this.params.dayWidth 161 | ); 162 | this.svgGroupEl.createSvg("line", { 163 | attr: { 164 | x1: todayX, 165 | y1: 0, 166 | x2: todayX, 167 | y2: totalHeight, 168 | class: "gantt-grid-today-marker", 169 | }, 170 | }); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/components/habit/habitcard/counthabitcard.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, Component, Notice, setIcon } from "obsidian"; 2 | import { CountHabitProps } from "../../../types/habit-card"; 3 | import { HabitCard } from "./habitcard"; 4 | import { t } from "../../../translations/helper"; 5 | import TaskProgressBarPlugin from "../../../index"; 6 | 7 | export class CountHabitCard extends HabitCard { 8 | constructor( 9 | public habit: CountHabitProps, 10 | public container: HTMLElement, 11 | public plugin: TaskProgressBarPlugin 12 | ) { 13 | super(habit, container, plugin); 14 | } 15 | 16 | onload(): void { 17 | super.onload(); 18 | this.render(); 19 | } 20 | 21 | render(): void { 22 | super.render(); 23 | 24 | const card = this.container.createDiv({ 25 | cls: "habit-card count-habit-card", 26 | }); 27 | 28 | const contentWrapper = card.createDiv({ cls: "card-content-wrapper" }); 29 | 30 | const button = new ButtonComponent(contentWrapper) 31 | .setClass("habit-icon-button") 32 | .setIcon((this.habit.icon as string) || "plus-circle") 33 | .onClick(() => { 34 | this.toggleHabitCompletion(this.habit.id); 35 | if (this.habit.max && countToday + 1 === this.habit.max) { 36 | new Notice(`${t("Goal reached")} ${this.habit.name}! ✅`); 37 | } else if (this.habit.max && countToday + 1 > this.habit.max) { 38 | new Notice(`${t("Exceeded goal")} ${this.habit.name}! 💪`); 39 | } 40 | }); 41 | 42 | const today = new Date().toISOString().split("T")[0]; 43 | let countToday = this.habit.completions[today] ?? 0; 44 | 45 | const infoDiv = contentWrapper.createDiv( 46 | { cls: "habit-info" }, 47 | (el) => { 48 | el.createEl("div", { 49 | cls: "habit-card-name", 50 | text: this.habit.name, 51 | }); 52 | el.createEl("span", { 53 | cls: "habit-active-day", 54 | text: this.habit.completions[today] 55 | ? `${t("Active")} ${t("today")}` 56 | : `${t("Inactive")} ${t("today")}`, 57 | }); 58 | } 59 | ); 60 | 61 | const progressArea = contentWrapper.createDiv({ 62 | cls: "habit-progress-area", 63 | }); 64 | const heatmapContainer = progressArea.createDiv({ 65 | cls: "habit-heatmap-small", 66 | }); 67 | if (this.habit.max && this.habit.max > 0) { 68 | this.renderHeatmap( 69 | heatmapContainer, 70 | this.habit.completions, 71 | "md", 72 | (value: any) => value >= (this.habit.max ?? 0) 73 | ); 74 | this.renderProgressBar(progressArea, countToday, this.habit.max); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/habit/habitcard/dailyhabitcard.ts: -------------------------------------------------------------------------------- 1 | import { Component, Notice, setIcon } from "obsidian"; 2 | import { DailyHabitProps } from "../../../types/habit-card"; 3 | import { HabitCard } from "./habitcard"; 4 | import { t } from "../../../translations/helper"; 5 | import TaskProgressBarPlugin from "../../../index"; 6 | 7 | export class DailyHabitCard extends HabitCard { 8 | constructor( 9 | public habit: DailyHabitProps, 10 | public container: HTMLElement, 11 | public plugin: TaskProgressBarPlugin 12 | ) { 13 | super(habit, container, plugin); 14 | } 15 | 16 | onload(): void { 17 | super.onload(); 18 | this.render(); 19 | } 20 | 21 | render(): void { 22 | super.render(); 23 | 24 | const card = this.container.createDiv({ 25 | cls: "habit-card daily-habit-card", 26 | }); 27 | const header = card.createDiv({ cls: "card-header" }); 28 | 29 | const titleDiv = header.createDiv({ cls: "card-title" }); 30 | const iconEl = titleDiv.createSpan({ cls: "habit-icon" }); 31 | setIcon(iconEl, (this.habit.icon as string) || "dice"); // Use default icon 'dice' if none provided 32 | 33 | // Add completion text indicator if defined 34 | const titleText = this.habit.completionText 35 | ? `${this.habit.name} (${this.habit.completionText})` 36 | : this.habit.name; 37 | 38 | titleDiv 39 | .createSpan({ text: titleText, cls: "habit-name" }) 40 | .onClickEvent(() => { 41 | new Notice(`Chart for ${this.habit.name} (Not Implemented)`); 42 | // TODO: Implement Chart Dialog 43 | }); 44 | 45 | const checkboxContainer = header.createDiv({ 46 | cls: "habit-checkbox-container", 47 | }); 48 | const checkbox = checkboxContainer.createEl("input", { 49 | type: "checkbox", 50 | cls: "habit-checkbox", 51 | }); 52 | const today = new Date().toISOString().split("T")[0]; 53 | 54 | // Check if completed based on completion text or any value 55 | let isCompletedToday = false; 56 | const todayValue = this.habit.completions[today]; 57 | 58 | if (this.habit.completionText) { 59 | // If completionText is defined, check if value is 1 (meaning it matched completionText) 60 | isCompletedToday = todayValue === 1; 61 | } else { 62 | // Default behavior: any truthy value means completed 63 | isCompletedToday = !!todayValue; 64 | } 65 | 66 | checkbox.checked = isCompletedToday; 67 | 68 | this.registerDomEvent(checkbox, "click", (e) => { 69 | e.preventDefault(); // Prevent default toggle, handle manually 70 | this.toggleHabitCompletion(this.habit.id); 71 | if (!isCompletedToday) { 72 | // Optional: trigger confetti only on completion 73 | new Notice(`${t("Completed")} ${this.habit.name}! 🎉`); 74 | } 75 | }); 76 | 77 | const contentWrapper = card.createDiv({ cls: "card-content-wrapper" }); 78 | this.renderHeatmap( 79 | contentWrapper, 80 | this.habit.completions, 81 | "lg", 82 | (value: any) => { 83 | // If completionText is defined, check if value is 1 (meaning it matched completionText) 84 | if (this.habit.completionText) { 85 | return value === 1; 86 | } 87 | // Default behavior: any truthy value means completed 88 | return value > 0; 89 | } 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/habit/habitcard/index.ts: -------------------------------------------------------------------------------- 1 | export { HabitCard } from "./habitcard"; 2 | export { DailyHabitCard } from "./dailyhabitcard"; 3 | export { CountHabitCard } from "./counthabitcard"; 4 | export { ScheduledHabitCard } from "./scheduledhabitcard"; 5 | export { MappingHabitCard } from "./mappinghabitcard"; 6 | -------------------------------------------------------------------------------- /src/components/habit/habitcard/mappinghabitcard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonComponent, 3 | Component, 4 | Notice, 5 | setIcon, 6 | Setting, 7 | SliderComponent, 8 | } from "obsidian"; 9 | import { MappingHabitProps } from "../../../types/habit-card"; 10 | import { HabitCard } from "./habitcard"; 11 | import TaskProgressBarPlugin from "../../../index"; 12 | 13 | export class MappingHabitCard extends HabitCard { 14 | constructor( 15 | public habit: MappingHabitProps, 16 | public container: HTMLElement, 17 | public plugin: TaskProgressBarPlugin 18 | ) { 19 | super(habit, container, plugin); 20 | } 21 | 22 | onload(): void { 23 | super.onload(); 24 | this.render(); 25 | } 26 | 27 | render(): void { 28 | super.render(); 29 | 30 | const card = this.container.createDiv({ 31 | cls: "habit-card mapping-habit-card", 32 | }); 33 | const header = card.createDiv({ cls: "card-header" }); 34 | const titleDiv = header.createDiv({ cls: "card-title" }); 35 | const iconEl = titleDiv.createSpan({ cls: "habit-icon" }); 36 | setIcon(iconEl, (this.habit.icon as string) || "smile-plus"); // Better default icon 37 | titleDiv.createSpan({ text: this.habit.name, cls: "habit-name" }); 38 | 39 | const contentWrapper = card.createDiv({ cls: "card-content-wrapper" }); 40 | 41 | const heatmapContainer = contentWrapper.createDiv({ 42 | cls: "habit-heatmap-medium", 43 | }); 44 | this.renderHeatmap( 45 | heatmapContainer, 46 | this.habit.completions, 47 | "md", 48 | (value: any) => typeof value === "number" && value > 0, // Check if it's a positive number 49 | (value: number) => { 50 | // Custom renderer for emoji 51 | if (typeof value !== "number" || value <= 0) return null; 52 | const emoji = this.habit.mapping?.[value] || "?"; 53 | const cellContent = createSpan({ text: emoji }); 54 | 55 | // Add tooltip showing the mapped value label if available 56 | if (this.habit.mapping && this.habit.mapping[value]) { 57 | cellContent.setAttribute( 58 | "aria-label", 59 | `${this.habit.mapping[value]}` 60 | ); 61 | cellContent.addClass("has-tooltip"); 62 | } else { 63 | cellContent.setAttribute("aria-label", `Value: ${value}`); 64 | } 65 | 66 | return cellContent; 67 | } 68 | ); 69 | 70 | const controlsDiv = contentWrapper.createDiv({ cls: "habit-controls" }); 71 | const today = new Date().toISOString().split("T")[0]; 72 | const defaultValue = Object.keys(this.habit.mapping || {}) 73 | .map(Number) 74 | .includes(3) 75 | ? 3 76 | : Object.keys(this.habit.mapping || {}) 77 | .map(Number) 78 | .sort((a, b) => a - b)[0] || 1; 79 | let currentSelection = this.habit.completions[today] ?? defaultValue; 80 | 81 | const mappingButton = new ButtonComponent(controlsDiv) 82 | .setButtonText(this.habit.mapping?.[currentSelection] || "?") 83 | .setClass("habit-mapping-button") 84 | .onClick(() => { 85 | if ( 86 | currentSelection > 0 && 87 | this.habit.mapping?.[currentSelection] 88 | ) { 89 | // Ensure a valid selection is made 90 | this.toggleHabitCompletion(this.habit.id, currentSelection); 91 | 92 | const noticeText = 93 | this.habit.mapping && 94 | this.habit.mapping[currentSelection] 95 | ? `Recorded ${this.habit.name} as ${this.habit.mapping[currentSelection]}` 96 | : `Recorded ${this.habit.name} as ${this.habit.mapping[currentSelection]}`; 97 | 98 | new Notice(noticeText); 99 | } else { 100 | new Notice( 101 | "Please select a valid value using the slider first." 102 | ); 103 | } 104 | }); 105 | 106 | // Slider using Obsidian Setting 107 | 108 | const slider = new SliderComponent(controlsDiv); 109 | const mappingKeys = Object.keys(this.habit.mapping || {}) 110 | .map(Number) 111 | .sort((a, b) => a - b); 112 | const min = mappingKeys[0] || 1; 113 | const max = mappingKeys[mappingKeys.length - 1] || 5; 114 | slider 115 | .setLimits(min, max, 1) 116 | .setValue(currentSelection) 117 | .setDynamicTooltip() 118 | .onChange((value) => { 119 | currentSelection = value; 120 | 121 | console.log(this.habit.mapping?.[currentSelection]); 122 | 123 | mappingButton.buttonEl.setText( 124 | this.habit.mapping?.[currentSelection] || "?" 125 | ); 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/components/inview-filter/custom/scroll-to-date-button.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "obsidian"; 2 | import { t } from "../../../translations/helper"; 3 | export class ScrollToDateButton extends Component { 4 | private containerEl: HTMLElement; 5 | private scrollToDateCallback: (date: Date) => void; 6 | 7 | constructor( 8 | containerEl: HTMLElement, 9 | scrollToDateCallback: (date: Date) => void 10 | ) { 11 | super(); 12 | this.containerEl = containerEl; 13 | this.scrollToDateCallback = scrollToDateCallback; 14 | } 15 | 16 | onload() { 17 | const todayButton = this.containerEl.createEl("button", { 18 | text: t("Today"), 19 | cls: "gantt-filter-today-button", 20 | }); 21 | 22 | this.registerDomEvent(todayButton, "click", () => { 23 | this.scrollToDateCallback(new Date()); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/inview-filter/filter-pill.ts: -------------------------------------------------------------------------------- 1 | import { Component, ExtraButtonComponent } from "obsidian"; 2 | import { ActiveFilter, FilterPillOptions } from "./filter-type"; 3 | 4 | export class FilterPill extends Component { 5 | private filter: ActiveFilter; 6 | private onRemove: (id: string) => void; 7 | public element: HTMLElement; // Made public for parent access 8 | 9 | constructor(options: FilterPillOptions) { 10 | super(); 11 | this.filter = options.filter; 12 | this.onRemove = options.onRemove; 13 | } 14 | 15 | override onload(): void { 16 | this.element = this.createPillElement(); 17 | } 18 | 19 | private createPillElement(): HTMLElement { 20 | // Create the main pill container 21 | const pill = document.createElement("div"); 22 | pill.className = "filter-pill"; 23 | pill.setAttribute("data-filter-id", this.filter.id); 24 | 25 | // Create and append category label span 26 | pill.createSpan({ 27 | cls: "filter-pill-category", 28 | text: `${this.filter.categoryLabel}:`, // Add colon here 29 | }); 30 | 31 | // Create and append value span 32 | pill.createSpan({ 33 | cls: "filter-pill-value", 34 | text: this.filter.value, 35 | }); 36 | 37 | // Create the remove button 38 | const removeButton = pill.createEl("span", { 39 | cls: "filter-pill-remove", 40 | attr: { "aria-label": "Remove filter" }, 41 | }); 42 | 43 | // Create and append the remove icon span inside the button 44 | removeButton.createSpan( 45 | { 46 | cls: "filter-pill-remove-icon", 47 | }, 48 | (el) => { 49 | new ExtraButtonComponent(el).setIcon("x").onClick(() => { 50 | this.removePill(); 51 | }); 52 | } 53 | ); 54 | 55 | return pill; 56 | } 57 | 58 | private removePill(): void { 59 | // Animate removal 60 | this.element.classList.add("filter-pill-removing"); 61 | 62 | // Use Obsidian's Component lifecycle to handle removal after animation 63 | setTimeout(() => { 64 | this.onRemove(this.filter.id); // Notify parent 65 | // Parent component should handle removing this child component 66 | }, 150); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/inview-filter/filter-type.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "obsidian"; 2 | 3 | export interface FilterCategory { 4 | id: string; 5 | label: string; 6 | options: string[]; 7 | } 8 | 9 | export interface ActiveFilter { 10 | id: string; 11 | category: string; 12 | categoryLabel: string; 13 | value: string; 14 | } 15 | 16 | export interface FilterComponentOptions { 17 | container: HTMLElement; 18 | options: FilterCategory[]; 19 | onChange?: (activeFilters: ActiveFilter[]) => void; 20 | components?: Component[]; 21 | } 22 | 23 | export interface FilterDropdownOptions { 24 | options: FilterCategory[]; 25 | anchorElement: HTMLElement; 26 | onSelect: (category: string, value: string) => void; 27 | onClose: () => void; 28 | } 29 | 30 | export interface FilterPillOptions { 31 | filter: ActiveFilter; 32 | onRemove: (id: string) => void; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/table/TableTypes.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "../../types/task"; 2 | 3 | /** 4 | * Table column definition 5 | */ 6 | export interface TableColumn { 7 | id: string; 8 | title: string; 9 | width: number; 10 | sortable: boolean; 11 | resizable: boolean; 12 | type: "text" | "number" | "date" | "status" | "priority" | "tags"; 13 | visible: boolean; 14 | align?: "left" | "center" | "right"; 15 | } 16 | 17 | /** 18 | * Table cell data 19 | */ 20 | export interface TableCell { 21 | columnId: string; 22 | value: any; 23 | displayValue: string; 24 | editable: boolean; 25 | className?: string; 26 | } 27 | 28 | /** 29 | * Table row data 30 | */ 31 | export interface TableRow { 32 | id: string; 33 | task: Task; 34 | level: number; // For tree view hierarchy 35 | expanded: boolean; // For tree view expansion state 36 | hasChildren: boolean; // Whether this row has child rows 37 | cells: TableCell[]; 38 | className?: string; 39 | } 40 | 41 | /** 42 | * Sort configuration 43 | */ 44 | export interface SortConfig { 45 | field: string; 46 | order: "asc" | "desc"; 47 | } 48 | 49 | /** 50 | * Column resize event data 51 | */ 52 | export interface ColumnResizeEvent { 53 | columnId: string; 54 | newWidth: number; 55 | oldWidth: number; 56 | } 57 | 58 | /** 59 | * Cell edit event data 60 | */ 61 | export interface CellEditEvent { 62 | rowId: string; 63 | columnId: string; 64 | oldValue: any; 65 | newValue: any; 66 | } 67 | 68 | /** 69 | * Row selection event data 70 | */ 71 | export interface RowSelectionEvent { 72 | selectedRowIds: string[]; 73 | selectedTasks: Task[]; 74 | } 75 | 76 | /** 77 | * Tree node for hierarchical display 78 | */ 79 | export interface TreeNode { 80 | task: Task; 81 | children: TreeNode[]; 82 | parent?: TreeNode; 83 | level: number; 84 | expanded: boolean; 85 | } 86 | 87 | /** 88 | * Virtual scroll viewport data 89 | */ 90 | export interface ViewportData { 91 | startIndex: number; 92 | endIndex: number; 93 | visibleRows: TableRow[]; 94 | totalHeight: number; 95 | scrollTop: number; 96 | } 97 | 98 | /** 99 | * Table configuration options 100 | */ 101 | export interface TableConfig { 102 | enableTreeView: boolean; 103 | enableLazyLoading: boolean; 104 | pageSize: number; 105 | enableInlineEditing: boolean; 106 | enableRowSelection: boolean; 107 | enableMultiSelect: boolean; 108 | showRowNumbers: boolean; 109 | sortableColumns: boolean; 110 | resizableColumns: boolean; 111 | defaultSortField: string; 112 | defaultSortOrder: "asc" | "desc"; 113 | visibleColumns: string[]; 114 | columnWidths: Record; 115 | } 116 | 117 | /** 118 | * Editor callbacks 119 | */ 120 | export interface EditorCallbacks { 121 | onCellEdit: (rowId: string, columnId: string, newValue: any) => void; 122 | onEditComplete: () => void; 123 | onEditCancel: () => void; 124 | } 125 | 126 | /** 127 | * Virtual scroll callbacks 128 | */ 129 | export interface VirtualScrollCallbacks { 130 | onLoadMore: () => void; 131 | onScroll: (scrollTop: number) => void; 132 | } 133 | -------------------------------------------------------------------------------- /src/components/table/TableViewAdapter.ts: -------------------------------------------------------------------------------- 1 | import { Component, App } from "obsidian"; 2 | import { Task } from "../../types/task"; 3 | import { TableView, TableViewCallbacks } from "./TableView"; 4 | import { TableSpecificConfig } from "../../common/setting-definition"; 5 | import TaskProgressBarPlugin from "../../index"; 6 | 7 | /** 8 | * Table view adapter to make TableView compatible with ViewComponentManager 9 | */ 10 | export class TableViewAdapter extends Component { 11 | public containerEl: HTMLElement; 12 | private tableView: TableView; 13 | 14 | constructor( 15 | private app: App, 16 | private plugin: TaskProgressBarPlugin, 17 | private parentEl: HTMLElement, 18 | private config: TableSpecificConfig, 19 | private callbacks: TableViewCallbacks 20 | ) { 21 | super(); 22 | 23 | // Create container 24 | this.containerEl = this.parentEl.createDiv("table-view-adapter"); 25 | 26 | // Create table view with all callbacks 27 | this.tableView = new TableView( 28 | this.app, 29 | this.plugin, 30 | this.containerEl, 31 | this.config, 32 | { 33 | onTaskSelected: this.callbacks.onTaskSelected, 34 | onTaskCompleted: this.callbacks.onTaskCompleted, 35 | onTaskContextMenu: this.callbacks.onTaskContextMenu, 36 | onTaskUpdated: this.callbacks.onTaskUpdated, 37 | } 38 | ); 39 | } 40 | 41 | onload() { 42 | this.addChild(this.tableView); 43 | this.tableView.load(); 44 | } 45 | 46 | onunload() { 47 | this.tableView.unload(); 48 | this.removeChild(this.tableView); 49 | } 50 | 51 | /** 52 | * Update tasks in the table view 53 | */ 54 | public updateTasks(tasks: Task[]) { 55 | this.tableView.updateTasks(tasks); 56 | } 57 | 58 | /** 59 | * Set tasks (alias for updateTasks for compatibility) 60 | */ 61 | public setTasks(tasks: Task[], allTasks?: Task[]) { 62 | this.updateTasks(tasks); 63 | } 64 | 65 | /** 66 | * Toggle tree view mode 67 | */ 68 | public toggleTreeView() { 69 | this.tableView.toggleTreeView(); 70 | } 71 | 72 | /** 73 | * Get selected tasks 74 | */ 75 | public getSelectedTasks(): Task[] { 76 | return this.tableView.getSelectedTasks(); 77 | } 78 | 79 | /** 80 | * Clear selection 81 | */ 82 | public clearSelection() { 83 | this.tableView.clearSelection(); 84 | } 85 | 86 | /** 87 | * Export table data 88 | */ 89 | public exportData(): any[] { 90 | return this.tableView.exportData(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/table/index.ts: -------------------------------------------------------------------------------- 1 | // Table view components 2 | export { TableView } from "./TableView"; 3 | export { TableViewAdapter } from "./TableViewAdapter"; 4 | export { TableRenderer } from "./TableRenderer"; 5 | export { TableEditor } from "./TableEditor"; 6 | export { TreeManager } from "./TreeManager"; 7 | export { VirtualScrollManager } from "./VirtualScrollManager"; 8 | 9 | // Table types 10 | export * from "./TableTypes"; 11 | -------------------------------------------------------------------------------- /src/components/task-edit/TaskDetailsModal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Task Details Modal Component 3 | * Used in mobile environments to display the full task details and editing interface. 4 | */ 5 | 6 | import { App, Modal, TFile, MarkdownView, ButtonComponent } from "obsidian"; 7 | import { Task } from "../../types/task"; 8 | import TaskProgressBarPlugin from "../../index"; 9 | import { TaskMetadataEditor } from "./MetadataEditor"; 10 | import { t } from "../../translations/helper"; 11 | 12 | export class TaskDetailsModal extends Modal { 13 | private task: Task; 14 | private plugin: TaskProgressBarPlugin; 15 | private metadataEditor: TaskMetadataEditor; 16 | private onTaskUpdated: (task: Task) => Promise; 17 | 18 | constructor( 19 | app: App, 20 | plugin: TaskProgressBarPlugin, 21 | task: Task, 22 | onTaskUpdated?: (task: Task) => Promise 23 | ) { 24 | super(app); 25 | this.task = task; 26 | this.plugin = plugin; 27 | this.onTaskUpdated = onTaskUpdated || (async () => {}); 28 | 29 | // Set modal style 30 | this.modalEl.addClass("task-details-modal"); 31 | this.titleEl.setText(t("Edit Task")); 32 | } 33 | 34 | onOpen() { 35 | const { contentEl } = this; 36 | contentEl.empty(); 37 | 38 | // Create metadata editor, use full mode 39 | this.metadataEditor = new TaskMetadataEditor( 40 | contentEl, 41 | this.app, 42 | this.plugin, 43 | false // Full mode, not compact mode 44 | ); 45 | 46 | // Initialize editor and display task 47 | this.metadataEditor.onload(); 48 | this.metadataEditor.showTask(this.task); 49 | 50 | new ButtonComponent(this.contentEl) 51 | .setIcon("check") 52 | .setTooltip(t("Save")) 53 | .onClick(async () => { 54 | await this.onTaskUpdated(this.task); 55 | this.close(); 56 | }); 57 | 58 | // Listen for metadata change events 59 | this.metadataEditor.onMetadataChange = async (event) => { 60 | // Create a base task object with the updated field 61 | const updatedTask = { 62 | ...this.task, 63 | [event.field]: event.value, 64 | line: this.task.line - 1, 65 | id: `${this.task.filePath}-L${this.task.line - 1}`, 66 | }; 67 | 68 | // Only update completed status and completedDate if the status field is changing to a completed state 69 | if ( 70 | event.field === "status" && 71 | (event.value === "x" || event.value === "X") 72 | ) { 73 | updatedTask.completed = true; 74 | updatedTask.completedDate = Date.now(); 75 | } else if (event.field === "status") { 76 | // If status is changing to something else, mark as not completed 77 | updatedTask.completed = false; 78 | updatedTask.completedDate = undefined; 79 | } 80 | 81 | this.task = updatedTask; 82 | }; 83 | } 84 | 85 | onClose() { 86 | const { contentEl } = this; 87 | if (this.metadataEditor) { 88 | this.metadataEditor.onunload(); 89 | } 90 | contentEl.empty(); 91 | } 92 | 93 | /** 94 | * Updates a task field. 95 | */ 96 | private updateTaskField(field: string, value: any) { 97 | if (field in this.task) { 98 | (this.task as any)[field] = value; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/task-filter/ViewTaskFilterModal.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { Modal } from "obsidian"; 3 | import { TaskFilterComponent, RootFilterState } from "./ViewTaskFilter"; 4 | import type TaskProgressBarPlugin from "../../index"; 5 | 6 | export class ViewTaskFilterModal extends Modal { 7 | public taskFilterComponent: TaskFilterComponent; 8 | public filterCloseCallback: 9 | | ((filterState?: RootFilterState) => void) 10 | | null = null; 11 | private plugin?: TaskProgressBarPlugin; 12 | 13 | constructor( 14 | app: App, 15 | private leafId?: string, 16 | plugin?: TaskProgressBarPlugin 17 | ) { 18 | super(app); 19 | this.plugin = plugin; 20 | } 21 | 22 | onOpen() { 23 | const { contentEl } = this; 24 | contentEl.empty(); 25 | 26 | this.taskFilterComponent = new TaskFilterComponent( 27 | this.contentEl, 28 | this.app, 29 | this.leafId, 30 | this.plugin 31 | ); 32 | } 33 | 34 | onClose() { 35 | const { contentEl } = this; 36 | 37 | // 获取过滤状态并触发回调 38 | let filterState: RootFilterState | undefined = undefined; 39 | if (this.taskFilterComponent) { 40 | try { 41 | filterState = this.taskFilterComponent.getFilterState(); 42 | this.taskFilterComponent.onunload(); 43 | } catch (error) { 44 | console.error( 45 | "Failed to get filter state before modal close", 46 | error 47 | ); 48 | } 49 | } 50 | 51 | contentEl.empty(); 52 | 53 | // 调用自定义关闭回调 54 | if (this.filterCloseCallback) { 55 | try { 56 | this.filterCloseCallback(filterState); 57 | } catch (error) { 58 | console.error("Error in filter close callback", error); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/task-filter/index.ts: -------------------------------------------------------------------------------- 1 | import { TaskFilterComponent } from "./ViewTaskFilter"; 2 | import { ViewTaskFilterModal } from "./ViewTaskFilterModal"; 3 | import { ViewTaskFilterPopover } from "./ViewTaskFilterPopover"; 4 | 5 | export { TaskFilterComponent, ViewTaskFilterModal, ViewTaskFilterPopover }; 6 | -------------------------------------------------------------------------------- /src/editor-ext/regexp-cursor.ts: -------------------------------------------------------------------------------- 1 | // from https://github.com/codemirror/search/blob/main/src/regexp.ts 2 | 3 | // @ts-ignore 4 | import { Text, TextIterator } from "@codemirror/text" 5 | import execWithIndices from 'regexp-match-indices'; 6 | 7 | const empty = { from: -1, to: -1, match: /.*/.exec("")! } 8 | 9 | const baseFlags = "gm" + (/x/.unicode == null ? "" : "u") 10 | 11 | /// This class is similar to [`SearchCursor`](#search.SearchCursor) 12 | /// but searches for a regular expression pattern instead of a plain 13 | /// string. 14 | export class RegExpCursor implements Iterator<{ from: number, to: number, match: RegExpExecArray }> { 15 | private iter!: TextIterator 16 | private re!: RegExp 17 | private curLine = "" 18 | private curLineStart!: number 19 | private matchPos!: number 20 | 21 | /// Set to `true` when the cursor has reached the end of the search 22 | /// range. 23 | done = false 24 | 25 | /// Will contain an object with the extent of the match and the 26 | /// match object when [`next`](#search.RegExpCursor.next) 27 | /// sucessfully finds a match. 28 | value = empty 29 | 30 | /// Create a cursor that will search the given range in the given 31 | /// document. `query` should be the raw pattern (as you'd pass it to 32 | /// `new RegExp`). 33 | constructor(text: Text, query: string, options?: { ignoreCase?: boolean }, from: number = 0, private to: number = text.length) { 34 | // if (/\\[sWDnr]|\n|\r|\[\^/.test(query)) return new MultilineRegExpCursor(text, query, options, from, to) as any 35 | this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")) 36 | this.iter = text.iter() 37 | let startLine = text.lineAt(from) 38 | this.curLineStart = startLine.from 39 | this.matchPos = from 40 | this.getLine(this.curLineStart) 41 | } 42 | 43 | private getLine(skip: number) { 44 | this.iter.next(skip) 45 | if (this.iter.lineBreak) { 46 | this.curLine = "" 47 | } else { 48 | this.curLine = this.iter.value 49 | if (this.curLineStart + this.curLine.length > this.to) 50 | this.curLine = this.curLine.slice(0, this.to - this.curLineStart) 51 | this.iter.next() 52 | } 53 | } 54 | 55 | private nextLine() { 56 | this.curLineStart = this.curLineStart + this.curLine.length + 1 57 | if (this.curLineStart > this.to) this.curLine = "" 58 | else this.getLine(0) 59 | } 60 | 61 | /// Move to the next match, if there is one. 62 | next() { 63 | for (let off = this.matchPos - this.curLineStart; ;) { 64 | this.re.lastIndex = off 65 | let match = this.matchPos <= this.to && execWithIndices(this.re, this.curLine) 66 | if (match) { 67 | let from = this.curLineStart + match.index, to = from + match[0].length 68 | this.matchPos = to + (from == to ? 1 : 0) 69 | if (from == this.curLine.length) this.nextLine() 70 | if (from < to || from > this.value.to) { 71 | this.value = { from, to, match } 72 | return this 73 | } 74 | off = this.matchPos - this.curLineStart 75 | } else if (this.curLineStart + this.curLine.length < this.to) { 76 | this.nextLine() 77 | off = 0 78 | } else { 79 | this.done = true 80 | return this 81 | } 82 | } 83 | } 84 | 85 | [Symbol.iterator]!: () => Iterator<{ from: number, to: number, match: RegExpExecArray }> 86 | } 87 | 88 | const flattened = new WeakMap() 89 | 90 | // Reusable (partially) flattened document strings 91 | class FlattenedDoc { 92 | constructor(readonly from: number, 93 | readonly text: string) { 94 | } 95 | 96 | get to() { 97 | return this.from + this.text.length 98 | } 99 | 100 | static get(doc: Text, from: number, to: number) { 101 | let cached = flattened.get(doc) 102 | if (!cached || cached.from >= to || cached.to <= from) { 103 | let flat = new FlattenedDoc(from, doc.sliceString(from, to)) 104 | flattened.set(doc, flat) 105 | return flat 106 | } 107 | if (cached.from == from && cached.to == to) return cached 108 | let { text, from: cachedFrom } = cached 109 | if (cachedFrom > from) { 110 | text = doc.sliceString(from, cachedFrom) + text 111 | cachedFrom = from 112 | } 113 | if (cached.to < to) 114 | text += doc.sliceString(cached.to, to) 115 | flattened.set(doc, new FlattenedDoc(cachedFrom, text)) 116 | return new FlattenedDoc(from, text.slice(from - cachedFrom, to - cachedFrom)) 117 | } 118 | } 119 | 120 | const enum Chunk { Base = 5000 } 121 | -------------------------------------------------------------------------------- /src/icon.ts: -------------------------------------------------------------------------------- 1 | export function getTaskGeniusIcon() { 2 | return ` 3 | 4 | 5 | 6 | `; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/base-view.css: -------------------------------------------------------------------------------- 1 | .internal-embed .task-genius-container { 2 | max-height: 800px; 3 | } 4 | 5 | .internal-embed .task-genius-container .task-sidebar { 6 | width: 44px; 7 | min-width: 44px; 8 | overflow: hidden; 9 | } 10 | 11 | .internal-embed .task-genius-container .task-sidebar .sidebar-nav-item { 12 | padding: 8px 10px; 13 | justify-content: center; 14 | width: var(--size-4-9); 15 | flex-shrink: 0; 16 | transition: width 0.3s ease-in-out, flex-shrink 0.3s ease-in-out; 17 | } 18 | 19 | .internal-embed .task-genius-container .task-sidebar .sidebar-nav { 20 | align-items: center; 21 | } 22 | 23 | .internal-embed .task-genius-container .task-sidebar .sidebar-nav-item { 24 | padding: 8px 10px; 25 | justify-content: center; 26 | width: var(--size-4-9); 27 | flex-shrink: 0; 28 | 29 | transition: width 0.3s ease-in-out, flex-shrink 0.3s ease-in-out; 30 | } 31 | 32 | .internal-embed .task-genius-container .task-sidebar .nav-item-icon { 33 | margin-right: 0; 34 | } 35 | 36 | .internal-embed .task-genius-container .task-list { 37 | max-height: 800px; 38 | } 39 | 40 | .internal-embed .projects-container { 41 | flex: 1; 42 | height: auto; 43 | } 44 | 45 | .internal-embed .forecast-left-column { 46 | width: 240px; 47 | } 48 | 49 | .internal-embed .forecast-left-column .mini-calendar-container .calendar-grid { 50 | display: grid; 51 | grid-template-columns: repeat(7, 1fr); 52 | gap: 1px; 53 | padding: 0 5px; 54 | } 55 | 56 | .internal-embed 57 | .forecast-left-column 58 | .mini-calendar-container 59 | .calendar-day-header { 60 | text-align: center; 61 | font-size: 0.7em; 62 | color: var(--text-muted); 63 | padding: 3px 0; 64 | border-bottom: 1px solid var(--background-modifier-border); 65 | margin-bottom: 3px; 66 | } 67 | 68 | .internal-embed 69 | .forecast-left-column 70 | .mini-calendar-container 71 | .calendar-day-header.calendar-weekend { 72 | color: var(--text-accent); 73 | } 74 | 75 | .internal-embed .forecast-left-column .mini-calendar-container .calendar-day { 76 | aspect-ratio: 1; 77 | border-radius: 3px; 78 | padding: 1px; 79 | cursor: pointer; 80 | position: relative; 81 | display: flex; 82 | flex-direction: column; 83 | transition: background-color 0.2s ease; 84 | } 85 | 86 | .internal-embed 87 | .forecast-left-column 88 | .mini-calendar-container 89 | .calendar-day:hover { 90 | background-color: var(--background-modifier-hover); 91 | } 92 | 93 | .internal-embed 94 | .forecast-left-column 95 | .mini-calendar-container 96 | .calendar-day.selected { 97 | background-color: var(--background-modifier-border-hover); 98 | } 99 | 100 | .internal-embed 101 | .forecast-left-column 102 | .mini-calendar-container 103 | .calendar-day.today { 104 | background-color: var(--interactive-accent-hover); 105 | color: var(--text-on-accent); 106 | } 107 | 108 | .internal-embed 109 | .forecast-left-column 110 | .mini-calendar-container 111 | .calendar-day.past-due { 112 | color: var(--text-error); 113 | } 114 | 115 | .internal-embed 116 | .forecast-left-column 117 | .mini-calendar-container 118 | .calendar-day.other-month { 119 | opacity: 0.5; 120 | } 121 | 122 | .internal-embed 123 | .forecast-left-column 124 | .mini-calendar-container 125 | .calendar-day-number { 126 | text-align: center; 127 | font-size: 0.75em; 128 | font-weight: 500; 129 | padding: 1px; 130 | } 131 | 132 | .internal-embed 133 | .forecast-left-column 134 | .mini-calendar-container 135 | .calendar-day-count { 136 | background-color: var(--background-modifier-border); 137 | color: var(--text-normal); 138 | border-radius: 8px; 139 | font-size: 0.6em; 140 | padding: 1px 3px; 141 | margin: 1px auto; 142 | text-align: center; 143 | width: fit-content; 144 | } 145 | 146 | .internal-embed 147 | .forecast-left-column 148 | .mini-calendar-container 149 | .calendar-day-count.has-priority { 150 | background-color: var(--text-accent); 151 | color: var(--text-on-accent); 152 | } 153 | 154 | .internal-embed .tags-container { 155 | height: auto; 156 | max-height: 100%; 157 | } 158 | 159 | .internal-embed 160 | .task-genius-container:has(.task-details.visible) 161 | .tags-left-column { 162 | display: none; 163 | } 164 | 165 | .internal-embed 166 | .task-genius-container:has(.task-details.visible) 167 | .projects-left-column { 168 | display: none; 169 | } 170 | 171 | .internal-embed .full-calendar-container { 172 | height: auto; 173 | } 174 | 175 | .internal-embed .tg-kanban-view { 176 | height: auto; 177 | } 178 | 179 | .bases-view .task-genius-container { 180 | border-top: unset; 181 | } 182 | -------------------------------------------------------------------------------- /src/styles/beta-warning.css: -------------------------------------------------------------------------------- 1 | /* Beta test warning banner styles */ 2 | .beta-test-warning-banner { 3 | display: flex; 4 | align-items: flex-start; 5 | gap: 12px; 6 | padding: 16px; 7 | margin-bottom: 20px; 8 | background-color: var(--background-modifier-warning); 9 | border: 1px solid var(--color-orange); 10 | border-radius: 8px; 11 | } 12 | 13 | .beta-warning-icon { 14 | font-size: 20px; 15 | line-height: 1; 16 | flex-shrink: 0; 17 | margin-top: 2px; 18 | } 19 | 20 | .beta-warning-content { 21 | flex: 1; 22 | min-width: 0; 23 | } 24 | 25 | .beta-warning-title { 26 | font-weight: 600; 27 | font-size: 14px; 28 | color: var(--text-normal); 29 | margin-bottom: 8px; 30 | } 31 | 32 | .beta-warning-text { 33 | font-size: 13px; 34 | line-height: 1.4; 35 | color: var(--text-muted); 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/calendar.css: -------------------------------------------------------------------------------- 1 | /* Calendar Component Styles */ 2 | .task-genius-view .mini-calendar-container { 3 | display: flex; 4 | flex-direction: column; 5 | width: 100%; 6 | border-bottom: 1px solid var(--background-modifier-border); 7 | padding-bottom: 10px; 8 | } 9 | 10 | .task-genius-view .mini-calendar-container .calendar-header { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | padding: 10px 15px; 15 | margin-bottom: 10px; 16 | } 17 | 18 | .task-genius-view .mini-calendar-container .calendar-title { 19 | font-weight: 600; 20 | display: flex; 21 | gap: 5px; 22 | } 23 | 24 | .task-genius-view .mini-calendar-container .calendar-month { 25 | margin-right: 5px; 26 | } 27 | 28 | .task-genius-view .mini-calendar-container .calendar-year { 29 | color: var(--text-muted); 30 | } 31 | 32 | .task-genius-view .mini-calendar-container .calendar-nav { 33 | display: flex; 34 | align-items: center; 35 | gap: 8px; 36 | } 37 | 38 | .task-genius-view .mini-calendar-container .calendar-nav-btn { 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | width: 24px; 43 | height: 24px; 44 | border-radius: 4px; 45 | background-color: var(--background-modifier-hover); 46 | cursor: pointer; 47 | opacity: 0.7; 48 | transition: opacity 0.2s ease; 49 | } 50 | 51 | .task-genius-view .mini-calendar-container .calendar-nav-btn:hover { 52 | opacity: 1; 53 | background-color: var(--background-modifier-border-hover); 54 | } 55 | 56 | .task-genius-view .mini-calendar-container .calendar-today-btn { 57 | padding: 2px 8px; 58 | border-radius: 4px; 59 | background-color: var(--background-modifier-hover); 60 | cursor: pointer; 61 | font-size: 0.8em; 62 | transition: background-color 0.2s ease; 63 | } 64 | 65 | .task-genius-view .mini-calendar-container .calendar-today-btn:hover { 66 | background-color: var(--background-modifier-border-hover); 67 | } 68 | 69 | .task-genius-view .mini-calendar-container .calendar-grid { 70 | display: grid; 71 | grid-template-columns: repeat(7, 1fr); 72 | gap: 2px; 73 | padding: 0 10px; 74 | } 75 | 76 | .task-genius-view .mini-calendar-container .calendar-day-header { 77 | text-align: center; 78 | font-size: 0.8em; 79 | color: var(--text-muted); 80 | padding: 5px 0; 81 | border-bottom: 1px solid var(--background-modifier-border); 82 | margin-bottom: 5px; 83 | } 84 | 85 | .task-genius-view 86 | .mini-calendar-container 87 | .calendar-day-header.calendar-weekend { 88 | color: var(--text-accent); 89 | } 90 | 91 | .task-genius-view .mini-calendar-container .calendar-day { 92 | aspect-ratio: 1; 93 | border-radius: 4px; 94 | padding: 2px; 95 | cursor: pointer; 96 | position: relative; 97 | display: flex; 98 | flex-direction: column; 99 | transition: background-color 0.2s ease; 100 | } 101 | 102 | .task-genius-view .mini-calendar-container .calendar-day:hover { 103 | background-color: var(--background-modifier-hover); 104 | } 105 | 106 | .task-genius-view .mini-calendar-container .calendar-day.selected { 107 | background-color: var(--background-modifier-border-hover); 108 | } 109 | 110 | .task-genius-view .mini-calendar-container .calendar-day.today { 111 | background-color: var(--interactive-accent-hover); 112 | color: var(--text-on-accent); 113 | } 114 | 115 | .task-genius-view .mini-calendar-container .calendar-day.past-due { 116 | color: var(--text-error); 117 | } 118 | 119 | .task-genius-view .mini-calendar-container .calendar-day.other-month { 120 | opacity: 0.5; 121 | } 122 | 123 | .task-genius-view .mini-calendar-container .calendar-day-number { 124 | text-align: center; 125 | font-size: 0.9em; 126 | font-weight: 500; 127 | padding: 2px; 128 | } 129 | 130 | .task-genius-view .mini-calendar-container .calendar-day-count { 131 | background-color: var(--background-modifier-border); 132 | color: var(--text-normal); 133 | border-radius: 10px; 134 | font-size: 0.7em; 135 | padding: 1px 5px; 136 | margin: 2px auto; 137 | text-align: center; 138 | width: fit-content; 139 | } 140 | 141 | .task-genius-view .mini-calendar-container .calendar-day-count.has-priority { 142 | background-color: var(--text-accent); 143 | color: var(--text-on-accent); 144 | } 145 | 146 | @media (max-width: 1400px) { 147 | .task-genius-container:has(.task-details.visible) 148 | .mini-calendar-container 149 | .forecast-left-column { 150 | display: none; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/styles/calendar/event.css: -------------------------------------------------------------------------------- 1 | .full-calendar-container .calendar-event-title-container p { 2 | padding-inline-start: 0; 3 | padding-inline-end: 0; 4 | margin-block-start: 0; 5 | margin-block-end: 0; 6 | } 7 | 8 | .full-calendar-container .calendar-event-title-container { 9 | /* Handle text overflow with ellipsis */ 10 | overflow: hidden; 11 | text-overflow: ellipsis; 12 | white-space: nowrap; 13 | max-width: 100%; 14 | } 15 | 16 | .full-calendar-container .calendar-event-title p { 17 | margin-block-start: 0; 18 | margin-block-end: 0; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | /* Define theme variables */ 2 | :root { 3 | /* Task status colors */ 4 | --task-completed-color: #4caf50; 5 | --task-doing-color: #80dee5; 6 | --task-in-progress-color: #f9d923; 7 | --task-abandoned-color: #eb5353; 8 | --task-planned-color: #9c27b0; /* Planned task color */ 9 | --task-question-color: #2196f3; /* Question tasks */ 10 | --task-important-color: #f44336; /* Important tasks */ 11 | --task-star-color: #ffc107; /* Star tasks */ 12 | --task-quote-color: #607d8b; /* Quote tasks */ 13 | --task-location-color: #795548; /* Location tasks */ 14 | --task-bookmark-color: #ff9800; /* Bookmark tasks */ 15 | --task-information-color: #00bcd4; /* Information tasks */ 16 | --task-idea-color: #9c27b0; /* Idea tasks */ 17 | --task-pros-color: #4caf50; /* Pros tasks */ 18 | --task-cons-color: #f44336; /* Cons tasks */ 19 | --task-fire-color: #ff5722; /* Fire tasks */ 20 | --task-key-color: #ffd700; /* Key tasks */ 21 | --task-win-color: #66bb6a; /* Win tasks */ 22 | --task-up-color: #4caf50; /* Up tasks */ 23 | --task-down-color: #f44336; /* Down tasks */ 24 | --task-note-color: #9e9e9e; /* Note tasks */ 25 | --task-amount-color: #8bc34a; /* Amount/savings tasks */ 26 | --task-speech-color: #03a9f4; /* Speech bubble tasks */ 27 | 28 | /* Progress bar gradient colors - light theme */ 29 | --progress-0-color: #ae431e; 30 | --progress-25-color: #e5890a; 31 | --progress-50-color: #b4c6a6; 32 | --progress-75-color: #6bcb77; 33 | --progress-100-color: #4d96ff; 34 | 35 | --progress-background-color: #f1f1f1; 36 | } 37 | 38 | /* Dark theme color adjustments */ 39 | .theme-dark { 40 | --task-completed-color: #4caf50; 41 | --task-doing-color: #379fa7; 42 | --task-in-progress-color: #ffc107; 43 | --task-abandoned-color: #f44336; 44 | --task-planned-color: #ce93d8; /* Planned task color for dark theme */ 45 | --task-question-color: #42a5f5; /* Question tasks dark theme */ 46 | --task-important-color: #ef5350; /* Important tasks dark theme */ 47 | --task-star-color: #ffd54f; /* Star tasks dark theme */ 48 | --task-quote-color: #90a4ae; /* Quote tasks dark theme */ 49 | --task-location-color: #8d6e63; /* Location tasks dark theme */ 50 | --task-bookmark-color: #ffb74d; /* Bookmark tasks dark theme */ 51 | --task-information-color: #26c6da; /* Information tasks dark theme */ 52 | --task-idea-color: #ce93d8; /* Idea tasks dark theme */ 53 | --task-pros-color: #66bb6a; /* Pros tasks dark theme */ 54 | --task-cons-color: #ef5350; /* Cons tasks dark theme */ 55 | --task-fire-color: #ff7043; /* Fire tasks dark theme */ 56 | --task-key-color: #ffd700; /* Key tasks dark theme */ 57 | --task-win-color: #81c784; /* Win tasks dark theme */ 58 | --task-up-color: #66bb6a; /* Up tasks dark theme */ 59 | --task-down-color: #ef5350; /* Down tasks dark theme */ 60 | --task-note-color: #bdbdbd; /* Note tasks dark theme */ 61 | --task-amount-color: #aed581; /* Amount/savings tasks dark theme */ 62 | --task-speech-color: #29b6f6; /* Speech bubble tasks dark theme */ 63 | 64 | --progress-0-color: #ae431e; 65 | --progress-25-color: #e5890a; 66 | --progress-50-color: #b4c6a6; 67 | --progress-75-color: #6bcb77; 68 | --progress-100-color: #4d96ff; 69 | 70 | --progress-background-color: #f1f1f1; 71 | } 72 | -------------------------------------------------------------------------------- /src/styles/habit-list.css: -------------------------------------------------------------------------------- 1 | /* 习惯列表容器 */ 2 | .habit-list-container { 3 | padding: 12px; 4 | width: 100%; 5 | } 6 | 7 | .habit-settings-container { 8 | padding-top: 12px; 9 | border-top: 1px solid var(--background-modifier-border); 10 | } 11 | 12 | /* 添加按钮 */ 13 | .habit-add-button-container { 14 | display: flex; 15 | justify-content: flex-end; 16 | margin-bottom: 16px; 17 | } 18 | 19 | .habit-add-button { 20 | display: flex; 21 | align-items: center; 22 | gap: 6px; 23 | padding: 6px 12px; 24 | background-color: var(--interactive-accent); 25 | color: var(--text-on-accent); 26 | border-radius: var(--radius-s); 27 | cursor: pointer; 28 | font-size: 14px; 29 | } 30 | 31 | .habit-add-button svg { 32 | width: 16px; 33 | height: 16px; 34 | } 35 | 36 | /* 习惯列表为空状态 */ 37 | .habit-empty-state { 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | justify-content: center; 42 | min-height: 200px; 43 | text-align: center; 44 | padding: 20px; 45 | border: 1px dashed var(--background-modifier-border); 46 | border-radius: var(--radius-m); 47 | background-color: var(--background-secondary); 48 | } 49 | 50 | .habit-empty-state h2 { 51 | margin: 0 0 10px 0; 52 | font-size: 1.2em; 53 | color: var(--text-normal); 54 | } 55 | 56 | .habit-empty-state p { 57 | margin: 0; 58 | color: var(--text-muted); 59 | } 60 | 61 | /* 习惯项列表 */ 62 | .habit-items-container { 63 | display: flex; 64 | flex-direction: column; 65 | gap: 10px; 66 | } 67 | 68 | /* 习惯项 */ 69 | .habit-item { 70 | display: flex; 71 | align-items: center; 72 | padding: 12px; 73 | border-radius: var(--radius-m); 74 | background-color: var(--background-secondary); 75 | border: 1px solid var(--background-modifier-border); 76 | transition: background-color 0.2s ease; 77 | cursor: pointer; 78 | height: 7.5rem; 79 | } 80 | 81 | .habit-item:hover { 82 | background-color: var(--background-modifier-hover); 83 | } 84 | 85 | /* 习惯图标 */ 86 | .habit-item-icon { 87 | --icon-size: 20px; 88 | display: flex; 89 | align-items: center; 90 | justify-content: center; 91 | width: 48px; 92 | height: 48px; 93 | border-radius: 50%; 94 | background-color: var(--background-primary); 95 | margin-right: 12px; 96 | } 97 | 98 | .habit-item-icon svg { 99 | color: var(--text-normal); 100 | } 101 | 102 | /* 习惯信息 */ 103 | .habit-item-info { 104 | flex: 1; 105 | min-width: 0; /* 防止内容过长撑开布局 */ 106 | } 107 | 108 | .habit-item-name { 109 | font-weight: 600; 110 | margin-bottom: 4px; 111 | font-size: 16px; 112 | white-space: nowrap; 113 | overflow: hidden; 114 | text-overflow: ellipsis; 115 | } 116 | 117 | .habit-item-description { 118 | color: var(--text-muted); 119 | font-size: 12px; 120 | white-space: nowrap; 121 | overflow: hidden; 122 | text-overflow: ellipsis; 123 | margin-bottom: 4px; 124 | } 125 | 126 | .habit-item-type { 127 | display: inline-block; 128 | font-size: 11px; 129 | padding: 2px 6px; 130 | border-radius: var(--radius-s); 131 | background-color: var(--background-modifier-border); 132 | color: var(--text-muted); 133 | } 134 | 135 | /* 习惯操作按钮 */ 136 | .habit-item-actions { 137 | display: flex; 138 | gap: 8px; 139 | margin-left: 12px; 140 | } 141 | 142 | .habit-edit-button, 143 | .habit-delete-button { 144 | display: flex; 145 | align-items: center; 146 | justify-content: center; 147 | width: 32px; 148 | height: 32px; 149 | border-radius: 50%; 150 | background-color: var(--background-primary); 151 | cursor: pointer; 152 | padding: 0; 153 | border: 1px solid var(--background-modifier-border); 154 | } 155 | 156 | .habit-edit-button:hover, 157 | .habit-delete-button:hover { 158 | background-color: var(--background-modifier-hover); 159 | } 160 | 161 | .habit-edit-button svg, 162 | .habit-delete-button svg { 163 | width: 16px; 164 | height: 16px; 165 | color: var(--text-muted); 166 | } 167 | 168 | .habit-delete-button:hover svg { 169 | color: var(--text-error); 170 | } 171 | 172 | /* 习惯删除对话框样式 */ 173 | .habit-delete-modal-buttons { 174 | display: flex; 175 | justify-content: flex-end; 176 | gap: 10px; 177 | margin-top: 20px; 178 | } 179 | 180 | .habit-delete-button-confirm { 181 | background-color: var(--text-error); 182 | color: #fff; 183 | border: none; 184 | border-radius: var(--radius-s); 185 | padding: 8px 16px; 186 | cursor: pointer; 187 | } 188 | -------------------------------------------------------------------------------- /src/styles/modal.css: -------------------------------------------------------------------------------- 1 | .confirm-modal-buttons { 2 | display: flex; 3 | gap: var(--size-4-3); 4 | justify-content: flex-end; 5 | margin-top: var(--size-4-3); 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/progressbar.css: -------------------------------------------------------------------------------- 1 | /* Set Default Progress Bar For Plugin */ 2 | .cm-task-progress-bar { 3 | display: inline-block; 4 | position: relative; 5 | margin-left: 5px; 6 | margin-bottom: 1px; 7 | } 8 | 9 | .no-progress-bar .cm-task-progress-bar { 10 | display: none !important; 11 | } 12 | 13 | .HyperMD-header .cm-task-progress-bar { 14 | display: inline-block; 15 | position: relative; 16 | margin-left: 5px; 17 | margin-bottom: 5px; 18 | } 19 | 20 | .progress-bar-inline { 21 | height: 8px; 22 | position: relative; 23 | } 24 | 25 | /* Progress bar colors for different percentages of completion */ 26 | .progress-bar-inline-empty { 27 | background-color: var(--progress-background-color); 28 | } 29 | 30 | .progress-bar-inline-0 { 31 | background-color: var(--progress-0-color); 32 | } 33 | 34 | .progress-bar-inline-1 { 35 | background-color: var(--progress-25-color); 36 | } 37 | 38 | .progress-bar-inline-2 { 39 | background-color: var(--progress-50-color); 40 | } 41 | 42 | .progress-bar-inline-3 { 43 | background-color: var(--progress-75-color); 44 | } 45 | 46 | .progress-bar-inline-complete { 47 | background-color: var(--progress-100-color); 48 | } 49 | 50 | /* Colors for different task statuses */ 51 | .progress-completed { 52 | background-color: var(--task-completed-color); 53 | z-index: 3; 54 | } 55 | 56 | .progress-in-progress { 57 | background-color: var(--task-in-progress-color); 58 | z-index: 2; 59 | position: absolute; 60 | top: 0; 61 | height: 100%; 62 | } 63 | 64 | .progress-abandoned { 65 | background-color: var(--task-abandoned-color); 66 | z-index: 1; 67 | position: absolute; 68 | top: 0; 69 | height: 100%; 70 | } 71 | 72 | .progress-planned { 73 | background-color: var(--task-planned-color); 74 | z-index: 1; 75 | position: absolute; 76 | top: 0; 77 | height: 100%; 78 | } 79 | 80 | .progress-bar-inline-background { 81 | color: #000 !important; 82 | background-color: var(--progress-background-color); 83 | border-radius: 10px; 84 | flex-direction: row; 85 | justify-content: flex-start; 86 | align-items: center; 87 | width: 85px; 88 | position: relative; 89 | overflow: hidden; 90 | } 91 | 92 | .progress-bar-inline-background.hidden { 93 | display: none; 94 | } 95 | 96 | /* Status indicators in the task number display */ 97 | .cm-task-progress-bar .task-status-indicator { 98 | display: inline-block; 99 | margin-right: 2px; 100 | } 101 | 102 | .cm-task-progress-bar .completed-indicator { 103 | color: var(--task-completed-color); 104 | } 105 | 106 | .cm-task-progress-bar .in-progress-indicator { 107 | color: var(--task-in-progress-color); 108 | } 109 | 110 | .cm-task-progress-bar .abandoned-indicator { 111 | color: var(--task-abandoned-color); 112 | } 113 | 114 | .cm-task-progress-bar .planned-indicator { 115 | color: var(--task-planned-color); 116 | } 117 | 118 | /* Set Default Progress Bar With Number For Plugin */ 119 | .cm-task-progress-bar.with-number { 120 | display: inline-flex; 121 | align-items: center; 122 | } 123 | 124 | .HyperMD-header 125 | .cm-task-progress-bar.with-number 126 | .progress-bar-inline-background, 127 | .HyperMD-header .cm-task-progress-bar.with-number .progress-status { 128 | margin-bottom: 5px; 129 | } 130 | 131 | .cm-task-progress-bar.with-number .progress-bar-inline-background { 132 | margin-bottom: -2px; 133 | width: 42px; 134 | } 135 | 136 | .cm-task-progress-bar.with-number .progress-status { 137 | font-size: 13px; 138 | margin-left: 3px; 139 | } 140 | 141 | /* Adaptations for dark theme */ 142 | .theme-dark .progress-completed { 143 | background-color: var(--task-completed-color); 144 | } 145 | 146 | .theme-dark .progress-in-progress { 147 | background-color: var(--task-in-progress-color); 148 | } 149 | 150 | .theme-dark .progress-abandoned { 151 | background-color: var(--task-abandoned-color); 152 | } 153 | 154 | .theme-dark .progress-planned { 155 | background-color: var(--task-planned-color); 156 | } 157 | 158 | .task-progress-bar-popover { 159 | width: 400px; 160 | } 161 | -------------------------------------------------------------------------------- /src/styles/project-view.css: -------------------------------------------------------------------------------- 1 | /* Projects View Styles */ 2 | .projects-container { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | width: 100%; 7 | overflow: hidden; 8 | } 9 | 10 | .projects-content { 11 | display: flex; 12 | flex-direction: row; 13 | flex: 1; 14 | overflow: hidden; 15 | } 16 | 17 | .projects-left-column { 18 | width: max(120px, 30%); 19 | min-width: min(120px, 30%); 20 | max-width: 300px; 21 | display: flex; 22 | flex-direction: column; 23 | border-right: 1px solid var(--background-modifier-border); 24 | overflow: hidden; 25 | } 26 | 27 | .is-phone .projects-left-column { 28 | max-width: 100%; 29 | } 30 | 31 | .projects-right-column { 32 | flex: 1; 33 | display: flex; 34 | flex-direction: column; 35 | overflow: hidden; 36 | } 37 | 38 | .projects-sidebar-header { 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: center; 42 | padding: var(--size-4-2) var(--size-4-4); 43 | border-bottom: 1px solid var(--background-modifier-border); 44 | height: var(--size-4-10); 45 | } 46 | 47 | .projects-sidebar-title { 48 | font-weight: 600; 49 | font-size: 14px; 50 | } 51 | 52 | .multi-select-mode .projects-multi-select-btn { 53 | color: var(--color-accent); 54 | } 55 | 56 | .projects-multi-select-btn { 57 | cursor: pointer; 58 | color: var(--text-muted); 59 | 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | } 64 | 65 | .projects-multi-select-btn:hover { 66 | color: var(--text-normal); 67 | } 68 | 69 | .projects-sidebar-list { 70 | flex: 1; 71 | overflow-y: auto; 72 | padding: var(--size-4-2); 73 | } 74 | 75 | .project-list-item { 76 | display: flex; 77 | align-items: center; 78 | padding: 4px 12px; 79 | cursor: pointer; 80 | border-radius: var(--radius-s); 81 | } 82 | 83 | .project-list-item:hover { 84 | background-color: var(--background-modifier-hover); 85 | } 86 | 87 | .project-list-item.selected { 88 | background-color: var(--background-modifier-active); 89 | } 90 | 91 | .project-icon { 92 | margin-right: 8px; 93 | color: var(--text-muted); 94 | 95 | display: flex; 96 | align-items: center; 97 | justify-content: center; 98 | } 99 | 100 | .project-name { 101 | flex: 1; 102 | white-space: nowrap; 103 | overflow: hidden; 104 | text-overflow: ellipsis; 105 | } 106 | 107 | .project-count { 108 | margin-left: 8px; 109 | font-size: 0.8em; 110 | color: var(--text-muted); 111 | background-color: var(--background-modifier-border); 112 | border-radius: 10px; 113 | padding: 1px 6px; 114 | } 115 | 116 | .projects-task-header { 117 | display: flex; 118 | justify-content: space-between; 119 | align-items: center; 120 | padding: var(--size-4-2) var(--size-4-4); 121 | border-bottom: 1px solid var(--background-modifier-border); 122 | height: var(--size-4-10); 123 | } 124 | 125 | .projects-task-title { 126 | font-weight: 600; 127 | font-size: 16px; 128 | } 129 | 130 | .projects-task-count { 131 | color: var(--text-muted); 132 | } 133 | 134 | .projects-task-list { 135 | flex: 1; 136 | overflow-y: auto; 137 | } 138 | 139 | .projects-empty-state { 140 | display: flex; 141 | align-items: center; 142 | justify-content: center; 143 | height: 100%; 144 | color: var(--text-muted); 145 | font-style: italic; 146 | padding: 16px; 147 | } 148 | 149 | /* Projects View - Mobile */ 150 | .is-phone .projects-left-column { 151 | position: absolute; 152 | left: 0; 153 | top: 0; 154 | height: 100%; 155 | z-index: 10; 156 | background-color: var(--background-secondary); 157 | width: 100%; 158 | transform: translateX(-100%); 159 | transition: transform 0.3s ease-in-out; 160 | border-right: 1px solid var(--background-modifier-border); 161 | } 162 | 163 | .is-phone .projects-left-column.is-visible { 164 | transform: translateX(0); 165 | } 166 | 167 | .is-phone .projects-sidebar-toggle { 168 | display: flex; 169 | align-items: center; 170 | justify-content: center; 171 | margin-right: 8px; 172 | } 173 | 174 | .is-phone .projects-sidebar-close { 175 | --icon-size: var(--size-4-4); 176 | position: absolute; 177 | top: var(--size-4-2); 178 | right: 10px; 179 | z-index: 15; 180 | display: flex; 181 | align-items: center; 182 | justify-content: center; 183 | } 184 | 185 | /* Add overlay when left column is visible on mobile */ 186 | .is-phone .projects-container:has(.projects-left-column.is-visible)::before { 187 | content: ""; 188 | position: absolute; 189 | top: 0; 190 | left: 0; 191 | width: 100%; 192 | height: 100%; 193 | background-color: var(--background-modifier-cover); 194 | opacity: 0.5; 195 | z-index: 5; 196 | transition: opacity 0.3s ease-in-out; 197 | } 198 | 199 | /* Add position relative to container for absolute positioning context */ 200 | .is-phone .projects-container { 201 | position: relative; 202 | overflow: hidden; 203 | } 204 | 205 | .is-phone .projects-sidebar-header:has(.projects-sidebar-close) { 206 | padding-right: var(--size-4-12); 207 | } 208 | -------------------------------------------------------------------------------- /src/styles/property-view.css: -------------------------------------------------------------------------------- 1 | /* task-property View Styles */ 2 | .task-property-container { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | width: 100%; 7 | overflow: hidden; 8 | } 9 | 10 | .task-property-content { 11 | display: flex; 12 | flex-direction: row; 13 | flex: 1; 14 | overflow: hidden; 15 | } 16 | 17 | .task-property-left-column { 18 | width: max(120px, 30%); 19 | min-width: min(120px, 30%); 20 | max-width: 300px; 21 | display: flex; 22 | flex-direction: column; 23 | border-right: 1px solid var(--background-modifier-border); 24 | overflow: hidden; 25 | } 26 | 27 | .is-phone .task-property-left-column { 28 | max-width: 100%; 29 | } 30 | 31 | .task-property-right-column { 32 | flex: 1; 33 | display: flex; 34 | flex-direction: column; 35 | overflow: hidden; 36 | } 37 | 38 | .task-property-sidebar-header { 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: center; 42 | padding: var(--size-4-2) var(--size-4-4); 43 | border-bottom: 1px solid var(--background-modifier-border); 44 | height: var(--size-4-10); 45 | } 46 | 47 | .task-property-sidebar-title { 48 | font-weight: 600; 49 | font-size: 14px; 50 | } 51 | 52 | .multi-select-mode .task-property-multi-select-btn { 53 | color: var(--color-accent); 54 | } 55 | 56 | .task-property-multi-select-btn { 57 | cursor: pointer; 58 | color: var(--text-muted); 59 | 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | } 64 | 65 | .task-property-multi-select-btn:hover { 66 | color: var(--text-normal); 67 | } 68 | 69 | .task-property-sidebar-list { 70 | flex: 1; 71 | overflow-y: auto; 72 | padding: var(--size-4-2); 73 | } 74 | 75 | .task-property-list-item { 76 | display: flex; 77 | align-items: center; 78 | padding: 4px 12px; 79 | cursor: pointer; 80 | border-radius: var(--radius-s); 81 | } 82 | 83 | .task-property-list-item:hover { 84 | background-color: var(--background-modifier-hover); 85 | } 86 | 87 | .task-property-list-item.selected { 88 | background-color: var(--background-modifier-active); 89 | } 90 | 91 | .task-property-icon { 92 | margin-right: 8px; 93 | color: var(--text-muted); 94 | 95 | display: flex; 96 | align-items: center; 97 | justify-content: center; 98 | } 99 | 100 | .task-property-name { 101 | flex: 1; 102 | white-space: nowrap; 103 | overflow: hidden; 104 | text-overflow: ellipsis; 105 | } 106 | 107 | .task-property-count { 108 | margin-left: 8px; 109 | font-size: 0.8em; 110 | color: var(--text-muted); 111 | background-color: var(--background-modifier-border); 112 | border-radius: 10px; 113 | padding: 1px 6px; 114 | } 115 | 116 | .task-property-task-header { 117 | display: flex; 118 | justify-content: space-between; 119 | align-items: center; 120 | padding: var(--size-4-2) var(--size-4-4); 121 | border-bottom: 1px solid var(--background-modifier-border); 122 | height: var(--size-4-10); 123 | } 124 | 125 | .task-property-task-title { 126 | font-weight: 600; 127 | font-size: 16px; 128 | } 129 | 130 | .task-property-task-count { 131 | color: var(--text-muted); 132 | } 133 | 134 | .task-property-task-list { 135 | flex: 1; 136 | overflow-y: auto; 137 | } 138 | 139 | .task-property-empty-state { 140 | display: flex; 141 | align-items: center; 142 | justify-content: center; 143 | height: 100%; 144 | color: var(--text-muted); 145 | font-style: italic; 146 | padding: 16px; 147 | } 148 | 149 | /* task-property View - Mobile */ 150 | .is-phone .task-property-left-column { 151 | position: absolute; 152 | left: 0; 153 | top: 0; 154 | height: 100%; 155 | z-index: 10; 156 | background-color: var(--background-secondary); 157 | width: 100%; 158 | transform: translateX(-100%); 159 | transition: transform 0.3s ease-in-out; 160 | border-right: 1px solid var(--background-modifier-border); 161 | } 162 | 163 | .is-phone .task-property-left-column.is-visible { 164 | transform: translateX(0); 165 | } 166 | 167 | .is-phone .task-property-sidebar-toggle { 168 | display: flex; 169 | align-items: center; 170 | justify-content: center; 171 | margin-right: 8px; 172 | } 173 | 174 | .is-phone .task-property-sidebar-close { 175 | --icon-size: var(--size-4-4); 176 | position: absolute; 177 | top: var(--size-4-2); 178 | right: 10px; 179 | z-index: 15; 180 | display: flex; 181 | align-items: center; 182 | justify-content: center; 183 | } 184 | 185 | /* Add overlay when left column is visible on mobile */ 186 | .is-phone 187 | .task-property-container:has( 188 | .task-property-left-column.is-visible 189 | )::before { 190 | content: ""; 191 | position: absolute; 192 | top: 0; 193 | left: 0; 194 | width: 100%; 195 | height: 100%; 196 | background-color: var(--background-modifier-cover); 197 | opacity: 0.5; 198 | z-index: 5; 199 | transition: opacity 0.3s ease-in-out; 200 | } 201 | 202 | /* Add position relative to container for absolute positioning context */ 203 | .is-phone .task-property-container { 204 | position: relative; 205 | overflow: hidden; 206 | } 207 | 208 | .is-phone .task-property-sidebar-header:has(.task-property-sidebar-close) { 209 | padding-right: var(--size-4-12); 210 | } 211 | -------------------------------------------------------------------------------- /src/styles/quick-capture.css: -------------------------------------------------------------------------------- 1 | /* Quick Capture Panel */ 2 | .quick-capture-panel { 3 | padding: var(--size-4-2); 4 | background-color: var(--background-primary); 5 | border-top: 1px solid var(--background-modifier-border); 6 | display: flex; 7 | flex-direction: column; 8 | gap: var(--size-4-2); 9 | } 10 | 11 | .quick-capture-header-container { 12 | display: flex; 13 | align-items: center; 14 | margin-bottom: var(--size-4-2); 15 | gap: var(--size-4-2); 16 | font-size: var(--font-ui-medium); 17 | font-weight: bold; 18 | color: var(--text-normal); 19 | padding: var(--size-2-1) var(--size-4-2); 20 | } 21 | 22 | .quick-capture-title { 23 | color: var(--text-normal); 24 | white-space: nowrap; 25 | } 26 | 27 | .quick-capture-target { 28 | flex: 1; 29 | border-radius: var(--radius-s); 30 | color: var(--text-accent); 31 | font-size: var(--font-text-size); 32 | font-weight: normal; 33 | min-width: 100px; 34 | max-width: 500px; 35 | white-space: nowrap; 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | } 39 | 40 | .quick-capture-target:focus { 41 | /* box-shadow: 0 0 0 2px var(--background-modifier-border-focus); */ 42 | outline: none; 43 | } 44 | 45 | .quick-capture-hint { 46 | font-size: 12px; 47 | color: var(--text-muted); 48 | margin-bottom: 8px; 49 | margin-top: -4px; 50 | text-align: right; 51 | } 52 | 53 | .quick-capture-editor { 54 | min-height: 200px; 55 | background-color: var(--background-primary); 56 | } 57 | 58 | .quick-capture-file-suggest { 59 | max-width: 500px; 60 | } 61 | 62 | .quick-capture-buttons { 63 | display: flex; 64 | justify-content: flex-end; 65 | gap: 8px; 66 | } 67 | 68 | .quick-capture-submit, 69 | .quick-capture-cancel { 70 | padding: 6px 12px; 71 | border-radius: 4px; 72 | cursor: pointer; 73 | } 74 | 75 | .quick-capture-submit { 76 | background-color: var(--interactive-accent); 77 | color: var(--text-on-accent); 78 | } 79 | 80 | .quick-capture-cancel { 81 | background-color: var(--background-modifier-border); 82 | color: var(--text-normal); 83 | } 84 | 85 | .quick-capture-modal .modal-title { 86 | display: flex; 87 | align-items: center; 88 | flex-direction: row; 89 | gap: 10px; 90 | 91 | font-size: var(--font-ui-medium); 92 | font-weight: bold; 93 | } 94 | .quick-capture-modal-editor { 95 | min-height: 150px; 96 | margin-bottom: 20px; 97 | } 98 | .quick-capture-modal-buttons { 99 | display: flex; 100 | justify-content: flex-end; 101 | gap: 10px; 102 | } 103 | 104 | /* Full-featured modal styles */ 105 | .quick-capture-modal.full { 106 | width: 80vw; 107 | max-width: 900px; 108 | } 109 | 110 | .quick-capture-layout { 111 | display: flex; 112 | height: 100%; 113 | gap: 16px; 114 | margin-bottom: 16px; 115 | } 116 | 117 | .quick-capture-config-panel { 118 | flex: 1; 119 | border-right: 1px solid var(--background-modifier-border); 120 | padding-right: 16px; 121 | overflow-y: auto; 122 | max-width: 40%; 123 | } 124 | 125 | .quick-capture-editor-panel { 126 | flex: 1.5; 127 | display: flex; 128 | flex-direction: column; 129 | } 130 | 131 | .quick-capture-section-title { 132 | font-weight: bold; 133 | margin-bottom: 8px; 134 | font-size: var(--font-ui-medium); 135 | color: var(--text-normal); 136 | } 137 | 138 | .quick-capture-target-container { 139 | margin-bottom: 16px; 140 | } 141 | 142 | .quick-capture-modal.full .quick-capture-modal-editor { 143 | min-height: 200px; 144 | flex: 1; 145 | overflow-y: auto; 146 | border: 1px solid var(--background-modifier-border); 147 | border-radius: var(--radius-s); 148 | padding: 8px; 149 | margin-top: 8px; 150 | } 151 | 152 | /* Mobile optimization */ 153 | @media (max-width: 768px) { 154 | .quick-capture-modal.full { 155 | width: 95vw; 156 | } 157 | 158 | .quick-capture-layout { 159 | flex-direction: column; 160 | } 161 | 162 | .quick-capture-config-panel { 163 | max-width: 100%; 164 | border-right: none; 165 | border-bottom: 1px solid var(--background-modifier-border); 166 | padding-right: 0; 167 | padding-bottom: 16px; 168 | margin-bottom: 16px; 169 | max-height: 40%; 170 | } 171 | } 172 | 173 | .quick-capture-config-panel .details-status-selector { 174 | display: flex; 175 | flex-direction: row; 176 | justify-content: space-between; 177 | 178 | margin-bottom: var(--size-4-2); 179 | margin-top: var(--size-4-2); 180 | } 181 | 182 | .quick-capture-config-panel .quick-capture-status-selector { 183 | display: flex; 184 | flex-direction: row; 185 | justify-content: space-between; 186 | 187 | gap: var(--size-4-3); 188 | } 189 | -------------------------------------------------------------------------------- /src/styles/reward.css: -------------------------------------------------------------------------------- 1 | /* Styles for the Reward Modal */ 2 | .reward-modal-content { 3 | text-align: center; /* Center align content */ 4 | } 5 | 6 | .reward-modal .modal-title { 7 | text-align: center; 8 | } 9 | 10 | .reward-name { 11 | font-size: 1.2em; 12 | font-weight: bold; 13 | margin-bottom: 15px; 14 | } 15 | 16 | .reward-image-container { 17 | margin-bottom: 20px; 18 | display: flex; /* Use flexbox for centering */ 19 | justify-content: center; /* Center horizontally */ 20 | align-items: center; /* Center vertically */ 21 | } 22 | 23 | .reward-image { 24 | max-width: 80%; /* Limit image width */ 25 | max-height: 300px; /* Limit image height */ 26 | border-radius: 8px; /* Optional: add rounded corners */ 27 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Optional: add subtle shadow */ 28 | } 29 | 30 | .reward-image-error { 31 | font-style: italic; 32 | color: var(--text-muted); /* Use Obsidian's muted text color */ 33 | } 34 | 35 | .reward-spacer { 36 | height: 20px; /* Add some space before the buttons */ 37 | } 38 | 39 | /* Style the buttons within the modal */ 40 | .task-genius-reward-modal .setting-item-control { 41 | display: flex; 42 | justify-content: center; /* Center buttons */ 43 | gap: 10px; /* Add space between buttons */ 44 | } 45 | -------------------------------------------------------------------------------- /src/styles/task-filter.css: -------------------------------------------------------------------------------- 1 | /* Task filter panel styles */ 2 | .task-filter-panel { 3 | padding: var(--size-4-4) var(--size-4-4); 4 | padding-bottom: var(--size-2-2); 5 | padding-left: var(--size-4-8); 6 | background-color: var(--background-primary); 7 | border-top: 1px solid var(--background-modifier-border); 8 | display: flex; 9 | flex-direction: column; 10 | max-height: 300px; 11 | overflow-y: auto; 12 | } 13 | 14 | .task-filter-active { 15 | color: var(--color-accent-2); 16 | font-weight: bold; 17 | } 18 | 19 | .task-filter-panel > .setting-item { 20 | border-top: unset; 21 | } 22 | 23 | .task-filter-header-container { 24 | display: flex; 25 | align-items: center; 26 | justify-content: flex-end; 27 | } 28 | 29 | .task-filter-title { 30 | font-size: var(--font-ui-small); 31 | color: var(--text-normal); 32 | } 33 | 34 | .task-filter-options { 35 | display: flex; 36 | flex-direction: column; 37 | gap: 10px; 38 | } 39 | 40 | .task-filter-section { 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | 45 | .task-filter-section h3 { 46 | font-size: 14px; 47 | margin: 5px 0; 48 | color: var(--text-muted); 49 | } 50 | 51 | .task-filter-section:last-child { 52 | border-bottom: unset; 53 | } 54 | 55 | .task-filter-option { 56 | display: flex; 57 | align-items: center; 58 | gap: 6px; 59 | } 60 | 61 | .task-filter-option input[type="checkbox"] { 62 | margin: 0; 63 | } 64 | 65 | .task-filter-option label { 66 | font-size: 13px; 67 | color: var(--text-normal); 68 | } 69 | 70 | .task-filter-buttons { 71 | display: flex; 72 | justify-content: flex-end; 73 | gap: 8px; 74 | margin-top: 8px; 75 | padding-top: 8px; 76 | border-top: 1px solid var(--background-modifier-border); 77 | } 78 | 79 | .task-filter-apply, 80 | .task-filter-close { 81 | padding: 6px 12px; 82 | border-radius: 4px; 83 | font-size: 12px; 84 | cursor: pointer; 85 | } 86 | 87 | .task-filter-apply { 88 | background-color: var(--interactive-accent); 89 | color: var(--text-on-accent); 90 | } 91 | 92 | .task-filter-reset { 93 | background-color: var(--background-modifier-border); 94 | color: var(--text-normal); 95 | } 96 | 97 | .task-filter-close { 98 | background-color: var(--background-secondary); 99 | color: var(--text-normal); 100 | } 101 | 102 | .task-filter-query-input { 103 | width: 100%; 104 | min-width: 250px; 105 | border-radius: 4px; 106 | padding: 8px 12px; 107 | font-family: var(--font-monospace); 108 | font-size: 14px; 109 | } 110 | 111 | .task-filter-query-input:focus { 112 | box-shadow: 0 0 0 2px var(--interactive-accent); 113 | outline: none; 114 | } 115 | 116 | .task-filter-section .setting-item-description { 117 | margin-top: 5px; 118 | margin-bottom: 10px; 119 | font-size: 12px; 120 | color: var(--text-muted); 121 | line-height: 1.4; 122 | } 123 | 124 | .task-filter-options { 125 | max-height: 70vh; 126 | overflow-y: auto; 127 | padding-right: 5px; 128 | } 129 | 130 | .task-filter-options { 131 | margin-bottom: 10px; 132 | padding-top: var(--size-4-4); 133 | } 134 | -------------------------------------------------------------------------------- /src/styles/task-gutter.css: -------------------------------------------------------------------------------- 1 | .markdown-source-view.mod-cm6 .cm-gutters.task-gutter { 2 | margin-inline-end: 0 !important; 3 | margin-inline-start: var(--file-folding-offset); 4 | } 5 | 6 | .is-mobile .markdown-source-view.mod-cm6 .cm-gutters.task-gutter { 7 | margin-inline-start: 0 !important; 8 | } 9 | 10 | .task-details-popover.tg-menu { 11 | z-index: 20; 12 | position: fixed; 13 | background-color: var(--background-primary); 14 | border: 1px solid var(--background-modifier-border); 15 | border-radius: var(--radius-s); 16 | padding: var(--size-4-3); 17 | box-shadow: var(--shadow-l); 18 | } 19 | 20 | .task-gutter { 21 | width: 26px; 22 | } 23 | 24 | .task-gutter-marker { 25 | cursor: pointer; 26 | font-size: var(--font-smaller); 27 | opacity: 0.1; 28 | transition: opacity 0.2s ease; 29 | } 30 | 31 | .task-gutter-marker:hover { 32 | opacity: 1; 33 | } 34 | 35 | .task-popover-content { 36 | padding: var(--size-4-3); 37 | max-width: 300px; 38 | max-height: 400px; 39 | overflow: auto; 40 | } 41 | 42 | .task-metadata-editor { 43 | display: flex; 44 | flex-direction: column; 45 | gap: var(--size-4-2); 46 | padding: var(--size-2-2); 47 | height: 100%; 48 | } 49 | 50 | .field-container { 51 | display: flex; 52 | flex-direction: column; 53 | margin-bottom: var(--size-2-2); 54 | } 55 | 56 | .field-label { 57 | font-size: var(--font-smallest); 58 | font-weight: var(--font-bold); 59 | margin-bottom: var(--size-2-1); 60 | color: var(--text-muted); 61 | } 62 | 63 | .action-buttons { 64 | display: flex; 65 | justify-content: space-between; 66 | margin-top: var(--size-4-2); 67 | gap: var(--size-4-2); 68 | } 69 | 70 | .action-button { 71 | padding: var(--size-2-2) var(--size-4-2); 72 | font-size: var(--font-smallest); 73 | border-radius: var(--radius-s); 74 | cursor: pointer; 75 | } 76 | 77 | .task-gutter-marker.clickable-icon { 78 | width: 24px; 79 | padding: var(--size-2-1); 80 | display: flex; 81 | justify-content: center; 82 | align-items: center; 83 | } 84 | 85 | /* Tabbed Interface Styles */ 86 | .task-details-popover .tabs-main-container { 87 | display: flex; 88 | flex-direction: column; 89 | width: 100%; /* Ensure it takes available width */ 90 | } 91 | 92 | .task-details-popover .tabs-navigation { 93 | display: flex; 94 | margin-bottom: var(--size-4-2); 95 | gap: var(--size-4-2); 96 | } 97 | 98 | .task-details-popover .tab-button { 99 | padding: var(--size-2-2) var(--size-4-2); 100 | cursor: pointer; 101 | border: none; 102 | background: none; 103 | font-size: var(--font-ui-small); /* Adjusted for consistency */ 104 | color: var(--text-muted); 105 | margin-bottom: -1px; /* Align with parent border */ 106 | transition: color 0.2s ease, border-color 0.2s ease; 107 | } 108 | 109 | .task-details-popover .tab-button:hover { 110 | color: var(--text-normal); 111 | } 112 | 113 | .task-details-popover .tab-button.active { 114 | color: var(--text-on-accent); 115 | font-weight: var(--font-bold); 116 | background-color: var(--interactive-accent); 117 | } 118 | 119 | .task-details-popover .tab-pane { 120 | display: none; /* Hide inactive panes by default */ 121 | flex-direction: column; /* Ensure content within pane flows vertically */ 122 | gap: var(--size-4-2); /* Add some gap between elements in the pane */ 123 | } 124 | 125 | .task-details-popover .tab-pane.active { 126 | display: flex; /* Show active pane */ 127 | } 128 | 129 | .task-details-popover .details-status-selector, 130 | .task-status-editor .details-status-selector { 131 | display: flex; 132 | flex-direction: row; 133 | justify-content: space-between; 134 | 135 | margin-bottom: var(--size-4-2); 136 | margin-top: var(--size-4-2); 137 | } 138 | 139 | .task-details-popover .quick-capture-status-selector, 140 | .task-status-editor .quick-capture-status-selector { 141 | display: flex; 142 | flex-direction: row; 143 | justify-content: space-between; 144 | 145 | gap: var(--size-4-3); 146 | } 147 | 148 | .task-details-popover .quick-capture-status-selector-label, 149 | .task-status-editor .quick-capture-status-selector-label { 150 | display: none; 151 | } 152 | 153 | .modal-content.task-metadata-editor { 154 | display: flex; 155 | flex-direction: column; 156 | gap: var(--size-4-2); 157 | } 158 | 159 | .metadata-full-container { 160 | display: flex; 161 | flex-direction: column; 162 | gap: var(--size-4-2); 163 | } 164 | 165 | .metadata-full-container .dates-container { 166 | display: flex; 167 | flex-direction: column; 168 | gap: var(--size-4-2); 169 | } 170 | -------------------------------------------------------------------------------- /src/styles/task-status.css: -------------------------------------------------------------------------------- 1 | /* Task Status Switcher Styles */ 2 | .task-status-widget { 3 | display: inline-flex; 4 | align-items: center; 5 | cursor: pointer; 6 | font-size: var(--font-ui-medium); 7 | font-weight: var(--font-bold); 8 | } 9 | 10 | .task-state.live-preview-mode { 11 | padding-inline-start: var(--size-4-2); 12 | padding-inline-end: var(--size-2-1); 13 | } 14 | 15 | .task-status-widget .list-bullet::after { 16 | background-color: var(--list-marker-color) !important; 17 | } 18 | 19 | /* TODO status style */ 20 | .task-state[data-task-state=" "] { 21 | color: var(--text-accent); 22 | } 23 | 24 | /* DOING status style */ 25 | .task-state[data-task-state="/"] { 26 | color: var(--task-doing-color); 27 | } 28 | 29 | /* IN-PROGRESS status style */ 30 | .task-state[data-task-state=">"] { 31 | color: var(--task-in-progress-color); 32 | } 33 | 34 | /* DONE status style */ 35 | .task-state[data-task-state="x"], 36 | .task-state[data-task-state="X"] { 37 | color: var(--task-completed-color); 38 | } 39 | 40 | /* CANCELLED status style */ 41 | .task-state[data-task-state="-"] { 42 | color: var(--task-abandoned-color); 43 | } 44 | 45 | /* SCHEDULED status style */ 46 | .task-state[data-task-state="<"] { 47 | color: var(--task-planned-color); 48 | } 49 | 50 | /* QUESTION status style */ 51 | .task-state[data-task-state="?"] { 52 | color: var(--task-question-color); 53 | } 54 | 55 | /* IMPORTANT status style */ 56 | .task-state[data-task-state="!"] { 57 | color: var(--task-important-color); 58 | } 59 | 60 | /* STAR status style */ 61 | .task-state[data-task-state="*"] { 62 | color: var(--task-star-color); 63 | } 64 | 65 | /* QUOTE status style */ 66 | .task-state[data-task-state='"'] { 67 | color: var(--task-quote-color); 68 | } 69 | 70 | /* LOCATION status style */ 71 | .task-state[data-task-state="l"] { 72 | color: var(--task-location-color); 73 | } 74 | 75 | /* BOOKMARK status style */ 76 | .task-state[data-task-state="b"] { 77 | color: var(--task-bookmark-color); 78 | } 79 | 80 | /* INFORMATION status style */ 81 | .task-state[data-task-state="i"] { 82 | color: var(--task-information-color); 83 | } 84 | 85 | /* IDEA status style */ 86 | .task-state[data-task-state="I"] { 87 | color: var(--task-idea-color); 88 | } 89 | 90 | /* PROS status style */ 91 | .task-state[data-task-state="p"] { 92 | color: var(--task-pros-color); 93 | } 94 | 95 | /* CONS status style */ 96 | .task-state[data-task-state="c"] { 97 | color: var(--task-cons-color); 98 | } 99 | 100 | /* FIRE status style */ 101 | .task-state[data-task-state="f"] { 102 | color: var(--task-fire-color); 103 | } 104 | 105 | /* KEY status style */ 106 | .task-state[data-task-state="k"] { 107 | color: var(--task-key-color); 108 | } 109 | 110 | /* WIN status style */ 111 | .task-state[data-task-state="w"] { 112 | color: var(--task-win-color); 113 | } 114 | 115 | /* UP status style */ 116 | .task-state[data-task-state="u"] { 117 | color: var(--task-up-color); 118 | } 119 | 120 | /* DOWN status style */ 121 | .task-state[data-task-state="d"] { 122 | color: var(--task-down-color); 123 | } 124 | 125 | /* NOTE status style */ 126 | .task-state[data-task-state="n"] { 127 | color: var(--task-note-color); 128 | } 129 | 130 | /* AMOUNT/SAVINGS status style */ 131 | .task-state[data-task-state="S"] { 132 | color: var(--task-amount-color); 133 | } 134 | 135 | /* SPEECH BUBBLE status style */ 136 | .task-state[data-task-state="0"], 137 | .task-state[data-task-state="1"], 138 | .task-state[data-task-state="2"], 139 | .task-state[data-task-state="3"], 140 | .task-state[data-task-state="4"], 141 | .task-state[data-task-state="5"], 142 | .task-state[data-task-state="6"], 143 | .task-state[data-task-state="7"], 144 | .task-state[data-task-state="8"], 145 | .task-state[data-task-state="9"] { 146 | color: var(--task-speech-color); 147 | } 148 | 149 | .task-fake-bullet { 150 | display: inline-block; 151 | width: 5px; 152 | height: 5px; 153 | border-radius: 50%; 154 | background-color: var(--text-normal); 155 | margin-right: 4px; 156 | vertical-align: middle; 157 | } 158 | 159 | ol > .task-list-item .task-fake-bullet { 160 | display: none; 161 | } 162 | 163 | ol > .task-list-item .task-state-container { 164 | margin-inline-start: 0; 165 | } 166 | -------------------------------------------------------------------------------- /src/styles/tree-view.css: -------------------------------------------------------------------------------- 1 | /* Tree View styles */ 2 | 3 | /* Tree item container */ 4 | .tree-task-item { 5 | position: relative; 6 | display: flex; 7 | flex-direction: column; 8 | padding: 8px 16px; 9 | transition: background-color 0.2s ease; 10 | } 11 | 12 | .task-children-container .task-item.tree-task-item { 13 | border-bottom: unset; 14 | padding-top: var(--size-2-2); 15 | padding-bottom: var(--size-2-2); 16 | gap: 0; 17 | } 18 | 19 | .task-item.tree-task-item { 20 | gap: 0; 21 | } 22 | 23 | .tree-task-item:hover { 24 | background-color: var(--background-secondary-alt); 25 | } 26 | 27 | .tree-task-item.selected { 28 | background-color: var(--background-modifier-active); 29 | } 30 | 31 | .tree-task-item.completed { 32 | opacity: 0.7; 33 | } 34 | 35 | /* Task content row (contains the main task content) */ 36 | .tree-task-item > div:first-of-type { 37 | width: 100%; 38 | display: flex; 39 | align-items: flex-start; 40 | gap: 6px; 41 | } 42 | 43 | /* Indentation for hierarchy */ 44 | .task-indent { 45 | flex-shrink: 0; 46 | } 47 | 48 | .task-item.tree-task-item .task-expand-toggle { 49 | padding-top: var(--size-2-2); 50 | } 51 | 52 | .task-item .task-checkbox { 53 | padding-top: var(--size-2-2); 54 | } 55 | 56 | /* Expand/collapse toggle */ 57 | .task-expand-toggle { 58 | cursor: pointer; 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | width: 16px; 63 | height: 16px; 64 | flex-shrink: 0; 65 | color: var(--text-muted); 66 | } 67 | 68 | .task-expand-toggle:hover { 69 | color: var(--text-normal); 70 | } 71 | 72 | /* Task checkbox */ 73 | .task-item.tree-task-item .task-checkbox { 74 | cursor: pointer; 75 | flex-shrink: 0; 76 | color: var(--text-muted); 77 | width: 16px; 78 | height: 16px; 79 | display: flex; 80 | align-items: center; 81 | justify-content: center; 82 | } 83 | 84 | .task-item.tree-task-item .task-checkbox:hover { 85 | color: var(--text-accent); 86 | } 87 | 88 | .task-item.tree-task-item .task-checkbox.checked { 89 | color: var(--text-accent); 90 | } 91 | 92 | /* Task content */ 93 | .task-content { 94 | flex-grow: 1; 95 | line-height: 1.4; 96 | } 97 | 98 | .tree-task-item.completed .task-content { 99 | text-decoration: line-through; 100 | color: var(--text-muted); 101 | } 102 | 103 | /* Task metadata */ 104 | .task-metadata { 105 | display: flex; 106 | gap: 8px; 107 | margin-top: 4px; 108 | font-size: 0.85em; 109 | color: var(--text-muted); 110 | } 111 | 112 | .task-metadata:empty { 113 | display: none; 114 | } 115 | 116 | .task-due-date.overdue { 117 | color: var(--text-error); 118 | font-weight: bold; 119 | } 120 | 121 | .task-item.tree-task-item .task-project { 122 | display: inline-block; 123 | padding: 1px 6px; 124 | border-radius: 4px; 125 | } 126 | 127 | .task-priority.priority-3 { 128 | color: var(--text-error); 129 | } 130 | 131 | .task-priority.priority-2 { 132 | color: var(--text-warning); 133 | } 134 | 135 | .task-priority.priority-1 { 136 | color: var(--text-accent); 137 | } 138 | 139 | /* Children container */ 140 | .task-children-container { 141 | /* margin-left: 20px; */ 142 | margin-top: 4px; 143 | width: 100%; 144 | } 145 | 146 | /* View toggle button */ 147 | .view-toggle-btn { 148 | cursor: pointer; 149 | display: flex; 150 | align-items: center; 151 | justify-content: center; 152 | width: 24px; 153 | height: 24px; 154 | color: var(--text-muted); 155 | border-radius: 4px; 156 | } 157 | 158 | .view-toggle-btn:hover { 159 | background-color: var(--background-modifier-hover); 160 | color: var(--text-normal); 161 | } 162 | 163 | .task-children-container:empty { 164 | display: none !important; 165 | } 166 | -------------------------------------------------------------------------------- /src/styles/view-config.css: -------------------------------------------------------------------------------- 1 | .task-genius-view-config-modal { 2 | width: max(70%, 500px); 3 | } 4 | 5 | /* Styling for the View Configuration Modal */ 6 | .task-genius-view-config-modal .setting-item { 7 | /* Add some spacing between settings in the modal */ 8 | margin-bottom: 15px; 9 | } 10 | 11 | .task-genius-view-config-modal 12 | .setting-item:not(.setting-item-heading) 13 | .setting-item-info { 14 | /* Ensure labels are aligned well */ 15 | width: 120px; 16 | } 17 | 18 | .task-genius-view-config-modal .setting-item-control input[type="text"], 19 | .task-genius-view-config-modal .setting-item-control input[type="number"] { 20 | /* Ensure text inputs take available width */ 21 | width: 100%; 22 | } 23 | 24 | .task-genius-view-config-modal .setting-item-description { 25 | /* Style descriptions */ 26 | font-size: var(--font-ui-smaller); 27 | color: var(--text-muted); 28 | margin-top: 2px; 29 | } 30 | 31 | /* Styling for the View Management List in Settings Tab */ 32 | .view-management-list .setting-item { 33 | border-bottom: 1px solid var(--background-modifier-border); 34 | padding: 10px 0; 35 | display: flex; /* Use flex for better control */ 36 | align-items: center; /* Align items vertically */ 37 | } 38 | 39 | .view-management-list .setting-item-info { 40 | flex-grow: 1; /* Allow name/description to take up space */ 41 | margin-right: 10px; 42 | } 43 | 44 | .view-management-list .setting-item-control { 45 | /* Keep controls together */ 46 | display: flex; 47 | align-items: center; 48 | gap: 8px; /* Space between toggles/buttons */ 49 | } 50 | 51 | .view-management-list .setting-item-control .button-component { 52 | padding: 5px; /* Smaller padding for icon buttons */ 53 | height: auto; 54 | } 55 | 56 | .view-management-list .view-order-button, 57 | .view-management-list .view-delete-button { 58 | /* Style action buttons */ 59 | margin-left: 5px; 60 | } 61 | 62 | .view-management-list .setting-item:last-child { 63 | border-bottom: none; 64 | } 65 | 66 | /* Specific styling for toggles in the list */ 67 | .view-management-list .setting-item-control .checkbox-container { 68 | margin: 0; /* Remove default margins if any */ 69 | } 70 | 71 | /* Icon Picker Menu Styles (Scoped) */ 72 | .tg-icon-menu { 73 | position: absolute; 74 | z-index: 100; 75 | background-color: var(--background-secondary); 76 | border: 1px solid var(--background-modifier-border); 77 | border-radius: var(--radius-m); 78 | box-shadow: var(--shadow-l); 79 | padding: 8px; 80 | max-height: 300px; /* Limit overall menu height */ 81 | width: 250px; 82 | display: flex; /* Use flexbox */ 83 | flex-direction: column; 84 | /* Prevent padding from affecting max-height calculation for flex children */ 85 | box-sizing: border-box; 86 | } 87 | 88 | /* Remove styles for the intermediate container */ 89 | /* .bm-plugin-icon-menu .bm-menu-content { 90 | flex-grow: 1; 91 | overflow-y: auto; 92 | display: flex; 93 | flex-direction: column; 94 | min-height: 0; 95 | } */ 96 | 97 | .tg-icon-menu .tg-menu-search { 98 | width: 100%; 99 | padding: 6px 8px; 100 | margin-bottom: 8px; 101 | border: 1px solid var(--background-modifier-border); 102 | border-radius: var(--radius-s); 103 | background-color: var(--background-primary); 104 | color: var(--text-normal); 105 | box-sizing: border-box; 106 | flex-shrink: 0; /* Prevent search bar from shrinking */ 107 | } 108 | 109 | .tg-icon-menu .tg-menu-icons { 110 | flex-grow: 1; /* Icon list takes remaining vertical space */ 111 | overflow-y: auto; /* Make the icon list scrollable */ 112 | min-height: 0; /* Crucial for allowing flex child to shrink and scroll */ 113 | display: grid; 114 | grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); 115 | gap: 4px; 116 | /* Remove min-height previously needed for grid in flex */ 117 | } 118 | 119 | /* Scope the clickable icon *within* the menu */ 120 | .tg-icon-menu .clickable-icon { 121 | display: flex; 122 | justify-content: center; 123 | align-items: center; 124 | padding: 6px; 125 | border-radius: var(--radius-s); 126 | cursor: pointer; 127 | background-color: var(--background-primary); 128 | border: 1px solid transparent; 129 | transition: background-color 0.1s ease-in-out, border-color 0.1s ease-in-out; 130 | } 131 | 132 | .tg-icon-menu .clickable-icon:hover { 133 | background-color: var(--background-modifier-hover); 134 | border-color: var(--background-modifier-border-hover); 135 | } 136 | 137 | .tg-icon-menu .clickable-icon svg { 138 | width: 20px; 139 | height: 20px; 140 | color: var(--text-muted); 141 | } 142 | -------------------------------------------------------------------------------- /src/translations/helper.ts: -------------------------------------------------------------------------------- 1 | // Modern translation system for Obsidian plugins 2 | import { moment } from "obsidian"; 3 | import { translationManager } from "./manager"; 4 | export type { TranslationKey } from "./types"; 5 | 6 | // Initialize translations 7 | export async function initializeTranslations(): Promise { 8 | const currentLocale = moment.locale(); 9 | translationManager.setLocale(currentLocale); 10 | } 11 | 12 | // Export the translation function 13 | export const t = translationManager.t.bind(translationManager); 14 | -------------------------------------------------------------------------------- /src/translations/locale/cz.ts: -------------------------------------------------------------------------------- 1 | // Czech translations 2 | const translations = {}; 3 | 4 | export default translations; 5 | -------------------------------------------------------------------------------- /src/translations/manager.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | import type { Translation, TranslationKey, TranslationOptions } from "./types"; 3 | 4 | // Import all locale files 5 | import ar from "./locale/ar"; 6 | import cz from "./locale/cz"; 7 | import da from "./locale/da"; 8 | import de from "./locale/de"; 9 | import en from "./locale/en"; 10 | import enGB from "./locale/en-gb"; 11 | import es from "./locale/es"; 12 | import fr from "./locale/fr"; 13 | import hi from "./locale/hi"; 14 | import id from "./locale/id"; 15 | import it from "./locale/it"; 16 | import ja from "./locale/ja"; 17 | import ko from "./locale/ko"; 18 | import nl from "./locale/nl"; 19 | import no from "./locale/no"; 20 | import pl from "./locale/pl"; 21 | import pt from "./locale/pt"; 22 | import ptBR from "./locale/pt-br"; 23 | import ro from "./locale/ro"; 24 | import ru from "./locale/ru"; 25 | import uk from "./locale/uk"; 26 | import tr from "./locale/tr"; 27 | import zhCN from "./locale/zh-cn"; 28 | import zhTW from "./locale/zh-tw"; 29 | 30 | // Define supported locales map 31 | const SUPPORTED_LOCALES = { 32 | ar, 33 | cs: cz, 34 | da, 35 | de, 36 | en, 37 | "en-gb": enGB, 38 | es, 39 | fr, 40 | hi, 41 | id, 42 | it, 43 | ja, 44 | ko, 45 | nl, 46 | nn: no, 47 | pl, 48 | pt, 49 | "pt-br": ptBR, 50 | ro, 51 | ru, 52 | tr, 53 | uk, 54 | "zh-cn": zhCN, 55 | "zh-tw": zhTW, 56 | } as const; 57 | 58 | export type SupportedLocale = keyof typeof SUPPORTED_LOCALES; 59 | 60 | class TranslationManager { 61 | private static instance: TranslationManager; 62 | private currentLocale: string = "en"; 63 | private translations: Map = new Map(); 64 | private fallbackTranslation: Translation = en; 65 | private lowercaseKeyMap: Map> = new Map(); 66 | 67 | private constructor() { 68 | this.currentLocale = moment.locale(); 69 | 70 | // Initialize with all supported translations 71 | Object.entries(SUPPORTED_LOCALES).forEach(([locale, translations]) => { 72 | this.translations.set(locale, translations as Translation); 73 | 74 | // Create lowercase key mapping for each locale 75 | const lowercaseMap = new Map(); 76 | Object.keys(translations).forEach((key) => { 77 | lowercaseMap.set(key.toLowerCase(), key); 78 | }); 79 | this.lowercaseKeyMap.set(locale, lowercaseMap); 80 | }); 81 | } 82 | 83 | public static getInstance(): TranslationManager { 84 | if (!TranslationManager.instance) { 85 | TranslationManager.instance = new TranslationManager(); 86 | } 87 | return TranslationManager.instance; 88 | } 89 | 90 | public setLocale(locale: string): void { 91 | if (locale in SUPPORTED_LOCALES) { 92 | this.currentLocale = locale; 93 | } else { 94 | console.warn( 95 | `Unsupported locale: ${locale}, falling back to English` 96 | ); 97 | this.currentLocale = "en"; 98 | } 99 | } 100 | 101 | public getSupportedLocales(): SupportedLocale[] { 102 | return Object.keys(SUPPORTED_LOCALES) as SupportedLocale[]; 103 | } 104 | 105 | public t(key: TranslationKey, options?: TranslationOptions): string { 106 | const translation = 107 | this.translations.get(this.currentLocale) || 108 | this.fallbackTranslation; 109 | 110 | // Try to get the exact match first 111 | let result = this.getNestedValue(translation, key); 112 | 113 | // If not found, try case-insensitive match 114 | if (!result) { 115 | const lowercaseKey = key.toLowerCase(); 116 | const lowercaseMap = this.lowercaseKeyMap.get(this.currentLocale); 117 | const originalKey = lowercaseMap?.get(lowercaseKey); 118 | 119 | if (originalKey) { 120 | result = this.getNestedValue(translation, originalKey); 121 | } 122 | } 123 | 124 | // If still not found, use fallback 125 | if (!result) { 126 | console.warn( 127 | `Missing translation for key: ${key} in locale: ${this.currentLocale}` 128 | ); 129 | 130 | // Try exact match in fallback 131 | result = this.getNestedValue(this.fallbackTranslation, key); 132 | 133 | // Try case-insensitive match in fallback 134 | if (!result) { 135 | const lowercaseKey = key.toLowerCase(); 136 | const lowercaseMap = this.lowercaseKeyMap.get("en"); 137 | const originalKey = lowercaseMap?.get(lowercaseKey); 138 | 139 | if (originalKey) { 140 | result = this.getNestedValue( 141 | this.fallbackTranslation, 142 | originalKey 143 | ); 144 | } else { 145 | result = key; 146 | } 147 | } 148 | } 149 | 150 | if (options?.interpolation) { 151 | result = this.interpolate(result, options.interpolation); 152 | } 153 | 154 | // Remove leading/trailing quotes if present 155 | result = result.replace(/^["""']|["""']$/g, ""); 156 | 157 | return result; 158 | } 159 | 160 | private getNestedValue(obj: Translation, path: string): string { 161 | // Don't split by dots since some translation keys contain dots 162 | return obj[path] as string; 163 | } 164 | 165 | private interpolate( 166 | text: string, 167 | values: Record 168 | ): string { 169 | return text.replace( 170 | /\{\{(\w+)\}\}/g, 171 | (_, key) => values[key]?.toString() || `{{${key}}}` 172 | ); 173 | } 174 | } 175 | 176 | export const translationManager = TranslationManager.getInstance(); 177 | export const t = (key: TranslationKey, options?: TranslationOptions): string => 178 | translationManager.t(key, options); 179 | -------------------------------------------------------------------------------- /src/translations/types.ts: -------------------------------------------------------------------------------- 1 | export type TranslationKey = keyof typeof import('./locale/en').default; 2 | 3 | export interface Translation { 4 | [key: string]: string | Translation; 5 | } 6 | 7 | export interface TranslationModule { 8 | default: Translation; 9 | } 10 | 11 | export interface TranslationOptions { 12 | namespace?: string; 13 | context?: string; 14 | interpolation?: Record; 15 | } 16 | 17 | // Translation status for generation 18 | export enum TranslationStatus { 19 | UNTRANSLATED = 'untranslated', 20 | OUTDATED = 'outdated', 21 | TRANSLATED = 'translated' 22 | } 23 | 24 | export interface TranslationEntry { 25 | key: string; 26 | status: TranslationStatus; 27 | context?: string; 28 | source: string; 29 | target?: string; 30 | } 31 | 32 | export interface TranslationTemplate { 33 | language: string; 34 | entries: TranslationEntry[]; 35 | lastUpdated: string; 36 | } -------------------------------------------------------------------------------- /src/types/file-task.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * File-level task system for managing tasks at the file level 3 | * Compatible with existing Task interface but uses file properties for data storage 4 | */ 5 | 6 | import { Task } from "./task"; 7 | 8 | // Forward declaration for BasesEntry 9 | interface BasesEntry { 10 | ctx: { 11 | _local: any; 12 | app: any; 13 | filter: any; 14 | formulas: any; 15 | localUsed: boolean; 16 | }; 17 | file: { 18 | parent: any; 19 | deleted: boolean; 20 | vault: any; 21 | path: string; 22 | name: string; 23 | extension: string; 24 | getShortName(): string; 25 | }; 26 | formulas: Record; 27 | implicit: { 28 | file: any; 29 | name: string; 30 | path: string; 31 | folder: string; 32 | ext: string; 33 | }; 34 | lazyEvalCache: Record; 35 | properties: Record; 36 | getValue(prop: { 37 | type: "property" | "file" | "formula"; 38 | name: string; 39 | }): any; 40 | updateProperty(key: string, value: any): void; 41 | getFormulaValue(formula: string): any; 42 | getPropertyKeys(): string[]; 43 | } 44 | 45 | /** File-level task that extends the base Task interface */ 46 | export interface FileTask extends Omit { 47 | /** File-level task doesn't have line numbers */ 48 | line?: never; 49 | /** File-level task doesn't have original markdown */ 50 | originalMarkdown?: never; 51 | 52 | /** Source entry from Bases plugin */ 53 | sourceEntry: BasesEntry; 54 | 55 | /** Indicates this is a file-level task */ 56 | isFileTask: true; 57 | } 58 | 59 | /** Configuration for file-level task property mapping */ 60 | export interface FileTaskPropertyMapping { 61 | /** Property name for task content */ 62 | contentProperty: string; 63 | /** Property name for task status */ 64 | statusProperty: string; 65 | /** Property name for completion state */ 66 | completedProperty: string; 67 | /** Property name for creation date */ 68 | createdDateProperty?: string; 69 | /** Property name for start date */ 70 | startDateProperty?: string; 71 | /** Property name for scheduled date */ 72 | scheduledDateProperty?: string; 73 | /** Property name for due date */ 74 | dueDateProperty?: string; 75 | /** Property name for completed date */ 76 | completedDateProperty?: string; 77 | /** Property name for recurrence */ 78 | recurrenceProperty?: string; 79 | /** Property name for tags */ 80 | tagsProperty?: string; 81 | /** Property name for project */ 82 | projectProperty?: string; 83 | /** Property name for context */ 84 | contextProperty?: string; 85 | /** Property name for priority */ 86 | priorityProperty?: string; 87 | /** Property name for estimated time */ 88 | estimatedTimeProperty?: string; 89 | /** Property name for actual time */ 90 | actualTimeProperty?: string; 91 | } 92 | 93 | /** Default property mapping for file-level tasks */ 94 | export declare const DEFAULT_FILE_TASK_MAPPING: FileTaskPropertyMapping; 95 | 96 | /** File task manager interface */ 97 | export interface FileTaskManager { 98 | /** Convert a BasesEntry to a FileTask */ 99 | entryToFileTask( 100 | entry: BasesEntry, 101 | mapping?: FileTaskPropertyMapping 102 | ): FileTask; 103 | 104 | /** Convert a FileTask back to property updates */ 105 | fileTaskToPropertyUpdates( 106 | task: FileTask, 107 | mapping?: FileTaskPropertyMapping 108 | ): Record; 109 | 110 | /** Update a file task by updating its properties */ 111 | updateFileTask(task: FileTask, updates: Partial): Promise; 112 | 113 | /** Get all file tasks from a list of entries */ 114 | getFileTasksFromEntries( 115 | entries: BasesEntry[], 116 | mapping?: FileTaskPropertyMapping 117 | ): FileTask[]; 118 | 119 | /** Filter file tasks based on criteria */ 120 | filterFileTasks(tasks: FileTask[], filters: any): FileTask[]; 121 | } 122 | 123 | /** File task view configuration */ 124 | export interface FileTaskViewConfig { 125 | /** Property mapping configuration */ 126 | propertyMapping: FileTaskPropertyMapping; 127 | 128 | /** Whether to show completed tasks */ 129 | showCompleted: boolean; 130 | 131 | /** Default view mode for file tasks */ 132 | defaultViewMode: string; 133 | 134 | /** Custom status mappings */ 135 | statusMappings?: Record; 136 | } 137 | -------------------------------------------------------------------------------- /src/types/habit-card.d.ts: -------------------------------------------------------------------------------- 1 | // 基础习惯类型(不含completions字段,用于存储基础配置) 2 | export interface BaseHabitProps { 3 | id: string; 4 | name: string; 5 | description?: string; 6 | icon: string; // Lucide icon id 7 | } 8 | 9 | // BaseDailyHabitData 10 | export interface BaseDailyHabitData extends BaseHabitProps { 11 | type: "daily"; 12 | completionText?: string; // Custom text that represents completion (default is any non-empty value) 13 | property: string; 14 | } 15 | 16 | // BaseCountHabitData 17 | export interface BaseCountHabitData extends BaseHabitProps { 18 | type: "count"; 19 | min?: number; // Minimum completion value 20 | max?: number; // Maximum completion value 21 | notice?: string; // Trigger notice when completion value is reached 22 | countUnit?: string; // Optional unit for the count (e.g., "cups", "times") 23 | property: string; 24 | } 25 | 26 | // BaseScheduledHabitData 27 | export interface ScheduledEvent { 28 | name: string; 29 | details: string; 30 | } 31 | 32 | export interface BaseScheduledHabitData extends BaseHabitProps { 33 | type: "scheduled"; 34 | events: ScheduledEvent[]; 35 | propertiesMap: Record; 36 | } 37 | 38 | export interface BaseMappingHabitData extends BaseHabitProps { 39 | type: "mapping"; 40 | mapping: Record; 41 | property: string; 42 | } 43 | 44 | // BaseHabitData 45 | export type BaseHabitData = 46 | | BaseDailyHabitData 47 | | BaseCountHabitData 48 | | BaseScheduledHabitData 49 | | BaseMappingHabitData; 50 | 51 | // DailyHabitProps 52 | export interface DailyHabitProps extends BaseDailyHabitData { 53 | completions: Record; // String is date, string or number is completion value 54 | } 55 | 56 | // CountHabitProps 57 | export interface CountHabitProps extends BaseCountHabitData { 58 | completions: Record; // String is date, number is completion value 59 | } 60 | 61 | export interface ScheduledHabitProps extends BaseScheduledHabitData { 62 | completions: Record>; // String is date, Record is event name and completion value 63 | } 64 | 65 | export interface MappingHabitProps extends BaseMappingHabitData { 66 | completions: Record; // String is date, number is completion value 67 | } 68 | 69 | // HabitProps 70 | export type HabitProps = 71 | | DailyHabitProps 72 | | CountHabitProps 73 | | ScheduledHabitProps 74 | | MappingHabitProps; 75 | 76 | // HabitCardProps 77 | export interface HabitCardProps { 78 | habit: HabitProps; 79 | toggleCompletion: (habitId: string, ...args: any[]) => void; 80 | triggerConfetti?: (pos: { 81 | x: number; 82 | y: number; 83 | width?: number; 84 | height?: number; 85 | }) => void; 86 | } 87 | 88 | // MappingHabitCardProps 89 | interface MappingHabitCardProps extends HabitCardProps { 90 | toggleCompletion: (habitId: string, value: number) => void; 91 | } 92 | 93 | interface ScheduledHabitCardProps extends HabitCardProps { 94 | toggleCompletion: ( 95 | habitId: string, 96 | { 97 | id, 98 | details, 99 | }: { 100 | id: string; 101 | details: string; 102 | } 103 | ) => void; 104 | } 105 | -------------------------------------------------------------------------------- /src/utils/DateHelper.ts: -------------------------------------------------------------------------------- 1 | export class DateHelper { 2 | public dateToX(date: Date, startDate: Date, dayWidth: number): number { 3 | if (!startDate) return 0; 4 | const clampedDate = new Date( 5 | Math.max(date.getTime(), startDate.getTime()) 6 | ); // Clamp date to be >= startDate 7 | const daysDiff = this.daysBetween(startDate, clampedDate); 8 | return daysDiff * dayWidth; 9 | } 10 | 11 | public xToDate(x: number, startDate: Date, dayWidth: number): Date | null { 12 | if (!startDate || dayWidth <= 0) return null; 13 | const days = x / dayWidth; 14 | return this.addDays(startDate, days); 15 | } 16 | 17 | // Simple days between calculation (ignores time part) 18 | public daysBetween(date1: Date, date2: Date): number { 19 | const d1 = this.startOfDay(date1).getTime(); 20 | const d2 = this.startOfDay(date2).getTime(); 21 | // Use Math.floor to handle potential floating point issues and DST changes slightly better 22 | return Math.floor((d2 - d1) / (1000 * 60 * 60 * 24)); 23 | } 24 | 25 | public addDays(date: Date, days: number): Date { 26 | const result = new Date(date); 27 | result.setDate(result.getDate() + days); 28 | return result; 29 | } 30 | 31 | public startOfDay(date: Date): Date { 32 | // Clone the date to avoid modifying the original object 33 | const result = new Date(date); 34 | result.setHours(0, 0, 0, 0); 35 | return result; 36 | } 37 | 38 | public startOfWeek(date: Date): Date { 39 | const result = new Date(date); 40 | const day = result.getDay(); // 0 = Sunday, 1 = Monday, ... 41 | // Adjust to Monday (handle Sunday case where getDay is 0) 42 | const diff = result.getDate() - day + (day === 0 ? -6 : 1); 43 | result.setDate(diff); 44 | return this.startOfDay(result); 45 | } 46 | 47 | public endOfWeek(date: Date): Date { 48 | const start = this.startOfWeek(date); 49 | const result = this.addDays(start, 6); // End on Sunday 50 | result.setHours(23, 59, 59, 999); // End of Sunday 51 | return result; 52 | } 53 | 54 | // ISO 8601 week number calculation 55 | public getWeekNumber(d: Date): number { 56 | d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); 57 | d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); // Set to Thursday of the week 58 | const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); 59 | const weekNo = Math.ceil( 60 | ((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7 61 | ); 62 | return weekNo; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/dateUtil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format a date in a human-readable format 3 | * @param date Date to format 4 | * @returns Formatted date string 5 | */ 6 | export function formatDate(date: Date): string { 7 | const now = new Date(); 8 | const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 9 | const tomorrow = new Date(today); 10 | tomorrow.setDate(tomorrow.getDate() + 1); 11 | 12 | // Check if date is today or tomorrow 13 | if (date.getTime() === today.getTime()) { 14 | return "Today"; 15 | } else if (date.getTime() === tomorrow.getTime()) { 16 | return "Tomorrow"; 17 | } 18 | 19 | // Format as Month Day, Year for other dates 20 | const options: Intl.DateTimeFormatOptions = { 21 | month: "short", 22 | day: "numeric", 23 | }; 24 | 25 | // Only add year if it's not the current year 26 | if (date.getFullYear() !== now.getFullYear()) { 27 | options.year = "numeric"; 28 | } 29 | 30 | return date.toLocaleDateString(undefined, options); 31 | } 32 | 33 | /** 34 | * Parse a date string in the format YYYY-MM-DD 35 | * @param dateString Date string to parse 36 | * @returns Parsed date as a number or undefined if invalid 37 | */ 38 | export function parseLocalDate(dateString: string): number | undefined { 39 | if (!dateString) return undefined; 40 | // Basic regex check for YYYY-MM-DD format 41 | if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { 42 | console.warn(`Worker: Invalid date format encountered: ${dateString}`); 43 | return undefined; 44 | } 45 | const parts = dateString.split("-"); 46 | if (parts.length === 3) { 47 | const year = parseInt(parts[0], 10); 48 | const month = parseInt(parts[1], 10); // 1-based month 49 | const day = parseInt(parts[2], 10); 50 | // Validate date parts 51 | if ( 52 | !isNaN(year) && 53 | !isNaN(month) && 54 | month >= 1 && 55 | month <= 12 && 56 | !isNaN(day) && 57 | day >= 1 && 58 | day <= 31 59 | ) { 60 | // Use local time to create date object 61 | const date = new Date(year, month - 1, day); 62 | // Check if constructed date is valid (e.g., handle 2/30 case) 63 | if ( 64 | date.getFullYear() === year && 65 | date.getMonth() === month - 1 && 66 | date.getDate() === day 67 | ) { 68 | date.setHours(0, 0, 0, 0); // Standardize time part for date comparison 69 | return date.getTime(); 70 | } 71 | } 72 | } 73 | console.warn(`Worker: Invalid date values after parsing: ${dateString}`); 74 | return undefined; 75 | } 76 | 77 | /** 78 | * Convert a date to a relative time string, such as 79 | * "yesterday", "today", "tomorrow", etc. 80 | * using Intl.RelativeTimeFormat 81 | */ 82 | export function getRelativeTimeString( 83 | date: Date | number, 84 | lang = navigator.language 85 | ): string { 86 | // 允许传入日期对象或时间戳 87 | const timeMs = typeof date === "number" ? date : date.getTime(); 88 | 89 | // 获取当前日期(去除时分秒) 90 | const today = new Date(); 91 | today.setHours(0, 0, 0, 0); 92 | 93 | // 获取传入日期(去除时分秒) 94 | const targetDate = new Date(timeMs); 95 | targetDate.setHours(0, 0, 0, 0); 96 | 97 | // 计算日期差(以天为单位) 98 | const deltaDays = Math.round( 99 | (targetDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) 100 | ); 101 | 102 | // 创建相对时间格式化器 103 | const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" }); 104 | 105 | // 返回格式化后的相对时间字符串 106 | return rtf.format(deltaDays, "day"); 107 | } 108 | -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import { App, getFrontMatterInfo, TFile } from "obsidian"; 2 | import { QuickCaptureOptions } from "../editor-ext/quickCapture"; 3 | 4 | // Save the captured content to the target file 5 | export async function saveCapture( 6 | app: App, 7 | content: string, 8 | options: QuickCaptureOptions 9 | ): Promise { 10 | const { targetFile, appendToFile } = options; 11 | 12 | // Check if target file exists, create if not 13 | const filePath = targetFile || "Quick Capture.md"; 14 | let file = app.vault.getFileByPath(filePath); 15 | 16 | if (!file) { 17 | // Create directory structure if needed 18 | const pathParts = filePath.split("/"); 19 | if (pathParts.length > 1) { 20 | const dirPath = pathParts.slice(0, -1).join("/"); 21 | try { 22 | await app.vault.createFolder(dirPath); 23 | } catch (e) { 24 | // Directory might already exist, ignore error 25 | } 26 | } 27 | 28 | // Create the file 29 | file = await app.vault.create( 30 | filePath, 31 | appendToFile === "prepend" 32 | ? `# Quick Capture\n\n${content}` 33 | : appendToFile === "replace" 34 | ? content 35 | : `# Quick Capture\n\n${content}` 36 | ); 37 | } else if (file instanceof TFile) { 38 | // Append or replace content in existing file 39 | app.vault.process(file, (data) => { 40 | switch (appendToFile) { 41 | case "append": { 42 | // Get frontmatter information using Obsidian API 43 | const fmInfo = getFrontMatterInfo(data); 44 | 45 | // Add a newline before the new content if needed 46 | const separator = data.endsWith("\n") ? "" : "\n"; 47 | 48 | if (fmInfo.exists) { 49 | // If frontmatter exists, use the contentStart position to append after it 50 | const contentStartPos = fmInfo.contentStart; 51 | 52 | if (contentStartPos !== undefined) { 53 | const contentBeforeFrontmatter = data.slice( 54 | 0, 55 | contentStartPos 56 | ); 57 | const contentAfterFrontmatter = 58 | data.slice(contentStartPos); 59 | 60 | return ( 61 | contentBeforeFrontmatter + 62 | contentAfterFrontmatter + 63 | separator + 64 | content 65 | ); 66 | } else { 67 | // Fallback if we can't get the exact position 68 | return data + separator + content; 69 | } 70 | } else { 71 | // No frontmatter, just append to the end 72 | return data + separator + content; 73 | } 74 | } 75 | case "prepend": { 76 | // Get frontmatter information 77 | const fmInfo = getFrontMatterInfo(data); 78 | const separator = "\n"; 79 | 80 | if (fmInfo.exists && fmInfo.contentStart !== undefined) { 81 | // Insert after frontmatter but before content 82 | return ( 83 | data.slice(0, fmInfo.contentStart) + 84 | content + 85 | separator + 86 | data.slice(fmInfo.contentStart) 87 | ); 88 | } else { 89 | // No frontmatter, prepend to beginning 90 | return content + separator + data; 91 | } 92 | } 93 | case "replace": 94 | default: 95 | return content; 96 | } 97 | }); 98 | } else { 99 | throw new Error("Target is not a file"); 100 | } 101 | 102 | return; 103 | } 104 | -------------------------------------------------------------------------------- /src/utils/goal/editMode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extract the text content of a task from a markdown line 3 | * 4 | * @param lineText The full text of the markdown line containing the task 5 | * @return The extracted task text or null if no task was found 6 | */ 7 | 8 | import { REGEX_GOAL } from "./regexGoal"; 9 | 10 | function extractTaskText(lineText: string): string | null { 11 | if (!lineText) return null; 12 | 13 | const taskTextMatch = lineText.match(/^[\s|\t]*([-*+]|\d+\.)\s\[(.)\]\s*(.*?)$/); 14 | if (taskTextMatch && taskTextMatch[3]) { 15 | return taskTextMatch[3].trim(); 16 | } 17 | 18 | return null; 19 | } 20 | 21 | /** 22 | * Extract the goal value from a task text 23 | * Supports only g::number or goal::number format 24 | * 25 | * @param taskText The task text to extract the goal from 26 | * @return The extracted goal value or null if no goal found 27 | */ 28 | 29 | function extractTaskSpecificGoal(taskText: string): number | null { 30 | if (!taskText) return null; 31 | 32 | // Match only the patterns g::number or goal::number \b(g|goal):: {0,1}(\d+)\b 33 | const goalMatch = taskText.match(REGEX_GOAL); 34 | if (!goalMatch) return null; 35 | 36 | return Number(goalMatch[2]); 37 | } 38 | 39 | /** 40 | * Extract task text and goal information from a line 41 | * 42 | * @param lineText The full text of the markdown line containing the task 43 | * @return The extracted goal value or null if no goal found 44 | */ 45 | export function extractTaskAndGoalInfo(lineText: string | null): number | null { 46 | if (!lineText) return null; 47 | 48 | // Extract task text 49 | const taskText = extractTaskText(lineText); 50 | if (!taskText) return null; 51 | 52 | // Check for goal in g::number or goal::number format 53 | return extractTaskSpecificGoal(taskText); 54 | } -------------------------------------------------------------------------------- /src/utils/goal/readMode.ts: -------------------------------------------------------------------------------- 1 | import { REGEX_GOAL } from "./regexGoal"; 2 | 3 | function getParentTaskTextReadMode(taskElement: Element): string { 4 | // Clone the element to avoid modifying the original 5 | const clone = taskElement.cloneNode(true) as HTMLElement; 6 | 7 | // Remove all child lists (subtasks) 8 | const childLists = clone.querySelectorAll('ul'); 9 | childLists.forEach(list => list.remove()); 10 | 11 | // Remove the progress bar 12 | const progressBar = clone.querySelector('.cm-task-progress-bar'); 13 | if (progressBar) progressBar.remove(); 14 | 15 | // Get the text content and clean it up 16 | let text = clone.textContent || ''; 17 | 18 | // Remove any extra whitespace 19 | text = text.trim(); 20 | return text; 21 | } 22 | 23 | function extractTaskSpecificGoal(taskText: string): number | null { 24 | if (!taskText) return null; 25 | 26 | // Match only the patterns g::number or goal::number 27 | const goalMatch = taskText.match(REGEX_GOAL); 28 | if (!goalMatch) return null; 29 | 30 | return Number(goalMatch[2]); 31 | } 32 | 33 | export function extractTaskAndGoalInfoReadMode(taskElement: Element | null): number | null { 34 | if (!taskElement) return null; 35 | 36 | // Get the text content of the task 37 | const taskText = getParentTaskTextReadMode(taskElement); 38 | if (!taskText) return null; 39 | 40 | // Check for goal in g::number or goal::number format 41 | return extractTaskSpecificGoal(taskText); 42 | } 43 | export function getCustomTotalGoalReadMode(taskElement: HTMLElement | null | undefined): number | null { 44 | if (!taskElement) return null; 45 | 46 | // First check if the element already has a data-custom-goal attribute 47 | const customGoalAttr = taskElement.getAttribute('data-custom-goal'); 48 | if (customGoalAttr) { 49 | const goalValue = parseInt(customGoalAttr, 10); 50 | if (!isNaN(goalValue)) { 51 | return goalValue; 52 | } 53 | } 54 | 55 | // If not found in attribute, extract from task text 56 | const taskText = getParentTaskTextReadMode(taskElement); 57 | if (!taskText) return null; 58 | 59 | // Extract goal using pattern g::number or goal::number 60 | const goalMatch = taskText.match(REGEX_GOAL); 61 | if (!goalMatch) return null; 62 | 63 | const goalValue = parseInt(goalMatch[2], 10); 64 | 65 | // Cache the result in the data attribute for future reference 66 | taskElement.setAttribute('data-custom-goal', goalValue.toString()); 67 | 68 | return goalValue; 69 | } 70 | 71 | export function checkIfParentElementHasGoalFormat(taskElement: HTMLElement | null | undefined): boolean { 72 | if (!taskElement) return false; 73 | 74 | // Get the text content of the task 75 | const taskText = getParentTaskTextReadMode(taskElement); 76 | if (!taskText) return false; 77 | 78 | // Check for goal in g::number or goal::number format 79 | const goalMatch = taskText.match(REGEX_GOAL); 80 | return !!goalMatch; 81 | } -------------------------------------------------------------------------------- /src/utils/goal/regexGoal.ts: -------------------------------------------------------------------------------- 1 | export const REGEX_FULL_TASK_LINE= /^[\s|\t]*([-*+]|\d+\.)\s\[(.)\]\s*(.*?)$/ 2 | export const REGEX_GOAL = /\b(g|goal)::\s{0,1}(\d+)\b/i -------------------------------------------------------------------------------- /src/utils/treeViewUtil.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "../types/task"; 2 | 3 | /** 4 | * Convert a flat list of tasks to a hierarchical tree structure 5 | * @param tasks Flat list of tasks 6 | * @returns List of root tasks with children populated recursively 7 | */ 8 | export function tasksToTree(tasks: Task[]): Task[] { 9 | // Create a map for quick task lookup 10 | const taskMap = new Map(); 11 | tasks.forEach((task) => { 12 | taskMap.set(task.id, { ...task }); 13 | }); 14 | 15 | // Find root tasks and build hierarchy 16 | const rootTasks: Task[] = []; 17 | 18 | // First pass: connect children to parents 19 | tasks.forEach((task) => { 20 | const taskWithChildren = taskMap.get(task.id)!; 21 | 22 | if (task.parent && taskMap.has(task.parent)) { 23 | // This task has a parent, add it to parent's children 24 | const parent = taskMap.get(task.parent)!; 25 | if (!parent.children.includes(task.id)) { 26 | parent.children.push(task.id); 27 | } 28 | } else { 29 | // No parent or parent not in current set, treat as root 30 | rootTasks.push(taskWithChildren); 31 | } 32 | }); 33 | 34 | return rootTasks; 35 | } 36 | 37 | /** 38 | * Flatten a tree of tasks back to a list, with child tasks following their parents 39 | * @param rootTasks List of root tasks with populated children 40 | * @param taskMap Map of all tasks by ID for lookup 41 | * @returns Flattened list of tasks in hierarchical order 42 | */ 43 | export function flattenTaskTree( 44 | rootTasks: Task[], 45 | taskMap: Map 46 | ): Task[] { 47 | const result: Task[] = []; 48 | 49 | function addTaskAndChildren(task: Task) { 50 | result.push(task); 51 | 52 | // Add all children recursively 53 | task.children.forEach((childId) => { 54 | const childTask = taskMap.get(childId); 55 | if (childTask) { 56 | addTaskAndChildren(childTask); 57 | } 58 | }); 59 | } 60 | 61 | // Process all root tasks 62 | rootTasks.forEach((task) => { 63 | addTaskAndChildren(task); 64 | }); 65 | 66 | return result; 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/types/worker.d.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | declare module "*/TaskIndex.worker" { 3 | const WorkerFactory: new () => Worker; 4 | export default WorkerFactory; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/workers/TaskIndexWorkerMessage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Message types for task indexing worker communication 3 | */ 4 | 5 | import { CachedMetadata, FileStats, ListItemCache } from "obsidian"; 6 | import { Task } from "../../types/task"; 7 | import { MetadataFormat } from "../taskUtil"; 8 | 9 | /** 10 | * Command to parse tasks from a file 11 | */ 12 | export interface ParseTasksCommand { 13 | type: "parseTasks"; 14 | 15 | /** The file path being processed */ 16 | filePath: string; 17 | /** The file contents to parse */ 18 | content: string; 19 | /** File stats information */ 20 | stats: FileStats; 21 | /** Additional metadata from Obsidian cache */ 22 | metadata?: { 23 | /** List items from Obsidian's metadata cache */ 24 | listItems?: ListItemCache[]; 25 | /** Full file metadata cache */ 26 | fileCache?: CachedMetadata; 27 | }; 28 | /** Settings for the task indexer */ 29 | settings: { 30 | preferMetadataFormat: "dataview" | "tasks"; 31 | useDailyNotePathAsDate: boolean; 32 | dailyNoteFormat: string; 33 | useAsDateType: "due" | "start" | "scheduled"; 34 | dailyNotePath: string; 35 | ignoreHeading: string; 36 | focusHeading: string; 37 | }; 38 | } 39 | 40 | /** 41 | * Command to batch index multiple files 42 | */ 43 | export interface BatchIndexCommand { 44 | type: "batchIndex"; 45 | 46 | /** Files to process in batch */ 47 | files: { 48 | /** The file path */ 49 | path: string; 50 | /** The file content */ 51 | content: string; 52 | /** File stats */ 53 | stats: FileStats; 54 | /** Optional metadata */ 55 | metadata?: { 56 | listItems?: ListItemCache[]; 57 | fileCache?: CachedMetadata; 58 | }; 59 | }[]; 60 | /** Settings for the task indexer */ 61 | settings: { 62 | preferMetadataFormat: "dataview" | "tasks"; 63 | useDailyNotePathAsDate: boolean; 64 | dailyNoteFormat: string; 65 | useAsDateType: "due" | "start" | "scheduled"; 66 | dailyNotePath: string; 67 | ignoreHeading: string; 68 | focusHeading: string; 69 | }; 70 | } 71 | 72 | /** 73 | * Available commands that can be sent to the worker 74 | */ 75 | export type IndexerCommand = ParseTasksCommand | BatchIndexCommand; 76 | 77 | /** 78 | * Result of task parsing 79 | */ 80 | export interface TaskParseResult { 81 | type: "parseResult"; 82 | 83 | /** Path of the file that was processed */ 84 | filePath: string; 85 | /** Tasks extracted from the file */ 86 | tasks: Task[]; 87 | /** Statistics about the parsing operation */ 88 | stats: { 89 | /** Total number of tasks found */ 90 | totalTasks: number; 91 | /** Number of completed tasks */ 92 | completedTasks: number; 93 | /** Time taken to process in milliseconds */ 94 | processingTimeMs: number; 95 | }; 96 | } 97 | 98 | /** 99 | * Result of batch indexing 100 | */ 101 | export interface BatchIndexResult { 102 | type: "batchResult"; 103 | 104 | /** Results for each file processed */ 105 | results: { 106 | /** File path */ 107 | filePath: string; 108 | /** Number of tasks found */ 109 | taskCount: number; 110 | }[]; 111 | /** Aggregated statistics */ 112 | stats: { 113 | /** Total number of files processed */ 114 | totalFiles: number; 115 | /** Total number of tasks found across all files */ 116 | totalTasks: number; 117 | /** Total processing time in milliseconds */ 118 | processingTimeMs: number; 119 | }; 120 | } 121 | 122 | /** 123 | * Error response 124 | */ 125 | export interface ErrorResult { 126 | type: "error"; 127 | 128 | /** Error message */ 129 | error: string; 130 | /** File path that caused the error (if available) */ 131 | filePath?: string; 132 | } 133 | 134 | /** 135 | * All possible results from the worker 136 | */ 137 | export type IndexerResult = TaskParseResult | BatchIndexResult | ErrorResult; 138 | 139 | /** 140 | * Custom settings for the task worker 141 | */ 142 | 143 | export type TaskWorkerSettings = { 144 | preferMetadataFormat: MetadataFormat; 145 | useDailyNotePathAsDate: boolean; 146 | dailyNoteFormat: string; 147 | useAsDateType: "due" | "start" | "scheduled"; 148 | dailyNotePath: string; 149 | ignoreHeading: string; 150 | focusHeading: string; 151 | }; 152 | -------------------------------------------------------------------------------- /src/utils/workers/deferred.ts: -------------------------------------------------------------------------------- 1 | /** A promise that can be resolved directly. */ 2 | export type Deferred = Promise & { 3 | resolve: (value: T) => void; 4 | reject: (error: any) => void; 5 | }; 6 | 7 | /** Create a new deferred object, which is a resolvable promise. */ 8 | export function deferred(): Deferred { 9 | let resolve: (value: T) => void; 10 | let reject: (error: any) => void; 11 | 12 | const promise = new Promise((res, rej) => { 13 | resolve = res; 14 | reject = rej; 15 | }); 16 | 17 | const deferred = promise as any as Deferred; 18 | deferred.resolve = resolve!; 19 | deferred.reject = reject!; 20 | 21 | return deferred; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7"], 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/utils/workers/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.7", 3 | "1.0.1": "0.12.0", 4 | "1.0.2": "0.12.0", 5 | "1.1.0": "0.12.0", 6 | "1.1.1": "0.12.0", 7 | "1.2.0": "0.15.2", 8 | "1.3.0": "0.15.2", 9 | "1.3.1": "0.15.2", 10 | "1.3.2": "0.15.2", 11 | "1.4.0": "0.15.2", 12 | "1.5.0": "0.15.2", 13 | "1.5.1": "0.15.2", 14 | "1.6.0": "0.15.2", 15 | "1.6.1": "0.15.2", 16 | "2.0.0": "0.15.2", 17 | "3.0.0": "0.15.2", 18 | "3.0.1": "0.15.2", 19 | "3.1.0": "0.15.2", 20 | "3.2.0": "0.15.2", 21 | "3.3.0": "0.15.2", 22 | "3.3.1": "0.15.2", 23 | "3.3.2": "0.15.2", 24 | "3.3.3": "0.15.2", 25 | "3.4.0": "0.15.2", 26 | "3.4.1": "0.15.2", 27 | "3.5.0": "0.15.2", 28 | "3.6.1": "0.15.2", 29 | "3.8.0": "0.15.2", 30 | "4.0.0": "0.15.2", 31 | "4.0.1": "0.15.2", 32 | "4.1.0": "0.15.2", 33 | "4.1.1": "0.15.2", 34 | "4.2.0": "0.15.2", 35 | "4.3.0": "0.15.2", 36 | "4.3.1": "0.15.2", 37 | "5.0.0": "0.15.2", 38 | "6.0.0": "0.15.2", 39 | "6.1.0": "0.15.2", 40 | "6.2.0": "0.15.2", 41 | "6.2.1": "0.15.2", 42 | "6.2.2": "0.15.2", 43 | "7.0.0": "0.15.2", 44 | "7.0.1": "0.15.2", 45 | "7.1.0": "0.15.2", 46 | "7.1.1": "0.15.2", 47 | "7.2.0": "0.15.2", 48 | "7.2.1": "0.15.2", 49 | "8.0.0": "0.15.2", 50 | "8.1.0": "0.15.2", 51 | "8.1.1": "0.15.2", 52 | "8.2.0": "0.15.2", 53 | "8.3.0": "0.15.2", 54 | "8.3.1": "0.15.2", 55 | "8.4.0": "0.15.2", 56 | "8.5.0": "0.15.2", 57 | "8.6.0": "0.15.2", 58 | "8.6.1": "0.15.2", 59 | "8.6.2": "0.15.2", 60 | "8.6.3": "0.15.2", 61 | "8.6.4": "0.15.2", 62 | "8.6.5": "0.15.2", 63 | "8.7.0": "0.15.2", 64 | "8.8.0": "0.15.2", 65 | "8.8.1": "0.15.2", 66 | "8.8.2": "0.15.2", 67 | "8.8.3": "0.15.2", 68 | "8.9.0": "0.15.2", 69 | "8.10.0": "0.15.2", 70 | "8.10.1": "0.15.2" 71 | } --------------------------------------------------------------------------------