mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
refactor(timer): use CodeMirror Facet for configuration passing
Replace global variable configuration with CodeMirror 6's recommended Facet pattern for passing configuration to StateField. This provides better type safety, reactive updates, and follows CodeMirror architecture best practices. Changes: - Replace globalTimerConfig with taskTimerConfigFacet - Update all config access to use state.facet() pattern - Return array of [facet, statefield] from extension function - Maintain compatibility with existing functionality
This commit is contained in:
parent
8999fd7f44
commit
7da5883184
30 changed files with 2608 additions and 15508 deletions
769
design/focus.md
769
design/focus.md
|
|
@ -1,769 +0,0 @@
|
|||
# 视图配置弹窗 (View Configuration Dialog) - 功能设计文档
|
||||
|
||||
## 1. 概览 (Overview)
|
||||
|
||||
### 1.1. 功能名称 (Feature Name)
|
||||
视图配置弹窗 (View Configuration Dialog)
|
||||
|
||||
### 1.2. 目标 (Goal)
|
||||
提供一个集中式的、用户友好的界面,允许用户定义和管理任务的筛选和排序规则。这些规则将应用于插件内的所有相关任务视图,从而统一和简化用户查看和组织任务的方式。
|
||||
|
||||
### 1.3. 核心价值 (Core Value)
|
||||
- **易用性**: 通过图形界面简化复杂的筛选和排序逻辑配置。
|
||||
- **一致性**: 应用统一的视图配置,确保在不同地方查看任务时行为一致。
|
||||
- **灵活性**: 支持多种筛选条件、条件组和排序规则的组合。
|
||||
- **效率**: 通过预设功能,快速切换不同的视图配置,适应不同工作场景。
|
||||
|
||||
## 2. 用户界面 (UI) 设计
|
||||
|
||||
### 2.1. 入口 (Access Point)
|
||||
- 在任务视图的主界面(例如,某个全局视图控制区域或特定视图的设置入口),提供一个按钮或菜单项,如"配置视图"、"筛选与排序"或一个设置图标。
|
||||
- 点击该入口将打开一个模态弹窗。
|
||||
|
||||
### 2.2. 弹窗布局 (Pop-up Layout)
|
||||
弹窗从上到下主要分为以下区域:
|
||||
|
||||
```
|
||||
+------------------------------------------------------+
|
||||
| 视图配置 [ X ] 关闭 |
|
||||
+------------------------------------------------------+
|
||||
| 预设 (Presets) |
|
||||
| [选择一个预设 v] [保存] [另存为...] [删除] |
|
||||
+------------------------------------------------------+
|
||||
| 筛选 (Filters) |
|
||||
| [ 所有/任一 v ] 条件满足 |
|
||||
| +------------------------------------------------+ |
|
||||
| | [属性 v] [操作符 v] [值输入欄] [ </> ] [🗑️] |
|
||||
| | [ AND/OR ] |
|
||||
| | +-- Group -----------------------------------+ |
|
||||
| | | [属性 v] [操作符 v] [值输入欄] [ </> ] [🗑️] |
|
||||
| | +--------------------------------------------+ |
|
||||
| +------------------------------------------------+ |
|
||||
| [+ 添加条件] [+ 添加条件组] |
|
||||
+------------------------------------------------------+
|
||||
| 排序 (Sorting) |
|
||||
| +------------------------------------------------+ |
|
||||
| | 排序依据: [属性 v] 顺序: [升序/降序 v] [⬆️][⬇️][🗑️] |
|
||||
| +------------------------------------------------+ |
|
||||
| [+ 添加排序规则] |
|
||||
+------------------------------------------------------+
|
||||
| [ 应用/保存配置 ] [ 取消 ] |
|
||||
+------------------------------------------------------+
|
||||
```
|
||||
|
||||
**图例说明:**
|
||||
- `[ 关闭 ]`: 关闭弹窗按钮。
|
||||
- `[选择一个预设 v]`: 下拉菜单选择已保存的预设。
|
||||
- `[保存]`: 保存对当前选中预设的修改。
|
||||
- `[另存为...]`: 将当前配置保存为一个新的预设。
|
||||
- `[删除]`: 删除当前选中的预设。
|
||||
- `[ 所有/任一 v ]`: 筛选条件组的逻辑操作符(AND/OR)。
|
||||
- `[属性 v]`: 选择任务的属性(如:内容、状态、优先级、截止日期、标签等)。
|
||||
- `[操作符 v]`: 选择筛选操作符(如:包含、不包含、等于、不等于、大于、小于、为空、不为空等)。
|
||||
- `[值输入欄]`: 输入筛选条件的值。
|
||||
- `[ </> ]`: (可选) 切换到高级/表达式模式编辑该条件。
|
||||
- `[🗑️]`: 删除该条件或排序规则。
|
||||
- `[+ 添加条件]`: 添加一个新的筛选条件行。
|
||||
- `[+ 添加条件组]`: 添加一个嵌套的筛选条件组。
|
||||
- `[升序/降序 v]`: 选择排序方向。
|
||||
- `[⬆️][⬇️]`: 调整排序规则的优先级。
|
||||
- `[+ 添加排序规则]`: 添加一个新的排序规则行。
|
||||
- `[ 应用/保存配置 ]`: 保存当前弹窗中的筛选和排序设置,并应用到所有视图。
|
||||
- `[ 取消 ]`: 关闭弹窗,不保存任何更改。
|
||||
|
||||
### 2.3. UI 元素详解 (Detailed UI Elements)
|
||||
|
||||
#### 2.3.1. 预设 (Presets)
|
||||
- **下拉菜单**: 列出所有已保存的预设名称。选择一项会加载其对应的筛选和排序配置到下方区域。包含一个"创建新预设"或"无预设"(即自定义配置)的选项。
|
||||
- **保存按钮**: 如果当前选中的是一个已存在的预设,则此按钮启用,点击后用当前界面中的配置覆盖该预设。
|
||||
- **另存为按钮**: 弹出一个输入框,要求用户输入新预设的名称,然后将当前界面中的配置保存为新的预设。
|
||||
- **删除按钮**: 如果当前选中的是一个已存在的预设,则此按钮启用,点击后会提示用户确认删除该预设。
|
||||
|
||||
#### 2.3.2. 筛选区域 (Filtering Area)
|
||||
- **顶层逻辑操作符**: 一个下拉菜单,允许用户选择顶层筛选条件是"所有条件都满足 (AND)"还是"任一条件满足 (OR)"。
|
||||
- **筛选条件行 (Filter Condition Row)**:
|
||||
- **属性下拉框**: 列出可供筛选的任务属性,例如:
|
||||
- `内容 (Content)` (文本)
|
||||
- `状态 (Status)` (特定值列表或文本)
|
||||
- `优先级 (Priority)` (特定值列表或文本,如 高,中,低 或 🔺, 🔼, 🔽)
|
||||
- `截止日期 (Due Date)` (日期)
|
||||
- `开始日期 (Start Date)` (日期)
|
||||
- `计划日期 (Scheduled Date)` (日期)
|
||||
- `标签 (Tags)` (文本,特殊处理包含逻辑)
|
||||
- `路径 (File Path)` (文本)
|
||||
- `已完成 (Completed)` (布尔值)
|
||||
- **操作符下拉框**: 根据所选"属性"的类型动态更新可用的操作符。
|
||||
- 文本: `包含 (contains)`, `不包含 (does not contain)`, `等于 (is)`, `不等于 (is not)`, `开头是 (starts with)`, `结尾是 (ends with)`, `为空 (is empty)`, `不为空 (is not empty)`
|
||||
- 数字/日期: `等于 (=)`, `不等于 (!=)`, `大于 (>)`, `小于 (<)`, `大于等于 (>=)`, `小于等于 (<=)`, `为空 (is empty)`, `不为空 (is not empty)`
|
||||
- 标签: `包含 (contains / has tag)`, `不包含 (does not contain / does not have tag)`
|
||||
- 布尔: `是 (is true)`, `否 (is false)`
|
||||
- **值输入区**:
|
||||
- 文本输入框 (用于文本、部分数字属性)。
|
||||
- 日期选择器 (用于日期属性)。
|
||||
- 特定值下拉框 (例如用于状态、优先级等预定义值的属性)。
|
||||
- **高级编辑按钮 `[ </> ]` (可选)**: 对于复杂条件,允许用户切换到文本模式,直接编写类似 `filterUtils.ts` 中的表达式片段。
|
||||
- **删除按钮 `[🗑️]`**: 删除此筛选条件行。
|
||||
- **筛选条件组 (Filter Condition Group)**:
|
||||
- 用户可以通过点击 `[+ 添加条件组]` 来创建一个嵌套的条件组。
|
||||
- 每个组内部也拥有自己的逻辑操作符(AND/OR)和一系列条件/子组。
|
||||
- 视觉上通过缩进和边框与父级条件区分。
|
||||
- **添加按钮**:
|
||||
- `[+ 添加条件]`: 在当前层级(或选定的组内)添加一个新的筛选条件行。
|
||||
- `[+ 添加条件组]`: 在当前层级(或选定的组内)添加一个新的筛选条件组。
|
||||
|
||||
#### 2.3.3. 排序区域 (Sorting Area)
|
||||
- **排序规则行 (Sort Criterion Row)**:
|
||||
- **排序依据下拉框**: 列出可供排序的任务属性,与筛选属性类似,但通常是具有可比较性的属性(如:`截止日期`, `优先级`, `内容`, `创建日期`等)。
|
||||
- **顺序下拉框**: `升序 (Ascending)` 或 `降序 (Descending)`。
|
||||
- **调整优先级按钮 `[⬆️][⬇️]`**: 允许用户上下移动排序规则,决定排序的优先顺序(首要排序依据、次要排序依据等)。
|
||||
- **删除按钮 `[🗑️]`**: 删除此排序规则。
|
||||
- **添加按钮 `[+ 添加排序规则]`**: 添加一个新的排序规则行。
|
||||
|
||||
## 3. 交互模型 (Interaction Model)
|
||||
|
||||
### 3.1. 打开弹窗 (Opening the Pop-up)
|
||||
- 点击入口后,弹窗显示。
|
||||
- 默认情况下,弹窗可能加载当前全局应用的筛选和排序配置,或者上一次在弹窗中编辑但未保存的临时配置,或者一个默认的空配置。
|
||||
|
||||
### 3.2. 预设管理 (Preset Management)
|
||||
- **选择预设**: 从下拉菜单选择一个预设。界面下方的筛选和排序区域将更新以反映所选预设的配置。
|
||||
- **保存/更新预设**:
|
||||
- 如果当前选择的是一个已存在的预设,并且用户修改了筛选或排序配置,"保存"按钮将变为可用。
|
||||
- 点击"保存",当前配置将覆盖所选预设。
|
||||
- **另存为新预设**:
|
||||
- 用户点击"另存为..."按钮。
|
||||
- 弹出对话框要求输入新预设的名称。
|
||||
- 确认后,当前的筛选和排序配置将保存为一个新的预设条目,并自动选中这个新预设。
|
||||
- **删除预设**:
|
||||
- 用户选择一个预设,然后点击"删除"按钮。
|
||||
- 弹出确认对话框。
|
||||
- 确认后,该预设从列表中移除。如果被删除的是当前加载的预设,则界面可能清空或加载一个默认状态。
|
||||
|
||||
### 3.3. 筛选配置 (Filter Configuration)
|
||||
- **添加条件/条件组**: 点击相应按钮,在当前焦点所在的层级(顶层或某个组内)添加新的条件行或条件组。
|
||||
- **删除条件/条件组**: 点击条件行或条件组旁边的 `[🗑️]` 图标。如果删除组,则其内部所有条件一并删除。
|
||||
- **修改条件**: 用户直接在条件行的属性、操作符、值输入区进行修改。操作符列表会根据属性类型动态变化。
|
||||
- **修改组逻辑**: 更改条件组头部的"所有/任一 (AND/OR)"选择。
|
||||
|
||||
### 3.4. 排序配置 (Sort Configuration)
|
||||
- **添加排序规则**: 点击 `[+ 添加排序规则]` 按钮,在列表末尾添加一个新的排序规则行。
|
||||
- **删除排序规则**: 点击规则行旁边的 `[🗑️]` 图标。
|
||||
- **修改排序规则**: 用户直接在规则行的"排序依据"和"顺序"下拉框中进行选择。
|
||||
- **调整排序优先级**: 点击 `[⬆️]` 或 `[⬇️]` 按钮,改变规则在列表中的位置。列表顶部的规则具有最高排序优先级。
|
||||
|
||||
### 3.5. 保存与应用 (Saving and Applying)
|
||||
- 用户完成配置后,点击 `[ 应用/保存配置 ]` 按钮。
|
||||
- 当前弹窗内的筛选和排序配置(无论是否属于某个预设)将被保存为全局/默认的视图配置。
|
||||
- 触发一个事件或机制,通知所有相关的任务视图更新其显示,根据新的配置重新筛选和排序任务。
|
||||
- 弹窗关闭。
|
||||
- 如果用户点击 `[ 取消 ]`,则所有未通过预设"保存"或未点击 `[ 应用/保存配置 ]` 的更改都将丢失,弹窗关闭。
|
||||
|
||||
## 4. 数据结构与配置 (Data Structures and Configuration)
|
||||
|
||||
### 4.1. 预设对象结构 (Preset Object Structure)
|
||||
```typescript
|
||||
interface ViewPreset {
|
||||
id: string; // Unique identifier for the preset
|
||||
name: string; // User-defined name for the preset
|
||||
filterConfig: FilterConfig; // Structure defined below
|
||||
sortConfig: SortConfigItem[]; // Array of sort criteria
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2. 筛选配置结构 (Filter Configuration Structure)
|
||||
此结构需要能够映射到 `filterUtils.ts` 中的 `FilterNode`。UI上的配置将转换为 `FilterNode` 树。
|
||||
|
||||
```typescript
|
||||
// Represents a single filter condition UI row
|
||||
interface FilterConditionItem {
|
||||
property: string; // e.g., 'content', 'dueDate', 'priority', 'tags.myTag'
|
||||
operator: string; // e.g., 'contains', 'is', '>=', 'isEmpty'
|
||||
value?: any; // Value for the condition, type depends on property and operator
|
||||
// For advanced mode, could store a raw expression string
|
||||
// rawExpression?: string;
|
||||
}
|
||||
|
||||
// Represents a group of filter conditions in the UI
|
||||
interface FilterGroupItem {
|
||||
logicalOperator: 'AND' | 'OR'; // How conditions/groups within this group are combined
|
||||
items: (FilterConditionItem | FilterGroupItem)[]; // Can contain conditions or nested groups
|
||||
}
|
||||
|
||||
// Top-level filter configuration from the UI
|
||||
type FilterConfig = FilterGroupItem;
|
||||
```
|
||||
**转换逻辑**:
|
||||
- `FilterGroupItem` 将递归地转换为 `FilterNode` 的 `AND` 或 `OR` 类型。
|
||||
- `FilterConditionItem` 将转换为 `FilterNode` 的 `TEXT`, `TAG`, `PRIORITY`, `DATE` 等类型,具体取决于 `property` 和 `operator`。
|
||||
- 例如: `{ property: 'content', operator: 'contains', value: 'test' }` -> `{ type: 'TEXT', value: 'test' }` (简化示例,实际转换会更复杂,例如处理大小写,或根据操作符调整节点类型或值)
|
||||
- `{ property: 'priority', operator: '=', value: 'High' }` -> `{ type: 'PRIORITY', op: '=', value: 'High' }`
|
||||
- `{ property: 'dueDate', operator: '<', value: '2024-12-31' }` -> `{ type: 'DATE', op: '<', value: '2024-12-31' }`
|
||||
|
||||
### 4.3. 排序配置结构 (Sort Configuration Structure)
|
||||
此结构直接对应 `sortTaskCommands.ts` 中的 `SortCriterion`。
|
||||
|
||||
```typescript
|
||||
interface SortConfigItem {
|
||||
field: string; // Property to sort by (e.g., 'dueDate', 'priority', 'content')
|
||||
order: 'asc' | 'desc'; // Sort order
|
||||
}
|
||||
|
||||
// The overall sort configuration will be an array of these items:
|
||||
// type SortConfiguration = SortConfigItem[];
|
||||
```
|
||||
|
||||
### 4.4. 存储 (Storage)
|
||||
- **预设列表 (`ViewPreset[]`)**: 存储在插件的设置 (`settings.json`) 中。
|
||||
- **当前全局配置**: 当前应用的筛选 (`FilterConfig`) 和排序 (`SortConfigItem[]`) 配置也应存储在插件设置中,作为所有视图的默认配置。预设仅仅是快速加载这些配置的一种方式。
|
||||
|
||||
## 5. 与现有系统集成 (Integration with Existing Systems)
|
||||
|
||||
### 5.1. `filterUtils.ts`
|
||||
- **UI 到 `FilterNode` 转换**: 需要编写逻辑将用户在筛选区域创建的 `FilterConfig` (嵌套的 `FilterGroupItem` 和 `FilterConditionItem`) 转换为 `filterUtils.ts` 可以理解的 `FilterNode` 树结构。
|
||||
- **应用筛选**: 一旦 `FilterNode` 树生成,视图将使用 `evaluateFilterNode` 函数来判断每个任务是否满足筛选条件。
|
||||
- **属性和操作符**: 需要确保UI中提供的属性和操作符能够有效地映射到 `filterUtils.ts` 中各种 `FilterNode` 类型的判断逻辑。例如,`PRIORITY` 节点需要 `op` 和 `value`,`DATE` 节点也类似。
|
||||
|
||||
### 5.2. `sortTaskCommands.ts`
|
||||
- **UI 到 `SortCriterion[]` 转换**: UI 排序区域的配置 (`SortConfigItem[]`) 可以直接用作 `sortTaskCommands.ts` 中 `sortTasks` 函数所需的 `criteria` 参数。
|
||||
- **应用排序**: 视图将使用 `sortTasks` 函数(或其核心比较逻辑 `compareTasks`),传入从UI配置生成的 `SortConfigItem[]` 数组和插件设置,对筛选后的任务列表进行排序。
|
||||
- **可用排序字段**: UI 中"排序依据"下拉框应列出 `compareTasks` 函数支持的排序字段。
|
||||
|
||||
### 5.3. 视图更新机制 (View Update Mechanism)
|
||||
- 当用户点击 `[ 应用/保存配置 ]` 按钮并成功保存新的全局筛选/排序配置后:
|
||||
- 插件需要将新的配置(转换后的 `FilterNode` 和 `SortCriterion[]`)存储到其全局设置中。
|
||||
- 插件需要触发一个全局事件或调用一个方法,通知所有当前打开的、依赖此配置的任务视图进行刷新。
|
||||
- 各视图在收到通知后,会重新获取任务数据,应用新的全局筛选条件和排序规则,然后重新渲染其内容。
|
||||
|
||||
## 6. 未来展望 (Future Enhancements)
|
||||
|
||||
- **共享预设**: 允许用户导入/导出预设配置。
|
||||
- **更高级的筛选操作符**: 在UI中直接支持更复杂的筛选逻辑,如正则表达式匹配。
|
||||
- **实时预览**: 在弹窗中配置时,下方或侧边有一个小区域实时显示符合当前筛选/排序条件的部分任务预览。
|
||||
- **视图特定配置**: 除了全局配置外,允许用户为单个特定视图覆盖全局配置,并拥有独立的预设(这会增加复杂性,需要权衡)。
|
||||
- **自然语言输入筛选**: 允许用户通过类似 "tasks due this week with high priority" 的自然语言短语创建筛选。
|
||||
|
||||
## 7. 待定问题 (Open Questions)
|
||||
|
||||
- **属性列表的来源**: "属性"下拉列表是硬编码的,还是动态生成的(例如,基于用户在 frontmatter 中定义的属性)?初期可以硬编码核心属性,未来可考虑扩展。
|
||||
- **"无值"的具体实现**: 筛选操作符 "为空 (is empty)" / "不为空 (is not empty)" 如何准确对应到任务数据的实际空值情况 (e.g., `undefined`, `null`, 空字符串)。
|
||||
- **性能**: 对于非常大的任务列表,频繁更改筛选和排序配置并实时更新所有视图可能会有性能影响,需要关注和优化。
|
||||
- **错误处理和用户反馈**: 当用户输入无效的筛选值或配置冲突时,如何提供清晰的错误提示。
|
||||
|
||||
示例代码:
|
||||
|
||||
```HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>可堆叠筛选器 UI - 紧凑型</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.filter-group-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0; /* Reduced margin */
|
||||
color: #9ca3af; /* gray-400 */
|
||||
font-size: 0.75rem; /* Smaller text for separator */
|
||||
}
|
||||
.filter-group-separator::before,
|
||||
.filter-group-separator::after {
|
||||
content: '';
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
background-color: #e5e7eb; /* gray-200 */
|
||||
margin: 0 0.25rem; /* Reduced margin */
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.3rem center; /* Adjusted position */
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.2em 1.2em; /* Adjusted size */
|
||||
padding-right: 2rem; /* Reduced padding */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
/* Smaller text and padding for buttons and inputs */
|
||||
.compact-btn {
|
||||
padding: 0.25rem 0.5rem; /* Reduced padding */
|
||||
font-size: 0.875rem; /* Smaller font */
|
||||
}
|
||||
.compact-input, .compact-select {
|
||||
padding: 0.35rem 0.5rem; /* Reduced padding */
|
||||
font-size: 0.875rem; /* Smaller font */
|
||||
height: 2rem; /* Fixed height for better alignment */
|
||||
}
|
||||
.compact-icon-btn {
|
||||
padding: 0.2rem; /* Reduced padding for icon buttons */
|
||||
}
|
||||
.compact-icon-btn svg {
|
||||
width: 14px; /* Smaller icons */
|
||||
height: 14px; /* Smaller icons */
|
||||
}
|
||||
.compact-text {
|
||||
font-size: 0.875rem; /* Smaller text for labels */
|
||||
}
|
||||
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-50 p-2 md:p-4 min-h-screen flex items-center justify-center">
|
||||
<div class="container mx-auto max-w-2xl bg-white p-4 rounded-md shadow-lg">
|
||||
<div id="root-filter-container" class="space-y-3">
|
||||
<div class="flex items-center space-x-2 p-2 bg-slate-100 rounded-md border border-slate-200">
|
||||
<label for="root-condition" class="text-xs font-medium text-slate-600 compact-text">匹配</label>
|
||||
<select id="root-condition" class="block w-auto border border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-xs text-slate-700 compact-select">
|
||||
<option value="any">任意一个</option>
|
||||
<option value="all">所有</option>
|
||||
<option value="none">没有一个</option>
|
||||
</select>
|
||||
<span class="text-xs text-slate-600 compact-text">筛选器组满足条件</span>
|
||||
</div>
|
||||
|
||||
<div id="filter-groups-container" class="space-y-3">
|
||||
<div id="filter-group-template" class="filter-group p-3 border border-slate-300 rounded-md bg-white space-y-2 shadow-sm" style="display: none;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-grip-vertical drag-handle text-slate-400 hover:text-slate-500" viewBox="0 0 16 16">
|
||||
<path d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
||||
</svg>
|
||||
<label class="text-xs font-medium text-slate-600 compact-text">匹配</label>
|
||||
<select class="group-condition-select block w-auto border border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-xs text-slate-700 compact-select">
|
||||
<option value="all">所有</option>
|
||||
<option value="any">任意一个</option>
|
||||
<option value="none">没有一个</option>
|
||||
</select>
|
||||
<span class="text-xs text-slate-600 compact-text">此组中的筛选器</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<button type="button" class="duplicate-group-btn text-slate-500 hover:text-indigo-600 rounded-md hover:bg-indigo-50 compact-icon-btn" title="复制筛选器组">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1V5H2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="remove-group-btn text-slate-500 hover:text-red-600 rounded-md hover:bg-red-50 compact-icon-btn" title="移除筛选器组">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-list space-y-1.5 pl-4 border-l-2 border-slate-200 ml-1.5">
|
||||
</div>
|
||||
<div class="pl-4 mt-1.5">
|
||||
<button type="button" class="add-filter-btn text-xs text-indigo-600 hover:text-indigo-800 font-medium rounded-md hover:bg-indigo-50 compact-btn">
|
||||
+ 添加筛选器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filter-item-template" class="filter-item flex items-center space-x-1.5 p-1.5 bg-slate-50 rounded-md" style="display: none;">
|
||||
<span class="filter-conjunction text-2xs font-semibold text-slate-400 self-center">&</span>
|
||||
<select class="filter-property-select block w-1/3 border border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-xs text-slate-700 compact-select">
|
||||
<option value="propertyA">属性 A</option>
|
||||
<option value="propertyB">属性 B</option>
|
||||
<option value="propertyC">属性 C</option>
|
||||
<option value="propertyD">属性 D</option>
|
||||
</select>
|
||||
<select class="filter-condition-select block w-auto border border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-xs text-slate-700 compact-select">
|
||||
<option value="isSet">已设定</option>
|
||||
<option value="isNotSet">未设定</option>
|
||||
<option value="equals">等于</option>
|
||||
<option value="contains">包含</option>
|
||||
</select>
|
||||
<input type="text" class="filter-value-input block flex-grow p-1 border border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-xs text-slate-700 compact-input" placeholder="值" style="display:none;">
|
||||
<button type="button" class="remove-filter-btn text-slate-500 hover:text-red-600 rounded-md hover:bg-red-50 compact-icon-btn" title="移除筛选器">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="add-filter-group-btn" type="button" class="w-full flex items-center justify-center px-3 py-2 border border-dashed border-slate-300 text-xs font-medium rounded-md text-indigo-600 hover:text-indigo-700 hover:border-indigo-400 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 compact-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-plus-lg mr-1.5" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
添加筛选器组
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 p-3 bg-slate-50 rounded-md border border-slate-200">
|
||||
<h3 class="text-sm font-medium text-slate-700 mb-1.5 compact-text">当前筛选器状态 (JSON):</h3>
|
||||
<pre id="filter-state-json" class="text-2xs bg-white p-2 rounded-md overflow-x-auto"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// --- Interfaces (for clarity, actual JS code follows) ---
|
||||
// interface Filter {
|
||||
// id: string;
|
||||
// property: string;
|
||||
// condition: string;
|
||||
// value?: string;
|
||||
// }
|
||||
// interface FilterGroup {
|
||||
// id: string;
|
||||
// groupCondition: 'all' | 'any' | 'none';
|
||||
// filters: Filter[];
|
||||
// }
|
||||
// interface RootFilterState {
|
||||
// rootCondition: 'all' | 'any' | 'none';
|
||||
// filterGroups: FilterGroup[];
|
||||
// }
|
||||
|
||||
// --- Global State ---
|
||||
let rootFilterState = {
|
||||
rootCondition: 'any', // Default root condition
|
||||
filterGroups: []
|
||||
};
|
||||
|
||||
// --- DOM Elements ---
|
||||
const rootConditionSelect = document.getElementById('root-condition');
|
||||
const filterGroupsContainer = document.getElementById('filter-groups-container');
|
||||
const filterGroupTemplate = document.getElementById('filter-group-template');
|
||||
const filterItemTemplate = document.getElementById('filter-item-template');
|
||||
const addFilterGroupBtn = document.getElementById('add-filter-group-btn');
|
||||
const filterStateJsonOutput = document.getElementById('filter-state-json');
|
||||
|
||||
// --- Utility Functions ---
|
||||
function generateId() {
|
||||
return `id-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
function updateJsonOutput() {
|
||||
filterStateJsonOutput.textContent = JSON.stringify(rootFilterState, null, 2);
|
||||
}
|
||||
|
||||
// --- Event Handlers & DOM Manipulation ---
|
||||
|
||||
// Update root condition in state
|
||||
rootConditionSelect.addEventListener('change', (event) => {
|
||||
rootFilterState.rootCondition = event.target.value;
|
||||
updateJsonOutput();
|
||||
updateGroupSeparators();
|
||||
});
|
||||
|
||||
// Add a new filter group
|
||||
addFilterGroupBtn.addEventListener('click', () => {
|
||||
addFilterGroup();
|
||||
});
|
||||
|
||||
function createFilterGroupElement(groupData) {
|
||||
const newGroup = filterGroupTemplate.cloneNode(true);
|
||||
newGroup.id = groupData.id;
|
||||
newGroup.style.display = 'block'; // Make it visible
|
||||
|
||||
const groupConditionSelect = newGroup.querySelector('.group-condition-select');
|
||||
groupConditionSelect.value = groupData.groupCondition;
|
||||
groupConditionSelect.addEventListener('change', (event) => {
|
||||
const selectedValue = event.target.value;
|
||||
groupData.groupCondition = selectedValue;
|
||||
updateJsonOutput();
|
||||
// Update conjunctions within this specific group when its condition changes
|
||||
updateFilterConjunctions(newGroup.querySelector('.filters-list'), selectedValue);
|
||||
});
|
||||
|
||||
const filtersList = newGroup.querySelector('.filters-list');
|
||||
|
||||
// Add existing filters if any (e.g., when duplicating)
|
||||
groupData.filters.forEach(filterData => {
|
||||
const filterElement = createFilterItemElement(filterData, groupData);
|
||||
filtersList.appendChild(filterElement);
|
||||
});
|
||||
updateFilterConjunctions(filtersList, groupData.groupCondition);
|
||||
|
||||
|
||||
const addFilterBtn = newGroup.querySelector('.add-filter-btn');
|
||||
addFilterBtn.addEventListener('click', () => {
|
||||
addFilterToGroup(groupData, filtersList);
|
||||
});
|
||||
|
||||
const removeGroupBtn = newGroup.querySelector('.remove-group-btn');
|
||||
removeGroupBtn.addEventListener('click', () => {
|
||||
rootFilterState.filterGroups = rootFilterState.filterGroups.filter(g => g.id !== groupData.id);
|
||||
newGroup.remove(); // Remove the group element from DOM
|
||||
// Also remove its separator if it exists
|
||||
const nextSibling = newGroup.nextElementSibling;
|
||||
if (nextSibling && nextSibling.classList.contains('filter-group-separator-container')) {
|
||||
nextSibling.remove();
|
||||
} else {
|
||||
// If it was the last group, the separator might be before it
|
||||
const prevSibling = newGroup.previousElementSibling;
|
||||
if (prevSibling && prevSibling.classList.contains('filter-group-separator-container')) {
|
||||
prevSibling.remove();
|
||||
}
|
||||
}
|
||||
updateJsonOutput();
|
||||
updateGroupSeparators(); // Re-evaluate all separators
|
||||
});
|
||||
|
||||
const duplicateGroupBtn = newGroup.querySelector('.duplicate-group-btn');
|
||||
duplicateGroupBtn.addEventListener('click', () => {
|
||||
const newGroupId = generateId();
|
||||
// Deep clone filters to avoid shared references
|
||||
const duplicatedFilters = groupData.filters.map(f => ({ ...f, id: generateId() }));
|
||||
const duplicatedGroupData = {
|
||||
...groupData, // copy condition
|
||||
id: newGroupId,
|
||||
filters: duplicatedFilters
|
||||
};
|
||||
addFilterGroup(duplicatedGroupData, newGroup); // Pass original group to insert after
|
||||
});
|
||||
|
||||
return newGroup;
|
||||
}
|
||||
|
||||
function addFilterGroup(groupDataToClone = null, insertAfterElement = null) {
|
||||
const newGroupId = groupDataToClone ? groupDataToClone.id : generateId(); // Use cloned ID or generate new
|
||||
|
||||
let newGroupData;
|
||||
if (groupDataToClone && insertAfterElement) { // This means it's a duplication
|
||||
newGroupData = { // Ensure it's a deep copy for the state
|
||||
id: newGroupId, // This is the new ID for the duplicated group
|
||||
groupCondition: groupDataToClone.groupCondition,
|
||||
filters: groupDataToClone.filters.map(f => ({...f, id: generateId()})) // New IDs for filters too
|
||||
};
|
||||
} else { // This is a brand new group
|
||||
newGroupData = {
|
||||
id: newGroupId,
|
||||
groupCondition: 'all',
|
||||
filters: []
|
||||
};
|
||||
}
|
||||
|
||||
// Add to state
|
||||
if (insertAfterElement) { // Duplicating: insert after the original in the state array
|
||||
const originalIndex = rootFilterState.filterGroups.findIndex(g => g.id === insertAfterElement.id);
|
||||
if (originalIndex !== -1) {
|
||||
rootFilterState.filterGroups.splice(originalIndex + 1, 0, newGroupData);
|
||||
} else { // Fallback if original not found (should not happen)
|
||||
rootFilterState.filterGroups.push(newGroupData);
|
||||
}
|
||||
} else { // Adding a new group: push to the end
|
||||
rootFilterState.filterGroups.push(newGroupData);
|
||||
}
|
||||
|
||||
|
||||
const newGroupElement = createFilterGroupElement(newGroupData); // Create DOM element with the new data
|
||||
|
||||
if (insertAfterElement) {
|
||||
// Insert the new group element and its separator
|
||||
filterGroupsContainer.insertBefore(newGroupElement, insertAfterElement.nextSibling);
|
||||
} else {
|
||||
filterGroupsContainer.appendChild(newGroupElement);
|
||||
}
|
||||
|
||||
// If it's a brand new group (not a clone) or a clone of an empty group, add one default filter.
|
||||
if ((!groupDataToClone && !insertAfterElement) || (groupDataToClone && groupDataToClone.filters.length === 0) ) {
|
||||
addFilterToGroup(newGroupData, newGroupElement.querySelector('.filters-list'));
|
||||
}
|
||||
|
||||
|
||||
updateJsonOutput();
|
||||
updateGroupSeparators();
|
||||
makeSortable(); // Ensure new group (and its filter list) is sortable
|
||||
}
|
||||
|
||||
|
||||
// Add a new filter to a group
|
||||
function addFilterToGroup(groupData, filtersListElement) {
|
||||
const newFilterId = generateId();
|
||||
const newFilterData = {
|
||||
id: newFilterId,
|
||||
property: 'propertyA',
|
||||
condition: 'isSet',
|
||||
value: ''
|
||||
};
|
||||
groupData.filters.push(newFilterData);
|
||||
|
||||
const newFilterElement = createFilterItemElement(newFilterData, groupData);
|
||||
filtersListElement.appendChild(newFilterElement);
|
||||
|
||||
updateFilterConjunctions(filtersListElement, groupData.groupCondition);
|
||||
updateJsonOutput();
|
||||
}
|
||||
|
||||
function createFilterItemElement(filterData, groupData) {
|
||||
const newFilter = filterItemTemplate.cloneNode(true);
|
||||
newFilter.id = filterData.id;
|
||||
newFilter.style.display = 'flex'; // Make it visible
|
||||
|
||||
const propertySelect = newFilter.querySelector('.filter-property-select');
|
||||
propertySelect.value = filterData.property;
|
||||
propertySelect.addEventListener('change', (event) => {
|
||||
filterData.property = event.target.value;
|
||||
updateJsonOutput();
|
||||
});
|
||||
|
||||
const conditionSelect = newFilter.querySelector('.filter-condition-select');
|
||||
conditionSelect.value = filterData.condition;
|
||||
const valueInput = newFilter.querySelector('.filter-value-input');
|
||||
|
||||
const toggleValueInput = () => {
|
||||
if (conditionSelect.value === 'equals' || conditionSelect.value === 'contains') {
|
||||
valueInput.style.display = 'block';
|
||||
} else {
|
||||
valueInput.style.display = 'none';
|
||||
if (filterData.value) { // Only clear if it had a value
|
||||
filterData.value = '';
|
||||
valueInput.value = '';
|
||||
updateJsonOutput(); // Update JSON if value is cleared
|
||||
}
|
||||
}
|
||||
};
|
||||
toggleValueInput();
|
||||
|
||||
conditionSelect.addEventListener('change', (event) => {
|
||||
filterData.condition = event.target.value;
|
||||
toggleValueInput();
|
||||
updateJsonOutput();
|
||||
});
|
||||
|
||||
valueInput.value = filterData.value || '';
|
||||
valueInput.addEventListener('input', (event) => {
|
||||
filterData.value = event.target.value;
|
||||
updateJsonOutput();
|
||||
});
|
||||
|
||||
const removeFilterBtn = newFilter.querySelector('.remove-filter-btn');
|
||||
removeFilterBtn.addEventListener('click', () => {
|
||||
groupData.filters = groupData.filters.filter(f => f.id !== filterData.id);
|
||||
newFilter.remove();
|
||||
updateFilterConjunctions(newFilter.parentElement, groupData.groupCondition);
|
||||
updateJsonOutput();
|
||||
});
|
||||
return newFilter;
|
||||
}
|
||||
|
||||
function updateFilterConjunctions(filtersListElement, groupCondition = 'all') {
|
||||
if (!filtersListElement) return;
|
||||
const filters = filtersListElement.querySelectorAll('.filter-item');
|
||||
filters.forEach((filter, index) => {
|
||||
const conjunctionElement = filter.querySelector('.filter-conjunction');
|
||||
if (conjunctionElement) {
|
||||
if (index === 0) {
|
||||
conjunctionElement.style.visibility = 'hidden';
|
||||
} else {
|
||||
conjunctionElement.style.visibility = 'visible';
|
||||
if (groupCondition === 'any') {
|
||||
conjunctionElement.textContent = '或';
|
||||
} else if (groupCondition === 'none') {
|
||||
conjunctionElement.textContent = '且非'; // Example, adjust as needed
|
||||
}
|
||||
else { // 'all'
|
||||
conjunctionElement.textContent = '且';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupSeparators() {
|
||||
document.querySelectorAll('.filter-group-separator-container').forEach(sep => sep.remove());
|
||||
const groups = Array.from(filterGroupsContainer.children).filter(child => child.classList.contains('filter-group'));
|
||||
|
||||
if (groups.length > 1) {
|
||||
groups.forEach((group, index) => {
|
||||
if (index < groups.length - 1) {
|
||||
const separatorContainer = document.createElement('div');
|
||||
separatorContainer.className = 'filter-group-separator-container'; // For easy removal
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'filter-group-separator';
|
||||
const rootCond = rootFilterState.rootCondition;
|
||||
let separatorText = '或者';
|
||||
if (rootCond === 'all') separatorText = '并且';
|
||||
else if (rootCond === 'none') separatorText = '并且不';
|
||||
|
||||
separator.textContent = separatorText.toUpperCase();
|
||||
separatorContainer.appendChild(separator);
|
||||
// Insert separator after the current group
|
||||
group.parentNode.insertBefore(separatorContainer, group.nextSibling);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function makeSortable() {
|
||||
if (filterGroupsContainer && typeof Sortable !== 'undefined') {
|
||||
// Destroy existing sortable instance for filter groups if it exists
|
||||
if (filterGroupsContainer.sortableInstance) {
|
||||
filterGroupsContainer.sortableInstance.destroy();
|
||||
}
|
||||
filterGroupsContainer.sortableInstance = new Sortable(filterGroupsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
filter: '.filter-group-separator-container', // Ignore separators for dragging
|
||||
preventOnFilter: true, // Required for filter option
|
||||
ghostClass: 'bg-slate-200 dragging-placeholder',
|
||||
onEnd: function (evt) {
|
||||
const itemEl = evt.item;
|
||||
const oldIndex = evt.oldDraggableIndex;
|
||||
const newIndex = evt.newDraggableIndex;
|
||||
|
||||
const movedGroup = rootFilterState.filterGroups.splice(oldIndex, 1)[0];
|
||||
rootFilterState.filterGroups.splice(newIndex, 0, movedGroup);
|
||||
|
||||
updateJsonOutput();
|
||||
// The separators are rebuilt based on the new order of groups in the DOM
|
||||
// We need to ensure the DOM order matches the state order before rebuilding separators
|
||||
// The SortableJS library already reorders the DOM elements.
|
||||
updateGroupSeparators();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.filters-list').forEach(list => {
|
||||
if (list.sortableInstance) { // Destroy existing instance
|
||||
list.sortableInstance.destroy();
|
||||
}
|
||||
if (!list.classList.contains('sortable-initialized-inner')) {
|
||||
list.sortableInstance = new Sortable(list, {
|
||||
animation: 150,
|
||||
ghostClass: 'bg-blue-100 dragging-placeholder',
|
||||
onEnd: function(evt) {
|
||||
const itemEl = evt.item;
|
||||
const parentGroupElement = itemEl.closest('.filter-group');
|
||||
if (!parentGroupElement) return;
|
||||
const groupId = parentGroupElement.id;
|
||||
const groupData = rootFilterState.filterGroups.find(g => g.id === groupId);
|
||||
|
||||
if (groupData) {
|
||||
const movedFilter = groupData.filters.splice(evt.oldDraggableIndex, 1)[0];
|
||||
groupData.filters.splice(evt.newDraggableIndex, 0, movedFilter);
|
||||
updateFilterConjunctions(list, groupData.groupCondition);
|
||||
updateJsonOutput();
|
||||
}
|
||||
}
|
||||
});
|
||||
list.classList.add('sortable-initialized-inner');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
function initializeApp() {
|
||||
addFilterGroup(); // Add one default filter group
|
||||
updateJsonOutput();
|
||||
makeSortable();
|
||||
}
|
||||
|
||||
initializeApp();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
509
design/habit.md
509
design/habit.md
|
|
@ -1,509 +0,0 @@
|
|||
# Habit Tracking 功能设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Habit Tracking 是 Task Genius 插件的一个扩展功能模块,旨在利用 Obsidian 的日记功能,提供习惯追踪和可视化能力。它允许用户定义习惯,并通过每日笔记中的元数据自动记录完成情况,与现有的任务管理系统互补。
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
- 习惯定义与管理
|
||||
- 基于日记元数据的习惯完成情况自动索引
|
||||
- 习惯日历视图
|
||||
- 习惯统计与 streaks (连续完成天数) 展示
|
||||
- 与 Task View 的潜在集成 (例如,将习惯打卡显示为特殊任务)
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
### 3.1 习惯定义
|
||||
|
||||
```typescript
|
||||
interface Habit {
|
||||
id: string; // unique identifier (e.g., 'habit-meditation')
|
||||
name: string; // Display name (e.g., "Meditation")
|
||||
description?: string; // Optional description
|
||||
goal?: string; // Description of the goal (e.g., "Meditate for 10 minutes daily")
|
||||
// Frequency definition - determines expected occurrences.
|
||||
// 'daily' is simple. 'weekly' might imply checking specific weekdays. 'monthly' specific month days.
|
||||
// number could mean 'every N days'. Needs careful definition for streak calculation.
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | number;
|
||||
metadataKey: string; // The frontmatter key used to track this habit (e.g., 'meditation-done')
|
||||
// Define the type of habit, influencing how data might be stored and interpreted.
|
||||
// Corresponds to the HabitProps types defined later for UI/consumption.
|
||||
type: 'daily' | 'count' | 'scheduled' | 'mapping';
|
||||
// Defines how to determine if the habit is 'completed' based on the metadata value.
|
||||
// Default: { condition: 'exists' } - any value present means completed.
|
||||
completionCondition?: {
|
||||
condition: 'exists' | 'equals' | 'greaterThan' | 'lessThan' | 'contains'; // Type of comparison
|
||||
value?: any; // The value to compare against (for 'equals', 'greaterThan', 'lessThan', 'contains')
|
||||
};
|
||||
// Potential future fields: icon, color, target value (e.g., for count type)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 习惯日志条目 (内部索引结构)
|
||||
|
||||
```typescript
|
||||
interface HabitLogEntry {
|
||||
habitId: string; // Reference to Habit.id
|
||||
date: number; // Timestamp (representing the start of the day) of the log entry
|
||||
// Determined based on the Habit's completionCondition and the raw value found in metadata.
|
||||
completed: boolean;
|
||||
filePath: string; // Path to the daily note file
|
||||
// Stores the raw value found in the frontmatter associated with the habit's metadataKey for this date.
|
||||
// Useful for 'count', 'mapping', or 'scheduled' types, or showing specific recorded data.
|
||||
value?: any;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 习惯缓存结构
|
||||
|
||||
```typescript
|
||||
interface HabitCache {
|
||||
habits: Map<string, Habit>; // habitId -> Habit definition
|
||||
logs: Map<string, HabitLogEntry[]>; // habitId -> sorted array of log entries
|
||||
// Index for quick lookup by date might be needed
|
||||
logsByDate: Map<number, { habitId: string, filePath: string, completed: boolean }[]>; // dateTimestamp -> entries for that day
|
||||
// Index for file path to related habit logs
|
||||
logsByFile: Map<string, { habitId: string, date: number }[]>; // filePath -> entries in that file
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 索引方案
|
||||
|
||||
### 4.1 挑战
|
||||
|
||||
与 Task View 不同,习惯数据并非来自特定的文本行格式 (`- [ ]`), 而是分散在大量日记文件的 Frontmatter 元数据中。这要求一个不同的索引策略。
|
||||
|
||||
### 4.2 索引流程
|
||||
|
||||
1. **习惯定义加载**: 从插件设置或指定配置文件中加载用户定义的 `Habit` 列表。`metadataKey` 是关键,用于关联元数据。
|
||||
2. **初始扫描**:
|
||||
* 识别日记文件 (基于 Obsidian 日历插件设置或用户自定义的路径/格式)。
|
||||
* 使用 `app.metadataCache` 访问每个日记文件的 Frontmatter。
|
||||
* 遍历已定义的 `Habit`,检查每个日记文件的 Frontmatter 是否包含对应的 `metadataKey`。
|
||||
* 如果找到 `metadataKey`,解析其值 (通常是 `true` 或具体数值),并创建 `HabitLogEntry`。
|
||||
* 构建 `HabitCache` 中的 `logs`, `logsByDate`, 和 `logsByFile` 索引。
|
||||
3. **实时更新**:
|
||||
* 监听 `app.metadataCache.on('changed', (file, data, cache) => ...)` 事件。
|
||||
* 当一个文件的元数据变化时,检查该文件是否是日记文件。
|
||||
* 如果 是日记文件,重新解析其 Frontmatter 中与已定义习惯相关的 `metadataKey`。
|
||||
* 更新 `HabitCache` 中与该文件和日期相关的 `HabitLogEntry` 以及 `logsByDate`, `logsByFile` 索引。
|
||||
* **注意**: 需要高效地处理 `metadataCache` 事件,避免对非日记文件或无关元数据变化的过多处理。可能需要维护一个已知日记文件的集合。
|
||||
4. **习惯定义变更**: 如果用户添加、删除或修改了 `Habit` 定义 (特别是 `metadataKey`),可能需要触发一次部分或全部的重新扫描来更新索引。
|
||||
|
||||
### 4.3 实现细节
|
||||
|
||||
```typescript
|
||||
class HabitIndexer extends Component {
|
||||
private habitCache: HabitCache;
|
||||
private dailyNoteFormat: string; // Store the daily note format (e.g., YYYY-MM-DD)
|
||||
|
||||
constructor(plugin: TaskGeniusPlugin) {
|
||||
this.habitCache = this.initEmptyCache();
|
||||
// Load habit definitions from settings
|
||||
this.loadHabitDefinitions(plugin.settings.habits);
|
||||
// Determine daily note format/location
|
||||
this.dailyNoteFormat = this.getDailyNoteFormat();
|
||||
this.setupMetadataListener(plugin);
|
||||
}
|
||||
|
||||
loadHabitDefinitions(definitions: Habit[]): void {
|
||||
this.habitCache.habits.clear();
|
||||
definitions.forEach(habit => {
|
||||
this.habitCache.habits.set(habit.id, habit);
|
||||
// Initialize log arrays if not present
|
||||
if (!this.habitCache.logs.has(habit.id)) {
|
||||
this.habitCache.logs.set(habit.id, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initialScan(plugin: TaskGeniusPlugin): Promise<void> {
|
||||
const files = plugin.app.vault.getMarkdownFiles();
|
||||
// Clear existing logs before scan
|
||||
this.clearLogs();
|
||||
|
||||
for (const file of files) {
|
||||
if (this.isDailyNote(file.path)) {
|
||||
await this.indexFileMetadata(file, plugin.app.metadataCache);
|
||||
}
|
||||
}
|
||||
this.sortAllLogsByDate(); // Ensure logs are sorted after initial scan
|
||||
}
|
||||
|
||||
private async indexFileMetadata(file: TFile, metadataCache: MetadataCache): Promise<void> {
|
||||
const fileCache = metadataCache.getFileCache(file);
|
||||
const frontmatter = fileCache?.frontmatter;
|
||||
const dateFromName = this.getDateFromPath(file.path); // Extract date from filename/path
|
||||
|
||||
if (!frontmatter || !dateFromName) return;
|
||||
|
||||
const dateTimestamp = dateFromName.getTime();
|
||||
|
||||
// Remove existing entries for this file before adding new ones
|
||||
this.removeLogsForFile(file.path);
|
||||
let fileLogs: { habitId: string, date: number }[] = [];
|
||||
|
||||
|
||||
for (const [habitId, habit] of this.habitCache.habits.entries()) {
|
||||
if (frontmatter.hasOwnProperty(habit.metadataKey)) {
|
||||
const metadataValue = frontmatter[habit.metadataKey];
|
||||
|
||||
// Determine completion status based on the configured condition
|
||||
let completed = false;
|
||||
const conditionConfig = habit.completionCondition || { condition: 'exists' }; // Default to 'exists'
|
||||
|
||||
if (metadataValue !== undefined && metadataValue !== null) {
|
||||
switch (conditionConfig.condition) {
|
||||
case 'exists':
|
||||
completed = true; // Value exists
|
||||
break;
|
||||
case 'equals':
|
||||
// Use strict equality for predictability
|
||||
completed = metadataValue === conditionConfig.value;
|
||||
break;
|
||||
case 'greaterThan':
|
||||
completed = typeof metadataValue === 'number' && typeof conditionConfig.value === 'number' && metadataValue > conditionConfig.value;
|
||||
break;
|
||||
case 'lessThan':
|
||||
completed = typeof metadataValue === 'number' && typeof conditionConfig.value === 'number' && metadataValue < conditionConfig.value;
|
||||
break;
|
||||
case 'contains':
|
||||
// Basic check - might need refinement based on expected data types (string, array)
|
||||
if (typeof metadataValue === 'string' && typeof conditionConfig.value === 'string') {
|
||||
completed = metadataValue.includes(conditionConfig.value);
|
||||
} else if (Array.isArray(metadataValue) && conditionConfig.value) {
|
||||
completed = metadataValue.includes(conditionConfig.value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Fallback or default behavior if condition is unknown - treat as 'exists'
|
||||
completed = true;
|
||||
}
|
||||
}
|
||||
// If metadataValue is null/undefined, 'completed' remains false.
|
||||
|
||||
const logEntry: HabitLogEntry = {
|
||||
habitId,
|
||||
date: dateTimestamp,
|
||||
completed, // Use the calculated completion status
|
||||
filePath: file.path,
|
||||
value: metadataValue // Store the raw value found in frontmatter
|
||||
};
|
||||
|
||||
// Add to main logs map
|
||||
const logs = this.habitCache.logs.get(habitId) || [];
|
||||
logs.push(logEntry);
|
||||
this.habitCache.logs.set(habitId, logs); // Re-set in case it was new
|
||||
|
||||
// Add to logsByDate index - Note: we store `completed` status here too
|
||||
const dateLogs = this.habitCache.logsByDate.get(dateTimestamp) || [];
|
||||
// Consider if the structure of logsByDate needs the raw 'value' as well
|
||||
dateLogs.push({ habitId, filePath: file.path, completed });
|
||||
this.habitCache.logsByDate.set(dateTimestamp, dateLogs);
|
||||
|
||||
// Add to logsByFile index (for efficient removal/update)
|
||||
fileLogs.push({ habitId, date: dateTimestamp });
|
||||
}
|
||||
}
|
||||
if (fileLogs.length > 0) {
|
||||
this.habitCache.logsByFile.set(file.path, fileLogs);
|
||||
}
|
||||
}
|
||||
|
||||
private setupMetadataListener(plugin: TaskGeniusPlugin) {
|
||||
plugin.registerEvent(
|
||||
plugin.app.metadataCache.on('changed', async (file, _, cache) => {
|
||||
if (this.isDailyNote(file.path) && this.containsRelevantMetadata(cache.frontmatter)) {
|
||||
console.log(`Metadata changed for daily note: ${file.path}, re-indexing habit data.`);
|
||||
await this.indexFileMetadata(file, plugin.app.metadataCache);
|
||||
this.sortLogsForHabitsInFile(file.path); // Re-sort affected logs
|
||||
// Trigger UI update if necessary
|
||||
plugin.eventBus.emit('habit-index-updated');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helper methods ---
|
||||
|
||||
private isDailyNote(filePath: string): boolean {
|
||||
// Implementation depends on daily note settings (e.g., regex match path)
|
||||
// Placeholder: Assumes YYYY-MM-DD.md format in root
|
||||
return /^\d{4}-\d{2}-\d{2}\.md$/.test(filePath.split('/').pop() || '');
|
||||
}
|
||||
|
||||
private getDateFromPath(filePath: string): Date | null {
|
||||
// Extract date based on isDailyNote logic
|
||||
const match = filePath.match(/(\d{4}-\d{2}-\d{2})\.md$/);
|
||||
if (match) {
|
||||
const date = new Date(match[1] + 'T00:00:00'); // Use T00:00:00 for consistency
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private containsRelevantMetadata(frontmatter: any): boolean {
|
||||
if (!frontmatter) return false;
|
||||
for (const habit of this.habitCache.habits.values()) {
|
||||
if (frontmatter.hasOwnProperty(habit.metadataKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private initEmptyCache(): HabitCache {
|
||||
return {
|
||||
habits: new Map(),
|
||||
logs: new Map(),
|
||||
logsByDate: new Map(),
|
||||
logsByFile: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
private clearLogs(): void {
|
||||
this.habitCache.logs.clear();
|
||||
this.habitCache.logsByDate.clear();
|
||||
this.habitCache.logsByFile.clear();
|
||||
// Re-initialize empty arrays for known habits
|
||||
this.habitCache.habits.forEach(habit => {
|
||||
this.habitCache.logs.set(habit.id, []);
|
||||
});
|
||||
}
|
||||
|
||||
private removeLogsForFile(filePath: string): void {
|
||||
const fileEntries = this.habitCache.logsByFile.get(filePath);
|
||||
if (!fileEntries) return;
|
||||
|
||||
fileEntries.forEach(({ habitId, date }) => {
|
||||
// Remove from main logs
|
||||
const habitLogs = this.habitCache.logs.get(habitId);
|
||||
if (habitLogs) {
|
||||
const index = habitLogs.findIndex(log => log.filePath === filePath && log.date === date);
|
||||
if (index > -1) {
|
||||
habitLogs.splice(index, 1);
|
||||
}
|
||||
}
|
||||
// Remove from logsByDate
|
||||
const dateLogs = this.habitCache.logsByDate.get(date);
|
||||
if (dateLogs) {
|
||||
const index = dateLogs.findIndex(log => log.filePath === filePath && log.habitId === habitId);
|
||||
if (index > -1) {
|
||||
dateLogs.splice(index, 1);
|
||||
}
|
||||
if(dateLogs.length === 0) {
|
||||
this.habitCache.logsByDate.delete(date);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.habitCache.logsByFile.delete(filePath);
|
||||
}
|
||||
|
||||
private sortLogsForHabitsInFile(filePath: string): void {
|
||||
const fileEntries = this.habitCache.logsByFile.get(filePath);
|
||||
if (!fileEntries) return;
|
||||
const affectedHabitIds = new Set(fileEntries.map(e => e.habitId));
|
||||
affectedHabitIds.forEach(habitId => {
|
||||
const logs = this.habitCache.logs.get(habitId);
|
||||
if (logs) {
|
||||
logs.sort((a, b) => a.date - b.date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sortAllLogsByDate(): void {
|
||||
this.habitCache.logs.forEach(logs => {
|
||||
logs.sort((a, b) => a.date - b.date);
|
||||
});
|
||||
}
|
||||
|
||||
// Other methods: getDailyNoteFormat, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### 习惯的种类和类型
|
||||
|
||||
```typescript
|
||||
// 基础习惯类型
|
||||
interface BaseHabitProps {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | React.ReactNode;
|
||||
completions: Record<string, number>;
|
||||
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
// 日常习惯类型
|
||||
export interface DailyHabitProps extends BaseHabitProps {
|
||||
type: 'daily';
|
||||
}
|
||||
|
||||
// 计数习惯类型
|
||||
export interface CountHabitProps extends BaseHabitProps {
|
||||
type: 'count';
|
||||
maxCount: number;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledEvent {
|
||||
name: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ScheduledHabitProps extends BaseHabitProps {
|
||||
type: 'scheduled';
|
||||
events: ScheduledEvent[];
|
||||
completions: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface MappingHabitProps extends BaseHabitProps {
|
||||
type: 'mapping';
|
||||
mapping: Record<number, string>;
|
||||
completions: Record<string, number>;
|
||||
}
|
||||
|
||||
// 所有习惯类型的联合
|
||||
export type HabitProps = DailyHabitProps | CountHabitProps | ScheduledHabitProps | MappingHabitProps;
|
||||
|
||||
// 习惯卡片属性
|
||||
export interface HabitCardProps {
|
||||
habit: HabitProps;
|
||||
toggleCompletion: (habitId: string) => void;
|
||||
triggerConfetti?: (pos: {
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
}) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface MappingHabitCardProps extends HabitCardProps {
|
||||
toggleCompletion: (habitId: string, value: number) => void;
|
||||
}
|
||||
|
||||
interface ScheduledHabitCardProps extends HabitCardProps {
|
||||
toggleCompletion: (habitId: string, {
|
||||
id,
|
||||
details
|
||||
}: {
|
||||
id: string;
|
||||
details: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 设置和相关类型
|
||||
|
||||
```typescript
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
// 基础习惯类型
|
||||
interface BaseHabitProps {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | React.ReactNode;
|
||||
completions: Record<string, number>;
|
||||
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
// 日常习惯类型
|
||||
export interface DailyHabitProps extends BaseHabitProps {
|
||||
type: 'daily';
|
||||
}
|
||||
|
||||
// 计数习惯类型
|
||||
export interface CountHabitProps extends BaseHabitProps {
|
||||
type: 'count';
|
||||
maxCount: number;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledEvent {
|
||||
name: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ScheduledHabitProps extends BaseHabitProps {
|
||||
type: 'scheduled';
|
||||
events: ScheduledEvent[];
|
||||
completions: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface MappingHabitProps extends BaseHabitProps {
|
||||
type: 'mapping';
|
||||
mapping: Record<number, string>;
|
||||
completions: Record<string, number>;
|
||||
}
|
||||
|
||||
// 所有习惯类型的联合
|
||||
export type HabitProps = DailyHabitProps | CountHabitProps | ScheduledHabitProps | MappingHabitProps;
|
||||
|
||||
// 习惯卡片属性
|
||||
export interface HabitCardProps {
|
||||
habit: HabitProps;
|
||||
toggleCompletion: (habitId: string) => void;
|
||||
triggerConfetti?: (pos: {
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
}) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface MappingHabitCardProps extends HabitCardProps {
|
||||
toggleCompletion: (habitId: string, value: number) => void;
|
||||
}
|
||||
|
||||
interface ScheduledHabitCardProps extends HabitCardProps {
|
||||
toggleCompletion: (habitId: string, {
|
||||
id,
|
||||
details
|
||||
}: {
|
||||
id: string;
|
||||
details: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## 5. 习惯视图 UI
|
||||
|
||||
- **日历视图**: 显示一个日历(例如,月视图),其中每个日期单元格通过视觉方式(如颜色编码的点)指示所选习惯的完成状态。点击某一天可以导航到对应的日记笔记。
|
||||
- **列表/统计视图**: 显示已定义习惯的列表。对于每个习惯,展示:
|
||||
- 当前连续完成天数(连续完成的天数/周期)
|
||||
- 最长连续完成记录
|
||||
- 完成百分比(例如,过去30天内)
|
||||
- 最近活动日志
|
||||
- **筛选/选择**: 允许用户选择在视图中显示哪些习惯。
|
||||
- **与任务视图集成**: 如果某个习惯当天尚未记录,可能在相关的任务视图透视图中将其显示为"为今天记录习惯X"的循环任务。
|
||||
|
||||
## 6. 设置
|
||||
|
||||
- **习惯定义**: 专门的部分用于添加、编辑和删除习惯(`id`、`name`、`metadataKey`、`frequency`等)。
|
||||
- **日记笔记配置**: 允许用户指定路径模式或依赖周期性笔记/日历插件设置来识别日记笔记。
|
||||
- **视觉设置**: 日历外观、颜色等选项。
|
||||
- **数据管理**: 触发习惯数据完全重新扫描/重新索引的按钮。
|
||||
|
||||
## 7. 性能与可扩展性
|
||||
|
||||
- **元数据缓存依赖**: 严重依赖Obsidian的`metadataCache`。性能取决于其效率。
|
||||
- **高效更新**: `metadataCache.on('changed')`处理程序必须高效,快速过滤无关变更并仅更新`HabitCache`中必要的部分。
|
||||
- **初始扫描时间**: 对于包含数千个日记笔记的保险库,初始扫描可能需要时间。考虑后台处理或进度指示。
|
||||
- **缓存大小**: `HabitCache`大小可能会显著增长。确保高效的数据结构,并考虑如果持久化存储,可能的序列化/反序列化性能。更新时需要谨慎管理日志排序。
|
||||
|
||||
## 8. 开放问题与未来考虑
|
||||
|
||||
- 如何处理非每日频率的习惯(例如,每周)?基于`date`的索引可能需要调整。
|
||||
- 怎么实现每天
|
||||
- 如何为非每日习惯定义*预期*完成情况,以准确计算连续完成天数?
|
||||
- 支持数值型习惯值(例如,跟踪分钟数、阅读页数)并将其可视化。
|
||||
- 更复杂的连续完成天数计算(处理非每日习惯的跳过天数)。
|
||||
- 导出/导入习惯数据和统计信息。
|
||||
- 习惯数据的高级查询/筛选。
|
||||
|
|
@ -1,599 +0,0 @@
|
|||
# Progress Bar Text Formatter Design Document
|
||||
|
||||
## 1. Overview
|
||||
|
||||
当前进度条实现在自定义方面存在局限性,特别是对于非百分比显示。我们将设计一个更灵活的文本格式化系统,允许:
|
||||
|
||||
1. 任何显示模式下的完全文本自定义
|
||||
2. 自定义任务计数的格式
|
||||
3. 基于进度百分比范围的动态文本
|
||||
4. 数据计算和文本呈现的更好分离
|
||||
5. 与现有任务状态标记的集成
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
```typescript
|
||||
interface ProgressData {
|
||||
completed: number;
|
||||
total: number;
|
||||
inProgress: number;
|
||||
abandoned: number;
|
||||
notStarted: number;
|
||||
planned: number;
|
||||
|
||||
// 派生数据(按需计算)
|
||||
percentages: {
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
abandoned: number;
|
||||
planned: number;
|
||||
notStarted: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProgressFormatOptions {
|
||||
// 显示模式
|
||||
displayMode: "percentage" | "fraction" | "custom" | "range-based";
|
||||
|
||||
// 自定义显示模式
|
||||
customFormat: string; // 使用占位符如 {{COMPLETED}}, {{TOTAL}} 等
|
||||
|
||||
// 根据百分比范围的自定义文本模板(保留原有设计)
|
||||
progressRanges: Array<{
|
||||
min: number;
|
||||
max: number;
|
||||
text: string; // 带占位符如 {{PROGRESS}}
|
||||
}>;
|
||||
|
||||
// 不同状态的显示符号(默认使用相应taskStatus的第一个字符)
|
||||
statusDisplaySymbols: {
|
||||
completed: string; // 默认: "✓"
|
||||
inProgress: string; // 默认: "⟳"
|
||||
abandoned: string; // 默认: "✗"
|
||||
planned: string; // 默认: "?"
|
||||
notStarted: string; // 默认: " "
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 实现结构
|
||||
|
||||
### 3.1 数据计算层
|
||||
|
||||
```typescript
|
||||
class ProgressCalculator {
|
||||
// 从原始任务计数计算所有派生数据
|
||||
static calculateProgressData(data: Partial<ProgressData>): ProgressData {
|
||||
// 为缺失值填充默认值
|
||||
const fullData: ProgressData = {
|
||||
completed: data.completed || 0,
|
||||
total: data.total || 0,
|
||||
inProgress: data.inProgress || 0,
|
||||
abandoned: data.abandoned || 0,
|
||||
notStarted: data.notStarted || 0,
|
||||
planned: data.planned || 0,
|
||||
percentages: { completed: 0, inProgress: 0, abandoned: 0, planned: 0, notStarted: 0 }
|
||||
};
|
||||
|
||||
// 如果总数 > 0,计算百分比
|
||||
if (fullData.total > 0) {
|
||||
fullData.percentages = {
|
||||
completed: Math.round((fullData.completed / fullData.total) * 10000) / 100,
|
||||
inProgress: Math.round((fullData.inProgress / fullData.total) * 10000) / 100,
|
||||
abandoned: Math.round((fullData.abandoned / fullData.total) * 10000) / 100,
|
||||
planned: Math.round((fullData.planned / fullData.total) * 10000) / 100,
|
||||
notStarted: Math.round((fullData.notStarted / fullData.total) * 10000) / 100
|
||||
};
|
||||
}
|
||||
|
||||
return fullData;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 文本格式化器
|
||||
|
||||
```typescript
|
||||
class ProgressTextFormatter {
|
||||
// 从任务状态初始化显示符号
|
||||
static initStatusDisplaySymbols(
|
||||
taskStatuses: {
|
||||
completed: string;
|
||||
inProgress: string;
|
||||
abandoned: string;
|
||||
notStarted: string;
|
||||
planned: string;
|
||||
},
|
||||
customSymbols?: Partial<ProgressFormatOptions['statusDisplaySymbols']>
|
||||
): ProgressFormatOptions['statusDisplaySymbols'] {
|
||||
// 从每个任务状态提取第一个字符作为默认符号
|
||||
const getDefaultSymbol = (statusStr: string, defaultSymbol: string): string => {
|
||||
const parts = statusStr.split('|');
|
||||
return parts[0].trim().charAt(0) || defaultSymbol;
|
||||
};
|
||||
|
||||
return {
|
||||
completed: customSymbols?.completed || getDefaultSymbol(taskStatuses.completed, "✓"),
|
||||
inProgress: customSymbols?.inProgress || getDefaultSymbol(taskStatuses.inProgress, "⟳"),
|
||||
abandoned: customSymbols?.abandoned || getDefaultSymbol(taskStatuses.abandoned, "✗"),
|
||||
planned: customSymbols?.planned || getDefaultSymbol(taskStatuses.planned, "?"),
|
||||
notStarted: customSymbols?.notStarted || getDefaultSymbol(taskStatuses.notStarted, " ")
|
||||
};
|
||||
}
|
||||
|
||||
// 替换模板字符串中的所有占位符
|
||||
static formatTemplate(
|
||||
template: string,
|
||||
data: ProgressData,
|
||||
options: ProgressFormatOptions,
|
||||
taskStatuses: {
|
||||
completed: string;
|
||||
inProgress: string;
|
||||
abandoned: string;
|
||||
notStarted: string;
|
||||
planned: string;
|
||||
}
|
||||
): string {
|
||||
// 确保我们有显示符号
|
||||
const displaySymbols = this.initStatusDisplaySymbols(taskStatuses, options.statusDisplaySymbols);
|
||||
|
||||
// 基本替换
|
||||
let result = template
|
||||
.replace(/{{COMPLETED}}/g, data.completed.toString())
|
||||
.replace(/{{TOTAL}}/g, data.total.toString())
|
||||
.replace(/{{IN_PROGRESS}}/g, data.inProgress.toString())
|
||||
.replace(/{{ABANDONED}}/g, data.abandoned.toString())
|
||||
.replace(/{{PLANNED}}/g, data.planned.toString())
|
||||
.replace(/{{NOT_STARTED}}/g, data.notStarted.toString())
|
||||
.replace(/{{PERCENT}}/g, data.percentages.completed.toString())
|
||||
.replace(/{{PROGRESS}}/g, data.percentages.completed.toString()) // 兼容原有占位符
|
||||
.replace(/{{PERCENT_IN_PROGRESS}}/g, data.percentages.inProgress.toString())
|
||||
.replace(/{{PERCENT_ABANDONED}}/g, data.percentages.abandoned.toString())
|
||||
.replace(/{{PERCENT_PLANNED}}/g, data.percentages.planned.toString())
|
||||
.replace(/{{COMPLETED_SYMBOL}}/g, displaySymbols.completed)
|
||||
.replace(/{{IN_PROGRESS_SYMBOL}}/g, displaySymbols.inProgress)
|
||||
.replace(/{{ABANDONED_SYMBOL}}/g, displaySymbols.abandoned)
|
||||
.replace(/{{PLANNED_SYMBOL}}/g, displaySymbols.planned)
|
||||
.replace(/{{NOT_STARTED_SYMBOL}}/g, displaySymbols.notStarted);
|
||||
|
||||
// 支持简单的表达式计算,例如进度条文本生成
|
||||
// 处理形如 ${=expression} 的模式
|
||||
result = result.replace(/\${=(.+?)}/g, (match, expr) => {
|
||||
try {
|
||||
// 使用Function构造器安全地执行表达式,提供data和displaySymbols作为上下文
|
||||
return new Function('data', 'displaySymbols', `return ${expr}`)(data, displaySymbols);
|
||||
} catch (e) {
|
||||
console.error("Error evaluating expression:", expr, e);
|
||||
return match; // 出错时返回原始匹配
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 基于进度范围获取文本模板 - 保留原有设计
|
||||
static getRangeBasedTemplate(data: ProgressData, options: ProgressFormatOptions): string {
|
||||
const percent = data.percentages.completed;
|
||||
|
||||
// 检查是否有匹配的范围
|
||||
if (options.progressRanges && options.progressRanges.length > 0) {
|
||||
for (const range of options.progressRanges) {
|
||||
if (percent >= range.min && percent <= range.max) {
|
||||
return range.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配的范围,返回默认格式
|
||||
return "{{PROGRESS}}%";
|
||||
}
|
||||
|
||||
// 基于显示模式获取适当的文本模板
|
||||
static getTextTemplate(data: ProgressData, options: ProgressFormatOptions): string {
|
||||
// 基于显示模式的默认选项
|
||||
switch(options.displayMode) {
|
||||
case "percentage":
|
||||
return "{{PERCENT}}%";
|
||||
case "fraction":
|
||||
return "[{{COMPLETED}}/{{TOTAL}}]";
|
||||
case "range-based":
|
||||
return this.getRangeBasedTemplate(data, options);
|
||||
case "custom":
|
||||
return options.customFormat;
|
||||
default:
|
||||
// 保持向后兼容性:如果启用了范围或百分比,使用相应格式
|
||||
if (options.progressRanges && options.progressRanges.length > 0) {
|
||||
return this.getRangeBasedTemplate(data, options);
|
||||
} else {
|
||||
return "[{{COMPLETED}}/{{TOTAL}}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主要格式化函数:计算数据并生成最终的文本表示
|
||||
static formatProgressText(
|
||||
rawData: Partial<ProgressData>,
|
||||
options: ProgressFormatOptions,
|
||||
taskStatuses: {
|
||||
completed: string;
|
||||
inProgress: string;
|
||||
abandoned: string;
|
||||
notStarted: string;
|
||||
planned: string;
|
||||
}
|
||||
): string {
|
||||
// 计算完整数据
|
||||
const data = ProgressCalculator.calculateProgressData(rawData);
|
||||
|
||||
// 获取适当的模板
|
||||
const template = this.getTextTemplate(data, options);
|
||||
|
||||
// 使用模板生成最终文本
|
||||
return this.formatTemplate(template, data, options, taskStatuses);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 设置界面
|
||||
|
||||
```typescript
|
||||
// 在 TaskProgressBarSettingTab 类中
|
||||
addProgressBarTextSettings() {
|
||||
const { containerEl } = this;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("进度条文本格式"))
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("显示模式"))
|
||||
.setDesc(t("选择如何显示任务进度"))
|
||||
.addDropdown(dropdown => {
|
||||
dropdown
|
||||
.addOption("percentage", t("百分比"))
|
||||
.addOption("fraction", t("分数"))
|
||||
.addOption("range-based", t("基于进度范围"))
|
||||
.addOption("custom", t("自定义格式"))
|
||||
.setValue(this.plugin.settings.progressBarFormat.displayMode || "fraction")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.progressBarFormat.displayMode = value;
|
||||
this.applySettingsUpdate();
|
||||
// 有条件地显示自定义格式设置
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
// 仅在选择自定义格式时显示
|
||||
if (this.plugin.settings.progressBarFormat.displayMode === "custom") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("自定义格式"))
|
||||
.setDesc(t("使用占位符如 {{COMPLETED}}, {{TOTAL}}, {{PERCENT}} 等"))
|
||||
.addText(text => {
|
||||
text.setValue(this.plugin.settings.progressBarFormat.customFormat || "[{{COMPLETED}}/{{TOTAL}}]")
|
||||
.setPlaceholder("[{{COMPLETED}}/{{TOTAL}}]")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.progressBarFormat.customFormat = value;
|
||||
this.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// 添加占位符的帮助提示
|
||||
containerEl.createEl("div", {
|
||||
cls: "setting-item-description",
|
||||
text: t("可用占位符: {{COMPLETED}}, {{TOTAL}}, {{IN_PROGRESS}}, {{ABANDONED}}, {{PLANNED}}, {{NOT_STARTED}}, {{PERCENT}}, {{COMPLETED_SYMBOL}}, {{IN_PROGRESS_SYMBOL}}, {{ABANDONED_SYMBOL}}, {{PLANNED_SYMBOL}}, {{NOT_STARTED_SYMBOL}}")
|
||||
});
|
||||
|
||||
// 高级表达式示例
|
||||
containerEl.createEl("div", {
|
||||
cls: "setting-item-description",
|
||||
text: t("高级用法: 您可以使用 ${= } 包裹JavaScript表达式,比如: ${=\"=\".repeat(Math.floor(data.percentages.completed/10))}")
|
||||
});
|
||||
}
|
||||
|
||||
// 基于范围的进度文本 (保留原有设计)
|
||||
if (this.plugin.settings.progressBarFormat.displayMode === "range-based" || this.plugin.settings.progressBarFormat.displayMode === undefined) {
|
||||
this.addProgressRangesSettings();
|
||||
}
|
||||
|
||||
// 显示符号设置
|
||||
new Setting(containerEl)
|
||||
.setName(t("显示符号"))
|
||||
.setDesc(t("自定义进度条文本中使用的符号(默认使用任务状态标记)"));
|
||||
|
||||
// 从任务状态获取默认符号
|
||||
const displaySymbols = ProgressTextFormatter.initStatusDisplaySymbols(this.plugin.settings.taskStatuses);
|
||||
|
||||
const statusTypes = [
|
||||
{ id: "completed", name: t("已完成"), default: displaySymbols.completed },
|
||||
{ id: "inProgress", name: t("进行中"), default: displaySymbols.inProgress },
|
||||
{ id: "abandoned", name: t("已放弃"), default: displaySymbols.abandoned },
|
||||
{ id: "notStarted", name: t("未开始"), default: displaySymbols.notStarted },
|
||||
{ id: "planned", name: t("已计划"), default: displaySymbols.planned }
|
||||
];
|
||||
|
||||
for (const statusType of statusTypes) {
|
||||
new Setting(containerEl)
|
||||
.setName(statusType.name)
|
||||
.addText(text => {
|
||||
const currentValue = this.plugin.settings.progressBarFormat.statusDisplaySymbols?.[statusType.id];
|
||||
text.setValue(currentValue || statusType.default)
|
||||
.setPlaceholder(statusType.default)
|
||||
.onChange(async (value) => {
|
||||
if (!this.plugin.settings.progressBarFormat.statusDisplaySymbols) {
|
||||
this.plugin.settings.progressBarFormat.statusDisplaySymbols = {} as any;
|
||||
}
|
||||
this.plugin.settings.progressBarFormat.statusDisplaySymbols[statusType.id] = value;
|
||||
this.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 添加进度条文本预览
|
||||
new Setting(containerEl)
|
||||
.setName(t("预览"))
|
||||
.setDesc(t("当前设置的进度条文本预览"));
|
||||
|
||||
const previewContainer = containerEl.createDiv({ cls: "progress-bar-text-preview-container" });
|
||||
|
||||
// 创建示例数据用于预览
|
||||
const sampleData = {
|
||||
completed: 3,
|
||||
total: 5,
|
||||
inProgress: 1,
|
||||
abandoned: 0,
|
||||
notStarted: 0,
|
||||
planned: 1,
|
||||
percentages: {
|
||||
completed: 60,
|
||||
inProgress: 20,
|
||||
abandoned: 0,
|
||||
planned: 20,
|
||||
notStarted: 0
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染预览文本
|
||||
const previewText = ProgressTextFormatter.formatProgressText(
|
||||
sampleData,
|
||||
this.plugin.settings.progressBarFormat,
|
||||
this.plugin.settings.taskStatuses
|
||||
);
|
||||
|
||||
previewContainer.setText(previewText);
|
||||
}
|
||||
|
||||
// 保留原有的进度范围设置 - 与当前实现保持兼容
|
||||
addProgressRangesSettings() {
|
||||
new Setting(this.containerEl)
|
||||
.setName(t("进度范围"))
|
||||
.setDesc(
|
||||
t(
|
||||
"定义进度范围及其对应的文本表示形式。使用 {{PROGRESS}} 作为百分比值的占位符。"
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
// 显示现有范围
|
||||
this.plugin.settings.progressRanges.forEach((range, index) => {
|
||||
new Setting(this.containerEl)
|
||||
.setName(`范围 ${index + 1}: ${range.min}%-${range.max}%`)
|
||||
.setDesc(
|
||||
`使用 {{PROGRESS}} 作为百分比值的占位符`
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(
|
||||
"包含 {{PROGRESS}} 占位符的模板文本"
|
||||
)
|
||||
.setValue(range.text)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.progressRanges[index].text =
|
||||
value;
|
||||
this.applySettingsUpdate();
|
||||
})
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("删除").onClick(async () => {
|
||||
this.plugin.settings.progressRanges.splice(index, 1);
|
||||
this.applySettingsUpdate();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName(t("添加新范围"))
|
||||
.setDesc(t("添加新的进度百分比范围及自定义文本"));
|
||||
|
||||
// 添加新范围
|
||||
const newRangeSetting = new Setting(this.containerEl);
|
||||
newRangeSetting.infoEl.detach();
|
||||
|
||||
newRangeSetting
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("最小百分比 (0-100)"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// 将在用户点击添加按钮时处理
|
||||
})
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("最大百分比 (0-100)"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// 将在用户点击添加按钮时处理
|
||||
})
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("文本模板 (使用 {{PROGRESS}})"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// 将在用户点击添加按钮时处理
|
||||
})
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("添加").onClick(async () => {
|
||||
const settingsContainer = button.buttonEl.parentElement;
|
||||
if (!settingsContainer) return;
|
||||
|
||||
const inputs = settingsContainer.querySelectorAll("input");
|
||||
if (inputs.length < 3) return;
|
||||
|
||||
const min = parseInt(inputs[0].value);
|
||||
const max = parseInt(inputs[1].value);
|
||||
const text = inputs[2].value;
|
||||
|
||||
if (isNaN(min) || isNaN(max) || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugin.settings.progressRanges.push({
|
||||
min,
|
||||
max,
|
||||
text,
|
||||
});
|
||||
|
||||
// 清空输入
|
||||
inputs[0].value = "";
|
||||
inputs[1].value = "";
|
||||
inputs[2].value = "";
|
||||
|
||||
this.applySettingsUpdate();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
// 重置为默认值
|
||||
new Setting(this.containerEl)
|
||||
.setName(t("重置为默认值"))
|
||||
.setDesc(t("将进度范围重置为默认值"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("重置")).onClick(async () => {
|
||||
this.plugin.settings.progressRanges = [
|
||||
{
|
||||
min: 0,
|
||||
max: 20,
|
||||
text: t("刚刚开始 {{PROGRESS}}%"),
|
||||
},
|
||||
{
|
||||
min: 20,
|
||||
max: 40,
|
||||
text: t("正在推进 {{PROGRESS}}%"),
|
||||
},
|
||||
{ min: 40, max: 60, text: t("进行一半 {{PROGRESS}}%") },
|
||||
{
|
||||
min: 60,
|
||||
max: 80,
|
||||
text: t("进展良好 {{PROGRESS}}%"),
|
||||
},
|
||||
{
|
||||
min: 80,
|
||||
max: 100,
|
||||
text: t("即将完成 {{PROGRESS}}%"),
|
||||
},
|
||||
];
|
||||
this.applySettingsUpdate();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 实现步骤
|
||||
|
||||
1. **添加新设置到插件设置接口**:
|
||||
- 创建新的 `progressBarFormat` 对象
|
||||
- 为所有自定义选项添加默认值
|
||||
- 确保与现有 taskStatuses 集成
|
||||
- 保留现有的 progressRanges 设计
|
||||
|
||||
2. **实现文本格式化器**:
|
||||
- 实现 `ProgressCalculator` 用于数据处理
|
||||
- 创建 `ProgressTextFormatter` 用于文本模板处理
|
||||
- 添加与任务状态系统的集成
|
||||
- 维护对基于范围模板的支持
|
||||
|
||||
3. **更新设置界面**:
|
||||
- 添加新的设置部分
|
||||
- 确保与现有设置的向后兼容性
|
||||
- 保留现有的进度范围设置界面
|
||||
- 添加实时预览功能
|
||||
|
||||
4. **与现有实现的桥接**:
|
||||
- 支持旧设置格式
|
||||
- 自动将现有设置转换为新格式
|
||||
- 为用户提供平滑过渡
|
||||
|
||||
## 6. 迁移策略
|
||||
|
||||
```typescript
|
||||
function migrateOldProgressBarSettings(oldSettings: any): ProgressFormatOptions {
|
||||
// 检测是否使用百分比或范围显示
|
||||
const usesPercentage = oldSettings.showPercentage;
|
||||
const usesRanges = oldSettings.customizeProgressRanges && oldSettings.progressRanges && oldSettings.progressRanges.length > 0;
|
||||
|
||||
return {
|
||||
// 根据现有配置自动选择最合适的显示模式
|
||||
displayMode: usesRanges ? "range-based" : (usesPercentage ? "percentage" : "fraction"),
|
||||
customFormat: "[{{COMPLETED}}/{{TOTAL}}]",
|
||||
progressRanges: oldSettings.progressRanges || [
|
||||
{ min: 0, max: 20, text: t("刚刚开始 {{PROGRESS}}%") },
|
||||
{ min: 20, max: 40, text: t("正在推进 {{PROGRESS}}%") },
|
||||
{ min: 40, max: 60, text: t("进行一半 {{PROGRESS}}%") },
|
||||
{ min: 60, max: 80, text: t("进展良好 {{PROGRESS}}%") },
|
||||
{ min: 80, max: 100, text: t("即将完成 {{PROGRESS}}%") },
|
||||
],
|
||||
statusDisplaySymbols: ProgressTextFormatter.initStatusDisplaySymbols(oldSettings.taskStatuses)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 自定义格式示例
|
||||
|
||||
以下是可以通过自定义格式实现的一些例子:
|
||||
|
||||
1. **带括号的简单分数**:
|
||||
`[{{COMPLETED}}/{{TOTAL}}]`
|
||||
|
||||
2. **自定义符号**:
|
||||
`【{{COMPLETED}}⭐ / {{TOTAL}}⭐】`
|
||||
|
||||
3. **基于任务状态的进度计量**:
|
||||
`{{COMPLETED}}{{COMPLETED_SYMBOL}} {{IN_PROGRESS}}{{IN_PROGRESS_SYMBOL}} {{ABANDONED}}{{ABANDONED_SYMBOL}} / {{TOTAL}}`
|
||||
|
||||
4. **表情符号进度条**:
|
||||
`${="⬛".repeat(Math.floor(data.percentages.completed/10)) + "⬜".repeat(10-Math.floor(data.percentages.completed/10))}`
|
||||
|
||||
5. **文本进度条**:
|
||||
`[${="=".repeat(Math.floor(data.percentages.completed/10)) + " ".repeat(10-Math.floor(data.percentages.completed/10))}]`
|
||||
|
||||
6. **状态感知自定义格式**:
|
||||
`[{{COMPLETED_SYMBOL}}:{{COMPLETED}} {{IN_PROGRESS_SYMBOL}}:{{IN_PROGRESS}} {{PLANNED_SYMBOL}}:{{PLANNED}} / {{TOTAL}}]`
|
||||
|
||||
7. **彩色文本**:
|
||||
`{{COMPLETED}}/{{TOTAL}} 完成率: ${=data.percentages.completed < 30 ? '🔴低' : data.percentages.completed < 70 ? '🟠中' : '🟢高'}`
|
||||
|
||||
8. **范围示例** (基于progressRanges配置):
|
||||
- 0-20%: "刚刚开始 15%"
|
||||
- 20-40%: "正在推进 35%"
|
||||
- 40-60%: "进行一半 50%"
|
||||
- 60-80%: "进展良好 75%"
|
||||
- 80-100%: "即将完成 90%"
|
||||
|
||||
## 8. 性能考虑
|
||||
|
||||
1. **懒计算**:
|
||||
- 仅在需要时计算百分比
|
||||
- 对于重复渲染,尽可能缓存结果
|
||||
|
||||
2. **表达式处理**:
|
||||
- 对于常用格式,预编译模板
|
||||
- 缓存处理过的模板
|
||||
|
||||
3. **向后兼容性**:
|
||||
- 确保现有的进度范围设置仍然可用
|
||||
- 无缝支持从旧版本升级
|
||||
|
||||
160
design/reward.md
160
design/reward.md
|
|
@ -1,160 +0,0 @@
|
|||
# Reward 功能设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Reward (奖励) 功能是 Task Genius 插件的一个激励模块,旨在通过在用户完成任务时提供随机或有条件的奖励,提升用户的积极性和任务完成动力。用户可以自定义奖励列表、触发条件和概率,使得完成任务更具趣味性。
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
- **奖励定义**: 用户可以在指定的 Markdown 文件中定义奖励列表,每行一个奖励。
|
||||
- **奖励属性**:
|
||||
- **名称 (Name)**: 奖励的描述性文字 (例如, "喝杯好茶", "看一集喜欢的剧")。
|
||||
- **稀有度/出现率 (Occurrence)**: 定义奖励出现的频率 (例如, `common`, `rare`, `legendary`)。允许自定义稀有度等级及其概率。默认为 `common`。
|
||||
- **库存 (Inventory)**: 定义奖励可用的次数。每次获得奖励后,库存会自动减少。库存为 0 后该奖励不再出现。默认为无限。
|
||||
- **图片 (Image)**: 可选,指定一个图片 URL (本地或网络),在获得奖励时显示。
|
||||
- **条件 (Condition)**: 可选,指定奖励触发的条件,例如要求任务包含特定标签 (`#difficult`, `#project`) 或满足特定优先级。支持简单的逻辑组合 (AND, OR, NOT)。
|
||||
- **触发机制**: 监听 Task View 或者 Task Genius 本身的任务完成事件。
|
||||
- **奖励抽取**:
|
||||
- 当任务完成时,根据任务属性 (标签、优先级、内容) 筛选符合条件的奖励。
|
||||
- 根据符合条件的奖励的稀有度进行加权随机抽取。
|
||||
- 考虑奖励库存。
|
||||
- **奖励通知**: 通过 Obsidian 的通知系统或者自定义模态框向用户显示获得的奖励信息(名称、图片)。
|
||||
- **跳过奖励**: 用户可以选择跳过当前获得的奖励,跳过后不消耗库存。
|
||||
- **库存管理**: 如果奖励被接受(未跳过)且有库存限制,自动更新奖励定义文件中的库存数量。
|
||||
- **配置管理**: 提供设置界面,用于配置奖励文件路径、稀有度等级、条件语法等。
|
||||
- **快速开始**: 跳转到设置中的奖励设置页面。
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
### 3.1 奖励定义 (Reward Item)
|
||||
|
||||
奖励定义存储在一个 JSON 文件中 (例如 `rewards.json`)。该文件包含一个 JSON 数组,每个数组元素是一个代表奖励的 JSON 对象。
|
||||
|
||||
*示例 (`rewards.json`):*
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "reward-tea", // 用户定义的唯一 ID
|
||||
"name": "喝杯好茶",
|
||||
"occurrence": "common"
|
||||
// inventory 默认为无限
|
||||
},
|
||||
{
|
||||
"id": "reward-series-episode",
|
||||
"name": "看一集喜欢的剧",
|
||||
"occurrence": "rare",
|
||||
"inventory": 20
|
||||
},
|
||||
{
|
||||
"id": "reward-champagne-project",
|
||||
"name": "打开那瓶珍藏的香槟",
|
||||
"occurrence": "legendary",
|
||||
"inventory": 1,
|
||||
"condition": "#project AND #milestone" // 条件仍可定义
|
||||
},
|
||||
{
|
||||
"id": "reward-chocolate-quick",
|
||||
"name": "吃块巧克力",
|
||||
"occurrence": "common",
|
||||
"inventory": 10,
|
||||
"condition": "#quickwin",
|
||||
"imageUrl": "app://local/C:/images/chocolate.png" // 图片 URL
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3.2 内部奖励对象 (Parsed Reward Object)
|
||||
|
||||
```typescript
|
||||
interface RewardCondition {
|
||||
raw: string; // e.g., "#project AND #milestone"
|
||||
// Parsed structure for evaluation, e.g.,:
|
||||
// { type: 'AND', conditions: [{ type: 'TAG', value: 'project' }, { type: 'TAG', value: 'milestone' }] }
|
||||
// Or a function: (task: Task) => boolean
|
||||
}
|
||||
|
||||
interface Reward {
|
||||
id: string; // Unique identifier (e.g., generated hash or line number)
|
||||
name: string; // The reward text
|
||||
occurrence: string; // Name of the occurrence level (e.g., "common", "rare"). Needs mapping to probability.
|
||||
probability?: number; // Calculated probability based on occurrence level
|
||||
inventory: number; // Remaining count (Infinity for unlimited)
|
||||
imageUrl?: string; // Optional image URL
|
||||
condition?: RewardCondition; // Optional condition for triggering
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 奖励设置 (Reward Settings)
|
||||
|
||||
```typescript
|
||||
interface OccurrenceLevel {
|
||||
name: string;
|
||||
chance: number; // Probability percentage (e.g., 70 for 70%)
|
||||
}
|
||||
|
||||
interface RewardSettings {
|
||||
rewardFilePath: string; // Path to the rewards JSON file (default: rewards.json)
|
||||
occurrenceLevels: OccurrenceLevel[]; // e.g., [{ name: 'common', chance: 70 }, { name: 'rare', chance: 25 }, { name: 'legendary', chance: 5 }]
|
||||
conditionSyntax: 'tags' | 'dataview' | 'simple_keywords'; // How conditions are defined and parsed
|
||||
enableRewards: boolean; // Master switch
|
||||
// Tag condition logic (default AND?) - Future enhancement
|
||||
// Formatting for reward attributes in file (default {}) - Future enhancement
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 奖励缓存 (Internal Cache)
|
||||
|
||||
```typescript
|
||||
interface RewardCache {
|
||||
rewards: Reward[]; // Parsed list of all available rewards
|
||||
filePath: string; // Path of the file the cache is based on
|
||||
lastModified: number; // Timestamp of the reward file when last parsed
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 实现方案
|
||||
|
||||
1. **加载与解析**:
|
||||
* 使用 Task Genius 的设置中的 Reward 部分,读取为 RewardItem 列表。
|
||||
2. **任务完成挂钩 (Hook)**:
|
||||
* 监听 Task Genius 内部的任务完成事件 ( Task Genius 提供事件总线 `this.app.workspace.on('task-genius:task-completed', task => ...)` )。
|
||||
* 获取完成的任务对象 (`task`),包含其文本、标签、优先级等信息。
|
||||
3. **奖励筛选**:
|
||||
* 遍历上述流程中的 RewardItem 列表。
|
||||
* 对于每个 `RewardItem`,检查其 `inventory` 是否大于 0 (或为无限)。
|
||||
* 如果 `RewardItem` 有 `condition`,则使用任务信息 (`task`) 对其进行评估。只保留条件满足的奖励。 (例如, `condition.evaluate(task)` 返回 `true`)。
|
||||
4. **奖励抽取**:
|
||||
* 从筛选后的奖励列表中,根据各自的 `occurrence` (对应的 `chance`) 进行加权随机抽取。
|
||||
* 例如,如果剩下 Common (70%), Rare (25%), Legendary (5%) 的奖励,按此概率分布随机选择一个。
|
||||
5. **通知与交互**:
|
||||
* 如果抽中奖励,显示 Obsidian 通知 (`new Notice(...)`) 或一个更丰富的模态框,包含奖励名称、图片(若有),以及 "领取" (隐式关闭) 和 "跳过" 按钮。
|
||||
6. **库存更新**:
|
||||
* 如果用户 **没有** 点击 "跳过",并且抽中的奖励 `inventory` 不是无限 (`Infinity`):
|
||||
* 将 `RewardItem` 中对应奖励的 `inventory` 减 1。
|
||||
* **更新奖励文件**: 更新 Task Genius 的设置中的 Reward 部分对应的 `RewardItem` 的 `inventory` 字段。
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
- **奖励通知**:
|
||||
- 使用 Obsidian 的 `Notice` API 显示简短通知,包含奖励名称和可选的 "跳过" 按钮。
|
||||
- 或者,使用 Obsidian 的 `Modal` API 创建一个更醒目的弹窗,可以展示图片和更清晰的按钮。
|
||||
- **设置界面**:
|
||||
- 在 Task Genius 的设置面板中增加 "Rewards" 标签页。
|
||||
- 包含字段:启用/禁用开关、奖励文件路径输入框、稀有度等级配置(允许增删改名称和概率)、条件解析方式选择等。
|
||||
|
||||
## 6. 设置
|
||||
|
||||
- **Enable Rewards**: 总开关,启用或禁用奖励功能。
|
||||
- **Reward Items**: 可以新增条目来配置奖励项目。
|
||||
- **Occurrence Levels**: 点击每个 Reward Item 能配置稀有度等级及其对应的抽取概率(分成三档,默认是 common, rare, legendary)。
|
||||
- **Condition Settings**: 配置每个 Reward Item 的触发条件,置空则默认加入队列随机触发。
|
||||
|
||||
## 7. 开放问题与未来考虑
|
||||
|
||||
- **复杂的条件逻辑**: 如何优雅地支持 AND, OR, NOT 组合,甚至 Dataview 查询作为奖励条件?
|
||||
- **奖励历史/统计**: 记录用户获得的奖励历史。
|
||||
- **与其他插件的集成**: 能否从其他插件(如 Habitica、游戏化插件)获取奖励定义或触发奖励?
|
||||
- **奖励分组/分类**: 允许用户将奖励分组(例如,按项目、按类型),并可能根据任务的上下文优先选择某个组的奖励。
|
||||
- **UI/UX**: 如何使奖励通知既有效又不打扰用户流程?是否需要更丰富的奖励展示界面?
|
||||
- **与 Habit 功能联动**: 能否在完成某个习惯打卡时也触发奖励?
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
# Side Handler 功能设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Side Handler (侧边栏处理器或行号栏交互器) 是 Task Genius 插件中的一个增强交互功能。它利用编辑器 (CodeMirror) 的行号栏 (gutter) 区域,在用户点击特定任务相关的标记时,提供一个包含该任务详细信息的弹出层 (Popover 或 Modal)。用户可以直接在此弹出层中查看和快速修改任务的某些属性,旨在提升任务管理的便捷性和效率。
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
- **Gutter 标记**: 在编辑器的行号栏为识别出的任务行显示一个可交互的标记。
|
||||
- **任务信息展示**: 点击 Gutter 标记后,根据平台类型(桌面或移动端)弹出相应的界面(Popover 或 Modal)。
|
||||
- **桌面端**: 默认显示一个紧凑的 Popover 菜单。
|
||||
- **移动端**: 默认显示一个功能更全面的 Modal 弹窗。
|
||||
- **快速信息概览**: 弹出层清晰展示任务的核心信息,例如:内容、状态、截止日期、优先级、标签等。
|
||||
- **便捷信息编辑**: 允许用户在弹出层内直接修改任务的多个属性,如状态、优先级、日期等。编辑功能参考 `TaskDetailsComponent` (`details.ts`) 的实现。
|
||||
- **动态界面切换**: 根据 `Platform.isDesktop` 自动判断并切换 Popover 与 Modal 的显示。
|
||||
- **上下文操作**:
|
||||
- 提供 "在文件中编辑" 的快捷入口,跳转到任务所在行。
|
||||
- 提供 "标记完成/未完成" 的快捷操作。
|
||||
|
||||
## 3. 交互设计
|
||||
|
||||
### 3.1 Gutter 交互
|
||||
|
||||
- 当鼠标悬停在 Gutter 中的任务标记上时,标记高亮,并可显示 Tooltip 提示 (例如 "查看/编辑任务")。
|
||||
- 单击 Gutter 中的任务标记,触发弹出层(Popover 或 Modal)。
|
||||
|
||||
### 3.2 桌面端: Popover 菜单
|
||||
|
||||
- 在桌面环境下 (`Platform.isDesktop === true`),点击 Gutter 标记后,在标记附近弹出一个非模态的 Popover。
|
||||
- Popover 内容区域将集成 `TaskDetailsComponent` 的核心展示和编辑能力。
|
||||
- Popover 应包含以下元素:
|
||||
- 任务内容预览 (只读或截断显示)。
|
||||
- 任务状态切换器 (使用 `StatusComponent`)。
|
||||
- 关键元数据展示与编辑 (例如:优先级、截止日期)。可参考 `details.ts` 中的 `showEditForm` 方法提供的字段。
|
||||
- 操作按钮:
|
||||
- "编辑详细信息" (可选,如果 Popover 只提供部分编辑,此按钮可打开一个更全面的 Modal 或跳转至任务详情视图)。
|
||||
- "在文件中编辑"。
|
||||
- "切换完成状态"。
|
||||
- 点击 Popover 外部区域或按下 `Esc` 键可关闭 Popover。
|
||||
|
||||
### 3.3 移动端: Modal 弹窗
|
||||
|
||||
- 在非桌面环境(如移动端,`Platform.isDesktop === false`)下,点击 Gutter 标记后,屏幕中央弹出一个模态对话框 (Modal)。
|
||||
- Modal 的设计和实现可以参考 `QuickCaptureModal.ts` 的结构和交互模式,但内容主要用于展示和编辑现有任务,而非创建新任务。
|
||||
- Modal 内容将更全面地集成 `TaskDetailsComponent` 的功能,提供比 Popover 更丰富的编辑选项。
|
||||
- Modal 应包含:
|
||||
- 清晰的标题,如 "编辑任务"。
|
||||
- 完整的任务内容展示 (可编辑,参考 `details.ts` 的 `contentInput`)。
|
||||
- 任务状态选择 (参考 `StatusComponent`)。
|
||||
- 各项可编辑的任务元数据字段(如项目、标签、上下文、优先级、各项日期、重复规则等),布局和控件参考 `details.ts` 的 `showEditForm`。
|
||||
- 底部操作按钮:
|
||||
- "保存" 或 "应用更改"。
|
||||
- "取消"。
|
||||
- "在文件中编辑" (打开对应文件并定位)。
|
||||
- "切换完成状态"。
|
||||
|
||||
## 4. 数据展示与编辑
|
||||
|
||||
弹出层 (Popover/Modal) 中展示和允许编辑的任务信息主要基于 `Task`对象的属性,其实现逻辑参考 `src/components/task-view/details.ts` 中的 `TaskDetailsComponent`。
|
||||
|
||||
### 4.1 展示信息
|
||||
|
||||
- 任务原始内容 ( `task.content` )
|
||||
- 任务状态 ( `task.status`, 通过 `getStatus` 或 `StatusComponent` 展示)
|
||||
- 项目 ( `task.project` )
|
||||
- 截止日期 ( `task.dueDate` )
|
||||
- 开始日期 ( `task.startDate` )
|
||||
- 计划日期 ( `task.scheduledDate` )
|
||||
- 完成日期 ( `task.completedDate` )
|
||||
- 优先级 ( `task.priority` )
|
||||
- 标签 ( `task.tags` )
|
||||
- 上下文 ( `task.context` )
|
||||
- 重复规则 ( `task.recurrence` )
|
||||
- 文件路径 ( `task.filePath` )
|
||||
|
||||
### 4.2 可编辑信息
|
||||
|
||||
以下字段应允许用户在 Popover 或 Modal 中直接修改,修改逻辑和UI组件参考 `TaskDetailsComponent` 的 `showEditForm` 方法:
|
||||
|
||||
- 任务内容 (`contentInput`)
|
||||
- 项目 (`projectInput` 与 `ProjectSuggest`)
|
||||
- 标签 (`tagsInput` 与 `TagSuggest`)
|
||||
- 上下文 (`contextInput` 与 `ContextSuggest`)
|
||||
- 优先级 (`priorityDropdown`)
|
||||
- 截止日期 (`dueDateInput`)
|
||||
- 开始日期 (`startDateInput`)
|
||||
- 计划日期 (`scheduledDateInput`)
|
||||
- 重复规则 (`recurrenceInput`)
|
||||
- 状态 (通过 `StatusComponent` 或类似的机制)
|
||||
|
||||
保存更新后的任务数据将调用 `onTaskUpdate` 回调,与 `TaskDetailsComponent` 中的保存逻辑类似,可能包含防抖处理。
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
### 5.1 Gutter Marker (行号栏标记)
|
||||
|
||||
- 在任务行的行号栏显示一个简洁、直观的图标 (例如:一个小圆点、任务勾选框图标的变体、或者插件特有的图标)。
|
||||
- 标记的颜色或形态可以根据任务状态(例如,未完成、已完成)有细微变化。
|
||||
- 鼠标悬停时标记有视觉反馈(如放大、改变颜色)。
|
||||
|
||||
### 5.2 Popover (桌面端)
|
||||
|
||||
- 设计应紧凑,避免遮挡过多编辑器内容。
|
||||
- 风格与 Obsidian 主题保持一致。
|
||||
- 包含任务核心信息和常用编辑字段。
|
||||
- 字段布局参考 `TaskDetailsComponent` 中非编辑状态下的信息排布,但控件为编辑形态。
|
||||
|
||||
### 5.3 Modal (移动端 / 详细编辑)
|
||||
|
||||
- Modal 弹窗的设计参考 `QuickCaptureModal.ts` 的全功能模式 (`createFullFeaturedModal`),但侧重于编辑而非捕获。
|
||||
- 移除或调整文件目标选择器等不适用于编辑现有任务的元素。
|
||||
- 表单布局清晰,易于在小屏幕上操作。
|
||||
- 包含 `TaskDetailsComponent` `showEditForm` 中几乎所有的可编辑字段。
|
||||
- 提供明确的 "保存" 和 "取消" 按钮。
|
||||
|
||||
## 6. 实现要点
|
||||
|
||||
1. **CodeMirror Gutter API**:
|
||||
* 使用 CodeMirror 6 的 Gutter API (`gutter`, `lineMarker` 等) 来添加和管理行号栏标记。
|
||||
* 需要监听 Gutter 标记的点击事件。
|
||||
2. **任务识别**:
|
||||
* 需要一种机制来确定哪些行是任务行,以便在这些行旁边显示 Gutter 标记。这可能依赖插件已有的任务解析逻辑。
|
||||
3. **动态 UI 加载**:
|
||||
* 根据 `Platform.isDesktop` 的值,在点击事件回调中动态创建和显示 Popover (可能使用 Obsidian 的 `Menu` 或自定义浮动元素) 或 Modal (继承 Obsidian `Modal` 类)。
|
||||
4. **组件复用**:
|
||||
* 尽可能复用 `TaskDetailsComponent` (`details.ts`) 中的任务信息展示逻辑、表单字段创建逻辑 (`createFormField`) 以及数据更新逻辑 (`onTaskUpdate`, `saveTask`)。
|
||||
* 对于 Modal 的基础框架,可以借鉴 `QuickCaptureModal.ts` 的结构,特别是其参数化配置和内容组织方式。
|
||||
5. **状态管理**:
|
||||
* 确保 Popover/Modal 中的任务数据与原始任务数据同步。
|
||||
* 修改后正确更新任务对象,并通过事件或回调通知其他组件(如任务列表视图)刷新。
|
||||
6. **性能考虑**:
|
||||
* Gutter 标记的渲染不应对编辑器性能产生显著影响,尤其是在处理大量任务时。
|
||||
|
||||
## 7. 开放问题与未来考虑
|
||||
|
||||
- **Gutter 标记自定义**: 是否允许用户自定义 Gutter 标记的图标或行为?
|
||||
- **Popover/Modal 内容可配置性**: 是否允许用户选择在 Popover/Modal 中显示或编辑哪些字段?
|
||||
- **键盘可访问性**: 如何确保通过键盘也能方便地触发 Gutter 标记和操作弹出层?
|
||||
- **与其他视图的交互**: 编辑后,如何更流畅地更新其他打开的任务视图或日历视图?
|
||||
- **右键菜单集成**: 除了左键点击,是否考虑在 Gutter 标记上支持右键菜单,提供更多上下文操作(如复制任务链接、快速设置提醒等)?
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
# PRD:Obsidian 插件的日历与甘特图视图 (原生实现侧重)
|
||||
|
||||
**版本:** 1.0
|
||||
**日期:** 2025-04-15
|
||||
|
||||
## 1. 引言
|
||||
|
||||
本文档概述了在 Obsidian 插件中实现日历(Calendar)和甘特图(Gantt chart)视图的需求。这些视图旨在为用户提供其任务的可视化表示,从而在 Obsidian 内部改善计划、跟踪和整体任务管理工作流程。**核心目标是尽可能使用原生 Web 技术 (HTML, CSS, SVG/Canvas) 进行实现,** 并将利用 `TaskIndex.ts` 已索引的任务数据。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
* 为用户提供直观的任务日历和甘特图可视化。
|
||||
* 实现更好的基于日期的计划和进度跟踪。
|
||||
* 高效利用现有的 `Task` 数据结构 (`src/utils/types/TaskIndex.ts`)。
|
||||
* 提供交互式元素用于任务检查,并可能支持基本修改(例如,重新安排)。
|
||||
* 即使存在大量任务,也能确保良好的性能 **(需要特别关注手动优化)**。
|
||||
* 将视图无缝集成到 Obsidian 用户界面中。
|
||||
|
||||
## 3. 目标用户
|
||||
|
||||
在笔记中管理任务并希望使用可视化工具来规划和跟踪项目时间表和截止日期的 Obsidian 用户。这包括项目经理、学生、开发人员、作家以及任何使用 Obsidian 进行任务管理的人员。
|
||||
|
||||
## 4. 核心功能
|
||||
|
||||
### 4.1. 数据基础
|
||||
|
||||
* **来源:** 所有任务数据将来源于 `TaskIndexer` 组件及其缓存。
|
||||
* **相关的 `Task` 字段:**
|
||||
* `id`: 唯一标识符。
|
||||
* `content`: 任务描述。
|
||||
* `filePath`, `line`: 用于链接回源文件。
|
||||
* `completed`, `status`: 用于可视化状态指示器。
|
||||
* `startDate`, `scheduledDate`, `dueDate`, `completedDate`: 用于在时间线/日历上放置任务的主要字段。
|
||||
* `project`: 用于过滤和分组。
|
||||
* `tags`: 用于过滤。
|
||||
* `priority`: 用于可视化高亮或排序。
|
||||
* `parent`, `children`: 用于甘特图中的层级显示。
|
||||
|
||||
### 4.2. 日历视图
|
||||
|
||||
* **显示模式:** 月、周、日视图。
|
||||
* **任务放置:**
|
||||
* 任务将主要根据其 `dueDate`(截止日期)放置。
|
||||
* 提供一个选项(用户设置)以使用 `scheduledDate`(计划日期)或 `startDate`(开始日期)作为放置的主要日期。
|
||||
* 跨越多天的任务(如果 `startDate` 和 `dueDate` 都可用)应在视觉上跨越这些天(在月视图中可能受限)。
|
||||
* 没有相关日期的任务可能会根据用户偏好显示在一个单独的"未安排"面板中或隐藏。
|
||||
* **可视化表示:**
|
||||
* 在日历单元格内显示任务 `content`(如有必要则截断)。
|
||||
* 使用视觉提示(颜色、图标)来指示状态 (`completed`)、优先级或项目。
|
||||
* 考虑根据任务映射到的日期字段(`dueDate`, `scheduledDate`, `startDate`)使用不同的视觉样式。
|
||||
* **交互性:**
|
||||
* 鼠标悬停在任务上时,在工具提示中显示完整的 `content` 和关键细节(日期、项目、状态)。
|
||||
* 点击任务可以导航到源文件/行或打开一个详细信息模态框。
|
||||
* **(可选 V2)** 拖放重新安排:允许将任务拖动到不同的日期以更新其 `dueDate` / `scheduledDate` / `startDate`(需要调用 `TaskIndexer.updateTask`)。
|
||||
* **导航与过滤:**
|
||||
* 用于在上一/下一周期(月、周、日)之间导航的控件。
|
||||
* 一个"今天"按钮。
|
||||
* 基于 `project`, `tags`, `status`, `filePath` 的过滤选项。
|
||||
|
||||
### 4.3. 甘特图视图
|
||||
|
||||
* **时间线显示:**
|
||||
* 水平轴代表时间(日、周、月缩放级别)。
|
||||
* 垂直轴列出任务。
|
||||
* **任务表示:**
|
||||
* 同时具有 `startDate` 和 `dueDate` 的任务显示为跨越该持续时间的条形。
|
||||
* 只有一个相关日期(`dueDate` 或 `startDate`)的任务在该日期显示为里程碑(例如,菱形)。
|
||||
* 任务条应显示 `content`(截断)。
|
||||
* 根据 `project` 或 `status` 对条形进行颜色编码。
|
||||
* 在条形上视觉指示完成进度(例如,填充部分)。
|
||||
* **层级结构:**
|
||||
* 利用 `parent` 和 `children` 字段以层级树结构显示任务(例如,使用缩进和折叠/展开控件)。子任务应在视觉上嵌套在父任务下。
|
||||
* **依赖关系:**
|
||||
* **(可选 V2/V3)** 如果建立了可靠的定义依赖关系的机制(例如,特定的链接语法 `dependsOn:[[task-id]]` 或 `blocks:[[task-id]]`),则可视化任务依赖关系。在依赖的任务之间绘制箭头。*由于数据模型的限制,初始版本可能会省略明确的依赖线。*
|
||||
* **交互性:**
|
||||
* 鼠标悬停在任务条/里程碑上时,在工具提示中显示完整细节。
|
||||
* 点击可导航到源文件或打开详细信息模态框。
|
||||
* 时间线的水平滚动和缩放(放大/缩小)。
|
||||
* 任务列表的垂直滚动。
|
||||
* **(可选 V2)** 拖放调整 `startDate` / `dueDate`。拖动条形的两端或整个条形。
|
||||
* **过滤与排序:**
|
||||
* 类似于日历视图的过滤选项 (`project`, `tags`, `status`, `filePath`)。
|
||||
* 任务列表的排序选项(例如,按 `startDate`, `dueDate`, `priority`, `content`)。
|
||||
|
||||
## 5. 非目标(初期)
|
||||
|
||||
* 直接从日历/甘特图视图创建新任务(首先关注可视化)。
|
||||
* 高级资源分配或关键路径分析。
|
||||
* 高度复杂的依赖类型(完成-开始,开始-开始等),除非可以轻松推导。
|
||||
* 实时多用户协作功能。
|
||||
|
||||
## 6. 实现步骤
|
||||
|
||||
1. **技术选型与基础:**
|
||||
* **主要技术:** 使用 TypeScript、HTML、CSS 以及可能的 SVG 或 Canvas 进行核心渲染和交互逻辑。如果插件已使用 Svelte,则优先利用 Svelte 构建组件。
|
||||
* **第三方库:** **原则上避免使用大型 UI/图表库 (如 FullCalendar, Frappe Gantt 等)。** 只有在遇到极其复杂且独立的算法问题(例如,高效的二维空间重叠检测、复杂的布局算法)且自研成本过高时,才考虑引入 **小型、专注** 的辅助库。任何引入的库都需要仔细评估其大小、性能和许可证。
|
||||
2. **视图搭建:** 为日历和甘特图创建新的 Obsidian `ItemView` 类。使用选定的基础技术(原生 DOM 或 Svelte)构建视图的基本结构和布局。
|
||||
3. **数据获取与转换:**
|
||||
* 访问 `TaskIndexer` 实例。
|
||||
* 在每个视图中,使用 `TaskIndexer.getCache()` 或 `TaskIndexer.queryTasks()` 获取任务数据。
|
||||
* **手动** 将 `Task` 对象转换为适合在视图中渲染的结构(例如,计算日历单元格位置、甘特图条形坐标和尺寸)。密切关注日期对象的处理和转换。
|
||||
4. **渲染逻辑:**
|
||||
* **手动实现渲染逻辑。** 基于转换后的数据,使用 DOM 操作、SVG 元素创建或 Canvas 绘图 API 来绘制日历网格、任务条目、甘特图时间轴和任务。
|
||||
* 实现切换视图模式(月/周/日)或缩放级别时 **重新计算布局和重新渲染** 的逻辑。
|
||||
5. **交互性实现:**
|
||||
* **手动** 绑定事件监听器 (e.g., `mouseover`, `click`, `mousedown`, `mousemove`, `mouseup`) 来实现工具提示、点击导航/模态框以及拖放功能。
|
||||
* 对于拖放,需要手动计算拖动过程中的元素位置、检测放置目标、更新 `Task` 对象并调用 `TaskIndexer.updateTask`,最后刷新视图。对高频事件(如 `mousemove`)使用防抖/节流。
|
||||
6. **过滤/排序 UI:** 添加用于过滤和排序的 UI 控件(原生 HTML 或 Svelte 组件)。实现当控件更改时重新获取/重新过滤数据并 **触发视图重新渲染** 的逻辑。
|
||||
7. **样式:** 应用 CSS 以确保视图与 Obsidian 的主题匹配并具有视觉吸引力。利用 Obsidian CSS 变量。重点关注布局、定位和自定义元素的视觉样式。
|
||||
8. **优化:** **这是原生实现的关键环节。**
|
||||
* 使用大量任务对视图进行性能分析。
|
||||
* **手动实现性能优化:**
|
||||
* **DOM 优化:** 减少 DOM 操作次数,使用 `requestAnimationFrame` 调度更新,考虑 DOM diffing 或 Svelte 的响应式更新(如果使用)。
|
||||
* **渲染优化:** 对于甘特图等可能包含大量元素的视图,实现 **视口虚拟化**(仅渲染可见区域内的元素)。对于 Canvas,优化绘图调用。对于 SVG,管理元素数量。
|
||||
* **事件处理优化:** 对滚动、缩放、拖动等高频事件使用防抖/节流。
|
||||
* **数据处理优化:** 确保数据转换和布局计算高效。
|
||||
|
||||
## 7. 关键考虑因素
|
||||
|
||||
* **性能:** **(更加关键)** 由于缺少库的内置优化,需要开发者投入大量精力进行手动性能调优,尤其是在处理大量任务时。
|
||||
* **开发复杂度:** 从零开始构建复杂的日历和甘特图 UI 比使用现有库需要更多的时间和精力,特别是在处理布局、交互和跨浏览器/平台一致性方面。
|
||||
* **日期处理:** 使用健壮的日期库(例如 `moment.js` 或 `date-fns` - 检查 Obsidian 或其他插件可能已包含哪些以最小化体积)。一致地处理时区(Obsidian/系统默认或 UTC)。明确定义如何处理缺少 `startDate`/`dueDate` 的任务。
|
||||
* **状态管理:** 有效管理视图的状态(当前日期范围、过滤器、缩放级别)。
|
||||
* **错误处理:** 优雅地处理数据获取、转换或渲染过程中的错误。
|
||||
* **任务更新:** 确保通过 `TaskIndexer.updateTask`(例如,通过拖放)进行的更新能正确地将更改持久化回 Markdown 文件。向用户提供成功/失败的反馈。
|
||||
* **代码可维护性:** 自定义实现的 UI 代码可能比使用标准化库更难维护,需要良好的代码结构和文档。
|
||||
* **配置:** 提供用户设置来自定义视图行为(例如,默认日期字段、未安排任务的可见性、日期格式)。
|
||||
|
||||
## 8. 潜在挑战与解决方案
|
||||
|
||||
* **挑战:** **从零开始构建 UI 的复杂性与工作量。**
|
||||
* **解决方案:**
|
||||
* **分阶段实现:** 从最核心的功能(例如,基本的月视图、无交互的甘特图条)开始,逐步迭代添加更复杂的功能(周/日视图、缩放、拖放、层级)。
|
||||
* **抽象与组件化:** 即使不使用外部框架,也要将 UI 逻辑分解为可重用的函数或类/组件(如果使用 Svelte)。
|
||||
* **专注核心价值:** 优先实现对用户最有价值的功能,对于复杂但次要的功能(如复杂的依赖线绘制)可以推迟或简化。
|
||||
* **挑战:** **手动实现高性能渲染和虚拟化。**
|
||||
* **解决方案:**
|
||||
* **深入理解渲染瓶颈:** 使用浏览器开发者工具分析性能,找出是 DOM 操作、计算还是绘制过程的瓶颈。
|
||||
* **学习虚拟滚动技术:** 研究常见的虚拟滚动实现模式,并将其应用于任务列表和时间轴。
|
||||
* **按需渲染:** 确保仅在数据或视图状态实际更改时才重新渲染,并尽可能只更新变化的部分。
|
||||
* **挑战:** 处理各种交互(拖放、缩放、滚动)的细节和边缘情况。
|
||||
* **解决方案:**
|
||||
* **仔细规划交互逻辑:** 在编码前明确定义每次交互的状态转换和预期行为。
|
||||
* **单元测试:** 为交互逻辑编写测试用例,覆盖各种场景。
|
||||
* **参考现有实现:** 研究开源项目或文章中类似交互的实现方式(即使不直接使用代码)。
|
||||
* **挑战:** 双向数据同步(UI <-> Markdown)。
|
||||
* **解决方案:** 依赖 `TaskIndexer` 的 `updateTask` 和 `deleteTask`。确保更新是具体的,并针对正确的行/任务。处理在视图外部可能发生的文件修改或竞态条件。在索引器成功更新后刷新视图。
|
||||
* **挑战:** 处理多样化的日期格式和部分日期。
|
||||
* **解决方案:** 解析后内部统一使用 ISO 8601 / Unix 时间戳。直接使用 `TaskIndexer` 的日期字段,因为它们应该已经被解析为 `number`(时间戳)。为缺少 `startDate` 或 `dueDate` 的任务定义清晰的逻辑(例如,视为里程碑,根据可用日期放置,省略)。
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
# Task View 功能设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
Task View 是 Task Genius 插件的核心功能模块,旨在为 Obsidian 提供统一的任务管理界面,不破坏原生文本记录体验的同时,提供类似 OmniFocus 的任务管理功能,并支持与现有 Tasks 插件的兼容集成。
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
- 任务收集与索引
|
||||
- 自定义视图 (Perspectives)
|
||||
- 任务过滤和分组
|
||||
- 任务编辑
|
||||
- 任务状态追踪
|
||||
- Tasks 插件兼容支持
|
||||
|
||||
## 3. 技术架构
|
||||
|
||||
### 3.1 基础组件
|
||||
|
||||
- **ItemView**: 使用 Obsidian 提供的 `ItemView` 创建任务视图
|
||||
- **TypeScript**: 使用原生 TypeScript 实现界面渲染
|
||||
- **EventEmitter**: 处理视图更新和数据变化
|
||||
- **Parser**: 解析 Tasks 插件兼容的任务语法
|
||||
|
||||
### 3.2 数据缓存方案
|
||||
|
||||
```typescript
|
||||
interface TaskCache {
|
||||
tasks: Map<string, Task>; // taskId -> Task
|
||||
files: Map<string, Set<string>>; // filePath -> Set<taskIds>
|
||||
tags: Map<string, Set<string>>; // tag -> Set<taskIds>
|
||||
projects: Map<string, Set<string>>; // project -> Set<taskIds>
|
||||
contexts: Map<string, Set<string>>; // context -> Set<taskIds>
|
||||
dueDate: Map<string, Set<string>>; // dueDate -> Set<taskIds>
|
||||
startDate: Map<string, Set<string>>; // startDate -> Set<taskIds>
|
||||
scheduledDate: Map<string, Set<string>>; // scheduledDate -> Set<taskIds>
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string; // unique identifier
|
||||
content: string; // task content
|
||||
filePath: string; // file path
|
||||
line: number; // line number
|
||||
completed: boolean; // completion status
|
||||
createdDate?: number; // creation date
|
||||
startDate?: number; // start date (Tasks plugin compatible)
|
||||
scheduledDate?: number; // scheduled date (Tasks plugin compatible)
|
||||
dueDate?: number; // due date
|
||||
completedDate?: number; // completion date
|
||||
recurrence?: string; // recurrence rule (Tasks plugin compatible)
|
||||
tags: string[]; // tags
|
||||
project?: string; // project
|
||||
context?: string; // context
|
||||
priority?: number; // priority
|
||||
parent?: string; // parent task ID
|
||||
children: string[]; // child task ID list
|
||||
originalMarkdown: string; // original markdown text
|
||||
estimatedTime?: number; // estimated time in minutes
|
||||
actualTime?: number; // actual time spent in minutes
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 任务解析器
|
||||
|
||||
专门处理 Tasks 插件兼容的语法解析:
|
||||
|
||||
```typescript
|
||||
class TaskParser {
|
||||
// Regular expressions for Tasks plugin syntax
|
||||
private readonly startDateRegex = /📅 (\d{4}-\d{2}-\d{2})/;
|
||||
private readonly completedDateRegex = /✅ (\d{4}-\d{2}-\d{2})/;
|
||||
private readonly dueDateRegex = /⏳ (\d{4}-\d{2}-\d{2})/;
|
||||
private readonly scheduledDateRegex = /⏰ (\d{4}-\d{2}-\d{2})/;
|
||||
private readonly recurrenceRegex = /🔁 (.*?)(?=\s|$)/;
|
||||
private readonly priorityRegex = /🔼|⏫|🔽/;
|
||||
|
||||
parseTask(text: string, filePath: string, lineNum: number): Task {
|
||||
// Basic task info
|
||||
const task: Task = {
|
||||
id: generateUniqueId(),
|
||||
content: text.replace(/- \[.\] /, ''),
|
||||
filePath,
|
||||
line: lineNum,
|
||||
completed: text.includes('- [x]'),
|
||||
tags: [],
|
||||
children: [],
|
||||
originalMarkdown: text
|
||||
};
|
||||
|
||||
// Parse Tasks plugin syntax
|
||||
const startDateMatch = text.match(this.startDateRegex);
|
||||
if (startDateMatch) {
|
||||
task.startDate = new Date(startDateMatch[1]).getTime();
|
||||
}
|
||||
|
||||
// Parse other metadata...
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
generateMarkdown(task: Task): string {
|
||||
// Convert task object back to markdown format
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 索引方案
|
||||
|
||||
1. **初始化索引**:
|
||||
- 使用 Obsidian 的 `vault.getMarkdownFiles()` 获取所有 Markdown 文件
|
||||
- 解析文件中的任务,构建初始缓存
|
||||
- 识别 Tasks 插件语法,提取元数据
|
||||
|
||||
2. **实时更新**:
|
||||
- 监听 Obsidian 的 `modify` 事件更新缓存
|
||||
- 使用 `InlineWorker` 在后台处理大型文件更新
|
||||
- 增量更新策略,只更新修改的行
|
||||
|
||||
```typescript
|
||||
class TaskIndexer {
|
||||
private taskCache: TaskCache;
|
||||
private worker: Worker | null = null;
|
||||
private parser: TaskParser;
|
||||
private lastIndexTime: Map<string, number> = new Map();
|
||||
|
||||
constructor(plugin: TaskGeniusPlugin) {
|
||||
this.taskCache = this.initEmptyCache();
|
||||
this.parser = new TaskParser();
|
||||
this.setupEventListeners(plugin);
|
||||
|
||||
if (window.Worker) {
|
||||
this.worker = new Worker('indexer-worker.js');
|
||||
this.worker.onmessage = this.handleWorkerMessage.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
async indexFile(file: TFile, plugin: TaskGeniusPlugin): Promise<void> {
|
||||
const fileContent = await plugin.app.vault.read(file);
|
||||
const lines = fileContent.split('\n');
|
||||
const taskIds: Set<string> = new Set();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (this.isTaskLine(line)) {
|
||||
const task = this.parser.parseTask(line, file.path, i);
|
||||
this.taskCache.tasks.set(task.id, task);
|
||||
taskIds.add(task.id);
|
||||
|
||||
// Update index maps
|
||||
this.updateIndexMaps(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Update file index
|
||||
this.taskCache.files.set(file.path, taskIds);
|
||||
this.lastIndexTime.set(file.path, Date.now());
|
||||
}
|
||||
|
||||
private updateIndexMaps(task: Task): void {
|
||||
// Add to tag index
|
||||
task.tags.forEach(tag => {
|
||||
const tasks = this.taskCache.tags.get(tag) || new Set();
|
||||
tasks.add(task.id);
|
||||
this.taskCache.tags.set(tag, tasks);
|
||||
});
|
||||
|
||||
// Add to date indexes
|
||||
if (task.startDate) {
|
||||
const dateStr = this.formatDate(task.startDate);
|
||||
const tasks = this.taskCache.startDate.get(dateStr) || new Set();
|
||||
tasks.add(task.id);
|
||||
this.taskCache.startDate.set(dateStr, tasks);
|
||||
}
|
||||
|
||||
// Update other indexes...
|
||||
}
|
||||
|
||||
// Helper methods...
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 设置项
|
||||
|
||||
1. **基本设置**:
|
||||
- 任务识别格式 (默认: `- [ ]`)
|
||||
- 完成任务格式 (默认: `- [x]`)
|
||||
- 排除文件夹列表
|
||||
- Tasks 插件兼容模式开关
|
||||
|
||||
2. **视图设置**:
|
||||
- 默认视图 (今日/收件箱/项目等)
|
||||
- 显示列 (标签/截止日期/优先级等)
|
||||
- 分组方式 (按项目/日期/标签等)
|
||||
- 排序方式 (按优先级/创建时间/名称等)
|
||||
|
||||
3. **日期格式设置**:
|
||||
- 起始日期表示方式 (`📅`, `start:` 等)
|
||||
- 截止日期表示方式 (`⏳`, `due:` 等)
|
||||
- 计划日期表示方式 (`⏰`, `scheduled:` 等)
|
||||
- 日期格式 (YYYY-MM-DD, MM/DD/YYYY 等)
|
||||
|
||||
4. **元数据设置**:
|
||||
- 特殊标签前缀 (如项目标签、上下文标签)
|
||||
- 优先级表示方式 (`🔼`, `⏫`, `priority:` 等)
|
||||
- 时间估算表示方式 (`estimate:` 等)
|
||||
|
||||
5. **快捷键**:
|
||||
- 打开任务视图
|
||||
- 快速添加任务
|
||||
- 任务完成/取消
|
||||
- 视图切换
|
||||
|
||||
## 5. 自定义视图 (Perspectives)
|
||||
|
||||
类似 OmniFocus 的 Perspectives,允许用户创建自定义视图:
|
||||
|
||||
```typescript
|
||||
interface Perspective {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
filters: TaskFilter[];
|
||||
groupBy?: GroupingMethod;
|
||||
sortBy: SortingCriteria[];
|
||||
columns: ColumnDefinition[];
|
||||
savedSearches?: SavedSearch[];
|
||||
}
|
||||
|
||||
interface TaskFilter {
|
||||
type: 'tag' | 'project' | 'context' | 'dueDate' | 'startDate' |
|
||||
'scheduledDate' | 'status' | 'priority' | 'recurrence';
|
||||
operator: '=' | '!=' | '<' | '>' | 'contains' | 'empty' | 'not-empty' | 'before' | 'after';
|
||||
value: any;
|
||||
conjunction?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: TaskFilter[];
|
||||
}
|
||||
```
|
||||
|
||||
默认视图:
|
||||
- 收件箱 (无项目/上下文的任务)
|
||||
- 今日任务 (今日截止或标记为今日)
|
||||
- 已规划 (已分配项目的任务)
|
||||
- 即将开始 (有起始日期的任务)
|
||||
- 已安排 (有计划日期的任务)
|
||||
- 已完成 (最近完成的任务)
|
||||
|
||||
## 6. 数据查询与过滤引擎
|
||||
|
||||
```typescript
|
||||
class TaskQueryEngine {
|
||||
constructor(private taskCache: TaskCache) {}
|
||||
|
||||
query(filters: TaskFilter[], sortBy: SortingCriteria[]): Task[] {
|
||||
// Initial set is all tasks
|
||||
let taskIds = new Set<string>();
|
||||
|
||||
// Get initial task set
|
||||
if (filters.length === 0) {
|
||||
this.taskCache.tasks.forEach((_, id) => taskIds.add(id));
|
||||
} else {
|
||||
// Apply each filter
|
||||
filters.forEach((filter, index) => {
|
||||
const filteredSet = this.applyFilter(filter);
|
||||
|
||||
if (index === 0) {
|
||||
taskIds = filteredSet;
|
||||
} else {
|
||||
// Apply conjunction (AND/OR) with previous results
|
||||
if (filter.conjunction === 'OR') {
|
||||
// Union sets
|
||||
filteredSet.forEach(id => taskIds.add(id));
|
||||
} else {
|
||||
// Intersection (AND is default)
|
||||
taskIds = new Set([...taskIds].filter(id => filteredSet.has(id)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to task array
|
||||
const tasks = [...taskIds].map(id => this.taskCache.tasks.get(id)!);
|
||||
|
||||
// Apply sorting
|
||||
return this.applySorting(tasks, sortBy);
|
||||
}
|
||||
|
||||
private applyFilter(filter: TaskFilter): Set<string> {
|
||||
switch (filter.type) {
|
||||
case 'dueDate':
|
||||
return this.filterByDate(this.taskCache.dueDate, filter);
|
||||
case 'startDate':
|
||||
return this.filterByDate(this.taskCache.startDate, filter);
|
||||
case 'scheduledDate':
|
||||
return this.filterByDate(this.taskCache.scheduledDate, filter);
|
||||
// Other filter types...
|
||||
}
|
||||
}
|
||||
|
||||
private filterByDate(dateMap: Map<string, Set<string>>, filter: TaskFilter): Set<string> {
|
||||
// Date filter implementation
|
||||
// ...
|
||||
}
|
||||
|
||||
private applySorting(tasks: Task[], sortBy: SortingCriteria[]): Task[] {
|
||||
// Sorting implementation
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据持久化
|
||||
|
||||
1. **缓存持久化**:
|
||||
- 将任务索引存储在 `.obsidian/plugins/task-genius/cache` 目录
|
||||
- 启动时快速加载缓存,然后在后台验证/更新
|
||||
- 定期自动保存以防数据丢失
|
||||
|
||||
2. **设置与视图持久化**:
|
||||
- 使用 Obsidian 的 `saveData` 和 `loadData` API
|
||||
- 将自定义视图和设置存储在 `.obsidian/plugins/task-genius/data.json`
|
||||
- 支持导入/导出自定义视图配置
|
||||
|
||||
3. **数据迁移**:
|
||||
- 支持从 Tasks 插件迁移设置和数据
|
||||
- 版本升级自动数据迁移机制
|
||||
|
||||
## 8. 性能考量
|
||||
|
||||
1. **增量更新**:
|
||||
- 只更新变更的文件,避免全局重新索引
|
||||
- 使用文件修改时间戳判断是否需要更新
|
||||
- 行级别的差异检测,只处理修改的任务
|
||||
|
||||
2. **延迟加载**:
|
||||
- 应用启动时只加载基本视图结构
|
||||
- 按需加载详细任务数据
|
||||
- 视图滚动时动态加载更多任务
|
||||
|
||||
3. **分批处理**:
|
||||
- 对大型库使用分批处理避免界面冻结
|
||||
- 使用 `requestIdleCallback` 优化处理时机
|
||||
- 基于用户交互优先级调整处理队列
|
||||
|
||||
4. **缓存策略**:
|
||||
- 多级缓存策略:内存、IndexedDB 和文件
|
||||
- LRU 缓存策略清理不常用数据
|
||||
- 压缩持久化数据减少存储需求
|
||||
|
||||
## 9. 用户界面
|
||||
|
||||
基于 OmniFocus 风格设计:
|
||||
- 左侧视图切换栏(自定义视图列表)
|
||||
- 上方过滤和搜索栏(高级过滤选项)
|
||||
- 中间任务列表区域(支持分组和折叠)
|
||||
- 右侧任务详情区域(元数据编辑)
|
||||
- 底部信息栏(统计和快速操作)
|
||||
|
||||
UI 组件:
|
||||
- 任务列表组件(支持嵌套、分组、批量操作)
|
||||
- 任务编辑器(支持快速编辑任务元数据)
|
||||
- 日期选择器(适配 Tasks 插件日期格式)
|
||||
- 快速过滤栏(预设过滤条件)
|
||||
- 拖放支持(重新排序和组织任务)
|
||||
|
||||
## 10. 与 Tasks 插件兼容
|
||||
|
||||
1. **语法兼容**:
|
||||
- 完全支持 Tasks 插件的任务语法
|
||||
- 兼容 Tasks 的日期格式 (📅, ⏳, ⏰)
|
||||
- 支持 Tasks 的优先级标记 (🔼, ⏫, 🔽)
|
||||
- 支持 Tasks 的重复任务语法 (🔁)
|
||||
|
||||
2. **功能兼容**:
|
||||
- 提供 Tasks 插件主要功能的超集
|
||||
- 可与 Tasks 插件并存,互不干扰
|
||||
- 可读取 Tasks 插件的设置和任务
|
||||
|
||||
3. **迁移工具**:
|
||||
- 提供从 Tasks 插件迁移配置的向导
|
||||
- 任务格式双向转换支持
|
||||
|
||||
## 11. 开发路线图
|
||||
|
||||
1. 第一阶段: 基础功能与 Tasks 兼容
|
||||
- 任务索引与缓存系统
|
||||
- Tasks 插件语法兼容
|
||||
- 基本视图与过滤
|
||||
- 任务编辑
|
||||
|
||||
2. 第二阶段: 高级功能
|
||||
- 自定义视图 (Perspectives)
|
||||
- 高级查询语言
|
||||
- 批量编辑功能
|
||||
- 任务依赖关系
|
||||
|
||||
3. 第三阶段: 性能优化与扩展
|
||||
- 大型库优化
|
||||
- 移动端支持
|
||||
- API 供其他插件使用
|
||||
- 插件集成能力
|
||||
|
||||
4. 第四阶段: 自动化与智能功能
|
||||
- 任务自动分类
|
||||
- 智能排序建议
|
||||
- 时间估算和提醒
|
||||
- 进度跟踪和报告
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Test Tasks for Advanced Filtering
|
||||
|
||||
## Basic Tasks
|
||||
|
||||
- [ ] A simple task
|
||||
- [x] A completed task
|
||||
- [>] An in-progress task
|
||||
- [-] An abandoned task
|
||||
- [?] A planned task
|
||||
|
||||
## Tasks with Tags
|
||||
|
||||
- [ ] Task with #tag1
|
||||
- [ ] Task with #tag2 and #tag3
|
||||
- [x] Completed task with #tag1 and #tag2
|
||||
- [ ] Task with #important #work
|
||||
|
||||
## Tasks with Priorities
|
||||
|
||||
- [ ] [#A] High priority task
|
||||
- [ ] [#B] Medium priority task
|
||||
- [ ] [#C] Low priority task
|
||||
- [x] [#A] Completed high priority task
|
||||
- [ ] 🔺 Highest priority task (priorityPicker.ts标准)
|
||||
- [ ] ⏫ High priority task (priorityPicker.ts标准)
|
||||
- [ ] 🔼 Medium priority task (priorityPicker.ts标准)
|
||||
- [ ] 🔽 Low priority task (priorityPicker.ts标准)
|
||||
- [ ] ⏬️ Lowest priority task (priorityPicker.ts标准)
|
||||
- [ ] 🔴 High priority task (颜色优先级)
|
||||
- [ ] 🟠 Medium priority task (颜色优先级)
|
||||
- [ ] 🟡 Medium-low priority task (颜色优先级)
|
||||
- [ ] 🟢 Low priority task (颜色优先级)
|
||||
- [ ] 🔵 Low-lowest priority task (颜色优先级)
|
||||
- [ ] ⚪️ Lowest priority task (颜色优先级)
|
||||
- [ ] ⚫️ Below lowest priority task (颜色优先级)
|
||||
|
||||
## Tasks with Dates
|
||||
|
||||
- [ ] Task due on 2023-05-15
|
||||
- [ ] Task due on 2023-08-22
|
||||
- [x] Completed task from 2022-01-10
|
||||
- [ ] Task planned for 2024-01-01
|
||||
- [ ] Meeting on 2023-07-15 with John #meeting
|
||||
|
||||
## Complex Tasks
|
||||
|
||||
- [ ] [#A] Important task with #project1 due on 2023-06-30
|
||||
- [x] [#B] Completed task with #project1 and #project2 from 2023-04-15
|
||||
- [>] ⏫ In-progress high priority task with #urgent due tomorrow 2023-05-10
|
||||
- [ ] 🔽 Low priority task with #waiting #followup for 2023-09-01
|
||||
- [-] 🔼 Abandoned medium priority task from 2023-02-28 #cancelled
|
||||
|
||||
## Nested Tasks
|
||||
|
||||
- [ ] Parent task 1
|
||||
- [ ] Child task 1.1
|
||||
- [x] Child task 1.2
|
||||
- [ ] Child task 1.3
|
||||
- [ ] Grandchild task 1.3.1
|
||||
- [>] Grandchild task 1.3.2 #inprogress
|
||||
- [ ] Parent task 2 [#A] with #important tag
|
||||
- [ ] Child task 2.1 due on 2023-07-20
|
||||
- [x] Child task 2.2 completed on 2023-06-15
|
||||
- [ ] Parent task 3
|
||||
- [-] Abandoned child task 3.1
|
||||
- [?] Planned child task 3.2 for 2023-10-01
|
||||
|
||||
## Advanced Filter Examples
|
||||
|
||||
Here are some example filters you can try:
|
||||
|
||||
1. Find all highest priority tasks: `PRIORITY:🔺`
|
||||
2. Find all high priority tasks: `PRIORITY:#A` or `PRIORITY:⏫` or `PRIORITY:🔴`
|
||||
3. Find all tasks with medium priority or higher: `PRIORITY:<=#B` or `PRIORITY:<=🔼`
|
||||
4. Find all tasks not with low priority: `PRIORITY:!=🔽` or `PRIORITY:!=🟢`
|
||||
5. Find tasks due before August 2023: `DATE:<2023-08-01`
|
||||
6. Find tasks due on or after January 1, 2024: `DATE:>=2024-01-01`
|
||||
7. Find high priority tasks about projects: `(PRIORITY:⏫ OR PRIORITY:🔴) AND project`
|
||||
8. Find tasks with tag1 that aren't completed: `#tag1 AND NOT [x]`
|
||||
9. Find all high priority tasks that contain "important" or have the #urgent tag: `(PRIORITY:#A OR PRIORITY:⏫ OR PRIORITY:🔴) AND (important OR #urgent)`
|
||||
10. Complex filter: `(#project1 OR #project2) AND (PRIORITY:<=🔼 OR PRIORITY:<=#B) AND DATE:>=2023-01-01 AND NOT (abandoned OR cancelled)`
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
# Task Progress Bar Plugin - TODO List
|
||||
|
||||
## 特性开发计划
|
||||
|
||||
### 任务状态循环增强
|
||||
- [ ] 实现可配置的任务工作流及自定义循环
|
||||
- [ ] 添加任务状态变更时间戳记录功能
|
||||
- [ ] 时间戳使用日期体现
|
||||
- [ ] 创建工作流配置设置界面
|
||||
- [ ] 支持右键菜单跳转到特定状态
|
||||
- [ ] 跳转至特定状态后再增加子任务
|
||||
|
||||
### 工作流系统
|
||||
- [ ] 设计工作流配置架构
|
||||
- [ ] 定义工作流模板(如专利流程、项目管理)
|
||||
- [ ] 允许自定义工作流创建
|
||||
- [ ] 实现工作流状态持久化
|
||||
- [ ] 添加工作流进度可视化
|
||||
|
||||
### 专利流程工作流示例
|
||||
- [ ] 创建专利工作流模板,包含以下阶段:
|
||||
- [ ] 开案
|
||||
- [ ] 交流交底(循环)
|
||||
- [ ] 等待提问
|
||||
- [ ] 等待回答
|
||||
- [ ] 撰写(可重复)
|
||||
- [ ] 审核(可重复)
|
||||
- [ ] 为每个阶段转换添加时间戳记录
|
||||
- [ ] 实现当前阶段状态指示器
|
||||
|
||||
## 实现方案
|
||||
|
||||
本插件提供两种核心实现方式:基于JSON配置的结构化实现和基于纯文本Markdown的轻量级实现。
|
||||
|
||||
### 实现方案一:结构化JSON配置
|
||||
|
||||
#### 配置结构
|
||||
- [ ] 创建工作流定义的JSON架构
|
||||
- [ ] 设计工作流管理的设置UI
|
||||
- [ ] 实现工作流模板的导入/导出功能
|
||||
|
||||
#### 用户界面
|
||||
- [ ] 在任务上下文菜单中添加工作流选择下拉框
|
||||
- [ ] 创建当前工作流阶段的视觉指示器
|
||||
- [ ] 实现进度可视化(时间线或进度条)
|
||||
- [ ] 添加显示阶段历史和时间戳的悬停提示
|
||||
|
||||
#### 交互模型
|
||||
- [ ] 左键点击:按顺序进入下一阶段
|
||||
- [ ] 右键点击:打开跳转到任意阶段的上下文菜单
|
||||
- [ ] Shift+点击:标记为循环(稍后返回此阶段)
|
||||
- [ ] Alt+点击:为阶段转换添加注释/备注
|
||||
|
||||
#### 专利流程工作流JSON配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "patent_process",
|
||||
"name": "专利处理流程",
|
||||
"description": "标准专利处理工作流程",
|
||||
"stages": [
|
||||
{
|
||||
"id": "case_opening",
|
||||
"name": "开案",
|
||||
"type": "linear",
|
||||
"next": "disclosure_communication"
|
||||
},
|
||||
{
|
||||
"id": "disclosure_communication",
|
||||
"name": "交流交底",
|
||||
"type": "cycle",
|
||||
"subStages": [
|
||||
{
|
||||
"id": "waiting_questions",
|
||||
"name": "等待提问",
|
||||
"next": "waiting_answers"
|
||||
},
|
||||
{
|
||||
"id": "waiting_answers",
|
||||
"name": "等待回答",
|
||||
"next": "waiting_questions"
|
||||
}
|
||||
],
|
||||
"canProceedTo": ["drafting", "case_closed"]
|
||||
},
|
||||
{
|
||||
"id": "drafting",
|
||||
"name": "撰写",
|
||||
"type": "cycle",
|
||||
"canProceedTo": ["review", "disclosure_communication"]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"name": "审核",
|
||||
"type": "cycle",
|
||||
"canProceedTo": ["drafting", "case_closed"]
|
||||
},
|
||||
{
|
||||
"id": "case_closed",
|
||||
"name": "结案",
|
||||
"type": "terminal"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"created": "2024-03-20",
|
||||
"lastModified": "2024-03-20"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 数据结构设计
|
||||
1. 工作流定义(WorkflowDefinition)
|
||||
- 基本信息(ID、名称、描述)
|
||||
- 阶段列表
|
||||
- 元数据(版本、创建时间等)
|
||||
|
||||
2. 阶段定义(StageDefinition)
|
||||
- 基本信息(ID、名称)
|
||||
- 类型(linear/cycle/terminal)
|
||||
- 子阶段(针对循环类型)
|
||||
- 可跳转目标(canProceedTo)
|
||||
|
||||
3. 任务状态(TaskState)
|
||||
- 当前阶段
|
||||
- 阶段历史记录
|
||||
- 时间戳记录
|
||||
- 备注信息
|
||||
|
||||
### 实现方案二:基于纯文本Markdown的工作流实现
|
||||
|
||||
#### 核心设计原则
|
||||
- 利用现有Markdown任务语法(- [ ] 和 - [x])
|
||||
- 最小化额外标记
|
||||
- 使用原生日期标记和Obsidian块引用
|
||||
|
||||
#### 状态表示方法
|
||||
|
||||
##### 1. 基本状态表示
|
||||
```markdown
|
||||
- [ ] 任务描述 #workflow/专利 ^task-123
|
||||
```
|
||||
|
||||
在任务后添加工作流标签和块引用ID,用于状态追踪。
|
||||
|
||||
##### 2. 阶段标记
|
||||
```markdown
|
||||
- [ ] 任务描述 #workflow/专利/开案 ^task-123
|
||||
```
|
||||
|
||||
工作流标签使用嵌套结构表示工作流类型和当前阶段。
|
||||
|
||||
##### 3. 历史记录与时间戳
|
||||
```markdown
|
||||
- [ ] 任务描述 #workflow/专利/撰写 ^task-123
|
||||
- [x] #workflow/专利/开案 (2024-05-01)
|
||||
- [x] #workflow/专利/交流交底 (2024-05-03 → 2024-05-10)
|
||||
- [x] 等待提问 (2024-05-03)
|
||||
- [x] 等待回答 (2024-05-05)
|
||||
- [x] 等待提问 (2024-05-07)
|
||||
- [x] 等待回答 (2024-05-10)
|
||||
```
|
||||
|
||||
使用子任务记录历史阶段,日期标记表示完成时间,箭头表示周期性阶段的开始和结束。
|
||||
|
||||
##### 4. 循环阶段处理
|
||||
```markdown
|
||||
- [ ] 任务描述 #workflow/专利/交流交底/等待回答 ^task-123
|
||||
- [x] 等待提问 (2024-05-03)
|
||||
```
|
||||
|
||||
对于循环子阶段,在标签中添加子阶段名称,并用子任务记录循环历史。
|
||||
|
||||
#### 实现示例:专利工作流
|
||||
|
||||
```markdown
|
||||
## 专利任务
|
||||
|
||||
### 当前任务
|
||||
- [ ] 量子计算专利 #workflow/专利/撰写 ^patent-001
|
||||
- [x] #workflow/专利/开案 (2024-01-15)
|
||||
- [x] #workflow/专利/交流交底 (2024-01-20 → 2024-02-15)
|
||||
- [x] 等待提问 (2024-01-20)
|
||||
- [x] 等待回答 (2024-01-25)
|
||||
- [x] 等待提问 (2024-02-05)
|
||||
- [x] 等待回答 (2024-02-15)
|
||||
|
||||
- [ ] AI推理加速器专利 #workflow/专利/交流交底/等待回答 ^patent-002
|
||||
- [x] #workflow/专利/开案 (2024-03-10)
|
||||
- [ ] #workflow/专利/交流交底 (2024-03-15 →)
|
||||
- [x] 等待提问 (2024-03-15)
|
||||
- [ ] 等待回答
|
||||
```
|
||||
|
||||
#### 插件交互设计
|
||||
|
||||
##### 工作流解析规则
|
||||
1. 通过标签 `#workflow/{工作流类型}/{阶段}[/{子阶段}]` 识别工作流
|
||||
2. 通过块引用 `^task-id` 唯一标识任务
|
||||
3. 子任务表示历史记录
|
||||
4. 日期标记表示时间戳
|
||||
|
||||
##### 命令与快捷键
|
||||
1. 推进到下一阶段: 单击任务复选框
|
||||
- 更新当前任务的工作流标签
|
||||
- 添加已完成子任务记录历史阶段
|
||||
- 自动添加日期时间戳
|
||||
|
||||
2. 跳转到特定阶段: 右键菜单
|
||||
- 右键点击任务,显示可用阶段
|
||||
- 选择目标阶段,更新标签和添加历史记录
|
||||
|
||||
3. 添加注释: Alt+单击
|
||||
```markdown
|
||||
- [ ] 任务描述 #workflow/专利/撰写 ^task-123
|
||||
- [x] #workflow/专利/开案 (2024-01-15)
|
||||
- [x] #workflow/专利/交流交底 (2024-01-20 → 2024-02-15) - 客户需求变更多次
|
||||
```
|
||||
|
||||
##### 视觉增强
|
||||
1. 使用CSS添加视觉指示器
|
||||
- 显示当前阶段图标
|
||||
- 根据工作流阶段使用不同颜色
|
||||
|
||||
2. 悬停信息
|
||||
- 显示完整历史记录
|
||||
- 显示预计完成时间
|
||||
|
||||
#### 配置示例
|
||||
预定义工作流使用YAML前置元数据:
|
||||
|
||||
```yaml
|
||||
---
|
||||
workflows:
|
||||
- id: patent_process
|
||||
name: 专利处理流程
|
||||
stages:
|
||||
- id: case_opening
|
||||
name: 开案
|
||||
next: disclosure_communication
|
||||
- id: disclosure_communication
|
||||
name: 交流交底
|
||||
type: cycle
|
||||
subStages:
|
||||
- id: waiting_questions
|
||||
name: 等待提问
|
||||
- id: waiting_answers
|
||||
name: 等待回答
|
||||
next: ["drafting","case_closed"]
|
||||
- id: drafting
|
||||
name: 撰写
|
||||
next: ["review", "disclosure_communication"]
|
||||
- id: review
|
||||
name: 审核
|
||||
type: cycle
|
||||
next: ["drafting", "case_closed"]
|
||||
- id: case_closed
|
||||
name: 结案
|
||||
type: terminal
|
||||
---
|
||||
```
|
||||
|
||||
#### 兼容性与优势
|
||||
1. 无插件环境下仍可读取和手动更新
|
||||
2. 历史记录作为常规Markdown列表存在
|
||||
3. 与其他任务插件兼容
|
||||
4. 使用原生Obsidian功能(标签、块引用)易于查询和链接
|
||||
|
||||
## 后续优化方向
|
||||
|
||||
1. 工作流分析
|
||||
- 阶段耗时统计
|
||||
- 瓶颈分析
|
||||
- 效率报告
|
||||
|
||||
2. 协作功能
|
||||
- 多用户支持
|
||||
- 阶段分配
|
||||
- 通知系统
|
||||
|
||||
3. 自动化
|
||||
- 条件触发
|
||||
- 定时提醒
|
||||
- 自动推进规则
|
||||
|
||||
204
src/__tests__/TaskTimerSegments.test.ts
Normal file
204
src/__tests__/TaskTimerSegments.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { TaskTimerManager, TimerState, TimeSegment } from "../utils/TaskTimerManager";
|
||||
import { TaskTimerSettings } from "../common/setting-definition";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: { [key: string]: string } = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
describe("TaskTimerManager - Time Segments", () => {
|
||||
let manager: TaskTimerManager;
|
||||
const mockSettings: TaskTimerSettings = {
|
||||
enabled: true,
|
||||
blockRefPrefix: "timer",
|
||||
timeFormat: "{h}hrs{m}mins{s}s",
|
||||
metadataDetection: {
|
||||
frontmatter: "",
|
||||
folders: [],
|
||||
tags: []
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
manager = new TaskTimerManager(mockSettings);
|
||||
});
|
||||
|
||||
test("should create initial segment when starting timer", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
const state = manager.getTimerState(taskId);
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].startTime).toBeDefined();
|
||||
expect(state!.segments[0].endTime).toBeUndefined();
|
||||
expect(state!.status).toBe('running');
|
||||
});
|
||||
|
||||
test("should close segment when pausing timer", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
// Pause the timer
|
||||
manager.pauseTimer(taskId);
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].endTime).toBeDefined();
|
||||
expect(state!.segments[0].duration).toBeDefined();
|
||||
expect(state!.status).toBe('paused');
|
||||
});
|
||||
|
||||
test("should create new segment when resuming timer", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
// Pause and resume
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(2);
|
||||
expect(state!.segments[0].endTime).toBeDefined();
|
||||
expect(state!.segments[1].startTime).toBeDefined();
|
||||
expect(state!.segments[1].endTime).toBeUndefined();
|
||||
expect(state!.status).toBe('running');
|
||||
});
|
||||
|
||||
test("should calculate total duration across multiple segments", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
// Mock segments with known durations
|
||||
const state = manager.getTimerState(taskId)!;
|
||||
state.segments = [
|
||||
{ startTime: 1000, endTime: 2000, duration: 1000 },
|
||||
{ startTime: 3000, endTime: 4500, duration: 1500 },
|
||||
{ startTime: 5000 } // Current running segment
|
||||
];
|
||||
|
||||
// Mock current time
|
||||
const originalNow = Date.now;
|
||||
Date.now = jest.fn(() => 6000);
|
||||
|
||||
// Save state
|
||||
localStorage.setItem(taskId, JSON.stringify(state));
|
||||
|
||||
// Get duration
|
||||
const duration = manager.getCurrentDuration(taskId);
|
||||
|
||||
// Should be 1000 + 1500 + 1000 = 3500
|
||||
expect(duration).toBe(3500);
|
||||
|
||||
// Restore Date.now
|
||||
Date.now = originalNow;
|
||||
});
|
||||
|
||||
test("should migrate legacy format to segments", () => {
|
||||
const taskId = "taskTimer_test.md#legacy-123";
|
||||
|
||||
// Store legacy format
|
||||
const legacyState = {
|
||||
taskId,
|
||||
filePath: "test.md",
|
||||
blockId: "legacy-123",
|
||||
startTime: 1000,
|
||||
pausedTime: 5000,
|
||||
totalPausedDuration: 1000,
|
||||
status: "paused",
|
||||
createdAt: 1000
|
||||
};
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(legacyState));
|
||||
|
||||
// Get state (should trigger migration)
|
||||
const state = manager.getTimerState(taskId);
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].startTime).toBe(2000); // startTime + totalPausedDuration
|
||||
expect(state!.segments[0].endTime).toBe(5000);
|
||||
expect(state!.segments[0].duration).toBe(3000);
|
||||
expect(state!.legacyStartTime).toBe(1000);
|
||||
expect(state!.legacyPausedTime).toBe(5000);
|
||||
expect(state!.legacyTotalPausedDuration).toBe(1000);
|
||||
});
|
||||
|
||||
test("should handle multiple pause/resume cycles", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
// Simulate multiple work sessions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
}
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(4); // Initial + 3 resume segments
|
||||
|
||||
// First 3 segments should be closed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(state!.segments[i].endTime).toBeDefined();
|
||||
expect(state!.segments[i].duration).toBeDefined();
|
||||
}
|
||||
|
||||
// Last segment should be open
|
||||
expect(state!.segments[3].endTime).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should get segment count correctly", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
expect(manager.getSegmentCount(taskId)).toBe(1);
|
||||
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
|
||||
expect(manager.getSegmentCount(taskId)).toBe(2);
|
||||
});
|
||||
|
||||
test("should complete timer and calculate final duration", () => {
|
||||
// Mock time progression
|
||||
const originalNow = Date.now;
|
||||
let currentTime = 1000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Start timer
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
// Work for 5 seconds
|
||||
currentTime = 6000;
|
||||
|
||||
// Complete timer
|
||||
const formattedDuration = manager.completeTimer(taskId);
|
||||
|
||||
// Should format 5 seconds
|
||||
expect(formattedDuration).toContain("5");
|
||||
|
||||
// Timer should be removed
|
||||
expect(manager.getTimerState(taskId)).toBeNull();
|
||||
|
||||
// Restore Date.now
|
||||
Date.now = originalNow;
|
||||
});
|
||||
});
|
||||
115
src/__tests__/autoDateManager.debug.test.ts
Normal file
115
src/__tests__/autoDateManager.debug.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findMetadataInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
// Mock the plugin
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
taskStatuses: {
|
||||
completed: "x|X",
|
||||
inProgress: "/|-",
|
||||
abandoned: "_",
|
||||
planned: "!",
|
||||
notStarted: " ",
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
describe("autoDateManager - Debug Specific Issue", () => {
|
||||
it("should insert cancelled date BEFORE block reference, not after", () => {
|
||||
// This is the exact line from the user's example
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
console.log("Original line:", lineText);
|
||||
console.log("Line length:", lineText.length);
|
||||
console.log("Index of 🛫:", lineText.indexOf("🛫"));
|
||||
console.log("Index of ^timer:", lineText.indexOf("^timer"));
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Insert position:", position);
|
||||
console.log("Text before position:", lineText.substring(0, position));
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// The cancelled date should be inserted BEFORE the block reference
|
||||
expect(lineText.substring(position)).toBe(" ^timer-161940-4775");
|
||||
|
||||
// Simulate inserting the cancelled date
|
||||
const cancelledDate = " ❌ 2025-07-31";
|
||||
const newLine = lineText.substring(0, position) + cancelledDate + lineText.substring(position);
|
||||
|
||||
console.log("New line after insertion:", newLine);
|
||||
|
||||
// Verify the block reference is still at the end
|
||||
expect(newLine.endsWith("^timer-161940-4775")).toBe(true);
|
||||
|
||||
// Verify the cancelled date is before the block reference
|
||||
expect(newLine).toBe("- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ❌ 2025-07-31 ^timer-161940-4775");
|
||||
});
|
||||
|
||||
it("should find correct position with 🛫 emoji", () => {
|
||||
const lineText = "- [-] Task with 🛫 2025-04-20 ^block-id";
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Position for cancelled date:", position);
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// Should insert after the 🛫 date but before block reference
|
||||
expect(lineText.substring(0, position)).toContain("🛫 2025-04-20");
|
||||
expect(lineText.substring(position)).toBe(" ^block-id");
|
||||
});
|
||||
|
||||
it("should handle MISMATCHED start date emoji (🚀 in settings but 🛫 in text)", () => {
|
||||
// Create a plugin with 🚀 as start marker
|
||||
const mismatchedPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
...mockPlugin.settings!,
|
||||
autoDateManager: {
|
||||
...mockPlugin.settings!.autoDateManager!,
|
||||
startDateMarker: "🚀", // Different from what's in the text!
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// But the text has 🛫
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mismatchedPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Position with mismatched emoji:", position);
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// Even with mismatched emoji, it should still find the date pattern
|
||||
// because 🛫 is followed by a date pattern
|
||||
expect(lineText.substring(position)).not.toContain("❌ 2025-07-31");
|
||||
});
|
||||
});
|
||||
105
src/__tests__/autoDateManager.final.test.ts
Normal file
105
src/__tests__/autoDateManager.final.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findMetadataInsertPosition,
|
||||
findCompletedDateInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
describe("autoDateManager - Final Verification", () => {
|
||||
it("should correctly place cancelled date for user's exact scenario", () => {
|
||||
// User's exact configuration
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🛫", // User's marker
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// User's exact line
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
// Test cancelled date position
|
||||
const cancelledPos = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
// For comparison, test completed date position
|
||||
const completedPos = findCompletedDateInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin
|
||||
);
|
||||
|
||||
// Use throw to see the output
|
||||
throw new Error(`
|
||||
FINAL TEST DEBUG:
|
||||
- Line: ${lineText}
|
||||
- Cancelled date position: ${cancelledPos}
|
||||
- Text before cancelled: "${lineText.substring(0, cancelledPos)}"
|
||||
- Text after cancelled: "${lineText.substring(cancelledPos)}"
|
||||
- Completed date position: ${completedPos}
|
||||
- Block ref index: ${lineText.indexOf("^timer")}
|
||||
- 🛫 index: ${lineText.indexOf("🛫")}
|
||||
- 🛫 date end: ${lineText.indexOf("2025-04-20") + "2025-04-20".length}
|
||||
`);
|
||||
|
||||
// Simulate insertion
|
||||
const cancelledDate = " ❌ 2025-07-31";
|
||||
const newLineWithCancelled =
|
||||
lineText.substring(0, cancelledPos) +
|
||||
cancelledDate +
|
||||
lineText.substring(cancelledPos);
|
||||
|
||||
console.log("\nAfter cancelled date insertion:");
|
||||
console.log(newLineWithCancelled);
|
||||
|
||||
// Verify structure is correct
|
||||
expect(newLineWithCancelled).toMatch(/🛫 2025-04-20 ❌ 2025-07-31 \^timer-161940-4775$/);
|
||||
});
|
||||
|
||||
it("should handle case with no 🚀 emoji in text", () => {
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Line without 🚀 emoji
|
||||
const lineText = "- [-] 交流交底 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("\nSimpler case without 🚀:");
|
||||
console.log("Line:", lineText);
|
||||
console.log("Position:", position);
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// Debug output
|
||||
const dateEndPos = lineText.indexOf("2025-04-20") + "2025-04-20".length;
|
||||
console.log("Date ends at:", dateEndPos);
|
||||
console.log("Character at dateEndPos:", lineText[dateEndPos]);
|
||||
console.log("Insert position:", position);
|
||||
console.log("Character at position:", lineText[position]);
|
||||
|
||||
// Should be after 🛫 date (allowing for immediate insertion after date)
|
||||
expect(position).toBeGreaterThanOrEqual(lineText.indexOf("2025-04-20") + "2025-04-20".length);
|
||||
expect(position).toBeLessThan(lineText.indexOf("^timer"));
|
||||
});
|
||||
});
|
||||
101
src/__tests__/autoDateManager.integration.test.ts
Normal file
101
src/__tests__/autoDateManager.integration.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import { EditorState, Transaction } from "@codemirror/state";
|
||||
import {
|
||||
handleAutoDateManagerTransaction,
|
||||
findTaskStatusChange,
|
||||
determineDateOperations,
|
||||
applyDateOperations,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { App } from "obsidian";
|
||||
|
||||
// Mock the plugin
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
taskStatuses: {
|
||||
completed: "x|X",
|
||||
inProgress: "/|-",
|
||||
abandoned: "_",
|
||||
planned: "!",
|
||||
notStarted: " ",
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
const mockApp = {} as App;
|
||||
|
||||
describe("autoDateManager - Integration Test", () => {
|
||||
it("should handle cancelled date insertion with real transaction", () => {
|
||||
// User's exact line - task status changing from ' ' to '_' (abandoned)
|
||||
const originalLine = "- [ ] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
const modifiedLine = "- [_] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
// Create an editor state
|
||||
const startState = EditorState.create({
|
||||
doc: originalLine,
|
||||
});
|
||||
|
||||
// Create a transaction that changes [ ] to [_]
|
||||
const tr = startState.update({
|
||||
changes: {
|
||||
from: 3,
|
||||
to: 4,
|
||||
insert: "_",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Original:", originalLine);
|
||||
console.log("Modified:", modifiedLine);
|
||||
console.log("Transaction newDoc:", tr.newDoc.toString());
|
||||
|
||||
// Find the task status change
|
||||
const statusChange = findTaskStatusChange(tr);
|
||||
if (!statusChange) {
|
||||
throw new Error("No status change found");
|
||||
}
|
||||
|
||||
console.log("Status change:", statusChange);
|
||||
|
||||
// Determine date operations
|
||||
const operations = determineDateOperations(
|
||||
statusChange.oldStatus,
|
||||
statusChange.newStatus,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
tr.newDoc.line(1).text
|
||||
);
|
||||
|
||||
console.log("Operations:", operations);
|
||||
|
||||
// Apply date operations
|
||||
const result = applyDateOperations(
|
||||
tr,
|
||||
tr.newDoc,
|
||||
1,
|
||||
operations,
|
||||
mockPlugin as TaskProgressBarPlugin
|
||||
);
|
||||
|
||||
// This would throw if there's an issue
|
||||
throw new Error(`
|
||||
INTEGRATION TEST DEBUG:
|
||||
- Original: ${originalLine}
|
||||
- Modified: ${modifiedLine}
|
||||
- Operations: ${JSON.stringify(operations)}
|
||||
- Result changes: ${JSON.stringify(result.changes)}
|
||||
`);
|
||||
});
|
||||
});
|
||||
147
src/__tests__/autoDateManager.pause-conflict.test.ts
Normal file
147
src/__tests__/autoDateManager.pause-conflict.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findTaskStatusChange,
|
||||
determineDateOperations,
|
||||
getStatusType,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
// Mock the plugin
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
taskStatuses: {
|
||||
completed: "x|X",
|
||||
inProgress: "/|-|>",
|
||||
abandoned: "_|-", // Note: '-' is used for both paused and abandoned
|
||||
planned: "!",
|
||||
notStarted: " ",
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
describe("autoDateManager - Pause Timer Conflict", () => {
|
||||
it("should identify conflict when pausing timer changes status to abandoned", () => {
|
||||
// When timer is paused, status changes from '/' to '-'
|
||||
const oldStatus = "/";
|
||||
const newStatus = "-";
|
||||
const lineText = "- [-] Task with timer 🛫 2025-04-20 ^timer-123";
|
||||
|
||||
// Check what autoDateManager will do
|
||||
const oldType = getStatusType(oldStatus, mockPlugin as TaskProgressBarPlugin);
|
||||
const newType = getStatusType(newStatus, mockPlugin as TaskProgressBarPlugin);
|
||||
|
||||
console.log(`Status change: "${oldStatus}" (${oldType}) -> "${newStatus}" (${newType})`);
|
||||
|
||||
// Both '/' and '-' are configured, so types should be identified
|
||||
expect(oldType).toBe("inProgress");
|
||||
expect(newType).toBe("abandoned");
|
||||
|
||||
// Determine what date operations would be triggered
|
||||
const operations = determineDateOperations(
|
||||
oldStatus,
|
||||
newStatus,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
lineText
|
||||
);
|
||||
|
||||
console.log("Date operations:", operations);
|
||||
|
||||
// PROBLEM: When pausing, autoDateManager will try to add a cancelled date
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]).toMatchObject({
|
||||
type: "add",
|
||||
dateType: "cancelled",
|
||||
});
|
||||
|
||||
// This is the conflict: pause operation triggers date insertion
|
||||
});
|
||||
|
||||
it("should show that '-' marker is ambiguous (used for both pause and abandoned)", () => {
|
||||
// The '-' marker is used for both:
|
||||
// 1. Paused tasks (temporary state while timer is paused)
|
||||
// 2. Abandoned/cancelled tasks (permanent state)
|
||||
|
||||
const pausedTaskStatus = "-";
|
||||
const abandonedTaskStatus = "-";
|
||||
|
||||
const pausedType = getStatusType(pausedTaskStatus, mockPlugin as TaskProgressBarPlugin);
|
||||
const abandonedType = getStatusType(abandonedTaskStatus, mockPlugin as TaskProgressBarPlugin);
|
||||
|
||||
// Both resolve to the same type
|
||||
expect(pausedType).toBe("abandoned");
|
||||
expect(abandonedType).toBe("abandoned");
|
||||
|
||||
// This ambiguity causes autoDateManager to treat paused tasks as abandoned
|
||||
// and insert a cancelled date, which may not be desired for temporary pauses
|
||||
});
|
||||
|
||||
it("should demonstrate the specific user scenario", () => {
|
||||
// User's exact scenario
|
||||
const taskBeforePause = "- [/] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
const taskAfterPause = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
// Status change from '/' to '-'
|
||||
const operations = determineDateOperations(
|
||||
"/",
|
||||
"-",
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
taskAfterPause
|
||||
);
|
||||
|
||||
// AutoDateManager will add a cancelled date
|
||||
expect(operations).toContainEqual({
|
||||
type: "add",
|
||||
dateType: "cancelled",
|
||||
format: "YYYY-MM-DD",
|
||||
});
|
||||
|
||||
// Expected result after autoDateManager processes it:
|
||||
// The cancelled date (❌ 2025-07-31) would be inserted
|
||||
const expectedResult = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ❌ 2025-07-31 ^timer-161940-4775";
|
||||
|
||||
console.log("Task before pause:", taskBeforePause);
|
||||
console.log("Task after pause:", taskAfterPause);
|
||||
console.log("Expected with date:", expectedResult);
|
||||
});
|
||||
|
||||
it("should suggest solutions for the conflict", () => {
|
||||
// Potential solutions:
|
||||
|
||||
// Solution 1: Check for timer-specific annotations
|
||||
const isTimerOperation = (annotation: string) => {
|
||||
return annotation === "taskTimer" || annotation.includes("timer");
|
||||
};
|
||||
|
||||
// Solution 2: Use different status markers for pause vs abandoned
|
||||
const alternativeStatuses = {
|
||||
paused: "p", // New marker specifically for paused
|
||||
abandoned: "_", // Keep _ for truly abandoned tasks
|
||||
inProgress: "/",
|
||||
};
|
||||
|
||||
// Solution 3: Add configuration to skip date management for timer operations
|
||||
const skipDateManagementForTimers = true;
|
||||
|
||||
// Solution 4: Check for timer-related block references
|
||||
const hasTimerBlockRef = (text: string) => {
|
||||
return /\^timer-\d+/.test(text);
|
||||
};
|
||||
|
||||
expect(isTimerOperation("taskTimer")).toBe(true);
|
||||
expect(hasTimerBlockRef("^timer-123")).toBe(true);
|
||||
});
|
||||
});
|
||||
101
src/__tests__/autoDateManager.realworld.test.ts
Normal file
101
src/__tests__/autoDateManager.realworld.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findMetadataInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
describe("autoDateManager - Real World Test", () => {
|
||||
it("should handle user's exact case", () => {
|
||||
// Mock plugin with user's actual settings
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🛫", // User's actual marker
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// User's exact line
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
console.log("\n=== REAL WORLD TEST ===");
|
||||
console.log("User's line:", lineText);
|
||||
console.log("Line length:", lineText.length);
|
||||
console.log("Block ref starts at:", lineText.indexOf("^timer"));
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
// Use throw to output debug info
|
||||
throw new Error(`
|
||||
DEBUG INFO:
|
||||
- Line: ${lineText}
|
||||
- Position: ${position}
|
||||
- Text before: "${lineText.substring(0, position)}"
|
||||
- Text after: "${lineText.substring(position)}"
|
||||
- Character at position: "${lineText[position]}"
|
||||
- Block ref index: ${lineText.indexOf("^timer")}
|
||||
`);
|
||||
|
||||
// The cancelled date should be inserted after 🛫 2025-04-20 but before ^timer
|
||||
const expectedPosition = lineText.indexOf(" ^timer");
|
||||
console.log("\nExpected position:", expectedPosition);
|
||||
console.log("Expected text after:", lineText.substring(expectedPosition));
|
||||
|
||||
// Simulate insertion
|
||||
const cancelledDate = " ❌ 2025-07-31";
|
||||
const newLine = lineText.substring(0, position) + cancelledDate + lineText.substring(position);
|
||||
console.log("\nNew line after insertion:", newLine);
|
||||
|
||||
// Verify the block reference is preserved
|
||||
expect(newLine).toContain("^timer-161940-4775");
|
||||
expect(newLine).not.toMatch(/\^timer.*❌/); // Cancelled date should not be after block ref
|
||||
});
|
||||
|
||||
it("should test with debugging enabled", () => {
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Simpler test case
|
||||
const lineText = "- [-] Task 🛫 2025-04-20 ^block-123";
|
||||
|
||||
console.log("\n=== SIMPLE TEST ===");
|
||||
console.log("Line:", lineText);
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Position:", position);
|
||||
console.log("Should insert at:", lineText.substring(0, position) + " <HERE>" + lineText.substring(position));
|
||||
|
||||
// Should be after the date but before block ref
|
||||
expect(position).toBeLessThan(lineText.indexOf("^block"));
|
||||
expect(position).toBeGreaterThan(lineText.indexOf("2025-04-20") + "2025-04-20".length);
|
||||
});
|
||||
});
|
||||
156
src/__tests__/autoDateManager.research.test.ts
Normal file
156
src/__tests__/autoDateManager.research.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findMetadataInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
describe("autoDateManager - Research: Why cancelled date goes to end", () => {
|
||||
it("should trace execution path for cancelled date insertion", () => {
|
||||
// Create plugin with 🚀 as configured emoji
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🚀", // Configured emoji
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
taskStatuses: {
|
||||
completed: "x|X",
|
||||
inProgress: "/|-",
|
||||
abandoned: "_",
|
||||
planned: "!",
|
||||
notStarted: " ",
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Test case 1: User's exact line with 🛫 (not configured)
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
console.log("\n=== RESEARCH: Cancelled Date Insertion Logic ===");
|
||||
console.log("Line text:", lineText);
|
||||
console.log("Configured start emoji: 🚀");
|
||||
console.log("Actual start emoji in text: 🛫");
|
||||
|
||||
// Create a version without common emoji support for comparison
|
||||
const oldPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
...mockPlugin.settings!,
|
||||
autoDateManager: {
|
||||
...mockPlugin.settings!.autoDateManager!,
|
||||
startDateMarker: "🚀", // Only looks for this
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Test with old logic (before fix)
|
||||
console.log("\n--- Without common emoji support ---");
|
||||
// This would fail to find 🛫 and insert at the end
|
||||
|
||||
// Test with new logic (after fix)
|
||||
console.log("\n--- With common emoji support ---");
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Calculated position:", position);
|
||||
console.log("Text before position:", lineText.substring(0, position));
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// Analyze what happens at each step
|
||||
console.log("\n=== Step-by-step analysis ===");
|
||||
|
||||
// Step 1: Extract block reference
|
||||
const blockRefMatch = lineText.match(/\s*\^[\w-]+\s*$/);
|
||||
if (blockRefMatch) {
|
||||
console.log("1. Block reference found:", blockRefMatch[0]);
|
||||
console.log(" Block ref starts at:", lineText.length - blockRefMatch[0].length);
|
||||
}
|
||||
|
||||
// Step 2: Look for start date with configured emoji
|
||||
const configuredPattern = /🚀\s*\d{4}-\d{2}-\d{2}/;
|
||||
const configuredMatch = lineText.match(configuredPattern);
|
||||
console.log("2. Configured emoji (🚀) match:", configuredMatch ? configuredMatch[0] : "NOT FOUND");
|
||||
|
||||
// Step 3: Look for start date with 🛫
|
||||
const actualPattern = /🛫\s*\d{4}-\d{2}-\d{2}/;
|
||||
const actualMatch = lineText.match(actualPattern);
|
||||
console.log("3. Actual emoji (🛫) match:", actualMatch ? actualMatch[0] : "NOT FOUND");
|
||||
|
||||
// Step 4: What happens when start date is not found
|
||||
if (!configuredMatch) {
|
||||
console.log("4. Since configured emoji not found in expected position:");
|
||||
console.log(" - Code falls back to finding end of all metadata");
|
||||
console.log(" - This could lead to position at end of line (before block ref)");
|
||||
}
|
||||
|
||||
// The code now correctly finds 🚀 2025-07-30 as a start date (even though config says 🚀)
|
||||
// So cancelled date will be inserted after that, not after 🛫
|
||||
// This is actually correct behavior - if there are multiple start dates, use the first one
|
||||
expect(lineText.substring(position)).toBe(" [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775");
|
||||
});
|
||||
|
||||
it("should analyze metadata detection patterns", () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "Simple task with 🛫",
|
||||
text: "- [ ] Task 🛫 2025-01-20 ^block-id",
|
||||
expectedAfter: " ^block-id"
|
||||
},
|
||||
{
|
||||
name: "Task with multiple emojis",
|
||||
text: "- [ ] Task 📅 2025-01-15 🛫 2025-01-20 ^block-id",
|
||||
expectedAfter: " ^block-id"
|
||||
},
|
||||
{
|
||||
name: "Task with dataview and 🛫",
|
||||
text: "- [ ] Task [due::2025-01-25] 🛫 2025-01-20 ^block-id",
|
||||
expectedAfter: " ^block-id"
|
||||
}
|
||||
];
|
||||
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🚀", // Different from test emojis
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
console.log("\n=== Metadata Detection Analysis ===");
|
||||
|
||||
testCases.forEach(testCase => {
|
||||
console.log(`\nTest: ${testCase.name}`);
|
||||
console.log(`Text: ${testCase.text}`);
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
testCase.text,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
const actualAfter = testCase.text.substring(position);
|
||||
console.log(`Position: ${position}`);
|
||||
console.log(`Text after: "${actualAfter}"`);
|
||||
console.log(`Expected after: "${testCase.expectedAfter}"`);
|
||||
console.log(`Match: ${actualAfter === testCase.expectedAfter ? "✓" : "✗"}`);
|
||||
|
||||
expect(actualAfter).toBe(testCase.expectedAfter);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/__tests__/autoDateManager.simple.test.ts
Normal file
67
src/__tests__/autoDateManager.simple.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
findMetadataInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
describe("autoDateManager - Simple Cancelled Date Test", () => {
|
||||
it("should insert cancelled date after 🛫 date", () => {
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Simple case: just 🛫 date and block ref
|
||||
const lineText = "- [-] Task 🛫 2025-04-20 ^block-id";
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Position:", position);
|
||||
console.log("Text after position:", lineText.substring(position));
|
||||
|
||||
// Should insert after 🛫 date
|
||||
expect(lineText.substring(position)).toBe(" ^block-id");
|
||||
});
|
||||
|
||||
it("should handle complex line with dataview", () => {
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
startDateMarker: "🛫",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
console.log("Complex line position:", position);
|
||||
console.log("Text before:", lineText.substring(0, position));
|
||||
console.log("Text after:", lineText.substring(position));
|
||||
|
||||
// Should insert after 🛫 2025-04-20
|
||||
const expectedAfter = " ^timer-161940-4775";
|
||||
expect(lineText.substring(position)).toBe(expectedAfter);
|
||||
});
|
||||
});
|
||||
258
src/__tests__/autoDateManager.test.ts
Normal file
258
src/__tests__/autoDateManager.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
||||
import {
|
||||
handleAutoDateManagerTransaction,
|
||||
findTaskStatusChange,
|
||||
determineDateOperations,
|
||||
getStatusType,
|
||||
applyDateOperations,
|
||||
isMoveOperation,
|
||||
findMetadataInsertPosition,
|
||||
} from "../editor-ext/autoDateManager";
|
||||
import { Transaction, Text, EditorState } from "@codemirror/state";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { App } from "obsidian";
|
||||
|
||||
// Mock the plugin
|
||||
const mockPlugin: Partial<TaskProgressBarPlugin> = {
|
||||
settings: {
|
||||
autoDateManager: {
|
||||
enabled: true,
|
||||
manageStartDate: true,
|
||||
manageCompletedDate: true,
|
||||
manageCancelledDate: true,
|
||||
startDateFormat: "YYYY-MM-DD",
|
||||
completedDateFormat: "YYYY-MM-DD",
|
||||
cancelledDateFormat: "YYYY-MM-DD",
|
||||
startDateMarker: "🚀",
|
||||
completedDateMarker: "✅",
|
||||
cancelledDateMarker: "❌",
|
||||
},
|
||||
preferMetadataFormat: "emoji",
|
||||
taskStatuses: {
|
||||
completed: "x|X",
|
||||
inProgress: "/|-",
|
||||
abandoned: "_",
|
||||
planned: "!",
|
||||
notStarted: " ",
|
||||
},
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
// Mock the App
|
||||
const mockApp = {} as App;
|
||||
|
||||
describe("autoDateManager - Block Reference Support", () => {
|
||||
describe("Block Reference Detection", () => {
|
||||
it("should detect simple block reference at end of line", () => {
|
||||
const lineText = "- [ ] Task with block reference ^task-123";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before the block reference
|
||||
expect(lineText.substring(position)).toBe(" ^task-123");
|
||||
});
|
||||
|
||||
it("should detect block reference with trailing spaces", () => {
|
||||
const lineText = "- [ ] Task with block reference ^task-123 ";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before the block reference
|
||||
expect(lineText.substring(position)).toBe(" ^task-123 ");
|
||||
});
|
||||
|
||||
it("should detect block reference with underscores and hyphens", () => {
|
||||
const lineText = "- [ ] Task with block reference ^task_123-abc";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before the block reference
|
||||
expect(lineText.substring(position)).toBe(" ^task_123-abc");
|
||||
});
|
||||
|
||||
it("should not confuse caret in middle of text with block reference", () => {
|
||||
const lineText = "- [ ] Task with ^caret in middle and more text";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert at the end since ^caret is not at the end
|
||||
expect(position).toBe(lineText.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Insertion with Block References", () => {
|
||||
it("should insert completed date before block reference", () => {
|
||||
const lineText = "- [ ] Task to complete ^task-123";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Position should be before the block reference
|
||||
expect(lineText.substring(0, position)).toBe("- [ ] Task to complete");
|
||||
expect(lineText.substring(position)).toBe(" ^task-123");
|
||||
});
|
||||
|
||||
it("should insert start date before block reference", () => {
|
||||
const lineText = "- [ ] Task to start ^task-456";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"start"
|
||||
);
|
||||
// Position should be before the block reference
|
||||
expect(lineText.substring(0, position)).toBe("- [ ] Task to start");
|
||||
expect(lineText.substring(position)).toBe(" ^task-456");
|
||||
});
|
||||
|
||||
it("should insert cancelled date after start date but before block reference", () => {
|
||||
const lineText = "- [ ] Task with start date 🚀 2024-01-15 ^task-789";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
// Position should be after start date but before block reference
|
||||
expect(lineText.substring(0, position)).toBe("- [ ] Task with start date 🚀 2024-01-15");
|
||||
expect(lineText.substring(position)).toBe(" ^task-789");
|
||||
});
|
||||
|
||||
it("should handle multiple metadata before block reference", () => {
|
||||
const lineText = "- [ ] Task with tags #important #urgent ^task-999";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Position should be after tags but before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^task-999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Removal with Block References", () => {
|
||||
it("should preserve block reference when removing completed date", () => {
|
||||
// This test would require mocking the full transaction system
|
||||
// For now, we test that the position calculation is correct
|
||||
const lineText = "- [x] Completed task ✅ 2024-01-20 ^task-123";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// The position should still respect the block reference
|
||||
expect(lineText.substring(position)).toBe(" ^task-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Block Reference Scenarios", () => {
|
||||
it("should handle task with dataview fields and block reference", () => {
|
||||
const lineText = "- [ ] Task [due::2024-01-25] [priority::high] ^complex-123";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert after dataview fields but before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^complex-123");
|
||||
});
|
||||
|
||||
it("should handle task with emojis and block reference", () => {
|
||||
const lineText = "- [ ] Task with emoji 📅 2024-01-25 ^emoji-task";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert after date emoji but before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^emoji-task");
|
||||
});
|
||||
|
||||
it("should handle task with wikilinks and block reference", () => {
|
||||
const lineText = "- [ ] Task mentioning [[Some Page]] ^wiki-task";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert after wikilink but before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^wiki-task");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty task with only block reference", () => {
|
||||
const lineText = "- [ ] ^empty-task";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^empty-task");
|
||||
});
|
||||
|
||||
it("should handle block reference without space", () => {
|
||||
const lineText = "- [ ] Task^no-space";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before block reference
|
||||
expect(lineText.substring(position)).toBe("^no-space");
|
||||
});
|
||||
|
||||
it("should handle very long block reference IDs", () => {
|
||||
const lineText = "- [ ] Task ^very-long-block-reference-id-that-might-be-generated-automatically";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPlugin as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^very-long-block-reference-id-that-might-be-generated-automatically");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoDateManager - Dataview Format with Block References", () => {
|
||||
const mockPluginDataview: Partial<TaskProgressBarPlugin> = {
|
||||
...mockPlugin,
|
||||
settings: {
|
||||
...mockPlugin.settings!,
|
||||
preferMetadataFormat: "dataview",
|
||||
},
|
||||
} as unknown as TaskProgressBarPlugin;
|
||||
|
||||
it("should insert dataview date before block reference", () => {
|
||||
const lineText = "- [ ] Task with dataview format ^dataview-123";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPluginDataview as TaskProgressBarPlugin,
|
||||
"completed"
|
||||
);
|
||||
// Should insert before block reference
|
||||
expect(lineText.substring(position)).toBe(" ^dataview-123");
|
||||
});
|
||||
|
||||
it("should handle existing dataview fields with block reference", () => {
|
||||
const lineText = "- [ ] Task [start::2024-01-15] ^dataview-456";
|
||||
const position = findMetadataInsertPosition(
|
||||
lineText,
|
||||
mockPluginDataview as TaskProgressBarPlugin,
|
||||
"cancelled"
|
||||
);
|
||||
// Should insert after start date but before block reference
|
||||
expect(lineText.substring(0, position)).toBe("- [ ] Task [start::2024-01-15]");
|
||||
expect(lineText.substring(position)).toBe(" ^dataview-456");
|
||||
});
|
||||
});
|
||||
100
src/__tests__/autoDateManager.tracing.test.ts
Normal file
100
src/__tests__/autoDateManager.tracing.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
|
||||
// Import the internal functions we need to test
|
||||
import { Text, EditorState } from "@codemirror/state";
|
||||
|
||||
describe("autoDateManager - Execution Tracing", () => {
|
||||
it("should trace the exact problem with user's text", () => {
|
||||
const lineText = "- [-] 交流交底 🚀 2025-07-30 [stage::disclosure_communication] 🛫 2025-04-20 ^timer-161940-4775";
|
||||
|
||||
console.log("\n=== DETAILED EXECUTION TRACE ===");
|
||||
console.log("Original line:", lineText);
|
||||
console.log("Line length:", lineText.length);
|
||||
|
||||
// Step 1: Manually extract block reference
|
||||
const blockRefPattern = /\s*(\^[\w-]+)\s*$/;
|
||||
const blockRefMatch = lineText.match(blockRefPattern);
|
||||
|
||||
if (blockRefMatch) {
|
||||
console.log("\n1. Block Reference Detection:");
|
||||
console.log(" - Full match:", JSON.stringify(blockRefMatch[0]));
|
||||
console.log(" - Block ID:", blockRefMatch[1]);
|
||||
console.log(" - Match index:", blockRefMatch.index);
|
||||
console.log(" - Match length:", blockRefMatch[0].length);
|
||||
|
||||
const cleanedText = lineText.substring(0, blockRefMatch.index).trimEnd();
|
||||
console.log(" - Cleaned text:", JSON.stringify(cleanedText));
|
||||
console.log(" - Cleaned length:", cleanedText.length);
|
||||
}
|
||||
|
||||
// Step 2: Check task pattern
|
||||
const taskMatch = lineText.match(/^[\s|\t]*([-*+]|\d+\.)\s\[.\]\s*/);
|
||||
if (taskMatch) {
|
||||
console.log("\n2. Task Pattern:");
|
||||
console.log(" - Task prefix:", JSON.stringify(taskMatch[0]));
|
||||
console.log(" - Initial position:", taskMatch[0].length);
|
||||
}
|
||||
|
||||
// Step 3: Look for 🚀 emoji (configured)
|
||||
console.log("\n3. Configured Emoji Search (🚀):");
|
||||
const rocketPattern = /🚀\s*\d{4}-\d{2}-\d{2}/g;
|
||||
let match;
|
||||
while ((match = rocketPattern.exec(lineText)) !== null) {
|
||||
console.log(" - Found at index:", match.index);
|
||||
console.log(" - Match:", JSON.stringify(match[0]));
|
||||
console.log(" - End position:", match.index + match[0].length);
|
||||
}
|
||||
|
||||
// Step 4: Look for 🛫 emoji (actual)
|
||||
console.log("\n4. Actual Emoji Search (🛫):");
|
||||
const planePattern = /🛫\s*\d{4}-\d{2}-\d{2}/g;
|
||||
while ((match = planePattern.exec(lineText)) !== null) {
|
||||
console.log(" - Found at index:", match.index);
|
||||
console.log(" - Match:", JSON.stringify(match[0]));
|
||||
console.log(" - End position:", match.index + match[0].length);
|
||||
}
|
||||
|
||||
// Step 5: Find all metadata
|
||||
console.log("\n5. All Metadata Search:");
|
||||
const metadataPatterns = [
|
||||
{ name: "Tags", pattern: /#[\w-]+/g },
|
||||
{ name: "Dataview", pattern: /\[[a-zA-Z]+::[^\]]*\]/g },
|
||||
{ name: "Date markers", pattern: /[📅🚀✅❌🛫▶️⏰🏁]\s*\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?/g }
|
||||
];
|
||||
|
||||
metadataPatterns.forEach(({ name, pattern }) => {
|
||||
console.log(` - ${name}:`);
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(lineText)) !== null) {
|
||||
console.log(` * Index ${match.index}: ${JSON.stringify(match[0])}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Determine where cancelled date would go
|
||||
console.log("\n6. Position Analysis:");
|
||||
|
||||
// If we found 🛫 2025-04-20
|
||||
const planeMatch = lineText.match(/🛫\s*\d{4}-\d{2}-\d{2}/);
|
||||
if (planeMatch) {
|
||||
const afterPlanePos = planeMatch.index! + planeMatch[0].length;
|
||||
console.log(" - After 🛫 date:", afterPlanePos);
|
||||
console.log(" - Text after that position:", JSON.stringify(lineText.substring(afterPlanePos)));
|
||||
}
|
||||
|
||||
// Find the last metadata item
|
||||
let lastMetadataEnd = 0;
|
||||
[/#[\w-]+/g, /\[[a-zA-Z]+::[^\]]*\]/g, /[📅🚀✅❌🛫▶️⏰🏁]\s*\d{4}-\d{2}-\d{2}/g].forEach(pattern => {
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(lineText)) !== null) {
|
||||
const end = match.index + match[0].length;
|
||||
if (end > lastMetadataEnd) {
|
||||
lastMetadataEnd = end;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(" - Last metadata ends at:", lastMetadataEnd);
|
||||
console.log(" - Text after last metadata:", JSON.stringify(lineText.substring(lastMetadataEnd)));
|
||||
});
|
||||
});
|
||||
105
src/__tests__/taskTimer.pause-fix.test.ts
Normal file
105
src/__tests__/taskTimer.pause-fix.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// @ts-ignore
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
|
||||
describe("Task Timer - Pause Fix", () => {
|
||||
it("should document the new pause behavior", () => {
|
||||
// OLD BEHAVIOR (problematic):
|
||||
// 1. User clicks "Pause" on timer
|
||||
// 2. Timer widget changes task status from [/] to [-]
|
||||
// 3. This triggers autoDateManager
|
||||
// 4. autoDateManager adds cancelled date (even if disabled)
|
||||
// 5. Multiple plugins might conflict over the status change
|
||||
|
||||
// NEW BEHAVIOR (fixed):
|
||||
// 1. User clicks "Pause" on timer
|
||||
// 2. Timer state is updated in localStorage (paused)
|
||||
// 3. Task status remains unchanged (still [/])
|
||||
// 4. No transaction is dispatched, so autoDateManager is not triggered
|
||||
// 5. Timer shows as paused in UI but task status reflects it's still in-progress
|
||||
|
||||
const oldBehavior = {
|
||||
pauseAction: "Update task status to [-]",
|
||||
sideEffect: "Triggers autoDateManager",
|
||||
conflict: "Even with cancelled date disabled, transaction is processed"
|
||||
};
|
||||
|
||||
const newBehavior = {
|
||||
pauseAction: "Only update timer state in localStorage",
|
||||
sideEffect: "No transaction, no autoDateManager trigger",
|
||||
benefit: "Clean separation between timer state and task status"
|
||||
};
|
||||
|
||||
expect(newBehavior.sideEffect).toBe("No transaction, no autoDateManager trigger");
|
||||
});
|
||||
|
||||
it("should explain the timer state vs task status distinction", () => {
|
||||
// Timer State (in localStorage):
|
||||
// - running: Timer is actively counting
|
||||
// - paused: Timer is temporarily stopped but preserves elapsed time
|
||||
// - stopped: Timer is reset to 00:00
|
||||
|
||||
// Task Status (in markdown):
|
||||
// - [ ]: Not started
|
||||
// - [/]: In progress
|
||||
// - [-]: Abandoned/Cancelled
|
||||
// - [x]: Completed
|
||||
|
||||
// Key insight: Timer state and task status are independent
|
||||
// A task can be "in progress" with a paused timer
|
||||
// This is actually more accurate - the task isn't abandoned, just temporarily paused
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
action: "Start timer",
|
||||
timerState: "running",
|
||||
taskStatus: "[/]", // Changes to in-progress
|
||||
statusChange: true
|
||||
},
|
||||
{
|
||||
action: "Pause timer",
|
||||
timerState: "paused",
|
||||
taskStatus: "[/]", // Remains in-progress
|
||||
statusChange: false
|
||||
},
|
||||
{
|
||||
action: "Resume timer",
|
||||
timerState: "running",
|
||||
taskStatus: "[/]", // Still in-progress
|
||||
statusChange: false
|
||||
},
|
||||
{
|
||||
action: "Complete timer",
|
||||
timerState: "stopped",
|
||||
taskStatus: "[x]", // Changes to completed
|
||||
statusChange: true
|
||||
},
|
||||
{
|
||||
action: "Reset timer",
|
||||
timerState: "stopped",
|
||||
taskStatus: "[/]", // Remains as is (user manages)
|
||||
statusChange: false
|
||||
}
|
||||
];
|
||||
|
||||
// Only Start and Complete should change task status
|
||||
const statusChangingActions = scenarios.filter(s => s.statusChange);
|
||||
expect(statusChangingActions).toHaveLength(2);
|
||||
expect(statusChangingActions[0].action).toBe("Start timer");
|
||||
expect(statusChangingActions[1].action).toBe("Complete timer");
|
||||
});
|
||||
|
||||
it("should list benefits of the new approach", () => {
|
||||
const benefits = [
|
||||
"No conflicts with autoDateManager",
|
||||
"No conflicts with other status-monitoring plugins",
|
||||
"Cleaner separation of concerns",
|
||||
"Timer state persists independently of task status",
|
||||
"User has full control over task status",
|
||||
"Pause is truly temporary - doesn't imply task abandonment",
|
||||
"Multiple pauses/resumes don't create multiple date entries"
|
||||
];
|
||||
|
||||
expect(benefits).toContain("No conflicts with autoDateManager");
|
||||
expect(benefits).toContain("Pause is truly temporary - doesn't imply task abandonment");
|
||||
});
|
||||
});
|
||||
|
|
@ -1391,7 +1391,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
folders: [],
|
||||
tags: ["timer", "tracked"]
|
||||
},
|
||||
timeFormat: "{h}hrs{m}mins",
|
||||
timeFormat: "{h}hrs {m}mins",
|
||||
blockRefPrefix: "timer"
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, Editor } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import {
|
||||
EditorState,
|
||||
Text,
|
||||
|
|
@ -71,7 +71,7 @@ function handleAutoDateManagerTransaction(
|
|||
return tr;
|
||||
}
|
||||
|
||||
const { doc, lineNumber, oldStatus, newStatus } = taskStatusChangeInfo;
|
||||
const {doc, lineNumber, oldStatus, newStatus} = taskStatusChangeInfo;
|
||||
|
||||
// Determine what date operations need to be performed
|
||||
const dateOperations = determineDateOperations(
|
||||
|
|
@ -458,10 +458,13 @@ function applyDateOperations(
|
|||
operations: DateOperation[],
|
||||
plugin: TaskProgressBarPlugin
|
||||
): TransactionSpec {
|
||||
const line = doc.line(lineNumber);
|
||||
// IMPORTANT: Use the NEW document state, not the old one
|
||||
const line = tr.newDoc.line(lineNumber);
|
||||
let lineText = line.text;
|
||||
const changes = [];
|
||||
|
||||
console.log(`[AutoDateManager] applyDateOperations - Working with line: "${lineText}"`);
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "add") {
|
||||
// Add a new date
|
||||
|
|
@ -496,6 +499,13 @@ function applyDateOperations(
|
|||
|
||||
const absolutePosition = line.from + insertPosition;
|
||||
|
||||
console.log(`[AutoDateManager] Inserting ${operation.dateType} date:`);
|
||||
console.log(` - Insert position (relative): ${insertPosition}`);
|
||||
console.log(` - Line.from: ${line.from}`);
|
||||
console.log(` - Absolute position: ${absolutePosition}`);
|
||||
console.log(` - Date text: "${dateText}"`);
|
||||
console.log(` - Text at insert point: "${lineText.substring(insertPosition)}"`);
|
||||
|
||||
changes.push({
|
||||
from: absolutePosition,
|
||||
to: absolutePosition,
|
||||
|
|
@ -519,8 +529,8 @@ function applyDateOperations(
|
|||
operation.dateType === "completed"
|
||||
? "completion"
|
||||
: operation.dateType === "cancelled"
|
||||
? "cancelled"
|
||||
: "unknown";
|
||||
? "cancelled"
|
||||
: "unknown";
|
||||
datePattern = new RegExp(
|
||||
`\\s*\\[${fieldName}::\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?\\]`,
|
||||
"g"
|
||||
|
|
@ -537,6 +547,7 @@ function applyDateOperations(
|
|||
}
|
||||
|
||||
// Find all matches and remove them (there might be multiple instances)
|
||||
// Work with the full lineText
|
||||
let match;
|
||||
const matchesToRemove = [];
|
||||
datePattern.lastIndex = 0; // Reset regex state
|
||||
|
|
@ -649,53 +660,162 @@ function findMetadataInsertPosition(
|
|||
plugin: TaskProgressBarPlugin,
|
||||
dateType: string
|
||||
): number {
|
||||
// Work with the full line text, don't extract block reference yet
|
||||
const blockRef = detectBlockReference(lineText);
|
||||
|
||||
// Find the end of the task content, right after the task description
|
||||
const taskMatch = lineText.match(/^[\s|\t]*([-*+]|\d+\.)\s\[.\]\s*/);
|
||||
if (!taskMatch) return lineText.length;
|
||||
if (!taskMatch) return blockRef ? blockRef.index : lineText.length;
|
||||
|
||||
let position = taskMatch[0].length;
|
||||
|
||||
// Find the main task content (description) before any metadata
|
||||
// FIXED: Removed @ from metadata detection since @ mentions (like @陈烽) are part of content, not metadata
|
||||
// Only actual metadata markers are: [ (dataview), # (tags), and emoji markers (📅🚀✅❌)
|
||||
const contentMatch = lineText
|
||||
.slice(position)
|
||||
.match(/^[^\[#📅🚀✅❌]*?(?=\s*[\[#📅🚀✅❌]|\s*$)/);
|
||||
|
||||
if (contentMatch) {
|
||||
position += contentMatch[0].trimEnd().length;
|
||||
}
|
||||
|
||||
// If we're inserting a cancelled date, we need to find the position after existing start dates
|
||||
// For cancelled date, we need special handling to insert after all metadata and start dates
|
||||
if (dateType === "cancelled") {
|
||||
const useDataviewFormat =
|
||||
plugin.settings.preferMetadataFormat === "dataview";
|
||||
const useDataviewFormat = plugin.settings.preferMetadataFormat === "dataview";
|
||||
|
||||
// Look for existing start dates and position after them
|
||||
const remainingText = lineText.slice(position);
|
||||
let startDateEnd = 0;
|
||||
// Find the last occurrence of either:
|
||||
// 1. Start date marker (🛫 or [start::)
|
||||
// 2. If no start date, find the end of all metadata
|
||||
|
||||
// Look for start date first
|
||||
let startDateFound = false;
|
||||
if (useDataviewFormat) {
|
||||
const startDateMatch = remainingText.match(/^\s*\[start::[^\]]*\]/);
|
||||
if (startDateMatch) {
|
||||
startDateEnd = startDateMatch[0].length;
|
||||
const startDateMatch = lineText.match(/\[start::[^\]]*\]/);
|
||||
if (startDateMatch && startDateMatch.index !== undefined) {
|
||||
position = startDateMatch.index + startDateMatch[0].length;
|
||||
startDateFound = true;
|
||||
}
|
||||
} else {
|
||||
// First try with the configured start marker
|
||||
const startMarker = getDateMarker("start", plugin);
|
||||
const escapedStartMarker = escapeRegex(startMarker);
|
||||
const startDatePattern = new RegExp(
|
||||
`^\\s*${escapeRegex(
|
||||
startMarker
|
||||
)}\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?`
|
||||
`${escapedStartMarker}\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?`
|
||||
);
|
||||
const startDateMatch = remainingText.match(startDatePattern);
|
||||
if (startDateMatch) {
|
||||
startDateEnd = startDateMatch[0].length;
|
||||
let startDateMatch = lineText.match(startDatePattern);
|
||||
|
||||
// If not found, look for any common start date emoji patterns
|
||||
if (!startDateMatch) {
|
||||
// Common start date emojis: 🚀, 🛫, ▶️, ⏰, 🏁
|
||||
const commonStartEmojis = ["🚀", "🛫", "▶️", "⏰", "🏁"];
|
||||
for (const emoji of commonStartEmojis) {
|
||||
const pattern = new RegExp(
|
||||
`${escapeRegex(emoji)}\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?`
|
||||
);
|
||||
startDateMatch = lineText.match(pattern);
|
||||
if (startDateMatch) {
|
||||
console.log(`[AutoDateManager] Found start date with emoji ${emoji}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startDateMatch && startDateMatch.index !== undefined) {
|
||||
position = startDateMatch.index + startDateMatch[0].length;
|
||||
startDateFound = true;
|
||||
console.log(`[AutoDateManager] Found start date at index ${startDateMatch.index}, length ${startDateMatch[0].length}, new position: ${position}`);
|
||||
}
|
||||
}
|
||||
|
||||
position += startDateEnd;
|
||||
console.log(`[AutoDateManager] Start date found: ${startDateFound}, position: ${position}`);
|
||||
|
||||
if (!startDateFound) {
|
||||
// No start date found, find the end of all metadata
|
||||
// This includes tags (#), dataview fields ([field::]), and other date markers
|
||||
let lastMetadataEnd = position;
|
||||
|
||||
// Find all metadata occurrences
|
||||
const metadataRegexes = [
|
||||
/#[\w-]+/g, // Tags
|
||||
/\[[a-zA-Z]+::[^\]]*\]/g, // Dataview fields
|
||||
/[📅🚀✅❌🛫▶️⏰🏁]\s*\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?/g // Date markers (including all common start emojis)
|
||||
];
|
||||
|
||||
for (const regex of metadataRegexes) {
|
||||
let match;
|
||||
while ((match = regex.exec(lineText)) !== null) {
|
||||
if (match.index >= position) {
|
||||
const matchEnd = match.index + match[0].length;
|
||||
if (matchEnd > lastMetadataEnd) {
|
||||
lastMetadataEnd = matchEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
position = lastMetadataEnd;
|
||||
}
|
||||
|
||||
// Ensure we have a space before the cancelled date
|
||||
if (position > 0 && lineText[position - 1] !== ' ') {
|
||||
// No need to add space here, it will be added with the date
|
||||
}
|
||||
} else if (dateType === "completed") {
|
||||
// For completed date, we want to go to the end of the line (before block reference)
|
||||
// This is different from cancelled/start dates which go after content/metadata
|
||||
position = lineText.length;
|
||||
|
||||
// If there's a block reference, insert before it
|
||||
if (blockRef) {
|
||||
position = blockRef.index;
|
||||
// Remove trailing space if exists
|
||||
if (position > 0 && lineText[position - 1] === ' ') {
|
||||
position--;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For start date, find the end of main content before metadata
|
||||
let contentEnd = position;
|
||||
let inBrackets = false;
|
||||
const chars = lineText.slice(position).split('');
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const char = chars[i];
|
||||
|
||||
// Track if we're inside dataview brackets
|
||||
if (char === '[' && !inBrackets) {
|
||||
// Check if this is a dataview field like [due::...]
|
||||
const remainingText = lineText.slice(position + i);
|
||||
if (remainingText.match(/^\[[a-zA-Z]+::/)) {
|
||||
// This is metadata, stop here
|
||||
break;
|
||||
}
|
||||
inBrackets = true;
|
||||
contentEnd = position + i + 1;
|
||||
} else if (char === ']' && inBrackets) {
|
||||
inBrackets = false;
|
||||
contentEnd = position + i + 1;
|
||||
} else if (!inBrackets) {
|
||||
// Check for metadata markers when not inside brackets
|
||||
if (char === '#' || char === '📅' || char === '🚀' || char === '✅' || char === '❌' || char === '🛫' || char === '▶️' || char === '⏰' || char === '🏁') {
|
||||
// This is metadata, stop here
|
||||
break;
|
||||
}
|
||||
contentEnd = position + i + 1;
|
||||
} else {
|
||||
// Inside brackets, keep going
|
||||
contentEnd = position + i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
position = contentEnd;
|
||||
|
||||
// Trim any trailing whitespace from position
|
||||
while (position > 0 && lineText[position - 1] === ' ') {
|
||||
position--;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure position doesn't exceed the block reference position
|
||||
if (blockRef && position > blockRef.index) {
|
||||
position = blockRef.index;
|
||||
// Remove trailing space if it exists
|
||||
if (position > 0 && lineText[position - 1] === ' ') {
|
||||
position--;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AutoDateManager] Final insert position for ${dateType}: ${position}`);
|
||||
return position;
|
||||
}
|
||||
|
||||
|
|
@ -709,11 +829,16 @@ function findCompletedDateInsertPosition(
|
|||
lineText: string,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): number {
|
||||
// Look for block reference ID pattern (^block-id) at the end
|
||||
const blockRefMatch = lineText.match(/\s*\^[\w-]+\s*$/);
|
||||
if (blockRefMatch) {
|
||||
// Use centralized block reference detection
|
||||
const blockRef = detectBlockReference(lineText);
|
||||
if (blockRef) {
|
||||
// Insert before the block reference ID
|
||||
return lineText.length - blockRefMatch[0].length;
|
||||
// Remove trailing space if exists
|
||||
let position = blockRef.index;
|
||||
if (position > 0 && lineText[position - 1] === ' ') {
|
||||
position--;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
// If no block reference, insert at the very end
|
||||
|
|
@ -729,6 +854,56 @@ function escapeRegex(string: string): string {
|
|||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects block reference ID in the text
|
||||
* @param text The text to check
|
||||
* @returns Object with block reference info or null if not found
|
||||
*/
|
||||
function detectBlockReference(text: string): {
|
||||
blockId: string;
|
||||
index: number;
|
||||
length: number;
|
||||
fullMatch: string;
|
||||
} | null {
|
||||
// More comprehensive block reference pattern:
|
||||
// - Matches ^block-id format
|
||||
// - Can have optional whitespace before and after
|
||||
// - Block ID can contain letters, numbers, hyphens, and underscores
|
||||
// - Must be at the end of the line
|
||||
const blockRefPattern = /\s*(\^[a-zA-Z0-9-]+)$/;
|
||||
const match = text.match(blockRefPattern);
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
return {
|
||||
blockId: match[1],
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
fullMatch: match[0]
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes block reference from text temporarily
|
||||
* @param text The text containing block reference
|
||||
* @returns Object with cleaned text and block reference info
|
||||
*/
|
||||
function extractBlockReference(text: string): {
|
||||
cleanedText: string;
|
||||
blockRef: ReturnType<typeof detectBlockReference>;
|
||||
} {
|
||||
const blockRef = detectBlockReference(text);
|
||||
|
||||
if (blockRef) {
|
||||
const cleanedText = text.substring(0, blockRef.index).trimEnd();
|
||||
return {cleanedText, blockRef};
|
||||
}
|
||||
|
||||
return {cleanedText: text, blockRef: null};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for date operations
|
||||
*/
|
||||
|
|
@ -745,4 +920,6 @@ export {
|
|||
getStatusType,
|
||||
applyDateOperations,
|
||||
isMoveOperation,
|
||||
findMetadataInsertPosition,
|
||||
findCompletedDateInsertPosition,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { taskStatusChangeAnnotation } from "./taskStatusSwitcher";
|
|||
import { getTasksAPI } from "../utils";
|
||||
import { priorityChangeAnnotation } from "./priorityPicker";
|
||||
import { parseTaskLine } from "../utils/taskUtil";
|
||||
|
||||
/**
|
||||
* Creates an editor extension that cycles through task statuses when a user clicks on a task marker
|
||||
* @param app The Obsidian app instance
|
||||
|
|
@ -68,7 +69,7 @@ function isValidTaskMarkerReplacement(
|
|||
// Check if user actively selected text before replacement
|
||||
const startSelection = tr.startState.selection.main;
|
||||
const hasUserSelection = startSelection && !startSelection.empty;
|
||||
|
||||
|
||||
// If user had a selection that covers the replacement range, this is intentional replacement
|
||||
if (hasUserSelection && startSelection && fromA >= startSelection.from && toA <= startSelection.to) {
|
||||
console.log(
|
||||
|
|
@ -78,13 +79,13 @@ function isValidTaskMarkerReplacement(
|
|||
}
|
||||
|
||||
// Get valid task status marks from plugin settings
|
||||
const { marks } = getTaskStatusConfig(plugin);
|
||||
const {marks} = getTaskStatusConfig(plugin);
|
||||
const validMarks = Object.values(marks);
|
||||
|
||||
|
||||
// Check if both the original and inserted characters are valid task status marks
|
||||
const isOriginalValidMark = validMarks.includes(originalText) || originalText === ' ';
|
||||
const isInsertedValidMark = validMarks.includes(insertedText) || insertedText === ' ';
|
||||
|
||||
|
||||
// If either character is not a valid task mark, this is likely manual input
|
||||
if (!isOriginalValidMark || !isInsertedValidMark) {
|
||||
return false;
|
||||
|
|
@ -93,7 +94,7 @@ function isValidTaskMarkerReplacement(
|
|||
// Check if the replacement position is at a task marker location
|
||||
const taskRegex = /^[\s|\t]*([-*+]|\d+\.)\s+\[(.)]/;
|
||||
const match = newLineText.match(taskRegex);
|
||||
|
||||
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -173,9 +174,9 @@ export function findTaskStatusChanges(
|
|||
change.text === " " ||
|
||||
(change.text === "" &&
|
||||
(tr.startState.doc.sliceString(
|
||||
change.fromA,
|
||||
change.toA
|
||||
) === "\t" ||
|
||||
change.fromA,
|
||||
change.toA
|
||||
) === "\t" ||
|
||||
tr.startState.doc.sliceString(
|
||||
change.fromA,
|
||||
change.toA
|
||||
|
|
@ -330,7 +331,7 @@ export function findTaskStatusChanges(
|
|||
// Check if this is a replacement operation and validate if it's a valid task marker replacement
|
||||
if (fromA !== toA) {
|
||||
const originalText = tr.startState.doc.sliceString(fromA, toA);
|
||||
|
||||
|
||||
// Only perform validation if plugin is provided
|
||||
if (plugin) {
|
||||
const isValidReplacement = isValidTaskMarkerReplacement(
|
||||
|
|
@ -343,14 +344,14 @@ export function findTaskStatusChanges(
|
|||
newLineText,
|
||||
plugin
|
||||
);
|
||||
|
||||
|
||||
if (!isValidReplacement) {
|
||||
console.log(
|
||||
`Detected invalid task marker replacement (fromA=${fromA}, toA=${toA}). User manually input '${insertedText}' (original: '${originalText}'), skipping automatic cycling.`
|
||||
);
|
||||
return; // Skip this change, don't add to taskChanges
|
||||
}
|
||||
|
||||
|
||||
console.log(
|
||||
`Detected valid task marker replacement (fromA=${fromA}, toA=${toA}). Original: '${originalText}' -> New: '${insertedText}', proceeding with automatic cycling.`
|
||||
);
|
||||
|
|
@ -412,13 +413,13 @@ export function findTaskStatusChanges(
|
|||
wasCompleteTask: wasCompleteTask,
|
||||
tasksInfo: triggerByTasks
|
||||
? {
|
||||
isTaskChange: triggerByTasks,
|
||||
originalFromA: fromA,
|
||||
originalToA: toA,
|
||||
originalFromB: fromB,
|
||||
originalToB: toB,
|
||||
originalInsertedText: insertedText,
|
||||
}
|
||||
isTaskChange: triggerByTasks,
|
||||
originalFromA: fromA,
|
||||
originalToA: toA,
|
||||
originalFromB: fromB,
|
||||
originalToB: toB,
|
||||
originalInsertedText: insertedText,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
|
@ -526,7 +527,7 @@ export function handleCycleCompleteStatusTransaction(
|
|||
}
|
||||
|
||||
// Get the task cycle and marks from plugin settings
|
||||
const { cycle, marks, excludeMarksFromCycle } = getTaskStatusConfig(plugin);
|
||||
const {cycle, marks, excludeMarksFromCycle} = getTaskStatusConfig(plugin);
|
||||
const remainingCycle = cycle.filter(
|
||||
(state) => !excludeMarksFromCycle.includes(state)
|
||||
);
|
||||
|
|
@ -660,7 +661,7 @@ export function handleCycleCompleteStatusTransaction(
|
|||
|
||||
// Process each task status change
|
||||
for (const taskStatusInfo of taskStatusChanges) {
|
||||
const { position, currentMark, wasCompleteTask, tasksInfo } =
|
||||
const {position, currentMark, wasCompleteTask, tasksInfo} =
|
||||
taskStatusInfo;
|
||||
|
||||
if (tasksInfo?.isTaskChange) {
|
||||
|
|
@ -729,10 +730,8 @@ export function handleCycleCompleteStatusTransaction(
|
|||
// If so, we may choose to leave it as is rather than immediately cycling it
|
||||
if (wasCompleteTask) {
|
||||
// Find the corresponding status for this mark
|
||||
let foundStatus = null;
|
||||
for (const [status, mark] of Object.entries(marks)) {
|
||||
for (const [_, mark] of Object.entries(marks)) {
|
||||
if (mark === currentMark) {
|
||||
foundStatus = status;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1199,7 +1199,7 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
}
|
||||
|
||||
this.registerEditorExtension([
|
||||
taskTimerExtension(this.app, this.settings.taskTimer, this.app.metadataCache),
|
||||
taskTimerExtension(this),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
/* Task Timer Widget Styles - Simple Text-Based */
|
||||
|
||||
.task-timer-widget {
|
||||
margin: 2px 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
padding: 2px 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Remove all complex button styles - we're using text links now */
|
||||
/* Start button and action links */
|
||||
.task-timer-start,
|
||||
.task-timer-action {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.task-timer-start:hover,
|
||||
.task-timer-action:hover {
|
||||
color: var(--text-accent-hover);
|
||||
}
|
||||
|
|
@ -115,15 +115,29 @@ export class TaskTimerExporter {
|
|||
// Restore each timer
|
||||
for (const timerData of backup.activeTimers) {
|
||||
// Recreate timer state in localStorage
|
||||
// Convert old format to new segments format
|
||||
const segments = [];
|
||||
if (timerData.startTime) {
|
||||
segments.push({
|
||||
startTime: timerData.startTime,
|
||||
endTime: timerData.pausedTime,
|
||||
duration: timerData.pausedTime ?
|
||||
timerData.pausedTime - timerData.startTime - (timerData.totalPausedDuration || 0) :
|
||||
undefined
|
||||
});
|
||||
}
|
||||
|
||||
const restoredTimer: TimerState = {
|
||||
taskId: timerData.taskId,
|
||||
filePath: timerData.filePath,
|
||||
blockId: timerData.blockId,
|
||||
startTime: timerData.startTime,
|
||||
pausedTime: timerData.pausedTime,
|
||||
totalPausedDuration: timerData.totalPausedDuration || 0,
|
||||
status: timerData.status,
|
||||
createdAt: timerData.createdAt
|
||||
segments: segments,
|
||||
status: timerData.status as 'idle' | 'running' | 'paused',
|
||||
createdAt: timerData.createdAt,
|
||||
// Keep legacy fields for reference
|
||||
legacyStartTime: timerData.startTime,
|
||||
legacyPausedTime: timerData.pausedTime,
|
||||
legacyTotalPausedDuration: timerData.totalPausedDuration || 0
|
||||
};
|
||||
|
||||
localStorage.setItem(timerData.taskId, JSON.stringify(restoredTimer));
|
||||
|
|
@ -194,16 +208,20 @@ export class TaskTimerExporter {
|
|||
|
||||
const currentDuration = this.timerManager.getCurrentDuration(timer.taskId);
|
||||
|
||||
// Get the first and last segments for export
|
||||
const firstSegment = timer.segments[0];
|
||||
const lastSegment = timer.segments[timer.segments.length - 1];
|
||||
|
||||
exportTimers.push({
|
||||
taskId: timer.taskId,
|
||||
filePath: timer.filePath,
|
||||
blockId: timer.blockId,
|
||||
startTime: timer.startTime,
|
||||
endTime: timer.status === 'idle' ? Date.now() : undefined,
|
||||
startTime: firstSegment ? firstSegment.startTime : timer.createdAt,
|
||||
endTime: lastSegment && lastSegment.endTime ? lastSegment.endTime : undefined,
|
||||
duration: currentDuration,
|
||||
status: timer.status,
|
||||
createdAt: timer.createdAt,
|
||||
totalPausedDuration: timer.totalPausedDuration
|
||||
totalPausedDuration: timer.legacyTotalPausedDuration || 0
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -183,19 +183,19 @@ export class TaskTimerFormatter {
|
|||
*/
|
||||
private static cleanupZeroValues(formatted: string): string {
|
||||
// Remove zero hours, minutes, seconds if they appear at the start
|
||||
formatted = formatted.replace(/^0hrs?/i, '');
|
||||
formatted = formatted.replace(/^0mins?/i, '');
|
||||
formatted = formatted.replace(/^0secs?/i, '');
|
||||
formatted = formatted.replace(/^0s/i, '');
|
||||
formatted = formatted.replace(/^0hrs?\b/i, '');
|
||||
formatted = formatted.replace(/^0mins?\b/i, '');
|
||||
formatted = formatted.replace(/^0secs?\b/i, '');
|
||||
formatted = formatted.replace(/^0s\b/i, '');
|
||||
|
||||
// Remove zero values that appear after spaces
|
||||
formatted = formatted.replace(/\s+0hrs?/gi, '');
|
||||
formatted = formatted.replace(/\s+0mins?/gi, '');
|
||||
formatted = formatted.replace(/\s+0secs?/gi, '');
|
||||
formatted = formatted.replace(/\s+0s/gi, '');
|
||||
// Remove zero values that appear after spaces (use word boundaries)
|
||||
formatted = formatted.replace(/\s+0hrs?\b/gi, '');
|
||||
formatted = formatted.replace(/\s+0mins?\b/gi, '');
|
||||
formatted = formatted.replace(/\s+0secs?\b/gi, '');
|
||||
formatted = formatted.replace(/\s+0s\b/gi, '');
|
||||
|
||||
// Handle patterns like "0h 0m 15s" -> "15s"
|
||||
formatted = formatted.replace(/0[hm]\s*/g, '');
|
||||
formatted = formatted.replace(/\b0[hm]\b\s*/g, '');
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,34 @@
|
|||
import { TaskTimerSettings } from "../common/setting-definition";
|
||||
|
||||
/**
|
||||
* Time segment interface - represents a single work session
|
||||
*/
|
||||
export interface TimeSegment {
|
||||
startTime: number;
|
||||
endTime?: number; // undefined means still running
|
||||
duration?: number; // cached duration for completed segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer state interface
|
||||
*/
|
||||
export interface TimerState {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
blockId: string;
|
||||
segments: TimeSegment[]; // Array of time segments
|
||||
status: 'idle' | 'running' | 'paused';
|
||||
createdAt: number;
|
||||
// Legacy fields for backward compatibility
|
||||
legacyStartTime?: number;
|
||||
legacyPausedTime?: number;
|
||||
legacyTotalPausedDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy timer state interface for migration
|
||||
*/
|
||||
export interface LegacyTimerState {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
blockId: string;
|
||||
|
|
@ -79,13 +104,14 @@ export class TaskTimerManager {
|
|||
return blockId;
|
||||
}
|
||||
|
||||
// Create new timer
|
||||
// Create new timer with initial segment
|
||||
const timerState: TimerState = {
|
||||
taskId,
|
||||
filePath,
|
||||
blockId,
|
||||
startTime: now,
|
||||
totalPausedDuration: 0,
|
||||
segments: [{
|
||||
startTime: now
|
||||
}],
|
||||
status: 'running',
|
||||
createdAt: now
|
||||
};
|
||||
|
|
@ -117,8 +143,16 @@ export class TaskTimerManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Close the current segment
|
||||
const currentSegment = timerState.segments[timerState.segments.length - 1];
|
||||
if (currentSegment && !currentSegment.endTime) {
|
||||
currentSegment.endTime = now;
|
||||
currentSegment.duration = now - currentSegment.startTime;
|
||||
}
|
||||
|
||||
timerState.status = 'paused';
|
||||
timerState.pausedTime = Date.now();
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
}
|
||||
|
|
@ -129,15 +163,18 @@ export class TaskTimerManager {
|
|||
*/
|
||||
resumeTimer(taskId: string): void {
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState || timerState.status !== 'paused' || !timerState.pausedTime) {
|
||||
if (!timerState || timerState.status !== 'paused') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add paused duration to total
|
||||
const pausedDuration = Date.now() - timerState.pausedTime;
|
||||
timerState.totalPausedDuration += pausedDuration;
|
||||
const now = Date.now();
|
||||
|
||||
// Create a new segment for the resumed work
|
||||
timerState.segments.push({
|
||||
startTime: now
|
||||
});
|
||||
|
||||
timerState.status = 'running';
|
||||
timerState.pausedTime = undefined;
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
}
|
||||
|
|
@ -152,10 +189,13 @@ export class TaskTimerManager {
|
|||
return;
|
||||
}
|
||||
|
||||
timerState.startTime = Date.now();
|
||||
timerState.totalPausedDuration = 0;
|
||||
const now = Date.now();
|
||||
|
||||
// Clear all segments and start fresh
|
||||
timerState.segments = [{
|
||||
startTime: now
|
||||
}];
|
||||
timerState.status = 'running';
|
||||
timerState.pausedTime = undefined;
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
}
|
||||
|
|
@ -175,24 +215,25 @@ export class TaskTimerManager {
|
|||
return "";
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
let totalDuration: number;
|
||||
|
||||
if (timerState.status === 'paused' && timerState.pausedTime) {
|
||||
// If paused, calculate duration up to pause time
|
||||
totalDuration = timerState.pausedTime - timerState.startTime - timerState.totalPausedDuration;
|
||||
console.log(`[TaskTimerManager] Calculating paused timer duration: ${totalDuration}ms`);
|
||||
} else {
|
||||
// If running, calculate duration up to now
|
||||
totalDuration = endTime - timerState.startTime - timerState.totalPausedDuration;
|
||||
console.log(`[TaskTimerManager] Calculating running timer duration: ${totalDuration}ms`);
|
||||
const now = Date.now();
|
||||
|
||||
// Close the current segment if running
|
||||
if (timerState.status === 'running') {
|
||||
const currentSegment = timerState.segments[timerState.segments.length - 1];
|
||||
if (currentSegment && !currentSegment.endTime) {
|
||||
currentSegment.endTime = now;
|
||||
currentSegment.duration = now - currentSegment.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total duration from all segments
|
||||
const totalDuration = this.calculateTotalDuration(timerState);
|
||||
console.log(`[TaskTimerManager] Total duration from ${timerState.segments.length} segments: ${totalDuration}ms`);
|
||||
|
||||
// Validate duration
|
||||
if (totalDuration < 0) {
|
||||
console.error(`[TaskTimerManager] Invalid duration calculated: ${totalDuration}ms`);
|
||||
console.error(`[TaskTimerManager] Timer state: start=${timerState.startTime}, pause=${timerState.pausedTime}, totalPaused=${timerState.totalPausedDuration}`);
|
||||
totalDuration = 0;
|
||||
return this.formatDuration(0);
|
||||
}
|
||||
|
||||
// Remove from storage
|
||||
|
|
@ -227,7 +268,16 @@ export class TaskTimerManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as TimerState;
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Check if this is a legacy format that needs migration
|
||||
if (this.isLegacyFormat(parsed)) {
|
||||
console.log(`[TaskTimerManager] Migrating legacy timer state for ${taskId}`);
|
||||
const migrated = this.migrateLegacyState(parsed as LegacyTimerState);
|
||||
// Save migrated state
|
||||
localStorage.setItem(taskId, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
|
||||
// Validate the parsed state structure
|
||||
if (!this.validateTimerState(parsed)) {
|
||||
|
|
@ -237,7 +287,7 @@ export class TaskTimerManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return parsed as TimerState;
|
||||
} catch (error) {
|
||||
console.error(`[TaskTimerManager] Error retrieving timer state for ${taskId}:`, error);
|
||||
// Clean up corrupted data
|
||||
|
|
@ -250,6 +300,57 @@ export class TaskTimerManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the state is in legacy format
|
||||
* @param state State to check
|
||||
* @returns true if legacy format
|
||||
*/
|
||||
private isLegacyFormat(state: any): boolean {
|
||||
return (
|
||||
state &&
|
||||
typeof state.startTime === 'number' &&
|
||||
!state.segments &&
|
||||
typeof state.totalPausedDuration === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy timer state to new format
|
||||
* @param legacy Legacy timer state
|
||||
* @returns Migrated timer state
|
||||
*/
|
||||
private migrateLegacyState(legacy: LegacyTimerState): TimerState {
|
||||
const segments: TimeSegment[] = [];
|
||||
|
||||
// Create segment from legacy data
|
||||
if (legacy.status === 'running') {
|
||||
// Running timer - create an open segment
|
||||
segments.push({
|
||||
startTime: legacy.startTime + legacy.totalPausedDuration
|
||||
});
|
||||
} else if (legacy.status === 'paused' && legacy.pausedTime) {
|
||||
// Paused timer - create a closed segment
|
||||
segments.push({
|
||||
startTime: legacy.startTime + legacy.totalPausedDuration,
|
||||
endTime: legacy.pausedTime,
|
||||
duration: legacy.pausedTime - legacy.startTime - legacy.totalPausedDuration
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: legacy.taskId,
|
||||
filePath: legacy.filePath,
|
||||
blockId: legacy.blockId,
|
||||
segments,
|
||||
status: legacy.status,
|
||||
createdAt: legacy.createdAt,
|
||||
// Keep legacy fields for reference
|
||||
legacyStartTime: legacy.startTime,
|
||||
legacyPausedTime: legacy.pausedTime,
|
||||
legacyTotalPausedDuration: legacy.totalPausedDuration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate timer state structure
|
||||
* @param state Parsed timer state to validate
|
||||
|
|
@ -261,11 +362,9 @@ export class TaskTimerManager {
|
|||
typeof state.taskId === 'string' &&
|
||||
typeof state.filePath === 'string' &&
|
||||
typeof state.blockId === 'string' &&
|
||||
typeof state.startTime === 'number' &&
|
||||
typeof state.totalPausedDuration === 'number' &&
|
||||
Array.isArray(state.segments) &&
|
||||
typeof state.createdAt === 'number' &&
|
||||
['idle', 'running', 'paused'].includes(state.status) &&
|
||||
(state.pausedTime === undefined || typeof state.pausedTime === 'number')
|
||||
['idle', 'running', 'paused'].includes(state.status)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +409,32 @@ export class TaskTimerManager {
|
|||
this.removeFromActiveList(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total duration from all segments
|
||||
* @param timerState Timer state
|
||||
* @returns Total duration in milliseconds
|
||||
*/
|
||||
private calculateTotalDuration(timerState: TimerState): number {
|
||||
const now = Date.now();
|
||||
|
||||
return timerState.segments.reduce((total, segment) => {
|
||||
let segmentDuration: number;
|
||||
|
||||
if (segment.duration) {
|
||||
// Use cached duration if available
|
||||
segmentDuration = segment.duration;
|
||||
} else if (segment.endTime) {
|
||||
// Calculate duration for completed segment
|
||||
segmentDuration = segment.endTime - segment.startTime;
|
||||
} else {
|
||||
// Calculate duration for running segment
|
||||
segmentDuration = now - segment.startTime;
|
||||
}
|
||||
|
||||
return total + segmentDuration;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current running time for a timer
|
||||
* @param taskId Timer task ID
|
||||
|
|
@ -321,15 +446,35 @@ export class TaskTimerManager {
|
|||
return 0;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (timerState.status === 'running') {
|
||||
return now - timerState.startTime - timerState.totalPausedDuration;
|
||||
} else if (timerState.status === 'paused' && timerState.pausedTime) {
|
||||
return timerState.pausedTime - timerState.startTime - timerState.totalPausedDuration;
|
||||
return this.calculateTotalDuration(timerState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of time segments (sessions) for a timer
|
||||
* @param taskId Timer task ID
|
||||
* @returns Number of segments
|
||||
*/
|
||||
getSegmentCount(taskId: string): number {
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return timerState.segments.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all time segments for a timer
|
||||
* @param taskId Timer task ID
|
||||
* @returns Array of time segments
|
||||
*/
|
||||
getSegments(taskId: string): TimeSegment[] {
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return timerState.segments;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -355,8 +500,9 @@ export class TaskTimerManager {
|
|||
template = template.replace("{ms}", duration.toString());
|
||||
|
||||
// Clean up zero values (remove 0hrs, 0mins if they are zero)
|
||||
template = template.replace(/0hrs/g, "");
|
||||
template = template.replace(/0mins/g, "");
|
||||
// Use word boundaries to avoid matching 10hrs, 20mins etc.
|
||||
template = template.replace(/\b0hrs\b/g, "");
|
||||
template = template.replace(/\b0mins\b/g, "");
|
||||
|
||||
// Clean up leading/trailing spaces and multiple spaces
|
||||
template = template.replace(/\s+/g, " ").trim();
|
||||
|
|
|
|||
12118
styles.css
12118
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue