├── .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 |
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 | |  |  |
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 |
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 ``;
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 | }
--------------------------------------------------------------------------------