mirror of
https://github.com/rioskit/obsidian-todo-txt-mode.git
synced 2026-07-22 05:49:24 +00:00
feat: add recurring tasks and automatic completion date features
- Implement task watcher for real-time monitoring and auto-completion dates - Add support for recurring tasks (daily, weekly, monthly, yearly, business days) - Refactor parser and sorter tests for better maintainability - Add comprehensive test coverage for new features
This commit is contained in:
parent
86754a0375
commit
4e1c2e4fd7
39 changed files with 3932 additions and 2772 deletions
28
.eslintrc.json
Normal file
28
.eslintrc.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-unused-vars": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"main.js",
|
||||
"esbuild.config.mjs",
|
||||
"version-bump.mjs",
|
||||
"jest.config.js"
|
||||
]
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ data.json
|
|||
.DS_Store
|
||||
|
||||
**/.claude/settings.local.json
|
||||
|
||||
coverage
|
||||
|
|
|
|||
194
CLAUDE.md
194
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
|||
# CLAUDE.md
|
||||
|
||||
このファイルはこのリポジトリのコードに関するClaudeのガイダンスを提供します。
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## プロジェクト概要
|
||||
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
- 期日(`due:yyyy-mm-dd`)のハイライト
|
||||
- 完了タスクを専用ファイルに移動するコマンド
|
||||
- タスクを様々な条件(優先度、プロジェクト、コンテキスト、期日)でソートするコマンド
|
||||
- 繰り返しタスクの自動生成(`rec:d`、`rec:w`、`rec:m`、`rec:y`)
|
||||
|
||||
## 開発コマンド
|
||||
|
||||
|
|
@ -36,8 +37,17 @@ npm run test:watch
|
|||
# テストカバレッジ
|
||||
npm run test:coverage
|
||||
|
||||
# Lint実行
|
||||
npm run lint
|
||||
|
||||
# Lint自動修正
|
||||
npm run lint:fix
|
||||
|
||||
# バージョン番号の更新(manifest.jsonとversions.jsonを更新)
|
||||
npm run version
|
||||
|
||||
# 全体的なチェック(lint、型チェック、テストを一括実行)
|
||||
npm run check
|
||||
```
|
||||
|
||||
## アーキテクチャ
|
||||
|
|
@ -61,12 +71,24 @@ npm run version
|
|||
- ハイライト設定(有効/無効、色)
|
||||
- ソート設定とバウンダリマーカー設定
|
||||
|
||||
4. **タスクソートとタスク移動機能(sort.ts)**:
|
||||
4. **タスクソート機能(sort.ts)**:
|
||||
- 優先度、プロジェクト、コンテキスト、期日によるソート
|
||||
|
||||
5. **タスク移動機能(movetasks.ts)**:
|
||||
- 完了タスク(`x `で始まる行)を抽出
|
||||
- 元ファイルから削除
|
||||
- 完了タスク専用ファイルに追加
|
||||
|
||||
6. **タスク監視機能(task-watcher.ts)**:
|
||||
- ファイル変更の監視
|
||||
- リアルタイムタスク更新
|
||||
|
||||
7. **Todo.txtコアライブラリ(utils/todotxt-core/)**:
|
||||
- parser.ts: Todo.txt形式のパーサー機能
|
||||
- sorter.ts: タスクソート機能
|
||||
- recurrence.ts: 繰り返しタスクの処理
|
||||
- interfaces.ts: 型定義
|
||||
|
||||
## ファイル構造
|
||||
|
||||
```
|
||||
|
|
@ -74,11 +96,21 @@ src/
|
|||
├── main.ts # プラグインのメインコード、ライフサイクル管理
|
||||
├── syntax.ts # 構文ハイライト関連の機能(CodeMirror ViewPlugin)
|
||||
├── settings.ts # 設定関連のインターフェースと設定タブUI
|
||||
├── sort.ts # タスクソート機能とタスク移動機能
|
||||
├── parser.ts # Todo.txt形式のパーサー機能
|
||||
├── sort.ts # タスクソート機能
|
||||
├── movetasks.ts # タスク移動機能
|
||||
├── task-watcher.ts # タスク監視機能
|
||||
├── utils/
|
||||
│ └── todotxt-core/ # Todo.txtコアライブラリ
|
||||
│ ├── parser.ts # Todo.txt形式のパーサー機能
|
||||
│ ├── sorter.ts # タスクソート機能
|
||||
│ ├── recurrence.ts # 繰り返しタスクの処理
|
||||
│ ├── interfaces.ts # 型定義
|
||||
│ ├── index.ts # エクスポート
|
||||
│ └── __tests__/ # コアライブラリのテスト
|
||||
└── __tests__/ # テストファイル
|
||||
├── sort.test.ts # ソート機能のテスト
|
||||
└── syntax.test.ts # 構文ハイライトのテスト
|
||||
├── recurrence.test.ts # 繰り返し機能のテスト
|
||||
├── syntax.test.ts # 構文ハイライトのテスト
|
||||
└── task-watcher.test.ts # タスク監視機能のテスト
|
||||
|
||||
# 設定ファイル
|
||||
├── manifest.json # プラグイン情報
|
||||
|
|
@ -125,135 +157,37 @@ Obsidianの開発者モードでプラグインをテストするには:
|
|||
|
||||
### ビルドプロセス
|
||||
- TypeScriptの型チェックを必ず実行: `npx tsc -noEmit -skipLibCheck`
|
||||
- Lintチェックを実行: `npm run lint`
|
||||
- プロダクションビルド前に開発ビルドで動作確認: `npm run dev`
|
||||
|
||||
## Obsidianプラグイン開発のベストプラクティス
|
||||
## プロジェクト特有の重要事項
|
||||
|
||||
### コード品質
|
||||
### Todo.txtコアライブラリの活用
|
||||
- `utils/todotxt-core/` 内のライブラリを使用してTodo.txt形式の処理を行う
|
||||
- パーサー、ソーター、繰り返し処理は既に実装済み
|
||||
- 新機能追加時はコアライブラリの拡張を検討する
|
||||
|
||||
### 命名規則
|
||||
- 一貫した命名スタイルを使用する(関数には camelCase、クラスには PascalCase)
|
||||
- CSS クラス名にプレフィックスを追加し、Obsidian クラスとの競合を避ける
|
||||
### テストカバレッジの維持
|
||||
- 新機能には必ずテストを追加
|
||||
- コアライブラリの変更時は`utils/todotxt-core/__tests__/`のテストも更新
|
||||
- 繰り返しタスク機能のテストは特に重要
|
||||
|
||||
### コーディングスタイル
|
||||
- `var` の代わりに `let` または `const` を使用する
|
||||
- 型安全性のために TypeScript を活用する(`event.target as any` ではなく `event.target as HTMLInputElement`)
|
||||
- サンプルプラグインからのボイラープレートコードを削除する
|
||||
- スタイル定義は JavaScript ではなく CSS ファイルに移動する
|
||||
|
||||
## セキュリティの懸念事項
|
||||
|
||||
### 安全な DOM 操作
|
||||
- `innerHTML`/`outerHTML` を避ける(セキュリティリスク)
|
||||
- 代わりに DOM API または Obsidian ヘルパー関数を使用する
|
||||
- ユーザー入力は `sanitizeHTMLToDom` でサニタイズする
|
||||
|
||||
### 安全でないコード評価の回避
|
||||
- `eval()` や類似の関数を使用しない
|
||||
- 信頼できないデータの実行を防ぐ
|
||||
- 安全な代替手段を使用する(`eval()` の代わりに `JSON.parse()` など)
|
||||
|
||||
### ネットワークリクエストと URL 処理
|
||||
- クロスプラットフォーム互換性のために Obsidian の `requestUrl` メソッドを使用する
|
||||
- 開く前に URL を検証し正規化する
|
||||
- URL プロトコルをチェックする(http/https に制限)
|
||||
|
||||
## パフォーマンスの問題
|
||||
|
||||
### メモリリークの防止
|
||||
- Obsidian のメソッドを使用して、イベントリスナーを適切に登録する
|
||||
- レンダーメソッドでのラムダ関数を避ける
|
||||
- イベントハンドラをコンストラクタにバインドする
|
||||
|
||||
### 効率的なファイル操作
|
||||
- キャッシュされたメソッドを使用する(`read` ではなく `cachedRead`)
|
||||
- 非効率な操作を避ける(ファイルをバッチで処理する)
|
||||
- 適切な API メソッドを使用する(`getAbstractFileByPath`)
|
||||
|
||||
### バックグラウンドタスクの最適化
|
||||
- 繰り返しタスクに適切な間隔を設定する(≥1000ms)
|
||||
- 頻繁なイベントにはスロットリングとデバウンシングを実装する
|
||||
|
||||
## 必須ファイルと設定
|
||||
|
||||
### 必須ファイル
|
||||
- リポジトリ: README.md、manifest.json、LICENSE、versions.json、ソースコード
|
||||
- GitHub リリース: main.js、manifest.json、styles.css(該当する場合)
|
||||
|
||||
### manifest.json の要件
|
||||
- すべての必須フィールドを含める(id、name、version、minAppVersion など)
|
||||
- 命名規則に従う(id に「obsidian」を含めない、名前に「Plugin」を含めない)
|
||||
- バージョンは GitHub リリースタグと完全に一致する必要がある(「v」プレフィックスなし)
|
||||
|
||||
### versions.json の要件
|
||||
- プラグインバージョンと最小互換 Obsidian バージョンをマッピングする
|
||||
|
||||
## ドキュメント要件
|
||||
|
||||
### README.md
|
||||
- 明確な目的説明
|
||||
- 使用方法の説明
|
||||
- スクリーンショット/デモ(該当する場合)
|
||||
- 他のプラグインからのコードに対する適切な帰属
|
||||
|
||||
### ライセンス要件
|
||||
- リポジトリのルートに LICENSE ファイルを含める
|
||||
- 適切なオープンソースライセンスを選択する
|
||||
- README.md に適切な帰属を確保する
|
||||
|
||||
## 互換性への配慮
|
||||
|
||||
### クロスプラットフォーム互換性
|
||||
- 複数のプラットフォームでテストする
|
||||
- プラットフォームに依存しない API を使用する
|
||||
- プラットフォーム固有のパス区切り文字の代わりに Obsidian の `normalizePath` を使用する
|
||||
|
||||
### Obsidian バージョンの互換性
|
||||
- manifest.json で minAppVersion を指定する
|
||||
- 新しい機能を使用する場合は API の可用性をチェックする
|
||||
- 古いバージョン用のフォールバックを提供する
|
||||
|
||||
### モバイル互換性
|
||||
- iOS JavaScript の制限を考慮する(後方参照正規表現を避ける)
|
||||
- タッチ操作のための UI 要素を設計する(サイズを適切に)
|
||||
|
||||
## ライセンスの問題
|
||||
|
||||
### ライセンスの選択
|
||||
- 適切なオープンソースライセンスを選択する
|
||||
- LICENSE ファイルに完全なライセンステキストを含める
|
||||
- package.json にライセンスを指定する
|
||||
|
||||
### コードの帰属
|
||||
- 他のソースからのコードに適切に帰属を表示する
|
||||
- 借用コードとのライセンス互換性を確保する
|
||||
|
||||
## Obsidian API のベストプラクティス
|
||||
|
||||
### リソース登録とライフサイクル
|
||||
- イベント、DOM イベント、インターバルに適切な登録メソッドを使用する
|
||||
- onunload での自動クリーンアップを許可する
|
||||
|
||||
### 安全な DOM 操作
|
||||
- Obsidian のヘルパー関数(createEl)を使用する
|
||||
- ユーザーコンテンツをサニタイズする
|
||||
|
||||
### ファイル操作
|
||||
- Vault API を適切に使用する
|
||||
- メタデータキャッシュを活用する
|
||||
|
||||
### プラグイン設定の管理
|
||||
- Obsidian の設定 API を使用する(loadData/saveData)
|
||||
|
||||
### UI 操作
|
||||
- Obsidian の組み込み UI コンポーネントを使用する(Notice、Modal)
|
||||
### Obsidian API の適切な使用
|
||||
- DOM操作は`innerHTML`を避け、Obsidianヘルパー関数を使用
|
||||
- ファイル操作は`this.app.vault.cachedRead`を使用
|
||||
- イベントリスナーは`this.registerDomEvent`で登録
|
||||
- 設定管理は`loadData`/`saveData`を使用
|
||||
|
||||
## 開発前チェックリスト
|
||||
1. **テスト実行**: `npm test` でテストがパスすることを確認
|
||||
2. **型チェック**: `npx tsc -noEmit -skipLibCheck` でTypeScriptエラーがないことを確認
|
||||
1. **統合チェック**: `npm run check` でLint、型チェック、テストを一括実行
|
||||
2. **個別チェック** (必要に応じて):
|
||||
- **テスト実行**: `npm test` でテストがパスすることを確認
|
||||
- **型チェック**: `npx tsc -noEmit -skipLibCheck` でTypeScriptエラーがないことを確認
|
||||
- **Lint確認**: `npm run lint` でコード品質を確認
|
||||
3. **ビルド確認**: `npm run build` でビルドが成功することを確認
|
||||
4. **コード品質**: 一貫した命名規則とコーディングスタイルを維持
|
||||
5. **セキュリティ**: DOM操作やユーザー入力の適切な処理
|
||||
6. **パフォーマンス**: メモリリークやブロッキング処理の回避
|
||||
7. **互換性**: クロスプラットフォーム・モバイル対応
|
||||
8. **Obsidian API**: 適切なAPI使用とリソース管理
|
||||
4. **余計なコメント不要**: コードを読んで理解できない特別な意味を持つ箇所以外はコメント不要
|
||||
|
||||
## 重要なテスト対象機能
|
||||
- **繰り返しタスク機能**: `rec:d`、`rec:w`、`rec:m`、`rec:y`形式の処理
|
||||
- **構文ハイライト**: CodeMirror ViewPluginの装飾適用
|
||||
- **タスク監視**: ファイル変更の検知と処理
|
||||
183
README.md
183
README.md
|
|
@ -6,79 +6,30 @@ Todo.txt Mode is an Obsidian plugin that provides support for the [todo.txt](htt
|
|||
|
||||
## Features
|
||||
|
||||
- **Syntax Highlighting**: Automatically highlights elements within todo.txt files
|
||||
- Strikethrough for completed tasks (lines beginning with `x `)
|
||||
- Highlighting for completion dates (date following `x ` in completed tasks)
|
||||
- Highlighting for creation dates (standalone dates at the beginning of tasks)
|
||||
- Highlighting for project tags (`+project`)
|
||||
- Highlighting for context tags (`@context`)
|
||||
- Highlighting for priority markers (`(A)`, `(B)`, `(123)`)
|
||||
- Highlighting for due dates (`due:yyyy-mm-dd`)
|
||||
- **Color Customization**: Customize colors with the [Style Settings Plugin](https://github.com/mgmeyers/obsidian-style-settings)
|
||||
- **Syntax Highlight**:
|
||||
- Automatic highlighting for todo.txt elements
|
||||
- Completed tasks, project tags (`+project`), context tags (`@context`), priority markers (`(A)`, `(B)`), due dates (`due:yyyy-mm-dd`), recurring task markers (`rec:d`, `rec:w`, `rec:m`, `rec:y`)
|
||||
- Customize colors with the [Style Settings Plugin](https://github.com/mgmeyers/obsidian-style-settings)
|
||||
|
||||
- **Automatic Completion Date**:
|
||||
- Automatically adds completion date when marking tasks as complete with `x `
|
||||
|
||||
- **Recurring tasks**:
|
||||
- Automatic recurring task generation using `rec:`
|
||||
- attribute Intervals: `d` (daily), `b` (business days), `w` (weekly), `m` (monthly), `y` (yearly)
|
||||
- Numeric prefixes: `rec:3m` (every 3 months)
|
||||
- Strict mode: `rec:+m` (based on original due date)
|
||||
|
||||
- **Task Management**:
|
||||
- Command to move completed tasks to a dedicated file
|
||||
- Extracts completed tasks (lines beginning with `x `)
|
||||
- Removes them from the original file
|
||||
- Adds them to the top of the specified done file (newest tasks at the top)
|
||||
- Move completed tasks to done file
|
||||
- `Todo.txt: Move completed tasks to done file`
|
||||
|
||||
- **Task Sorting**:
|
||||
- Sort by priority
|
||||
- Sort by project
|
||||
- Sort by context
|
||||
- Sort by due date
|
||||
- Support for boundary marker (lines below the marker won't be sorted)
|
||||
- ***Task Sorting***:
|
||||
- Sort by priority, project, context, due date
|
||||
- `Todo.txt: Sort by priority/project/context/due date`
|
||||
|
||||
- **Editor Monitoring and Syntax Highlighting Mechanism**:
|
||||
1. User Open and edits a todo.txt file
|
||||
2. Changes are detected via Obsidian's `workspace.on('editor-change')` event
|
||||
3. Checks if the modified file is in todo.txt format
|
||||
4. Examines file content for special characters:
|
||||
- "x " (completed task indicator)
|
||||
- "@" (context tag prefix)
|
||||
- "+" (project tag prefix)
|
||||
- "(A)" (priority indicator)
|
||||
- "due:" (due date indicator)
|
||||
5. Applies decorations
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual Installation
|
||||
1. Download the zip file from the latest [release](https://github.com/rioskit/obsidian-todo-txt-mode/releases/latest)
|
||||
2. Extract the zip and copy the folder to `.obsidian/plugins/`
|
||||
3. Restart Obsidian and enable the plugin in settings
|
||||
|
||||
### Community Plugin Installation
|
||||
1. Open Obsidian settings
|
||||
2. Go to "Third-party plugins" → "Community plugins" → "Browse"
|
||||
3. Search for "Todo.txt Mode" and install
|
||||
|
||||
## Usage
|
||||
|
||||
### Settings
|
||||
|
||||
1. Open Obsidian settings
|
||||
2. Select "Third-party plugins" → "Todo.txt Mode"
|
||||
3. Configure:
|
||||
- **Todo.txt File Patterns**: Paths to your todo files (relative to vault root)
|
||||
- **Done File Path**: File path where completed tasks will be moved
|
||||
- **Boundary Marker**: Line marker indicating where sorting should stop
|
||||
- **Highlight Settings**: Enable/disable options for highlighting
|
||||
- **Color Customization**: Install the [Style Settings Plugin](https://github.com/mgmeyers/obsidian-style-settings) to customize colors
|
||||
|
||||
### Commands
|
||||
|
||||
- **Todo.txt: Move completed tasks to done file**:
|
||||
- Available from the command palette
|
||||
- Moves all completed tasks at once
|
||||
- **Todo.txt: Sort by priority**:
|
||||
- Sorts tasks by priority
|
||||
- **Todo.txt: Sort by project**:
|
||||
- Sorts tasks by project tag
|
||||
- **Todo.txt: Sort by context**:
|
||||
- Sorts tasks by context tag
|
||||
- **Todo.txt: Sort by due date**:
|
||||
- Sorts tasks by due date
|
||||
|
||||
## About todo.txt format
|
||||
|
||||
|
|
@ -89,101 +40,9 @@ x 2023-05-08 Completed task +project @context
|
|||
Today's task +work @office
|
||||
(A) High priority task +project
|
||||
A task with due:2023-05-15 date
|
||||
Daily standup @work rec:d due:2023-05-08
|
||||
Monthly review +personal rec:+m due:2023-05-01
|
||||
Submit report @work rec:3b due:2023-05-12
|
||||
```
|
||||
|
||||
Visit the [official website](https://github.com/todotxt/todo.txt) for more details.
|
||||
|
||||
---
|
||||
|
||||
# Todo.txt Mode for Obsidian [JA]
|
||||
|
||||
Todo.txt Mode は [Obsidian](https://obsidian.md) で [todo.txt](https://github.com/todotxt/todo.txt) ファイル形式をサポートするためのプラグインです。
|
||||
|
||||
## 特徴
|
||||
|
||||
- **シンタックスハイライト**: todo.txtファイル内の要素を自動的に強調表示
|
||||
- 完了タスク(`x `で始まる行)に取り消し線表示
|
||||
- 完了日時(完了タスクの`x `に続く日付)をハイライト
|
||||
- 作成日時(タスクの冒頭にある単独の日付)をハイライト
|
||||
- プロジェクトタグ(`+project`)をハイライト
|
||||
- コンテキストタグ(`@context`)をハイライト
|
||||
- 優先度(`(A)`、`(B)`、`(123)`)をハイライト
|
||||
- 期日(`due:yyyy-mm-dd`)をハイライト
|
||||
- **色のカスタマイズ**: [Style Settings Plugin](https://github.com/mgmeyers/obsidian-style-settings)で色をカスタマイズ可能
|
||||
|
||||
- **タスク管理**:
|
||||
- 完了タスクを専用ファイルに移動するコマンド
|
||||
- 完了タスク(`x `で始まる行)を抽出
|
||||
- 元のファイルから削除
|
||||
- 指定したdoneファイルの先頭に追加(新しいタスクが上)
|
||||
|
||||
- **タスクのソート**:
|
||||
- 優先度でソート
|
||||
- プロジェクトでソート
|
||||
- コンテキストでソート
|
||||
- 期日でソート
|
||||
- 境界線マーカーのサポート(この線以下の行はソートされません)
|
||||
|
||||
- **エディタ監視とシンタックスハイライトの仕組み**:
|
||||
1. ユーザーがtodo.txt関連ファイルを開き、編集したとき
|
||||
2. Obsidianの`workspace.on('editor-change')`イベントで変更を検知
|
||||
3. 変更されたファイルがtodo.txt形式であるか確認
|
||||
4. ファイル内容を各チェックし、特殊文字の存在を確認
|
||||
- 「x 」(完了タスクの標識)
|
||||
- 「@」(コンテキストタグの先頭)
|
||||
- 「+」(プロジェクトタグの先頭)
|
||||
- 「(A)」(優先度の標識)
|
||||
- 「due:」(期日の標識)
|
||||
5. 装飾を適用
|
||||
|
||||
## インストール
|
||||
|
||||
### 手動インストール
|
||||
1. 最新の[リリース](https://github.com/rioskit/obsidian-todo-txt-mode/releases/latest)からzipファイルをダウンロード
|
||||
2. zipを解凍し、フォルダを `.obsidian/plugins/` にコピー
|
||||
3. Obsidianを再起動し、設定からプラグインを有効化
|
||||
|
||||
### Communityプラグインからのインストール
|
||||
1. Obsidianの設定を開く
|
||||
2. 「サードパーティプラグイン」→「コミュニティプラグイン」→「閲覧」
|
||||
3. "Todo.txt Mode" を検索してインストール
|
||||
|
||||
## 使い方
|
||||
|
||||
### 設定
|
||||
|
||||
1. Obsidianの設定を開く
|
||||
2. 「サードパーティプラグイン」→「Todo.txt Mode」を選択
|
||||
3. 以下の設定を行う:
|
||||
- **Todo.txt File Patterns**: todo.txtファイルとして認識するパス(Vaultのルートからの相対パス)
|
||||
- **Done File Path**: 完了タスクを移動する先のファイルパス
|
||||
- **Boundary Marker**: ソートの境界線を示すマーカー(この行以下はソートされません)
|
||||
- **Highlight Settings**: ハイライト表示の有効/無効設定
|
||||
- **色のカスタマイズ**: [Style Settings Plugin](https://github.com/mgmeyers/obsidian-style-settings)をインストールすると色をカスタマイズできます
|
||||
|
||||
### コマンド
|
||||
|
||||
- **Todo.txt: Move completed tasks to done file**:
|
||||
- コマンドパレットから利用可能
|
||||
- 完了したタスクを一括で移動
|
||||
- **Todo.txt: Sort by priority**:
|
||||
- タスクを優先度でソート(Aが最高)
|
||||
- **Todo.txt: Sort by project**:
|
||||
- タスクをプロジェクトタグでソート
|
||||
- **Todo.txt: Sort by context**:
|
||||
- タスクをコンテキストタグでソート
|
||||
- **Todo.txt: Sort by due date**:
|
||||
- タスクを期日でソート
|
||||
|
||||
## todo.txt形式について
|
||||
|
||||
todo.txt形式の基本:
|
||||
|
||||
```
|
||||
x 2023-05-08 完了したタスク +プロジェクト @コンテキスト
|
||||
今日のタスク +仕事 @オフィス
|
||||
(A) 優先度の高いタスク +プロジェクト
|
||||
期日 due:2023-05-15 のあるタスク
|
||||
```
|
||||
|
||||
詳細は[公式サイト](https://github.com/todotxt/todo.txt)をご覧ください。
|
||||
|
|
|
|||
124
TODOTXT.md
Normal file
124
TODOTXT.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Todo.txt フォーマット仕様
|
||||
|
||||
## 概要
|
||||
Todo.txtは、シンプルで可読性の高いプレーンテキスト形式のタスク管理フォーマットです。1行に1つのタスクを記述し、特別なソフトウェアなしでも読み書きできることが特徴です。
|
||||
|
||||
## 基本フォーマット
|
||||
```
|
||||
x (A) 2016-05-20 2016-04-30 measure space for +chapelShelving @chapel due:2016-05-30
|
||||
```
|
||||
|
||||
## フォーマット構成要素
|
||||
|
||||
### 1. 完了マーク(オプション)
|
||||
- `x` - タスクが完了したことを示す
|
||||
- 行の先頭に配置
|
||||
- 小文字の `x` のみ使用
|
||||
|
||||
### 2. 優先度(オプション)
|
||||
- `(A)` から `(Z)` の形式で表記
|
||||
- A が最高優先度、Z が最低優先度
|
||||
- 推奨は A〜E の範囲を使用
|
||||
- 完了マークの後、またはタスクの先頭に配置
|
||||
- 例:`(A) 重要なタスク`
|
||||
|
||||
### 3. 完了日(オプション)
|
||||
- 完了タスクの場合のみ使用
|
||||
- YYYY-MM-DD 形式
|
||||
- 完了マーク `x` の直後に配置
|
||||
- 例:`x 2016-05-20`
|
||||
|
||||
### 4. 作成日(オプション)
|
||||
- タスクを作成した日付
|
||||
- YYYY-MM-DD 形式
|
||||
- 優先度の後、またはタスクテキストの前に配置
|
||||
- 例:`2016-04-30 タスクの説明`
|
||||
|
||||
### 5. タスク説明(必須)
|
||||
- タスクの内容を記述
|
||||
- 日本語・英語どちらでも可
|
||||
- プロジェクトタグやコンテキストタグを含めることができる
|
||||
|
||||
### 6. プロジェクトタグ
|
||||
- `+` で始まる
|
||||
- プロジェクトやカテゴリを示す
|
||||
- 複数指定可能
|
||||
- 例:`+chapelShelving`、`+HogeProj`
|
||||
|
||||
### 7. コンテキストタグ
|
||||
- `@` で始まる
|
||||
- 場所、状況、ツールなどを示す
|
||||
- 複数指定可能
|
||||
- 例:`@chapel`、`@phone`、`@home`
|
||||
|
||||
### 8. 拡張メタデータ
|
||||
- `key:value` 形式で追加情報を記述
|
||||
- よく使われる例:
|
||||
- `due:2016-05-30` - 期限
|
||||
- `rec:+1w` - 繰り返し(毎週)
|
||||
- `rec:+1m` - 繰り返し(毎月)
|
||||
|
||||
### 9. 繰り返しタスク(Recurrence)
|
||||
- `rec:` キーワードで繰り返し間隔を指定
|
||||
- 基本形式:
|
||||
- `rec:1d` - 毎日
|
||||
- `rec:3d` - 3日ごと
|
||||
- `rec:1w` - 毎週
|
||||
- `rec:2w` - 2週間ごと
|
||||
- `rec:1m` - 毎月
|
||||
- `rec:1y` - 毎年
|
||||
- プラス記法(`+`付き):
|
||||
- `rec:+1d` - 元の期限日から1日後(厳密モード)
|
||||
- `rec:+1w` - 元の期限日から1週間後(厳密モード)
|
||||
- `rec:+1m` - 元の期限日から1ヶ月後(厳密モード)
|
||||
- プラスなし記法:
|
||||
- `rec:1d` - 完了日から1日後
|
||||
- `rec:1w` - 完了日から1週間後
|
||||
- 例:`毎日の日課 due:2023-12-25 rec:1d`
|
||||
|
||||
## タスクの例
|
||||
|
||||
### 基本的なタスク
|
||||
```
|
||||
先方にお礼の電話をする
|
||||
```
|
||||
|
||||
### 優先度付きタスク
|
||||
```
|
||||
(A) 先方にお礼の電話をする
|
||||
```
|
||||
|
||||
### プロジェクトとコンテキスト付きタスク
|
||||
```
|
||||
先方にお礼の電話をする @phone +HogeProj
|
||||
```
|
||||
|
||||
### 作成日付きタスク
|
||||
```
|
||||
2017-07-14 先方にお礼の電話をする
|
||||
```
|
||||
|
||||
### 完了タスク
|
||||
```
|
||||
x 2017-07-14 先方にお礼の電話をする
|
||||
```
|
||||
|
||||
### 完全な形式のタスク
|
||||
```
|
||||
x (A) 2017-07-14 2017-07-13 先方にお礼の電話をする @phone +HogeProj due:2017-07-14
|
||||
```
|
||||
|
||||
### 繰り返しタスクの例
|
||||
```
|
||||
掃除をする due:2023-12-25 rec:1d
|
||||
週次レポート提出 due:2023-12-29 rec:+1w @office
|
||||
月例会議 due:2023-12-31 rec:1m
|
||||
```
|
||||
|
||||
## ルールと推奨事項
|
||||
|
||||
1. **1行1タスク** - 各タスクは独立した1行に記述
|
||||
2. **優先度は控えめに** - すべてのタスクに優先度を付けず、本当に重要なものだけに使用
|
||||
3. **日付フォーマット** - 必ず YYYY-MM-DD 形式を使用
|
||||
4. **タグの一貫性** - プロジェクトタグとコンテキストタグは統一した命名規則を使用
|
||||
5. **シンプルさを保つ** - 必要な情報だけを記述し、過度に複雑にしない
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testEnvironment: 'jsdom',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
transform: {
|
||||
|
|
|
|||
68
no-todo.txt
68
no-todo.txt
|
|
@ -1,68 +0,0 @@
|
|||
Empty priority () should not parse
|
||||
Mixed case priority (a) should not parse as priority
|
||||
Lowercase x in middle of task x should not be completion
|
||||
X Uppercase X at start should not be completion
|
||||
x(A) No space after x should not parse correctly
|
||||
x2025-01-16 No space after x before date
|
||||
(A)No space after priority should still work
|
||||
2025-01-15No space after date might not parse
|
||||
+projectNoSpace@contextNoSpace tags without spaces
|
||||
Key without value due: should not parse
|
||||
Value without key :2025-12-31 should not parse
|
||||
(-1) Negative priority should not parse as priority
|
||||
Task with invalid date 2025-13-32
|
||||
Task with partial date 2025-01
|
||||
due:tomorrow Invalid date format
|
||||
due:2025/12/31 Different date separator
|
||||
( A ) Priority with spaces inside parentheses
|
||||
++invalid double plus project
|
||||
@@invalid double at context
|
||||
+val@id project with @ in name
|
||||
@val+id context with + in name
|
||||
+pro ject with space should be two tokens
|
||||
@con text with space should be two tokens
|
||||
+ project with space after plus
|
||||
@ context with space after at
|
||||
Task with +project+project2 no space between
|
||||
Task with @context@context2 no space between
|
||||
Task with due:2025-12-31due:2025-12-30 duplicate keys
|
||||
Task with +@ mixed symbols
|
||||
Task with @+ reversed symbols
|
||||
Task with ++double +plus
|
||||
Task with @@double @at
|
||||
Task with ++ @@ multiple symbol prefixes
|
||||
(A)(B) Double priority task
|
||||
x x Double completion marker
|
||||
2025-01-15 2025-01-15 Duplicate creation date
|
||||
Task with : colon but no key:value
|
||||
Task with key: no value after colon
|
||||
Task with :value no key before colon
|
||||
Task with + bare plus
|
||||
Task with @ bare at
|
||||
Task with ending plus +
|
||||
Task with ending at @
|
||||
Task with \n newline escape
|
||||
Task with \t tab escape
|
||||
Task with \r carriage return
|
||||
Task with null\0character
|
||||
Task with unicode escape \u0041 in text
|
||||
Task with +project\n@context broken by newline
|
||||
Multiline attempt line1\nline2 +project
|
||||
Long priority (URGENT) 2025-01-15 Task +project @context
|
||||
x (A) 2025-01-16 Priority completed with date +project
|
||||
x (A) 2025-01-16 Priority completed with date @context
|
||||
x (A) 2025-01-16 Priority completed with date due:2025-12-31
|
||||
x (A) 2025-01-16 2025-01-15 Priority completed both dates +project
|
||||
x (A) 2025-01-16 2025-01-15 Priority completed both dates @context
|
||||
x (A) 2025-01-16 2025-01-15 Priority completed both dates due:2025-12-31
|
||||
x (A) 2025-01-16 Priority completed +project @context
|
||||
x (A) 2025-01-16 Priority completed +project due:2025-12-31
|
||||
x (A) 2025-01-16 Priority completed @context due:2025-12-31
|
||||
x (A) 2025-01-16 Priority completed +project @context due:2025-12-31
|
||||
x (A) 2025-01-16 2025-01-15 Priority completed all +project @context due:2025-12-31
|
||||
x (A) 2025-01-16 2025-01-15 Completed priority with space after x
|
||||
+project@ Invalid project with @ symbol
|
||||
@context+ Invalid context with + symbol
|
||||
Empty line above and below task
|
||||
Task with newline\nshould be single line
|
||||
Task with tab\tcharacters inside
|
||||
1073
package-lock.json
generated
1073
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,10 @@
|
|||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"check": "npm run lint && npx tsc -noEmit -skipLibCheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"todo.txt",
|
||||
|
|
@ -28,7 +31,9 @@
|
|||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^8.57.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.4.0",
|
||||
"tslib": "2.4.0",
|
||||
|
|
|
|||
48
remove-test-comments.js
Normal file
48
remove-test-comments.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function removeCommentsFromTypeScriptFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
let result = content
|
||||
.replace(/\/\*(?!\s*eslint-)[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/(?!\s*eslint-).*$/gm, '')
|
||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||
.replace(/^\s*\n/gm, '')
|
||||
.replace(/\n\s*$/g, '\n');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeTestComments() {
|
||||
try {
|
||||
const findCommand = 'find . -name "*.test.ts" -path "*/src/*" -not -path "*/node_modules/*"';
|
||||
const testFilesOutput = execSync(findCommand, { encoding: 'utf8' });
|
||||
const projectTestFiles = testFilesOutput.trim().split('\n').filter(file => file.length > 0);
|
||||
|
||||
|
||||
for (const filePath of projectTestFiles) {
|
||||
|
||||
const originalContent = fs.readFileSync(filePath, 'utf8');
|
||||
const newContent = removeCommentsFromTypeScriptFile(filePath);
|
||||
|
||||
if (originalContent !== newContent) {
|
||||
fs.writeFileSync(filePath, newContent, 'utf8');
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
removeTestComments();
|
||||
}
|
||||
|
||||
module.exports = { removeCommentsFromTypeScriptFile, removeTestComments };
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { parseTodo } from '../utils/todotxt-core';
|
||||
|
||||
// Syntax highlighting specific tests - focuses on positioning for CSS class application
|
||||
describe('Syntax highlighting positioning', () => {
|
||||
describe('Highlighting position accuracy', () => {
|
||||
it('should provide accurate positions for all highlight-able elements', () => {
|
||||
const line = '(A) 2025-01-15 Task with +project @context due:2025-12-31';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
// Test all elements that syntax.ts highlights
|
||||
expect(positions.priority?.value).toBe('(A)');
|
||||
expect(positions.creationDate?.value).toBe('2025-01-15');
|
||||
expect(positions.projects[0]?.value).toBe('+project');
|
||||
expect(positions.contexts[0]?.value).toBe('@context');
|
||||
|
||||
// Due date is in keyValues
|
||||
const dueDate = positions.keyValues.find(kv => kv.value.startsWith('due:'));
|
||||
expect(dueDate?.value).toBe('due:2025-12-31');
|
||||
});
|
||||
|
||||
it('should provide positions for completed task elements', () => {
|
||||
const line = 'x 2025-01-16 2025-01-15 Completed +project @context due:2025-12-31';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
// Elements that get highlighted in completed tasks
|
||||
expect(positions.completion?.value).toBe('x');
|
||||
expect(positions.completionDate?.value).toBe('2025-01-16');
|
||||
expect(positions.creationDate?.value).toBe('2025-01-15');
|
||||
expect(positions.projects[0]?.value).toBe('+project');
|
||||
expect(positions.contexts[0]?.value).toBe('@context');
|
||||
});
|
||||
|
||||
it('should handle edge cases for syntax highlighting', () => {
|
||||
// Multiple projects and contexts - common highlighting scenario
|
||||
const line = 'Task with +proj1 +proj2 @home @work due:2025-12-31 rec:1w';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
expect(positions.projects).toHaveLength(2);
|
||||
expect(positions.contexts).toHaveLength(2);
|
||||
expect(positions.keyValues).toHaveLength(2);
|
||||
|
||||
// Verify positions don't overlap
|
||||
const allPositions = [
|
||||
...positions.projects,
|
||||
...positions.contexts,
|
||||
...positions.keyValues
|
||||
].sort((a, b) => a.start - b.start);
|
||||
|
||||
for (let i = 0; i < allPositions.length - 1; i++) {
|
||||
expect(allPositions[i].end).toBeLessThanOrEqual(allPositions[i + 1].start);
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract substrings correctly for highlighting verification', () => {
|
||||
const line = 'x (B) 2025-01-16 Task +emoji🚀 @café due:2025-12-31';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
// Verify all positions extract correct substrings (critical for highlighting)
|
||||
[
|
||||
positions.completion,
|
||||
positions.priority,
|
||||
positions.completionDate,
|
||||
...positions.projects,
|
||||
...positions.contexts,
|
||||
...positions.keyValues
|
||||
].filter(Boolean).forEach(pos => {
|
||||
if (pos) {
|
||||
const extracted = line.substring(pos.start, pos.end);
|
||||
expect(extracted).toBe(pos.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Syntax highlighting edge cases', () => {
|
||||
it('should handle indented tasks', () => {
|
||||
const line = ' (A) Indented task +project';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
expect(positions.priority?.start).toBe(4); // After indentation
|
||||
expect(positions.projects[0]?.start).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('should handle emoji in tags (Unicode highlighting)', () => {
|
||||
const line = 'Task +🚀rocket @🏠home';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
expect(positions.projects[0]?.value).toBe('+🚀rocket');
|
||||
expect(positions.contexts[0]?.value).toBe('@🏠home');
|
||||
|
||||
// Verify Unicode is handled correctly
|
||||
const projectExtracted = line.substring(
|
||||
positions.projects[0].start,
|
||||
positions.projects[0].end
|
||||
);
|
||||
expect(projectExtracted).toBe('+🚀rocket');
|
||||
});
|
||||
});
|
||||
});
|
||||
340
src/__tests__/task-watcher.test.ts
Normal file
340
src/__tests__/task-watcher.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { EditorView, ViewPlugin } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { createTaskWatcher } from '../task-watcher';
|
||||
import { TodoTxtSettings } from '../settings';
|
||||
jest.mock('../utils/todotxt-core/recurrence', () => ({
|
||||
...jest.requireActual('../utils/todotxt-core/recurrence'),
|
||||
getCurrentDate: jest.fn(() => '2025-07-02')
|
||||
}));
|
||||
global.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
setTimeout(() => callback(0), 0);
|
||||
return 0;
|
||||
};
|
||||
const mockApp = {
|
||||
workspace: {
|
||||
getActiveFile: jest.fn()
|
||||
}
|
||||
};
|
||||
const mockSettings: TodoTxtSettings = {
|
||||
todoFilePaths: ['todo.md'],
|
||||
doneFilePath: 'done.md',
|
||||
boundaryMarker: '--',
|
||||
highlightCompletedTask: true,
|
||||
completedTaskColor: '#808080',
|
||||
highlightProject: true,
|
||||
projectColor: '#4285F4',
|
||||
highlightContext: true,
|
||||
contextColor: '#34A853',
|
||||
highlightPriority: true,
|
||||
priorityColor: '#E91E63',
|
||||
highlightDueDate: true,
|
||||
dueDateColor: '#607D8B',
|
||||
highlightCompletionDate: true,
|
||||
completionDateColor: '#FF9800',
|
||||
highlightCreationDate: true,
|
||||
creationDateColor: '#9C27B0',
|
||||
highlightRecurringTask: true,
|
||||
recurringTaskColor: '#FF5722',
|
||||
enableRecurringTasks: true,
|
||||
enableAutoCompletionDate: true
|
||||
};
|
||||
const mockIsTodoTxtFile = (path: string) => path === 'todo.md';
|
||||
const mockGetSettings = () => mockSettings;
|
||||
describe('TaskWatcher', () => {
|
||||
let view: EditorView;
|
||||
let taskWatcherPlugin: ViewPlugin<{ update: (update: unknown) => void; destroy: () => void }>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockApp.workspace.getActiveFile.mockReturnValue({ path: 'todo.md' });
|
||||
const state = EditorState.create({
|
||||
doc: 'Task without recurrence\n(A) Task with priority +project @context due:2023-12-25 rec:d'
|
||||
});
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
taskWatcherPlugin = createTaskWatcher(
|
||||
mockApp as unknown as import('obsidian').App,
|
||||
mockIsTodoTxtFile,
|
||||
mockGetSettings
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
view.destroy();
|
||||
});
|
||||
describe('Basic functionality', () => {
|
||||
it('should not generate recurring tasks when enableRecurringTasks setting is false', async () => {
|
||||
mockSettings.enableRecurringTasks = false;
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task with rec:d',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(2); // Original + auto completion date only
|
||||
testView.destroy();
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
});
|
||||
it('should not generate recurring tasks for files not configured as todo files', async () => {
|
||||
mockApp.workspace.getActiveFile.mockReturnValue({ path: 'notes.md' });
|
||||
const state = EditorState.create({
|
||||
doc: 'Task with rec:d',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
testView.destroy();
|
||||
});
|
||||
});
|
||||
describe('Recurring task creation', () => {
|
||||
it('should generate new recurring task with incremented due date when marking task as complete', async () => {
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: '(A) Task with priority +project @context due:2025-07-02 rec:d',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(3); // Original + auto completion date + new recurring task
|
||||
const thirdCall = dispatchSpy.mock.calls[2][0];
|
||||
expect(thirdCall.changes).toBeDefined();
|
||||
const changes = thirdCall.changes;
|
||||
if (changes && typeof changes === 'object' && 'insert' in changes) {
|
||||
const insertedText = changes.insert as string;
|
||||
expect(insertedText).toContain('(A)');
|
||||
expect(insertedText).toContain('+project');
|
||||
expect(insertedText).toContain('@context');
|
||||
expect(insertedText).toContain('due:2025-07-03');
|
||||
expect(insertedText).toContain('rec:d');
|
||||
}
|
||||
testView.destroy();
|
||||
});
|
||||
it('should not generate new task when completing task without rec: pattern', async () => {
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task without recurrence',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(2); // Original + auto completion date only (no recurring task)
|
||||
testView.destroy();
|
||||
});
|
||||
it('should not generate recurring task when enableAutoCompletionDate is false', async () => {
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
mockSettings.enableAutoCompletionDate = false;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task with rec:d',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1); // Only original dispatch, no auto completion, no recurring task
|
||||
testView.destroy();
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
});
|
||||
it('should skip processing already completed recurring tasks to prevent duplicates', async () => {
|
||||
const state = EditorState.create({
|
||||
doc: 'Task with rec:d',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
const pluginInstance = testView.plugin(taskWatcherPlugin);
|
||||
const dispatchSpy = jest.spyOn(testView, 'dispatch');
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x 2025-07-02 ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const callCount = dispatchSpy.mock.calls.length;
|
||||
if (pluginInstance && 'update' in pluginInstance) {
|
||||
const mockUpdate = {
|
||||
docChanged: true,
|
||||
view: testView,
|
||||
state: testView.state,
|
||||
transactions: []
|
||||
} as { docChanged: boolean; view: EditorView; state: EditorState; transactions: unknown[] };
|
||||
(pluginInstance as { update: (update: unknown) => void }).update(mockUpdate);
|
||||
}
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(callCount);
|
||||
testView.destroy();
|
||||
});
|
||||
});
|
||||
describe('Auto completion date', () => {
|
||||
it('should insert current date after completion marker when enableAutoCompletionDate is true', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task without completion date',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const finalDoc = testView.state.doc.toString();
|
||||
expect(finalDoc).toMatch(/^x 2025-07-02 Task without completion date$/);
|
||||
testView.destroy();
|
||||
});
|
||||
it('should insert completion date between completion marker and existing creation date', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: '2023-12-20 Task with creation date',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const finalDoc = testView.state.doc.toString();
|
||||
expect(finalDoc).toBe('x 2025-07-02 2023-12-20 Task with creation date');
|
||||
testView.destroy();
|
||||
});
|
||||
it('should not insert completion date when enableAutoCompletionDate is false', async () => {
|
||||
mockSettings.enableAutoCompletionDate = false;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task without completion date',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const finalDoc = testView.state.doc.toString();
|
||||
expect(finalDoc).toBe('x Task without completion date');
|
||||
testView.destroy();
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
});
|
||||
it('should insert completion date even when recurring task generation is disabled', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
mockSettings.enableRecurringTasks = false;
|
||||
const state = EditorState.create({
|
||||
doc: 'Task without completion date',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const finalDoc = testView.state.doc.toString();
|
||||
expect(finalDoc).toMatch(/^x 2025-07-02 Task without completion date$/);
|
||||
testView.destroy();
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
});
|
||||
it('should insert completion date and generate new recurring task for tasks without creation date', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: 'Recurring task rec:d due:2025-07-02',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const doc = testView.state.doc.toString();
|
||||
expect(doc).toContain('x 2025-07-02 Recurring task rec:d due:2025-07-02');
|
||||
expect(doc).toContain('Recurring task rec:d due:2025-07-03');
|
||||
testView.destroy();
|
||||
});
|
||||
it('should insert completion date and generate new recurring task with current date as creation date', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
const state = EditorState.create({
|
||||
doc: '2023-12-20 Recurring task rec:d due:2025-07-02',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const doc = testView.state.doc.toString();
|
||||
expect(doc).toContain('x 2025-07-02 2023-12-20 Recurring task rec:d due:2025-07-02');
|
||||
expect(doc).toContain('2025-07-02 Recurring task rec:d due:2025-07-03');
|
||||
testView.destroy();
|
||||
});
|
||||
it('should place new recurring task before completed task in document', async () => {
|
||||
mockSettings.enableAutoCompletionDate = true;
|
||||
mockSettings.enableRecurringTasks = true;
|
||||
const state = EditorState.create({
|
||||
doc: '2025-07-01 hoge rec:d due:2025-07-03',
|
||||
extensions: [taskWatcherPlugin]
|
||||
});
|
||||
const testView = new EditorView({
|
||||
state,
|
||||
parent: document.createElement('div')
|
||||
});
|
||||
testView.dispatch({
|
||||
changes: { from: 0, to: 0, insert: 'x ' }
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const doc = testView.state.doc.toString();
|
||||
const lines = doc.split('\n');
|
||||
expect(lines[0]).toBe('2025-07-02 hoge rec:d due:2025-07-03');
|
||||
expect(lines[1]).toBe('x 2025-07-02 2025-07-01 hoge rec:d due:2025-07-03');
|
||||
testView.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
20
src/main.ts
20
src/main.ts
|
|
@ -3,21 +3,22 @@ import {
|
|||
Plugin,
|
||||
} from 'obsidian';
|
||||
|
||||
import { TodoTxtSettings, DEFAULT_SETTINGS, TodoTxtSettingTab } from './settings';
|
||||
import { TodoTxtSettings, DEFAULT_SETTINGS, TodoTxtSettingTab, SortType } from './settings';
|
||||
import { createTodoTxtExtension } from './syntax';
|
||||
import { TodoTxtSorter } from './sort';
|
||||
import { createTaskWatcher } from './task-watcher';
|
||||
import { createTodoTxtSorter } from './sort';
|
||||
import { createMoveCompletedTasks } from './movetasks';
|
||||
|
||||
export default class TodoTxtPlugin extends Plugin {
|
||||
settings: TodoTxtSettings;
|
||||
sorter: TodoTxtSorter;
|
||||
sorter?: { registerSortCommands: (plugin: Plugin) => void; sortTasks: (sortType: SortType) => Promise<void> };
|
||||
moveCompletedTasks: () => Promise<void>;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
this.sorter = new TodoTxtSorter(this.app, this.settings, this.isTodoTxtFile.bind(this));
|
||||
this.moveCompletedTasks = createMoveCompletedTasks(this.app, this.settings, this.isTodoTxtFile.bind(this));
|
||||
this.sorter = createTodoTxtSorter(this.app, this.settings, this.isTodoTxtFile.bind(this));
|
||||
this.moveCompletedTasks = createMoveCompletedTasks(this.app, this.settings);
|
||||
|
||||
this.addCommand({
|
||||
id: 'done',
|
||||
|
|
@ -35,20 +36,19 @@ export default class TodoTxtPlugin extends Plugin {
|
|||
return true;
|
||||
}
|
||||
});
|
||||
this.sorter.registerSortCommands(this);
|
||||
this.sorter?.registerSortCommands(this);
|
||||
this.addSettingTab(new TodoTxtSettingTab(this.app, this));
|
||||
|
||||
this.registerEditorExtension([
|
||||
createTodoTxtExtension(this.app, this.isTodoTxtFile.bind(this), () => this.settings)
|
||||
createTodoTxtExtension(this.app, this.isTodoTxtFile.bind(this), () => this.settings),
|
||||
createTaskWatcher(this.app, this.isTodoTxtFile.bind(this), () => this.settings)
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (this.sorter) {
|
||||
this.sorter = null as unknown as TodoTxtSorter;
|
||||
}
|
||||
this.sorter = undefined;
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { App, Notice, TFile, normalizePath } from 'obsidian';
|
||||
import { TodoTxtSettings } from './settings';
|
||||
import { isCompletedTask } from './utils/todotxt-core';
|
||||
|
||||
export function createMoveCompletedTasks(
|
||||
app: App,
|
||||
settings: TodoTxtSettings,
|
||||
isTodoTxtFile: (path: string) => boolean
|
||||
settings: TodoTxtSettings
|
||||
) {
|
||||
return async function moveCompletedTasks() {
|
||||
let completedTasks: string[] = [];
|
||||
const doneFilePath = settings.doneFilePath;
|
||||
|
||||
let todoFiles: TFile[] = [];
|
||||
const todoFiles: TFile[] = [];
|
||||
|
||||
for (const path of settings.todoFilePaths) {
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
|
|
@ -31,8 +31,7 @@ export function createMoveCompletedTasks(
|
|||
const remainingLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('x ')) {
|
||||
if (isCompletedTask(line)) {
|
||||
completedLines.push(line);
|
||||
} else {
|
||||
remainingLines.push(line);
|
||||
|
|
@ -41,7 +40,7 @@ export function createMoveCompletedTasks(
|
|||
|
||||
if (completedLines.length > 0) {
|
||||
completedTasks = [...completedTasks, ...completedLines];
|
||||
await app.vault.process(file, (data) => {
|
||||
await app.vault.process(file, () => {
|
||||
return remainingLines.join('\n');
|
||||
});
|
||||
}
|
||||
|
|
@ -66,7 +65,7 @@ export function createMoveCompletedTasks(
|
|||
const existingContent = await app.vault.cachedRead(doneFile);
|
||||
const newContent = completedTasks.join('\n') +
|
||||
(existingContent ? '\n' + existingContent : '');
|
||||
await app.vault.process(doneFile, (data) => {
|
||||
await app.vault.process(doneFile, () => {
|
||||
return newContent;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
100
src/settings.ts
100
src/settings.ts
|
|
@ -34,6 +34,12 @@ export interface TodoTxtSettings {
|
|||
|
||||
highlightCreationDate: boolean;
|
||||
creationDateColor: string;
|
||||
|
||||
highlightRecurringTask: boolean;
|
||||
recurringTaskColor: string;
|
||||
|
||||
enableRecurringTasks: boolean;
|
||||
enableAutoCompletionDate: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: TodoTxtSettings = {
|
||||
|
|
@ -61,7 +67,13 @@ export const DEFAULT_SETTINGS: TodoTxtSettings = {
|
|||
completionDateColor: "#FF9800",
|
||||
|
||||
highlightCreationDate: true,
|
||||
creationDateColor: "#9C27B0"
|
||||
creationDateColor: "#9C27B0",
|
||||
|
||||
highlightRecurringTask: true,
|
||||
recurringTaskColor: "#FF5722",
|
||||
|
||||
enableRecurringTasks: true,
|
||||
enableAutoCompletionDate: true
|
||||
}
|
||||
|
||||
export class TodoTxtSettingTab extends PluginSettingTab {
|
||||
|
|
@ -72,20 +84,11 @@ export class TodoTxtSettingTab extends PluginSettingTab {
|
|||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* ハイライト設定項目を作成する共通関数(トグルのみ)
|
||||
* @param containerEl コンテナ要素
|
||||
* @param name 設定名
|
||||
* @param desc 設定の説明
|
||||
* @param enableKey 有効/無効の設定キー
|
||||
* @param colorKey 色の設定キー(現在は未使用)
|
||||
*/
|
||||
createHighlightSetting(
|
||||
containerEl: HTMLElement,
|
||||
name: string,
|
||||
desc: string,
|
||||
enableKey: keyof TodoTxtSettings,
|
||||
colorKey: keyof TodoTxtSettings,
|
||||
): void {
|
||||
new Setting(containerEl)
|
||||
.setName(name)
|
||||
|
|
@ -99,14 +102,6 @@ export class TodoTxtSettingTab extends PluginSettingTab {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* テキスト設定項目を作成する共通関数
|
||||
* @param containerEl コンテナ要素
|
||||
* @param name 設定名
|
||||
* @param desc 設定の説明
|
||||
* @param placeholder プレースホルダーテキスト
|
||||
* @param key 設定キー
|
||||
*/
|
||||
createTextSetting(
|
||||
containerEl: HTMLElement,
|
||||
name: string,
|
||||
|
|
@ -121,7 +116,6 @@ export class TodoTxtSettingTab extends PluginSettingTab {
|
|||
.setPlaceholder(placeholder)
|
||||
.setValue(this.plugin.settings[key] as string)
|
||||
.onChange(async (value) => {
|
||||
// Normalize path for file path settings
|
||||
const normalizedValue = (key === 'doneFilePath') ? normalizePath(value) : value;
|
||||
(this.plugin.settings[key] as string) = normalizedValue;
|
||||
await this.plugin.saveSettings();
|
||||
|
|
@ -201,57 +195,97 @@ export class TodoTxtSettingTab extends PluginSettingTab {
|
|||
containerEl,
|
||||
'Highlight completed tasks',
|
||||
'Apply strikethrough to completed tasks (starting with "x ")',
|
||||
'highlightCompletedTask',
|
||||
'completedTaskColor'
|
||||
'highlightCompletedTask'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight projects',
|
||||
'Apply color to projects ("+project")',
|
||||
'highlightProject',
|
||||
'projectColor'
|
||||
'highlightProject'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight contexts',
|
||||
'Apply color to contexts ("@context")',
|
||||
'highlightContext',
|
||||
'contextColor'
|
||||
'highlightContext'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight priorities',
|
||||
'Apply color to priorities ("(A)", "(1)")',
|
||||
'highlightPriority',
|
||||
'priorityColor'
|
||||
'highlightPriority'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight due dates',
|
||||
'Apply color to due dates ("due:yyyy-mm-dd")',
|
||||
'highlightDueDate',
|
||||
'dueDateColor'
|
||||
'highlightDueDate'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight completion dates',
|
||||
'Apply color to completion dates in completed tasks ("x 2011-03-02")',
|
||||
'highlightCompletionDate',
|
||||
'completionDateColor'
|
||||
'highlightCompletionDate'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight creation dates',
|
||||
'Apply color to creation dates ("2011-03-01 Task" or "x date1 2011-03-01")',
|
||||
'highlightCreationDate',
|
||||
'creationDateColor'
|
||||
'highlightCreationDate'
|
||||
);
|
||||
|
||||
this.createHighlightSetting(
|
||||
containerEl,
|
||||
'Highlight recurring tasks',
|
||||
'Apply color to recurring tasks ("rec:value")',
|
||||
'highlightRecurringTask'
|
||||
);
|
||||
|
||||
new Setting(containerEl).setHeading().setName('Task management');
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable auto completion date')
|
||||
.setDesc('Automatically add completion date when marking tasks as complete')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableAutoCompletionDate)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableAutoCompletionDate = value;
|
||||
|
||||
// If disabling auto completion date, also disable recurring tasks
|
||||
if (!value && this.plugin.settings.enableRecurringTasks) {
|
||||
this.plugin.settings.enableRecurringTasks = false;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
// Refresh the settings display to update the recurring tasks toggle state
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable recurring tasks')
|
||||
.setDesc('Automatically create new tasks when completing recurring tasks (rec:). Requires "Enable auto completion date" to be enabled.')
|
||||
.addToggle(toggle => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.enableRecurringTasks)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableRecurringTasks = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
// Disable toggle if auto completion date is disabled
|
||||
if (!this.plugin.settings.enableAutoCompletionDate) {
|
||||
toggle.setDisabled(true);
|
||||
toggle.setValue(false);
|
||||
this.plugin.settings.enableRecurringTasks = false;
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(containerEl).setHeading().setName('Sort settings');
|
||||
|
||||
|
|
|
|||
208
src/sort.ts
208
src/sort.ts
|
|
@ -1,102 +1,29 @@
|
|||
import { App, MarkdownView, Notice, Plugin, TFile, normalizePath } from 'obsidian';
|
||||
import { App, MarkdownView, Notice, Plugin } from 'obsidian';
|
||||
import { SortType, TodoTxtSettings } from './settings';
|
||||
import { parseTodo, sortTodosByPriority, sortTodosByProject, sortTodosByContext, sortTodosByDueDate } from './utils/todotxt-core';
|
||||
import { parseTodo, sortTodosByPriority, sortTodosByProject, sortTodosByContext, sortTodosByDueDate, isCommentLine } from './utils/todotxt-core';
|
||||
|
||||
export class TodoTxtSorter {
|
||||
constructor(
|
||||
private app: App,
|
||||
private settings: TodoTxtSettings,
|
||||
private isTodoTxtFile: (path: string) => boolean
|
||||
) {}
|
||||
|
||||
|
||||
registerSortCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: 'sort-priority',
|
||||
name: 'Sort by priority',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = this.isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
this.sortTasks(SortType.Priority);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-project',
|
||||
name: 'Sort by project',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = this.isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
this.sortTasks(SortType.Project);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-context',
|
||||
name: 'Sort by context',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = this.isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
this.sortTasks(SortType.Context);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-due-date',
|
||||
name: 'Sort by due date',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = this.isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
this.sortTasks(SortType.DueDate);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sortTasks(sortType: SortType) {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
export function createTodoTxtSorter(
|
||||
app: App,
|
||||
settings: TodoTxtSettings,
|
||||
isTodoTxtFile: (path: string) => boolean
|
||||
) {
|
||||
async function sortTasks(sortType: SortType) {
|
||||
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView) {
|
||||
new Notice('No active markdown view');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = activeView.file;
|
||||
if (!file || !this.isTodoTxtFile(file.path)) {
|
||||
if (!file || !isTodoTxtFile(file.path)) {
|
||||
new Notice('Active file is not a todo.txt file');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.cachedRead(file);
|
||||
const content = await app.vault.cachedRead(file);
|
||||
const lines = content.split('\n');
|
||||
|
||||
const boundaryMarker = this.settings.boundaryMarker;
|
||||
const boundaryMarker = settings.boundaryMarker;
|
||||
const boundaryIndex = lines.findIndex(line => line.trim() === boundaryMarker);
|
||||
|
||||
let linesToSort: string[] = [];
|
||||
|
|
@ -110,10 +37,9 @@ export class TodoTxtSorter {
|
|||
linesAfterBoundary = [];
|
||||
}
|
||||
|
||||
// タスク行と非タスク行を分ける(元の行を保持)
|
||||
const allLinesWithMeta = linesToSort.map((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
const isTask = trimmed.length > 0 && !trimmed.startsWith('#');
|
||||
const isTask = trimmed.length > 0 && !isCommentLine(trimmed);
|
||||
const todo = isTask ? parseTodo(line) : null;
|
||||
return {
|
||||
line,
|
||||
|
|
@ -123,7 +49,6 @@ export class TodoTxtSorter {
|
|||
};
|
||||
});
|
||||
|
||||
// タスク行のみを抽出
|
||||
const taskLines = allLinesWithMeta.filter(item => item.isTask && item.todo);
|
||||
const nonTaskLines = allLinesWithMeta.filter(item => !item.isTask);
|
||||
|
||||
|
|
@ -132,8 +57,9 @@ export class TodoTxtSorter {
|
|||
return;
|
||||
}
|
||||
|
||||
// タスクをソート
|
||||
const todos = taskLines.map(item => item.todo!);
|
||||
const todos = taskLines
|
||||
.map(item => item.todo)
|
||||
.filter((todo): todo is import('./utils/todotxt-core').TodoInterfaceWithPositions => todo !== null);
|
||||
let sortedTodos;
|
||||
|
||||
switch (sortType) {
|
||||
|
|
@ -150,32 +76,112 @@ export class TodoTxtSorter {
|
|||
sortedTodos = sortTodosByDueDate(todos);
|
||||
break;
|
||||
default:
|
||||
sortedTodos = todos; // デフォルトでは並び替えなし
|
||||
sortedTodos = todos;
|
||||
}
|
||||
|
||||
// ソート済みのTodoから元の行文字列を復元
|
||||
const sortedTaskLines = sortedTodos.map(sortedTodo => {
|
||||
const originalItem = taskLines.find(item => item.todo === sortedTodo);
|
||||
return originalItem!.line;
|
||||
});
|
||||
return originalItem?.line || '';
|
||||
}).filter(line => line !== '');
|
||||
|
||||
// 非タスク行も元の位置順で保持
|
||||
const sortedNonTaskLines = nonTaskLines
|
||||
.sort((a, b) => a.originalIndex - b.originalIndex)
|
||||
.map(item => item.line);
|
||||
|
||||
// 最終的な並び順: ソート済みタスク行 + 非タスク行
|
||||
const sortedTasks = [...sortedTaskLines, ...sortedNonTaskLines];
|
||||
|
||||
// 区切り文字がある場合のみ1つ空行を追加
|
||||
const sortedLines = linesAfterBoundary.length > 0
|
||||
? [...sortedTasks, '', ...linesAfterBoundary]
|
||||
: sortedTasks;
|
||||
await this.app.vault.process(file, (data) => {
|
||||
return sortedLines.join('\n');
|
||||
});
|
||||
// Temporarily disable task watcher during sort
|
||||
const { setTaskWatcherSorting } = await import('./task-watcher');
|
||||
setTaskWatcherSorting(true);
|
||||
|
||||
try {
|
||||
await app.vault.process(file, () => {
|
||||
return sortedLines.join('\n');
|
||||
});
|
||||
} finally {
|
||||
// Re-enable task watcher after sort
|
||||
setTimeout(() => {
|
||||
setTaskWatcherSorting(false);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
new Notice(`Tasks sorted by ${sortType}`);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
registerSortCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: 'sort-priority',
|
||||
name: 'Sort by priority',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
sortTasks(SortType.Priority);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-project',
|
||||
name: 'Sort by project',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
sortTasks(SortType.Project);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-context',
|
||||
name: 'Sort by context',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
sortTasks(SortType.Context);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: 'sort-due-date',
|
||||
name: 'Sort by due date',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) return false;
|
||||
|
||||
const isTodoFile = isTodoTxtFile(activeView.file.path);
|
||||
if (checking) return isTodoFile;
|
||||
|
||||
if (isTodoFile) {
|
||||
sortTasks(SortType.DueDate);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
sortTasks
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Decoration, DecorationSet, EditorView as CMEditorView, ViewPlugin, ViewUpdate, PluginValue } from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { App } from 'obsidian';
|
||||
import { TodoTxtSettings } from './settings';
|
||||
import { parseTodo, ElementPosition } from './utils/todotxt-core';
|
||||
import { parseTodo, isDueDateKeyValue, isRecurrenceKeyValue } from './utils/todotxt-core';
|
||||
|
||||
|
||||
export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) => boolean, getSettings: () => TodoTxtSettings) {
|
||||
|
|
@ -51,7 +51,6 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
const todo = parseTodo(lineText);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
// 完了タスクの行全体ハイライト
|
||||
if (settings.highlightCompletedTask && todo.isDone()) {
|
||||
const deco = Decoration.line({
|
||||
attributes: { class: "todo-txt-mode-completed" }
|
||||
|
|
@ -63,9 +62,7 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
});
|
||||
}
|
||||
|
||||
// 各要素のハイライト処理
|
||||
|
||||
// プロジェクトタグのハイライト
|
||||
if (settings.highlightProject) {
|
||||
for (const project of positions.projects) {
|
||||
const deco = Decoration.mark({
|
||||
|
|
@ -79,7 +76,6 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
}
|
||||
}
|
||||
|
||||
// コンテキストタグのハイライト
|
||||
if (settings.highlightContext) {
|
||||
for (const context of positions.contexts) {
|
||||
const deco = Decoration.mark({
|
||||
|
|
@ -93,7 +89,6 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
}
|
||||
}
|
||||
|
||||
// 優先度のハイライト
|
||||
if (settings.highlightPriority && positions.priority) {
|
||||
const deco = Decoration.mark({
|
||||
attributes: { class: "todo-txt-mode-priority" }
|
||||
|
|
@ -105,10 +100,9 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
});
|
||||
}
|
||||
|
||||
// 期日のハイライト
|
||||
if (settings.highlightDueDate) {
|
||||
for (const keyValue of positions.keyValues) {
|
||||
if (keyValue.value.startsWith('due:')) {
|
||||
if (isDueDateKeyValue(keyValue.value)) {
|
||||
const deco = Decoration.mark({
|
||||
attributes: { class: "todo-txt-mode-due-date" }
|
||||
});
|
||||
|
|
@ -121,7 +115,21 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
}
|
||||
}
|
||||
|
||||
// 完了日のハイライト
|
||||
if (settings.highlightRecurringTask) {
|
||||
for (const keyValue of positions.keyValues) {
|
||||
if (isRecurrenceKeyValue(keyValue.value)) {
|
||||
const deco = Decoration.mark({
|
||||
attributes: { class: "todo-txt-mode-recurring-task" }
|
||||
});
|
||||
decorations.push({
|
||||
from: line.from + keyValue.start,
|
||||
to: line.from + keyValue.end,
|
||||
decoration: deco
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.highlightCompletionDate && positions.completionDate) {
|
||||
const deco = Decoration.mark({
|
||||
attributes: { class: "todo-txt-mode-completion-date" }
|
||||
|
|
@ -133,7 +141,6 @@ export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) =
|
|||
});
|
||||
}
|
||||
|
||||
// 作成日のハイライト
|
||||
if (settings.highlightCreationDate && positions.creationDate) {
|
||||
const deco = Decoration.mark({
|
||||
attributes: { class: "todo-txt-mode-creation-date" }
|
||||
|
|
|
|||
176
src/task-watcher.ts
Normal file
176
src/task-watcher.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { ViewPlugin, ViewUpdate, EditorView as CMEditorView, PluginValue } from '@codemirror/view';
|
||||
import { Transaction, Text } from '@codemirror/state';
|
||||
import { App } from 'obsidian';
|
||||
import { parseTodo, TodoInterface, TodoInterfaceWithPositions, shouldAddCompletionDate, addCompletionDate, buildTaskString, getNextRecurringTask, isCompletedTask } from './utils/todotxt-core';
|
||||
import { TodoTxtSettings } from './settings';
|
||||
|
||||
let globalIsSorting = false;
|
||||
|
||||
export function setTaskWatcherSorting(sorting: boolean) {
|
||||
globalIsSorting = sorting;
|
||||
}
|
||||
|
||||
export function createTaskWatcher(
|
||||
app: App,
|
||||
isTodoTxtFile: (path: string) => boolean,
|
||||
getSettings: () => TodoTxtSettings
|
||||
) {
|
||||
return ViewPlugin.fromClass(class implements PluginValue {
|
||||
view: CMEditorView;
|
||||
constructor(view: CMEditorView) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalIsSorting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = app.workspace.getActiveFile();
|
||||
if (!file || !isTodoTxtFile(file.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
if (!settings.enableRecurringTasks && !settings.enableAutoCompletionDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.transactions.forEach(transaction => {
|
||||
this.handleTransaction(transaction, update.view);
|
||||
});
|
||||
}
|
||||
|
||||
handleTransaction(transaction: Transaction, view: CMEditorView) {
|
||||
const newDoc = view.state.doc;
|
||||
const oldDoc = transaction.startState.doc;
|
||||
|
||||
const processedInTransaction = new Set<string>();
|
||||
const settings = getSettings();
|
||||
|
||||
transaction.changes.iterChanges((fromA, toA, fromB, toB) => {
|
||||
if (fromA !== toA && fromB === toB) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldFromLine = oldDoc.lineAt(fromA).number;
|
||||
|
||||
const newFromLine = newDoc.lineAt(fromB).number;
|
||||
const newToLine = fromB === toB ? newFromLine : newDoc.lineAt(toB - 1).number;
|
||||
|
||||
for (let lineNum = newFromLine; lineNum <= newToLine; lineNum++) {
|
||||
if (lineNum > newDoc.lines) continue;
|
||||
|
||||
const line = newDoc.line(lineNum);
|
||||
const lineText = line.text;
|
||||
|
||||
const lineKey = `${lineNum}:${lineText}`;
|
||||
if (processedInTransaction.has(lineKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompletedTask(lineText) && !this.wasAlreadyCompleted(oldDoc, oldFromLine, lineNum, newFromLine)) {
|
||||
this.processNewlyCompletedTask(line, lineText, oldDoc, oldFromLine, lineNum, newFromLine, view, settings, processedInTransaction, lineKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private wasAlreadyCompleted(oldDoc: Text, oldFromLine: number, lineNum: number, newFromLine: number): boolean {
|
||||
const oldLineNum = oldFromLine + (lineNum - newFromLine);
|
||||
if (oldLineNum >= 1 && oldLineNum <= oldDoc.lines) {
|
||||
const oldLine = oldDoc.line(oldLineNum);
|
||||
return isCompletedTask(oldLine.text);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private processNewlyCompletedTask(
|
||||
line: { from: number; to: number },
|
||||
lineText: string,
|
||||
oldDoc: Text,
|
||||
oldFromLine: number,
|
||||
lineNum: number,
|
||||
newFromLine: number,
|
||||
view: CMEditorView,
|
||||
settings: TodoTxtSettings,
|
||||
processedInTransaction: Set<string>,
|
||||
lineKey: string
|
||||
) {
|
||||
let updatedLineText = lineText;
|
||||
|
||||
if (settings.enableAutoCompletionDate) {
|
||||
const oldLineText = this.getOldLineText(oldDoc, oldFromLine, lineNum, newFromLine);
|
||||
|
||||
if (shouldAddCompletionDate(lineText, oldLineText)) {
|
||||
updatedLineText = addCompletionDate(lineText);
|
||||
this.updateLineText(view, line, updatedLineText);
|
||||
processedInTransaction.add(lineKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.enableRecurringTasks && settings.enableAutoCompletionDate) {
|
||||
const todo = parseTodo(updatedLineText);
|
||||
const nextTask = this.processRecurringTask(todo);
|
||||
|
||||
if (nextTask) {
|
||||
this.insertNewTask(view, line.from, nextTask);
|
||||
processedInTransaction.add(lineKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOldLineText(oldDoc: Text, oldFromLine: number, lineNum: number, newFromLine: number): string {
|
||||
const oldLineNum = oldFromLine + (lineNum - newFromLine);
|
||||
const oldText = (oldLineNum >= 1 && oldLineNum <= oldDoc.lines) ? oldDoc.line(oldLineNum).text : '';
|
||||
return oldText;
|
||||
}
|
||||
|
||||
private updateLineText(view: CMEditorView, line: { from: number; to: number }, updatedLineText: string) {
|
||||
requestAnimationFrame(() => {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
insert: updatedLineText
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private processRecurringTask(todo: TodoInterface): TodoInterface | null {
|
||||
const keyValues = todo.keyValues();
|
||||
const hasRecurrence = keyValues.rec ? keyValues.rec.length > 0 : false;
|
||||
|
||||
if (!hasRecurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getNextRecurringTask(todo as TodoInterfaceWithPositions);
|
||||
}
|
||||
|
||||
insertNewTask(view: CMEditorView, position: number, task: TodoInterface) {
|
||||
const taskStr = buildTaskString(task);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: position,
|
||||
to: position,
|
||||
insert: taskStr + '\n'
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// No cleanup needed - task watching is handled by CodeMirror
|
||||
}
|
||||
});
|
||||
}
|
||||
77
src/utils/todotxt-core/__tests__/completion-date.test.ts
Normal file
77
src/utils/todotxt-core/__tests__/completion-date.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { shouldAddCompletionDate, addCompletionDate } from '../completion-date';
|
||||
|
||||
describe('CompletionDateService', () => {
|
||||
const expectDatePattern = (text: string, pattern: string) => {
|
||||
expect(text).toMatch(new RegExp(pattern));
|
||||
};
|
||||
describe('shouldAddCompletionDate', () => {
|
||||
it('should return true when current line has no completion date', () => {
|
||||
const currentLine = 'x Task without completion date';
|
||||
const previousLine = 'Task without completion date';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it('should return true when current completion date matches previous creation date', () => {
|
||||
const currentLine = 'x 2023-12-20 Task with creation date';
|
||||
const previousLine = '2023-12-20 Task with creation date';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it('should return true when current completion date was originally creation date', () => {
|
||||
const currentLine = 'x 2025-07-02 Task with creation date';
|
||||
const previousLine = '2025-07-02 Task with creation date';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it('should return false when current line has proper completion date', () => {
|
||||
const currentLine = 'x 2025-07-04 2025-07-02 Task with both dates';
|
||||
const previousLine = '2025-07-02 Task with both dates';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it('should return true when current line has no completion date and previous line has no creation date', () => {
|
||||
const currentLine = 'x Simple task';
|
||||
const previousLine = 'Simple task';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it('should handle priority tasks correctly', () => {
|
||||
const currentLine = 'x (A) Priority task';
|
||||
const previousLine = '(A) Priority task';
|
||||
const result = shouldAddCompletionDate(currentLine, previousLine);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('addCompletionDate', () => {
|
||||
it('should add completion date to simple completed task', () => {
|
||||
const lineText = 'x Simple task';
|
||||
const result = addCompletionDate(lineText);
|
||||
expectDatePattern(result, '^x \\d{4}-\\d{2}-\\d{2} Simple task$');
|
||||
});
|
||||
it('should add completion date to completed task with priority', () => {
|
||||
const lineText = 'x (A) Priority task';
|
||||
const result = addCompletionDate(lineText);
|
||||
expectDatePattern(result, '^x \\(A\\) \\d{4}-\\d{2}-\\d{2} Priority task$');
|
||||
});
|
||||
it('should preserve indentation', () => {
|
||||
const lineText = ' x Indented task';
|
||||
const result = addCompletionDate(lineText);
|
||||
expectDatePattern(result, '^ {4}x \\d{4}-\\d{2}-\\d{2} Indented task$');
|
||||
});
|
||||
it('should handle tab indentation', () => {
|
||||
const lineText = '\t\tx Task with tabs';
|
||||
const result = addCompletionDate(lineText);
|
||||
expectDatePattern(result, '^\\t\\tx \\d{4}-\\d{2}-\\d{2} Task with tabs$');
|
||||
});
|
||||
it('should return unchanged text if not a completed task', () => {
|
||||
const lineText = 'Not completed task';
|
||||
const result = addCompletionDate(lineText);
|
||||
expect(result).toBe('Not completed task');
|
||||
});
|
||||
it('should handle priority with spaces correctly', () => {
|
||||
const lineText = 'x (B) Task with priority B';
|
||||
const result = addCompletionDate(lineText);
|
||||
expectDatePattern(result, '^x \\(B\\) \\d{4}-\\d{2}-\\d{2} Task with priority B$');
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/utils/todotxt-core/__tests__/parser-basic.test.ts
Normal file
81
src/utils/todotxt-core/__tests__/parser-basic.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { parseTodo } from '../parser';
|
||||
|
||||
describe('Todo.txt Parser - Basic Functionality', () => {
|
||||
const expectBasicTaskProperties = (todo: ReturnType<typeof parseTodo>, expectedTask: string, expectedDone = false) => {
|
||||
expect(todo.task()).toBe(expectedTask);
|
||||
expect(todo.isDone()).toBe(expectedDone);
|
||||
expect(todo.priority()).toBeNull();
|
||||
expect(todo.creationDate()).toBeNull();
|
||||
expect(todo.completionDate()).toBeNull();
|
||||
expect(todo.projects()).toEqual([]);
|
||||
expect(todo.contexts()).toEqual([]);
|
||||
expect(todo.keyValues()).toEqual({});
|
||||
expect(todo.dueDate()).toBeNull();
|
||||
};
|
||||
describe('Simple Tasks', () => {
|
||||
it('should extract task text from line without special syntax elements', () => {
|
||||
const todoText = 'Simple task without any parameters';
|
||||
const todo = parseTodo(todoText);
|
||||
expectBasicTaskProperties(todo, 'Simple task without any parameters');
|
||||
});
|
||||
it('should return empty task for empty string input', () => {
|
||||
const todoText = '';
|
||||
const todo = parseTodo(todoText);
|
||||
expectBasicTaskProperties(todo, '');
|
||||
});
|
||||
it('should return empty task for whitespace-only string', () => {
|
||||
const todoText = ' ';
|
||||
const todo = parseTodo(todoText);
|
||||
expectBasicTaskProperties(todo, '');
|
||||
});
|
||||
});
|
||||
describe('Completed Tasks', () => {
|
||||
it('should mark task as completed when line starts with "x "', () => {
|
||||
const todoText = 'x Simple completed task';
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.isDone()).toBe(true);
|
||||
expect(todo.task()).toBe('Simple completed task');
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{ text: 'X Not a completed task', expected: 'X Not a completed task', done: false },
|
||||
{ text: 'xNot a completed task', expected: 'xNot a completed task', done: false }
|
||||
];
|
||||
|
||||
testCases.forEach(({ text, expected, done }) => {
|
||||
it(`should handle case "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.isDone()).toBe(done);
|
||||
expect(todo.task()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Whitespace Handling', () => {
|
||||
const whitespaceTestCases = [
|
||||
{ text: ' Task with leading spaces', expected: 'Task with leading spaces' },
|
||||
{ text: 'Task with trailing spaces ', expected: 'Task with trailing spaces' },
|
||||
{ text: '\t\tTask with tab indentation', expected: 'Task with tab indentation' }
|
||||
];
|
||||
|
||||
whitespaceTestCases.forEach(({ text, expected }) => {
|
||||
it(`should handle whitespace in "${text}"`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.task()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Special Characters', () => {
|
||||
const specialCharTestCases = [
|
||||
{ text: 'Task with emoji 🎯 in description', expected: 'Task with emoji 🎯 in description' },
|
||||
{ text: 'タスクの説明に日本語を含む', expected: 'タスクの説明に日本語を含む' },
|
||||
{ text: 'Task with !@#$%^&*() special chars', expected: 'Task with !@#$%^&*() special chars' }
|
||||
];
|
||||
|
||||
specialCharTestCases.forEach(({ text, expected }) => {
|
||||
it(`should preserve special characters in "${text}"`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.task()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/utils/todotxt-core/__tests__/parser-dates.test.ts
Normal file
137
src/utils/todotxt-core/__tests__/parser-dates.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { parseTodo } from '../parser';
|
||||
|
||||
describe('Todo.txt Parser - Dates', () => {
|
||||
describe('Creation Date', () => {
|
||||
const creationDateTests = [
|
||||
{
|
||||
text: '2025-01-15 Task with creation date',
|
||||
expected: { creationDate: '2025-01-15', task: 'Task with creation date' }
|
||||
},
|
||||
{
|
||||
text: '(A) 2025-01-15 Priority task with creation date',
|
||||
expected: { priority: 'A', creationDate: '2025-01-15', task: 'Priority task with creation date' }
|
||||
},
|
||||
{
|
||||
text: '01-15-2025 American date format not accepted',
|
||||
expected: { creationDate: null, task: '01-15-2025 American date format not accepted' }
|
||||
},
|
||||
{
|
||||
text: 'Task with 2025-01-15 date in middle',
|
||||
expected: { creationDate: null, task: 'Task with 2025-01-15 date in middle' }
|
||||
}
|
||||
];
|
||||
|
||||
creationDateTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.creationDate()).toBe(expected.creationDate);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
if (expected.priority) {
|
||||
expect(todo.priority()).toBe(expected.priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Completion Date', () => {
|
||||
const completionDateTests = [
|
||||
{
|
||||
text: 'x 2025-01-16 Simple completed task',
|
||||
expected: { isDone: true, completionDate: '2025-01-16', creationDate: null, task: 'Simple completed task' }
|
||||
},
|
||||
{
|
||||
text: 'x 2025-01-16 2025-01-15 Task completed one day after creation',
|
||||
expected: { isDone: true, completionDate: '2025-01-16', creationDate: '2025-01-15', task: 'Task completed one day after creation' }
|
||||
},
|
||||
{
|
||||
text: '2025-01-16 Not a completion date',
|
||||
expected: { isDone: false, completionDate: null, creationDate: '2025-01-16', task: 'Not a completion date' }
|
||||
}
|
||||
];
|
||||
|
||||
completionDateTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.isDone()).toBe(expected.isDone);
|
||||
expect(todo.completionDate()).toBe(expected.completionDate);
|
||||
expect(todo.creationDate()).toBe(expected.creationDate);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Due Date', () => {
|
||||
const dueDateTests = [
|
||||
{
|
||||
text: 'Task with due:2025-12-31',
|
||||
expected: { dueDate: '2025-12-31', keyValue: '2025-12-31' }
|
||||
},
|
||||
{
|
||||
text: '(A) 2025-01-15 Task +project @context due:2025-12-31 rec:1w',
|
||||
expected: { dueDate: '2025-12-31' }
|
||||
},
|
||||
{
|
||||
text: 'Task with precise time due:2025-12-31T14:30:00',
|
||||
expected: { dueDate: '2025-12-31T14:30:00' }
|
||||
},
|
||||
{
|
||||
text: 'Task due:2025-12-31 with another due:2025-06-30',
|
||||
expected: { dueDate: '2025-06-30', keyValue: '2025-06-30' }
|
||||
}
|
||||
];
|
||||
|
||||
dueDateTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.dueDate()).toBe(expected.dueDate);
|
||||
if (expected.keyValue) {
|
||||
expect(todo.keyValues()).toHaveProperty('due', expected.keyValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Date Validation', () => {
|
||||
const validDateTests = [
|
||||
{ date: '2025-13-01', expected: '2025-13-01' },
|
||||
{ date: '2024-02-29', expected: '2024-02-29' },
|
||||
{ date: '2025-02-29', expected: '2025-02-29' }
|
||||
];
|
||||
|
||||
validDateTests.forEach(({ date, expected }) => {
|
||||
it(`should accept ${date} as valid format`, () => {
|
||||
const todo = parseTodo(`${date} Task with format-valid date`);
|
||||
expect(todo.creationDate()).toBe(expected);
|
||||
expect(todo.task()).toBe('Task with format-valid date');
|
||||
});
|
||||
});
|
||||
|
||||
const invalidDateFormats = ['2025-1-1', '25-01-01', 'today', '01-15-2025'];
|
||||
invalidDateFormats.forEach(date => {
|
||||
it(`should reject ${date} as invalid format`, () => {
|
||||
const todo = parseTodo(`${date} Task with invalid format`);
|
||||
expect(todo.creationDate()).toBeNull();
|
||||
expect(todo.task()).toBe(`${date} Task with invalid format`);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Complex Date Combinations', () => {
|
||||
const complexDateTests = [
|
||||
{
|
||||
text: 'x 2025-01-16 2025-01-15 Complex task due:2025-12-31',
|
||||
expected: { isDone: true, completionDate: '2025-01-16', creationDate: '2025-01-15', dueDate: '2025-12-31' }
|
||||
},
|
||||
{
|
||||
text: 'x 2025-01-15 2025-01-16 Completion before creation',
|
||||
expected: { completionDate: '2025-01-15', creationDate: '2025-01-16' }
|
||||
}
|
||||
];
|
||||
|
||||
complexDateTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
if (expected.isDone !== undefined) expect(todo.isDone()).toBe(expected.isDone);
|
||||
if (expected.completionDate !== undefined) expect(todo.completionDate()).toBe(expected.completionDate);
|
||||
if (expected.creationDate !== undefined) expect(todo.creationDate()).toBe(expected.creationDate);
|
||||
if (expected.dueDate !== undefined) expect(todo.dueDate()).toBe(expected.dueDate);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
165
src/utils/todotxt-core/__tests__/parser-keyvalues.test.ts
Normal file
165
src/utils/todotxt-core/__tests__/parser-keyvalues.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { parseTodo } from '../parser';
|
||||
|
||||
describe('Todo.txt Parser - Key:Value Pairs', () => {
|
||||
describe('Basic Key:Value Parsing', () => {
|
||||
const basicKeyValueTests = [
|
||||
{
|
||||
text: 'Task with due:2025-12-31',
|
||||
expected: { keyValues: { due: '2025-12-31' }, task: 'Task with' }
|
||||
},
|
||||
{
|
||||
text: 'Task with due:2025-12-31 rec:1w priority:high',
|
||||
expected: { keyValues: { due: '2025-12-31', rec: '1w', priority: 'high' }, task: 'Task with' }
|
||||
},
|
||||
{
|
||||
text: 'due:2025-12-31 Task in middle rec:1w and end note:important',
|
||||
expected: { keyValues: { due: '2025-12-31', rec: '1w', note: 'important' }, task: 'Task in middle and end' }
|
||||
},
|
||||
{
|
||||
text: 'Task due:2025-12-31 due:2025-06-30',
|
||||
expected: { keyValues: { due: '2025-06-30' }, task: 'Task' }
|
||||
}
|
||||
];
|
||||
|
||||
basicKeyValueTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.keyValues()).toEqual(expected.keyValues);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Special Key Names and Values', () => {
|
||||
const specialKeyValueTests = [
|
||||
{
|
||||
text: 'Task with t:2025-01-15 p:1',
|
||||
expected: { keyValues: { t: '2025-01-15', p: '1' } }
|
||||
},
|
||||
{
|
||||
text: 'Task with custom1:value1 test2:value2',
|
||||
expected: { keyValues: { custom1: 'value1', test2: 'value2' } }
|
||||
},
|
||||
{
|
||||
text: 'Task with url:https://example.com time:14:30:00',
|
||||
expected: { keyValues: { url: 'https://example.com', time: '14:30:00' } }
|
||||
},
|
||||
{
|
||||
text: 'Task with empty: key',
|
||||
expected: { keyValues: {} }
|
||||
},
|
||||
{
|
||||
text: 'Task with special:!@#$%^&*() chars',
|
||||
expected: { keyValues: { special: '!@#$%^&*()' } }
|
||||
}
|
||||
];
|
||||
|
||||
specialKeyValueTests.forEach(({ text, expected }) => {
|
||||
it(`should handle "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.keyValues()).toEqual(expected.keyValues);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Standard Key:Value Pairs', () => {
|
||||
it('parses due date', () => {
|
||||
const todoText = 'Task due:2025-12-31';
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.dueDate()).toBe('2025-12-31');
|
||||
expect(todo.keyValues().due).toBe('2025-12-31');
|
||||
});
|
||||
|
||||
const recurrencePatterns = ['d', '1d', '2w', '3m', '1y', '+1w', 'b', '5b'];
|
||||
recurrencePatterns.forEach(pattern => {
|
||||
it(`should parse recurrence pattern rec:${pattern}`, () => {
|
||||
const todo = parseTodo(`Task rec:${pattern}`);
|
||||
expect(todo.keyValues().rec).toBe(pattern);
|
||||
});
|
||||
});
|
||||
|
||||
it('parses custom keys', () => {
|
||||
const todoText = 'Task note:important id:123 status:pending';
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.keyValues()).toEqual({
|
||||
note: 'important',
|
||||
id: '123',
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Non Key:Value Cases', () => {
|
||||
const nonKeyValueTests = [
|
||||
{
|
||||
text: 'Visit https://example.com today',
|
||||
expected: { keyValues: { 'https': '//example.com' }, task: 'Visit today' }
|
||||
},
|
||||
{
|
||||
text: 'Meeting at 14:30 sharp',
|
||||
expected: { keyValues: {}, task: 'Meeting at 14:30 sharp' }
|
||||
},
|
||||
{
|
||||
text: 'Task with key : value notation',
|
||||
expected: { keyValues: {}, task: 'Task with key : value notation' }
|
||||
}
|
||||
];
|
||||
|
||||
nonKeyValueTests.forEach(({ text, expected }) => {
|
||||
it(`should handle "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.keyValues()).toEqual(expected.keyValues);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Complex Combinations', () => {
|
||||
const complexTests = [
|
||||
{
|
||||
text: '(A) 2025-01-15 Complex +project @context due:2025-12-31 rec:1w note:test',
|
||||
expected: {
|
||||
priority: 'A',
|
||||
creationDate: '2025-01-15',
|
||||
projects: ['project'],
|
||||
contexts: ['context'],
|
||||
keyValues: { due: '2025-12-31', rec: '1w', note: 'test' },
|
||||
task: 'Complex'
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'due:2025-12-31 rec:1w priority:high',
|
||||
expected: {
|
||||
keyValues: { due: '2025-12-31', rec: '1w', priority: 'high' },
|
||||
task: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Task a:1 b:2 c:3 d:4 e:5 f:6 g:7 h:8 i:9 j:10',
|
||||
expected: {
|
||||
keyValues: { a: '1', b: '2', c: '3', d: '4', e: '5', f: '6', g: '7', h: '8', i: '9', j: '10' },
|
||||
task: 'Task'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
complexTests.forEach(({ text, expected }) => {
|
||||
it(`should parse "${text}" correctly`, () => {
|
||||
const todo = parseTodo(text);
|
||||
if (expected.priority) expect(todo.priority()).toBe(expected.priority);
|
||||
if (expected.creationDate) expect(todo.creationDate()).toBe(expected.creationDate);
|
||||
if (expected.projects) expect(todo.projects()).toEqual(expected.projects);
|
||||
if (expected.contexts) expect(todo.contexts()).toEqual(expected.contexts);
|
||||
expect(todo.keyValues()).toEqual(expected.keyValues);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Position Accuracy', () => {
|
||||
it('provides accurate positions for key:value pairs', () => {
|
||||
const line = 'Task with due:2025-12-31 and rec:1w here';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
positions.keyValues.forEach(kv => {
|
||||
const extracted = line.substring(kv.start, kv.end);
|
||||
expect(extracted).toBe(kv.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
184
src/utils/todotxt-core/__tests__/parser-positions.test.ts
Normal file
184
src/utils/todotxt-core/__tests__/parser-positions.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { parseTodo } from '../parser';
|
||||
describe('Todo.txt Parser - Element Positions', () => {
|
||||
const verifyPosition = (line: string, position: { value: string; start: number; end: number }) => {
|
||||
const extracted = line.substring(position.start, position.end);
|
||||
expect(extracted).toBe(position.value);
|
||||
};
|
||||
describe('Basic Element Positions', () => {
|
||||
const basicPositionTests = [
|
||||
{
|
||||
line: 'x Simple completed task',
|
||||
element: 'completion',
|
||||
expected: { value: 'x', start: 0, end: 1 }
|
||||
},
|
||||
{
|
||||
line: '(A) Priority task',
|
||||
element: 'priority',
|
||||
expected: { value: '(A)', start: 0, end: 3 }
|
||||
},
|
||||
{
|
||||
line: '2025-01-15 Task with creation date',
|
||||
element: 'creationDate',
|
||||
expected: { value: '2025-01-15', start: 0, end: 10 }
|
||||
}
|
||||
];
|
||||
|
||||
basicPositionTests.forEach(({ line, element, expected }) => {
|
||||
it(`should provide accurate position for ${element}`, () => {
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions[element as keyof typeof positions]).toEqual(expected);
|
||||
if (positions[element as keyof typeof positions]) {
|
||||
verifyPosition(line, positions[element as keyof typeof positions] as { value: string; start: number; end: number });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Multiple Element Positions', () => {
|
||||
it('provides accurate positions for multiple projects', () => {
|
||||
const line = 'Task with +project1 and +project2 tags';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions.projects).toHaveLength(2);
|
||||
positions.projects.forEach(project => {
|
||||
verifyPosition(line, project);
|
||||
});
|
||||
});
|
||||
it('provides accurate positions for multiple contexts', () => {
|
||||
const line = 'Task @home @work @urgent';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions.contexts).toHaveLength(3);
|
||||
positions.contexts.forEach(context => {
|
||||
verifyPosition(line, context);
|
||||
});
|
||||
});
|
||||
it('provides accurate positions for key:value pairs', () => {
|
||||
const line = 'Task due:2025-12-31 rec:1w note:important';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions.keyValues).toHaveLength(3);
|
||||
positions.keyValues.forEach(kv => {
|
||||
verifyPosition(line, kv);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Positions with Leading Whitespace', () => {
|
||||
const whitespaceTests = [
|
||||
{
|
||||
line: ' (A) Indented priority task',
|
||||
element: 'priority',
|
||||
expected: { value: '(A)', start: 4, end: 7 }
|
||||
},
|
||||
{
|
||||
line: '\t\tx Task with tabs',
|
||||
element: 'completion',
|
||||
expected: { value: 'x', start: 2, end: 3 }
|
||||
}
|
||||
];
|
||||
|
||||
whitespaceTests.forEach(({ line, element, expected }) => {
|
||||
it(`should calculate positions correctly with whitespace for ${element}`, () => {
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions[element as keyof typeof positions]).toEqual(expected);
|
||||
if (positions[element as keyof typeof positions]) {
|
||||
verifyPosition(line, positions[element as keyof typeof positions] as { value: string; start: number; end: number });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Complex Task Positions', () => {
|
||||
it('provides accurate positions for all elements', () => {
|
||||
const line = 'x 2025-01-16 (A) 2025-01-15 Complex +project @context due:2025-12-31';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
if (positions.completion) verifyPosition(line, positions.completion);
|
||||
if (positions.completionDate) verifyPosition(line, positions.completionDate);
|
||||
if (positions.priority) verifyPosition(line, positions.priority);
|
||||
if (positions.creationDate) verifyPosition(line, positions.creationDate);
|
||||
positions.projects.forEach(p => verifyPosition(line, p));
|
||||
positions.contexts.forEach(c => verifyPosition(line, c));
|
||||
positions.keyValues.forEach(kv => verifyPosition(line, kv));
|
||||
});
|
||||
it('ensures positions do not overlap', () => {
|
||||
const line = 'Task +proj1 @ctx1 +proj2 @ctx2 due:2025-12-31';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
const allPositions = [
|
||||
...positions.projects,
|
||||
...positions.contexts,
|
||||
...positions.keyValues
|
||||
].sort((a, b) => a.start - b.start);
|
||||
for (let i = 0; i < allPositions.length - 1; i++) {
|
||||
expect(allPositions[i].end).toBeLessThanOrEqual(allPositions[i + 1].start);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('Positions with Special Characters', () => {
|
||||
const specialCharTests = [
|
||||
'Task +🚀rocket @🏠home',
|
||||
'タスク +プロジェクト @コンテキスト',
|
||||
'Task +café @naïve'
|
||||
];
|
||||
|
||||
specialCharTests.forEach(line => {
|
||||
it(`should calculate positions correctly for "${line}"`, () => {
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
positions.projects.forEach(p => verifyPosition(line, p));
|
||||
positions.contexts.forEach(c => verifyPosition(line, c));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Null Position Cases', () => {
|
||||
it('returns null for non-existent elements', () => {
|
||||
const line = 'Simple task without any special elements';
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
expect(positions.completion).toBeNull();
|
||||
expect(positions.priority).toBeNull();
|
||||
expect(positions.creationDate).toBeNull();
|
||||
expect(positions.completionDate).toBeNull();
|
||||
expect(positions.projects).toEqual([]);
|
||||
expect(positions.contexts).toEqual([]);
|
||||
expect(positions.keyValues).toEqual([]);
|
||||
});
|
||||
});
|
||||
describe('Edge Cases', () => {
|
||||
const edgeCaseTests = [
|
||||
{
|
||||
name: 'empty string',
|
||||
line: '',
|
||||
expectations: {
|
||||
completion: null,
|
||||
projects: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'very long line',
|
||||
line: 'x ' + 'a'.repeat(1000) + ' +project @context due:2025-12-31',
|
||||
expectations: {}
|
||||
}
|
||||
];
|
||||
|
||||
edgeCaseTests.forEach(({ name, line, expectations }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(line);
|
||||
const positions = todo.getElementPositions();
|
||||
|
||||
if (expectations.completion !== undefined) {
|
||||
expect(positions.completion).toBe(expectations.completion);
|
||||
}
|
||||
if (expectations.projects) {
|
||||
expect(positions.projects).toEqual(expectations.projects);
|
||||
}
|
||||
|
||||
if (positions.completion) verifyPosition(line, positions.completion);
|
||||
positions.projects.forEach(p => verifyPosition(line, p));
|
||||
positions.contexts.forEach(c => verifyPosition(line, c));
|
||||
positions.keyValues.forEach(kv => verifyPosition(line, kv));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
114
src/utils/todotxt-core/__tests__/parser-priority.test.ts
Normal file
114
src/utils/todotxt-core/__tests__/parser-priority.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { parseTodo } from '../parser';
|
||||
describe('Todo.txt Parser - Priority', () => {
|
||||
describe('Alphabetic Priorities', () => {
|
||||
it.each([
|
||||
['A', '(A) High priority task'],
|
||||
['B', '(B) Medium priority task'],
|
||||
['C', '(C) Low priority task'],
|
||||
['Z', '(Z) Lowest priority task']
|
||||
])('優先度%sの正確な解析', (expectedPriority, todoText) => {
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.priority()).toBe(expectedPriority);
|
||||
expect(todo.task()).toBe(todoText.substring(4));
|
||||
});
|
||||
it('does not accept lowercase priority letters', () => {
|
||||
const todoText = '(a) Lowercase priority task';
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.priority()).toBeNull();
|
||||
expect(todo.task()).toBe('(a) Lowercase priority task');
|
||||
});
|
||||
});
|
||||
describe('Numeric Priorities', () => {
|
||||
it.each([
|
||||
['1', '(1) Single digit priority'],
|
||||
['99', '(99) Double digit priority'],
|
||||
['123', '(123) Triple digit priority'],
|
||||
['999999', '(999999) Very large numeric priority']
|
||||
])('数値優先度%sの正確な解析', (expectedPriority, todoText) => {
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.priority()).toBe(expectedPriority);
|
||||
});
|
||||
it('accepts zero as valid priority', () => {
|
||||
const todoText = '(0) Zero priority task';
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.priority()).toBe('0');
|
||||
expect(todo.task()).toBe('Zero priority task');
|
||||
});
|
||||
});
|
||||
describe('Alphanumeric Priorities', () => {
|
||||
it.each([
|
||||
['A1', '(A1) Letter first alphanumeric'],
|
||||
['1a', '(1a) Number first alphanumeric'],
|
||||
['A1B2C3', '(A1B2C3) Complex alphanumeric']
|
||||
])('英数字優先度%sの正確な解析', (expectedPriority, todoText) => {
|
||||
const todo = parseTodo(todoText);
|
||||
expect(todo.priority()).toBe(expectedPriority);
|
||||
});
|
||||
});
|
||||
describe('Priority Position and Combinations', () => {
|
||||
const combinationTests = [
|
||||
{
|
||||
name: 'middle of line',
|
||||
text: 'Task with (A) priority in middle',
|
||||
expected: { priority: null, task: 'Task with (A) priority in middle' }
|
||||
},
|
||||
{
|
||||
name: 'priority with creation date',
|
||||
text: '(A) 2025-01-15 Priority with creation date',
|
||||
expected: { priority: 'A', creationDate: '2025-01-15', task: 'Priority with creation date' }
|
||||
},
|
||||
{
|
||||
name: 'completed task with priority',
|
||||
text: 'x 2025-01-16 (A) 2025-01-15 Completed task with priority',
|
||||
expected: { isDone: true, priority: 'A', completionDate: '2025-01-16', creationDate: null, task: '2025-01-15 Completed task with priority' }
|
||||
}
|
||||
];
|
||||
|
||||
combinationTests.forEach(({ name, text, expected }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
if (expected.priority !== undefined) expect(todo.priority()).toBe(expected.priority);
|
||||
if (expected.creationDate !== undefined) expect(todo.creationDate()).toBe(expected.creationDate);
|
||||
if (expected.isDone !== undefined) expect(todo.isDone()).toBe(expected.isDone);
|
||||
if (expected.completionDate !== undefined) expect(todo.completionDate()).toBe(expected.completionDate);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Invalid Priority Formats', () => {
|
||||
const invalidFormatTests = [
|
||||
{
|
||||
name: 'unclosed parenthesis',
|
||||
text: '(A Task without closing parenthesis',
|
||||
expected: { priority: null }
|
||||
},
|
||||
{
|
||||
name: 'empty parentheses',
|
||||
text: '() Empty parentheses task',
|
||||
expected: { priority: null }
|
||||
},
|
||||
{
|
||||
name: 'parentheses with spaces',
|
||||
text: '( A ) Priority with spaces',
|
||||
expected: { priority: null }
|
||||
},
|
||||
{
|
||||
name: 'no space after parenthesis',
|
||||
text: '(A)No space after priority',
|
||||
expected: { priority: 'A', task: 'No space after priority' }
|
||||
}
|
||||
];
|
||||
|
||||
invalidFormatTests.forEach(({ name, text, expected }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.priority()).toBe(expected.priority);
|
||||
if (expected.task) {
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
} else {
|
||||
expect(todo.task()).toBe(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
185
src/utils/todotxt-core/__tests__/parser-tags.test.ts
Normal file
185
src/utils/todotxt-core/__tests__/parser-tags.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { parseTodo } from '../parser';
|
||||
|
||||
describe('Todo.txt Parser - Tags', () => {
|
||||
describe('Project Tags', () => {
|
||||
const projectTagTests = [
|
||||
{
|
||||
name: 'single project tag',
|
||||
text: 'Task with +project',
|
||||
expected: { projects: ['project'], task: 'Task with' }
|
||||
},
|
||||
{
|
||||
name: 'multiple project tags',
|
||||
text: 'Task with +project1 +project2 +project3',
|
||||
expected: { projects: ['project1', 'project2', 'project3'], task: 'Task with' }
|
||||
},
|
||||
{
|
||||
name: 'project tags at any position',
|
||||
text: '+projectStart Task in middle +projectMiddle and end +projectEnd',
|
||||
expected: { projects: ['projectStart', 'projectMiddle', 'projectEnd'], task: 'Task in middle and end' }
|
||||
},
|
||||
{
|
||||
name: 'projects with special characters',
|
||||
text: 'Task +project-name +project_name +project.name +project123',
|
||||
expected: { projects: ['project-name', 'project_name', 'project.name', 'project123'] }
|
||||
},
|
||||
{
|
||||
name: 'projects with Japanese characters',
|
||||
text: 'タスク +プロジェクト +日本語プロジェクト',
|
||||
expected: { projects: ['プロジェクト', '日本語プロジェクト'] }
|
||||
},
|
||||
{
|
||||
name: 'projects with emojis',
|
||||
text: 'Task +🚀rocket +📱mobile +🎯target',
|
||||
expected: { projects: ['🚀rocket', '📱mobile', '🎯target'] }
|
||||
},
|
||||
{
|
||||
name: 'case preservation',
|
||||
text: 'Task +CamelCase +lowercase +UPPERCASE',
|
||||
expected: { projects: ['CamelCase', 'lowercase', 'UPPERCASE'] }
|
||||
}
|
||||
];
|
||||
|
||||
projectTagTests.forEach(({ name, text, expected }) => {
|
||||
it(`should parse ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.projects()).toEqual(expected.projects);
|
||||
if (expected.task) {
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Context Tags', () => {
|
||||
const contextTagTests = [
|
||||
{
|
||||
name: 'single context tag',
|
||||
text: 'Task with @context',
|
||||
expected: { contexts: ['context'], task: 'Task with' }
|
||||
},
|
||||
{
|
||||
name: 'multiple context tags',
|
||||
text: 'Task @home @phone @urgent',
|
||||
expected: { contexts: ['home', 'phone', 'urgent'], task: 'Task' }
|
||||
},
|
||||
{
|
||||
name: 'context tags at any position',
|
||||
text: '@contextStart Task in middle @contextMiddle and end @contextEnd',
|
||||
expected: { contexts: ['contextStart', 'contextMiddle', 'contextEnd'], task: 'Task in middle and end' }
|
||||
},
|
||||
{
|
||||
name: 'contexts with special characters',
|
||||
text: 'Task @context-name @context_name @context.name @context123',
|
||||
expected: { contexts: ['context-name', 'context_name', 'context.name', 'context123'] }
|
||||
},
|
||||
{
|
||||
name: 'contexts with emojis',
|
||||
text: 'Task @🏠home @💼work @🛒shopping',
|
||||
expected: { contexts: ['🏠home', '💼work', '🛒shopping'] }
|
||||
}
|
||||
];
|
||||
|
||||
contextTagTests.forEach(({ name, text, expected }) => {
|
||||
it(`should parse ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.contexts()).toEqual(expected.contexts);
|
||||
if (expected.task) {
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Mixed Tags', () => {
|
||||
const mixedTagTests = [
|
||||
{
|
||||
name: 'both projects and contexts',
|
||||
text: 'Task +project1 @context1 +project2 @context2',
|
||||
expected: { projects: ['project1', 'project2'], contexts: ['context1', 'context2'], task: 'Task' }
|
||||
},
|
||||
{
|
||||
name: 'order preservation',
|
||||
text: '+z +a +m @z @a @m',
|
||||
expected: { projects: ['z', 'a', 'm'], contexts: ['z', 'a', 'm'] }
|
||||
},
|
||||
{
|
||||
name: 'duplicate tag names',
|
||||
text: 'Task +project +project @context @context',
|
||||
expected: { projects: ['project', 'project'], contexts: ['context', 'context'] }
|
||||
}
|
||||
];
|
||||
|
||||
mixedTagTests.forEach(({ name, text, expected }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.projects()).toEqual(expected.projects);
|
||||
expect(todo.contexts()).toEqual(expected.contexts);
|
||||
if (expected.task) {
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Non-Tag Cases', () => {
|
||||
const nonTagTests = [
|
||||
{
|
||||
name: 'email addresses',
|
||||
text: 'Email user@example.com about project',
|
||||
expected: { projects: [], contexts: [], task: 'Email user@example.com about project' }
|
||||
},
|
||||
{
|
||||
name: 'standalone +',
|
||||
text: '1 + 1 equals 2',
|
||||
expected: { projects: [], contexts: [], task: '1 + 1 equals 2' }
|
||||
},
|
||||
{
|
||||
name: 'standalone @',
|
||||
text: 'Meet @ the office',
|
||||
expected: { projects: [], contexts: [], task: 'Meet @ the office' }
|
||||
},
|
||||
{
|
||||
name: 'escaped tags',
|
||||
text: 'Task with \\+notproject \\@notcontext',
|
||||
expected: { projects: [], contexts: [], task: 'Task with \\+notproject \\@notcontext' }
|
||||
}
|
||||
];
|
||||
|
||||
nonTagTests.forEach(({ name, text, expected }) => {
|
||||
it(`should not treat ${name} as tags`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.projects()).toEqual(expected.projects);
|
||||
expect(todo.contexts()).toEqual(expected.contexts);
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Edge Cases', () => {
|
||||
const edgeCaseTests = [
|
||||
{
|
||||
name: 'tags without task description',
|
||||
text: '+project @context',
|
||||
expected: { projects: ['project'], contexts: ['context'], task: '' }
|
||||
},
|
||||
{
|
||||
name: 'very long tag names',
|
||||
text: `Task +${'a'.repeat(100)} @${'a'.repeat(100)}`,
|
||||
expected: { projects: ['a'.repeat(100)], contexts: ['a'.repeat(100)] }
|
||||
},
|
||||
{
|
||||
name: 'tags with URL-like strings',
|
||||
text: '+project@email.com might confuse parser',
|
||||
expected: { projects: ['project@email.com'], contexts: [] }
|
||||
}
|
||||
];
|
||||
|
||||
edgeCaseTests.forEach(({ name, text, expected }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(text);
|
||||
expect(todo.projects()).toEqual(expected.projects);
|
||||
expect(todo.contexts()).toEqual(expected.contexts);
|
||||
if (expected.task !== undefined) {
|
||||
expect(todo.task()).toBe(expected.task);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
193
src/utils/todotxt-core/__tests__/recurrence.test.ts
Normal file
193
src/utils/todotxt-core/__tests__/recurrence.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
parseRecurrence,
|
||||
addInterval,
|
||||
getNextRecurringTask,
|
||||
parseTodo
|
||||
} from '../';
|
||||
const originalDate = global.Date;
|
||||
const mockDate = new Date('2023-05-08');
|
||||
beforeAll(() => {
|
||||
global.Date = jest.fn((dateString?: string) => {
|
||||
if (dateString) {
|
||||
return new originalDate(dateString);
|
||||
}
|
||||
return mockDate;
|
||||
}) as unknown as DateConstructor;
|
||||
global.Date.now = originalDate.now;
|
||||
global.Date.parse = originalDate.parse;
|
||||
global.Date.UTC = originalDate.UTC;
|
||||
});
|
||||
afterAll(() => {
|
||||
global.Date = originalDate;
|
||||
});
|
||||
describe('parseRecurrence', () => {
|
||||
const recurrenceParseTests = [
|
||||
// Valid cases
|
||||
{ pattern: 'd', expected: { interval: 'd', amount: 1, isStrict: false } },
|
||||
{ pattern: 'w', expected: { interval: 'w', amount: 1, isStrict: false } },
|
||||
{ pattern: 'm', expected: { interval: 'm', amount: 1, isStrict: false } },
|
||||
{ pattern: 'y', expected: { interval: 'y', amount: 1, isStrict: false } },
|
||||
{ pattern: 'b', expected: { interval: 'b', amount: 1, isStrict: false } },
|
||||
{ pattern: '3d', expected: { interval: 'd', amount: 3, isStrict: false } },
|
||||
{ pattern: '2w', expected: { interval: 'w', amount: 2, isStrict: false } },
|
||||
{ pattern: '6m', expected: { interval: 'm', amount: 6, isStrict: false } },
|
||||
{ pattern: '1y', expected: { interval: 'y', amount: 1, isStrict: false } },
|
||||
{ pattern: '5b', expected: { interval: 'b', amount: 5, isStrict: false } },
|
||||
{ pattern: '+d', expected: { interval: 'd', amount: 1, isStrict: true } },
|
||||
{ pattern: '+3m', expected: { interval: 'm', amount: 3, isStrict: true } },
|
||||
{ pattern: '+2w', expected: { interval: 'w', amount: 2, isStrict: true } },
|
||||
// Invalid cases
|
||||
{ pattern: '', expected: null },
|
||||
{ pattern: 'x', expected: null },
|
||||
{ pattern: '0d', expected: null },
|
||||
{ pattern: '-1d', expected: null },
|
||||
{ pattern: 'dm', expected: null },
|
||||
{ pattern: '3', expected: null }
|
||||
];
|
||||
|
||||
recurrenceParseTests.forEach(({ pattern, expected }) => {
|
||||
it(`should parse "${pattern}" as ${expected ? JSON.stringify(expected) : 'null'}`, () => {
|
||||
expect(parseRecurrence(pattern)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('addInterval', () => {
|
||||
const intervalTests = [
|
||||
// Days
|
||||
{ date: '2023-05-08', interval: { interval: 'd', amount: 1, isStrict: false }, expected: '2023-05-09' },
|
||||
{ date: '2023-05-08', interval: { interval: 'd', amount: 5, isStrict: false }, expected: '2023-05-13' },
|
||||
// Weeks
|
||||
{ date: '2023-05-08', interval: { interval: 'w', amount: 1, isStrict: false }, expected: '2023-05-15' },
|
||||
{ date: '2023-05-08', interval: { interval: 'w', amount: 2, isStrict: false }, expected: '2023-05-22' },
|
||||
// Months
|
||||
{ date: '2023-05-08', interval: { interval: 'm', amount: 1, isStrict: false }, expected: '2023-06-08' },
|
||||
{ date: '2023-05-08', interval: { interval: 'm', amount: 3, isStrict: false }, expected: '2023-08-08' },
|
||||
// Years
|
||||
{ date: '2023-05-08', interval: { interval: 'y', amount: 1, isStrict: false }, expected: '2024-05-08' },
|
||||
{ date: '2023-05-08', interval: { interval: 'y', amount: 2, isStrict: false }, expected: '2025-05-08' },
|
||||
// Business days
|
||||
{ date: '2023-05-08', interval: { interval: 'b', amount: 1, isStrict: false }, expected: '2023-05-09' },
|
||||
{ date: '2023-05-08', interval: { interval: 'b', amount: 5, isStrict: false }, expected: '2023-05-15' },
|
||||
{ date: '2023-05-12', interval: { interval: 'b', amount: 3, isStrict: false }, expected: '2023-05-17' }
|
||||
];
|
||||
|
||||
intervalTests.forEach(({ date, interval, expected }) => {
|
||||
it(`should add ${interval.amount}${interval.interval} to ${date} = ${expected}`, () => {
|
||||
expect(addInterval(date, interval as { interval: 'b' | 'd' | 'w' | 'm' | 'y'; amount: number; isStrict: boolean })).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('getNextRecurringTask', () => {
|
||||
it('should return null for tasks without recurrence', () => {
|
||||
const todo = parseTodo('Task without recurrence');
|
||||
expect(getNextRecurringTask(todo)).toBeNull();
|
||||
});
|
||||
|
||||
const nextTaskTests = [
|
||||
{
|
||||
name: 'daily recurrence',
|
||||
input: 'Daily task rec:d due:2023-05-08',
|
||||
expectations: {
|
||||
task: 'Daily task',
|
||||
dueDate: '2023-05-09',
|
||||
rec: 'd',
|
||||
creationDate: '2023-05-08'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'weekly recurrence with tags',
|
||||
input: 'Weekly meeting +project @work rec:w due:2023-05-08',
|
||||
expectations: {
|
||||
task: 'Weekly meeting',
|
||||
dueDate: '2023-05-15',
|
||||
projects: 'project',
|
||||
contexts: 'work'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'strict mode with due date',
|
||||
input: 'x 2023-05-10 2023-05-01 Monthly rent rec:+m due:2023-05-01',
|
||||
expectations: {
|
||||
task: 'Monthly rent',
|
||||
dueDate: '2023-06-01',
|
||||
isDone: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'strict mode without due date',
|
||||
input: 'x 2023-05-10 Task rec:+w',
|
||||
expectations: {
|
||||
dueDate: '2023-05-17'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'priority preservation',
|
||||
input: '(A) Important task rec:d due:2023-05-08',
|
||||
expectations: {
|
||||
priority: 'A'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'completed task priority extraction',
|
||||
input: 'x 2023-05-08 (A) 2023-05-07 Completed task rec:d',
|
||||
expectations: {
|
||||
priority: 'A'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'key-value preservation',
|
||||
input: 'Task rec:m due:2023-05-08 note:important id:123',
|
||||
expectations: {
|
||||
keyValues: { note: 'important', id: '123', rec: 'm' }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'business day recurrence',
|
||||
input: 'x 2023-05-12 Business task rec:2b due:2023-05-11',
|
||||
expectations: {
|
||||
dueDate: '2023-05-16'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'creation date reset',
|
||||
input: 'x 2023-05-08 2023-05-01 Task with creation date rec:d due:2023-05-08',
|
||||
expectations: {
|
||||
creationDate: '2023-05-08',
|
||||
task: 'Task with creation date',
|
||||
dueDate: '2023-05-09'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'non-strict mode completion date calculation',
|
||||
input: 'x 2023-05-08 2023-05-01 hoge rec:d due:2023-05-03',
|
||||
expectations: {
|
||||
creationDate: '2023-05-08',
|
||||
task: 'hoge',
|
||||
dueDate: '2023-05-09'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
nextTaskTests.forEach(({ name, input, expectations }) => {
|
||||
it(`should handle ${name}`, () => {
|
||||
const todo = parseTodo(input);
|
||||
const next = getNextRecurringTask(todo);
|
||||
expect(next).not.toBeNull();
|
||||
|
||||
if (expectations.task) expect(next!.task()).toBe(expectations.task);
|
||||
if (expectations.dueDate) expect(next!.dueDate()).toBe(expectations.dueDate);
|
||||
if (expectations.rec) expect(next!.keyValues().rec).toBe(expectations.rec);
|
||||
if (expectations.creationDate) expect(next!.creationDate()).toBe(expectations.creationDate);
|
||||
if (expectations.projects) expect(next!.projects()).toContain(expectations.projects);
|
||||
if (expectations.contexts) expect(next!.contexts()).toContain(expectations.contexts);
|
||||
if (expectations.isDone !== undefined) expect(next!.isDone()).toBe(expectations.isDone);
|
||||
if (expectations.priority) expect(next!.priority()).toBe(expectations.priority);
|
||||
if (expectations.keyValues) {
|
||||
Object.entries(expectations.keyValues).forEach(([key, value]) => {
|
||||
expect(next!.keyValues()[key]).toBe(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,252 +2,203 @@ import { parseTodo } from '../parser';
|
|||
import { sortTodosByPriority, sortTodosByProject, sortTodosByContext, sortTodosByDueDate } from '../sorter';
|
||||
|
||||
describe('Todo Sorter Functions', () => {
|
||||
const createTestTodos = (todoTexts: string[]) => todoTexts.map(text => parseTodo(text));
|
||||
describe('sortTodosByPriority', () => {
|
||||
test('sorts tasks by priority A, B, C', () => {
|
||||
const todos = [
|
||||
parseTodo('(C) Low priority task'),
|
||||
parseTodo('(A) High priority task'),
|
||||
parseTodo('(B) Medium priority task'),
|
||||
parseTodo('No priority task')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByPriority(todos);
|
||||
|
||||
expect(sorted[0].priority()).toBe('A');
|
||||
expect(sorted[1].priority()).toBe('B');
|
||||
expect(sorted[2].priority()).toBe('C');
|
||||
expect(sorted[3].priority()).toBeNull();
|
||||
const priorityTests = [
|
||||
{
|
||||
name: 'alphabetic priorities A, B, C',
|
||||
input: ['(C) Low priority task', '(A) High priority task', '(B) Medium priority task', 'No priority task'],
|
||||
expected: ['A', 'B', 'C', null]
|
||||
},
|
||||
{
|
||||
name: 'numeric priorities',
|
||||
input: ['(3) Priority 3 task', '(1) Priority 1 task', '(2) Priority 2 task', '(A) Priority A task'],
|
||||
expected: ['1', '2', '3', 'A']
|
||||
}
|
||||
];
|
||||
|
||||
priorityTests.forEach(({ name, input, expected }) => {
|
||||
it(`should sort ${name}`, () => {
|
||||
const todos = createTestTodos(input);
|
||||
const sorted = sortTodosByPriority(todos);
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.priority()).toBe(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('handles numeric priorities correctly', () => {
|
||||
const todos = [
|
||||
parseTodo('(3) Priority 3 task'),
|
||||
parseTodo('(1) Priority 1 task'),
|
||||
parseTodo('(2) Priority 2 task'),
|
||||
parseTodo('(A) Priority A task')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByPriority(todos);
|
||||
|
||||
expect(sorted[0].priority()).toBe('1');
|
||||
expect(sorted[1].priority()).toBe('2');
|
||||
expect(sorted[2].priority()).toBe('3');
|
||||
expect(sorted[3].priority()).toBe('A');
|
||||
});
|
||||
|
||||
test('puts completed tasks last when option is enabled', () => {
|
||||
const todos = [
|
||||
parseTodo('x (A) Completed high priority'),
|
||||
parseTodo('(B) Active medium priority'),
|
||||
parseTodo('x (C) Completed low priority'),
|
||||
parseTodo('(A) Active high priority')
|
||||
];
|
||||
|
||||
|
||||
it('puts completed tasks last when option is enabled', () => {
|
||||
const todos = createTestTodos([
|
||||
'x (A) Completed high priority',
|
||||
'(B) Active medium priority',
|
||||
'x (C) Completed low priority',
|
||||
'(A) Active high priority'
|
||||
]);
|
||||
const sorted = sortTodosByPriority(todos, { completedTasksLast: true });
|
||||
|
||||
expect(sorted[0].priority()).toBe('A');
|
||||
expect(sorted[0].isDone()).toBe(false);
|
||||
expect(sorted[1].priority()).toBe('B');
|
||||
expect(sorted[1].isDone()).toBe(false);
|
||||
expect(sorted[2].priority()).toBeNull();
|
||||
expect(sorted[2].isDone()).toBe(true);
|
||||
expect(sorted[3].priority()).toBeNull();
|
||||
expect(sorted[3].isDone()).toBe(true);
|
||||
const expectedOrder = [['A', false], ['B', false], ['A', true], ['C', true]];
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.priority()).toBe(expectedOrder[index][0]);
|
||||
expect(todo.isDone()).toBe(expectedOrder[index][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('maintains original order for same priority', () => {
|
||||
const todos = [
|
||||
parseTodo('(A) First A task'),
|
||||
parseTodo('(A) Second A task'),
|
||||
parseTodo('(A) Third A task')
|
||||
];
|
||||
|
||||
|
||||
it('maintains original order for same priority', () => {
|
||||
const todos = createTestTodos(['(A) First A task', '(A) Second A task', '(A) Third A task']);
|
||||
const sorted = sortTodosByPriority(todos);
|
||||
|
||||
expect(sorted[0].task()).toBe('First A task');
|
||||
expect(sorted[1].task()).toBe('Second A task');
|
||||
expect(sorted[2].task()).toBe('Third A task');
|
||||
const expectedTasks = ['First A task', 'Second A task', 'Third A task'];
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.task()).toBe(expectedTasks[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTodosByProject', () => {
|
||||
test('sorts tasks by project name alphabetically', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with +zebra project'),
|
||||
parseTodo('Task with +apple project'),
|
||||
parseTodo('Task with +banana project'),
|
||||
parseTodo('Task without project')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByProject(todos);
|
||||
|
||||
expect(sorted[0].projects()).toEqual(['apple']);
|
||||
expect(sorted[1].projects()).toEqual(['banana']);
|
||||
expect(sorted[2].projects()).toEqual(['zebra']);
|
||||
expect(sorted[3].projects()).toEqual([]);
|
||||
const projectTests = [
|
||||
{
|
||||
name: 'alphabetically',
|
||||
input: ['Task with +zebra project', 'Task with +apple project', 'Task with +banana project', 'Task without project'],
|
||||
expected: [['apple'], ['banana'], ['zebra'], []]
|
||||
},
|
||||
{
|
||||
name: 'multiple projects by first project',
|
||||
input: ['Task with +zebra +apple projects', 'Task with +banana +charlie projects'],
|
||||
expected: [['banana', 'charlie'], ['zebra', 'apple']]
|
||||
}
|
||||
];
|
||||
|
||||
projectTests.forEach(({ name, input, expected }) => {
|
||||
it(`should sort ${name}`, () => {
|
||||
const todos = createTestTodos(input);
|
||||
const sorted = sortTodosByProject(todos);
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.projects()).toEqual(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('handles multiple projects by using first project', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with +zebra +apple projects'),
|
||||
parseTodo('Task with +banana +charlie projects')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByProject(todos);
|
||||
|
||||
expect(sorted[0].projects()).toEqual(['banana', 'charlie']);
|
||||
expect(sorted[1].projects()).toEqual(['zebra', 'apple']);
|
||||
});
|
||||
|
||||
test('case sensitive sorting when option is enabled', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with +Zebra project'),
|
||||
parseTodo('Task with +apple project'),
|
||||
parseTodo('Task with +Banana project')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByProject(todos, { caseSensitive: true });
|
||||
|
||||
// Case sensitive: lowercase letters come before uppercase in localeCompare
|
||||
expect(sorted[0].projects()).toEqual(['apple']);
|
||||
expect(sorted[1].projects()).toEqual(['Banana']);
|
||||
expect(sorted[2].projects()).toEqual(['Zebra']);
|
||||
});
|
||||
|
||||
test('case insensitive sorting by default', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with +Zebra project'),
|
||||
parseTodo('Task with +apple project'),
|
||||
parseTodo('Task with +Banana project')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByProject(todos);
|
||||
|
||||
expect(sorted[0].projects()).toEqual(['apple']);
|
||||
expect(sorted[1].projects()).toEqual(['Banana']);
|
||||
expect(sorted[2].projects()).toEqual(['Zebra']);
|
||||
|
||||
const caseSensitivityTests = [
|
||||
{
|
||||
name: 'case sensitive sorting when option is enabled',
|
||||
caseSensitive: true,
|
||||
expected: [['apple'], ['Banana'], ['Zebra']]
|
||||
},
|
||||
{
|
||||
name: 'case insensitive sorting by default',
|
||||
caseSensitive: false,
|
||||
expected: [['apple'], ['Banana'], ['Zebra']]
|
||||
}
|
||||
];
|
||||
|
||||
caseSensitivityTests.forEach(({ name, caseSensitive, expected }) => {
|
||||
it(name, () => {
|
||||
const todos = createTestTodos(['Task with +Zebra project', 'Task with +apple project', 'Task with +Banana project']);
|
||||
const sorted = sortTodosByProject(todos, { caseSensitive });
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.projects()).toEqual(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTodosByContext', () => {
|
||||
test('sorts tasks by context name alphabetically', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with @work context'),
|
||||
parseTodo('Task with @home context'),
|
||||
parseTodo('Task with @phone context'),
|
||||
parseTodo('Task without context')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByContext(todos);
|
||||
|
||||
expect(sorted[0].contexts()).toEqual(['home']);
|
||||
expect(sorted[1].contexts()).toEqual(['phone']);
|
||||
expect(sorted[2].contexts()).toEqual(['work']);
|
||||
expect(sorted[3].contexts()).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles multiple contexts by using first context', () => {
|
||||
const todos = [
|
||||
parseTodo('Task with @work @urgent contexts'),
|
||||
parseTodo('Task with @home @weekend contexts')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByContext(todos);
|
||||
|
||||
expect(sorted[0].contexts()).toEqual(['home', 'weekend']);
|
||||
expect(sorted[1].contexts()).toEqual(['work', 'urgent']);
|
||||
const contextTests = [
|
||||
{
|
||||
name: 'alphabetically',
|
||||
input: ['Task with @work context', 'Task with @home context', 'Task with @phone context', 'Task without context'],
|
||||
expected: [['home'], ['phone'], ['work'], []]
|
||||
},
|
||||
{
|
||||
name: 'multiple contexts by first context',
|
||||
input: ['Task with @work @urgent contexts', 'Task with @home @weekend contexts'],
|
||||
expected: [['home', 'weekend'], ['work', 'urgent']]
|
||||
}
|
||||
];
|
||||
|
||||
contextTests.forEach(({ name, input, expected }) => {
|
||||
it(`should sort ${name}`, () => {
|
||||
const todos = createTestTodos(input);
|
||||
const sorted = sortTodosByContext(todos);
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.contexts()).toEqual(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTodosByDueDate', () => {
|
||||
test('sorts tasks by due date chronologically', () => {
|
||||
const todos = [
|
||||
parseTodo('Task due:2025-01-20'),
|
||||
parseTodo('Task due:2025-01-15'),
|
||||
parseTodo('Task due:2025-01-18'),
|
||||
parseTodo('Task without due date')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByDueDate(todos);
|
||||
|
||||
expect(sorted[0].dueDate()).toBe('2025-01-15');
|
||||
expect(sorted[1].dueDate()).toBe('2025-01-18');
|
||||
expect(sorted[2].dueDate()).toBe('2025-01-20');
|
||||
expect(sorted[3].dueDate()).toBeNull();
|
||||
});
|
||||
|
||||
test('handles invalid dates by putting them last', () => {
|
||||
const todos = [
|
||||
parseTodo('Task due:2025-01-15'),
|
||||
parseTodo('Task due:invalid-date'),
|
||||
parseTodo('Task due:2025-01-10'),
|
||||
parseTodo('Task without due date')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByDueDate(todos);
|
||||
|
||||
expect(sorted[0].dueDate()).toBe('2025-01-10');
|
||||
expect(sorted[1].dueDate()).toBe('2025-01-15');
|
||||
expect(sorted[2].dueDate()).toBe('invalid-date');
|
||||
expect(sorted[3].dueDate()).toBeNull();
|
||||
});
|
||||
|
||||
test('puts tasks without due date last', () => {
|
||||
const todos = [
|
||||
parseTodo('Task without due date'),
|
||||
parseTodo('Task due:2025-01-15'),
|
||||
parseTodo('Another task without due date')
|
||||
];
|
||||
|
||||
const sorted = sortTodosByDueDate(todos);
|
||||
|
||||
expect(sorted[0].dueDate()).toBe('2025-01-15');
|
||||
expect(sorted[1].dueDate()).toBeNull();
|
||||
expect(sorted[2].dueDate()).toBeNull();
|
||||
const dueDateTests = [
|
||||
{
|
||||
name: 'chronologically',
|
||||
input: ['Task due:2025-01-20', 'Task due:2025-01-15', 'Task due:2025-01-18', 'Task without due date'],
|
||||
expected: ['2025-01-15', '2025-01-18', '2025-01-20', null]
|
||||
},
|
||||
{
|
||||
name: 'invalid dates last',
|
||||
input: ['Task due:2025-01-15', 'Task due:invalid-date', 'Task due:2025-01-10', 'Task without due date'],
|
||||
expected: ['2025-01-10', '2025-01-15', 'invalid-date', null]
|
||||
},
|
||||
{
|
||||
name: 'tasks without due date last',
|
||||
input: ['Task without due date', 'Task due:2025-01-15', 'Another task without due date'],
|
||||
expected: ['2025-01-15', null, null]
|
||||
}
|
||||
];
|
||||
|
||||
dueDateTests.forEach(({ name, input, expected }) => {
|
||||
it(`should sort ${name}`, () => {
|
||||
const todos = createTestTodos(input);
|
||||
const sorted = sortTodosByDueDate(todos);
|
||||
sorted.forEach((todo, index) => {
|
||||
expect(todo.dueDate()).toBe(expected[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex sorting scenarios', () => {
|
||||
test('all sort functions preserve original todo objects', () => {
|
||||
const originalTodos = [
|
||||
parseTodo('(B) Task with +project @context due:2025-01-15'),
|
||||
parseTodo('(A) Another task')
|
||||
];
|
||||
|
||||
it('all sort functions preserve original todo objects', () => {
|
||||
const originalTodos = createTestTodos([
|
||||
'(B) Task with +project @context due:2025-01-15',
|
||||
'(A) Another task'
|
||||
]);
|
||||
const sortedByPriority = sortTodosByPriority(originalTodos);
|
||||
const sortedByProject = sortTodosByProject(originalTodos);
|
||||
const sortedByContext = sortTodosByContext(originalTodos);
|
||||
const sortedByDueDate = sortTodosByDueDate(originalTodos);
|
||||
|
||||
// Check that original objects are preserved (not cloned)
|
||||
|
||||
expect(sortedByPriority[0]).toBe(originalTodos[1]);
|
||||
expect(sortedByPriority[1]).toBe(originalTodos[0]);
|
||||
|
||||
// All arrays should contain the same objects
|
||||
expect(sortedByPriority).toHaveLength(2);
|
||||
expect(sortedByProject).toHaveLength(2);
|
||||
expect(sortedByContext).toHaveLength(2);
|
||||
expect(sortedByDueDate).toHaveLength(2);
|
||||
[sortedByPriority, sortedByProject, sortedByContext, sortedByDueDate].forEach(sorted => {
|
||||
expect(sorted).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('empty array handling', () => {
|
||||
const empty: any[] = [];
|
||||
|
||||
expect(sortTodosByPriority(empty)).toEqual([]);
|
||||
expect(sortTodosByProject(empty)).toEqual([]);
|
||||
expect(sortTodosByContext(empty)).toEqual([]);
|
||||
expect(sortTodosByDueDate(empty)).toEqual([]);
|
||||
});
|
||||
|
||||
test('single item array handling', () => {
|
||||
const singleTodo = [parseTodo('(A) Single task +project @context due:2025-01-15')];
|
||||
|
||||
expect(sortTodosByPriority(singleTodo)).toHaveLength(1);
|
||||
expect(sortTodosByProject(singleTodo)).toHaveLength(1);
|
||||
expect(sortTodosByContext(singleTodo)).toHaveLength(1);
|
||||
expect(sortTodosByDueDate(singleTodo)).toHaveLength(1);
|
||||
|
||||
expect(sortTodosByPriority(singleTodo)[0]).toBe(singleTodo[0]);
|
||||
|
||||
const edgeCaseTests = [
|
||||
{
|
||||
name: 'empty array handling',
|
||||
input: [],
|
||||
expected: []
|
||||
},
|
||||
{
|
||||
name: 'single item array handling',
|
||||
input: ['(A) Single task +project @context due:2025-01-15'],
|
||||
expected: { length: 1, preserveIdentity: true }
|
||||
}
|
||||
];
|
||||
|
||||
edgeCaseTests.forEach(({ name, input, expected }) => {
|
||||
it(name, () => {
|
||||
const todos = createTestTodos(input);
|
||||
const sortFunctions = [sortTodosByPriority, sortTodosByProject, sortTodosByContext, sortTodosByDueDate];
|
||||
|
||||
sortFunctions.forEach(sortFn => {
|
||||
const sorted = sortFn(todos);
|
||||
if (Array.isArray(expected)) {
|
||||
expect(sorted).toEqual(expected);
|
||||
} else {
|
||||
expect(sorted).toHaveLength(expected.length);
|
||||
if (expected.preserveIdentity && todos.length > 0) {
|
||||
expect(sorted[0]).toBe(todos[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
77
src/utils/todotxt-core/__tests__/string-builder.test.ts
Normal file
77
src/utils/todotxt-core/__tests__/string-builder.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { buildTaskString } from '../string-builder';
|
||||
import { parseTodo } from '../parser';
|
||||
|
||||
describe('buildTaskString', () => {
|
||||
const buildStringTests = [
|
||||
{
|
||||
name: 'simple task',
|
||||
input: 'Simple task',
|
||||
expected: 'Simple task'
|
||||
},
|
||||
{
|
||||
name: 'task with priority',
|
||||
input: '(A) Priority task',
|
||||
expected: '(A) Priority task'
|
||||
},
|
||||
{
|
||||
name: 'task with creation date',
|
||||
input: '2025-07-01 Task with date',
|
||||
expected: '2025-07-01 Task with date'
|
||||
},
|
||||
{
|
||||
name: 'task with projects',
|
||||
input: 'Task +work +important',
|
||||
expected: 'Task +work +important'
|
||||
},
|
||||
{
|
||||
name: 'task with contexts',
|
||||
input: 'Task @office @computer',
|
||||
expected: 'Task @office @computer'
|
||||
},
|
||||
{
|
||||
name: 'task with key-values',
|
||||
input: 'Task due:2025-07-15 rec:w',
|
||||
expected: 'Task due:2025-07-15 rec:w'
|
||||
},
|
||||
{
|
||||
name: 'complex task with all components',
|
||||
input: '(A) 2025-07-01 Complex task +work +important @office @computer due:2025-07-15 rec:w',
|
||||
expected: '(A) 2025-07-01 Complex task +work +important @office @computer due:2025-07-15 rec:w'
|
||||
},
|
||||
{
|
||||
name: 'completed task',
|
||||
input: 'x 2025-07-04 2025-07-01 Completed task +project @context',
|
||||
expected: '2025-07-01 Completed task +project @context'
|
||||
}
|
||||
];
|
||||
|
||||
buildStringTests.forEach(({ name, input, expected }) => {
|
||||
it(`should build ${name}`, () => {
|
||||
const todo = parseTodo(input);
|
||||
const result = buildTaskString(todo);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle round-trip consistency for non-completed tasks', () => {
|
||||
const originalStrings = [
|
||||
'(A) 2025-07-01 Test task +project @context due:2025-12-31 rec:w',
|
||||
'Simple task without metadata',
|
||||
'(B) Priority only task',
|
||||
'Task +multiple +projects @multiple @contexts'
|
||||
];
|
||||
|
||||
originalStrings.forEach(original => {
|
||||
const todo = parseTodo(original);
|
||||
const rebuilt = buildTaskString(todo);
|
||||
const reParsed = parseTodo(rebuilt);
|
||||
|
||||
expect(reParsed.task()).toBe(todo.task());
|
||||
expect(reParsed.priority()).toBe(todo.priority());
|
||||
expect(reParsed.creationDate()).toBe(todo.creationDate());
|
||||
expect(reParsed.projects()).toEqual(todo.projects());
|
||||
expect(reParsed.contexts()).toEqual(todo.contexts());
|
||||
expect(reParsed.keyValues()).toEqual(todo.keyValues());
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/utils/todotxt-core/completion-date.ts
Normal file
47
src/utils/todotxt-core/completion-date.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { getCurrentDate } from './recurrence';
|
||||
import { parseTodo } from './parser';
|
||||
|
||||
export function shouldAddCompletionDate(currentLine: string, previousLine: string): boolean {
|
||||
const todo = parseTodo(currentLine);
|
||||
const completionDate = todo.completionDate();
|
||||
const creationDate = todo.creationDate();
|
||||
|
||||
if (!completionDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If only completion date exists (no creation date), likely need to add proper completion date
|
||||
if (completionDate && !creationDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevTodo = parseTodo(previousLine);
|
||||
const prevCreationDate = prevTodo.creationDate();
|
||||
if (prevCreationDate && prevCreationDate === completionDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function addCompletionDate(lineText: string): string {
|
||||
const xMatch = lineText.match(/^(\s*)x\s+/);
|
||||
if (!xMatch) {
|
||||
return lineText;
|
||||
}
|
||||
|
||||
const indent = xMatch[1] || '';
|
||||
const afterX = lineText.substring(xMatch[0].length);
|
||||
|
||||
const priorityMatch = afterX.match(/^(\([A-Z]\)\s+)/);
|
||||
let remaining = afterX;
|
||||
let priority = '';
|
||||
|
||||
if (priorityMatch) {
|
||||
priority = priorityMatch[0];
|
||||
remaining = afterX.substring(priorityMatch[0].length);
|
||||
}
|
||||
|
||||
const completionDate = getCurrentDate();
|
||||
return `${indent}x ${priority}${completionDate} ${remaining}`;
|
||||
}
|
||||
|
|
@ -10,7 +10,11 @@ export type {
|
|||
} from './interfaces';
|
||||
|
||||
export {
|
||||
parseTodo
|
||||
parseTodo,
|
||||
isCompletedTask,
|
||||
isDueDateKeyValue,
|
||||
isRecurrenceKeyValue,
|
||||
isCommentLine
|
||||
} from './parser';
|
||||
|
||||
export {
|
||||
|
|
@ -18,4 +22,24 @@ export {
|
|||
sortTodosByProject,
|
||||
sortTodosByContext,
|
||||
sortTodosByDueDate
|
||||
} from './sorter';
|
||||
} from './sorter';
|
||||
|
||||
export {
|
||||
getNextRecurringTask,
|
||||
parseRecurrence,
|
||||
addInterval,
|
||||
getCurrentDate
|
||||
} from './recurrence';
|
||||
|
||||
export type {
|
||||
RecurrenceInfo
|
||||
} from './recurrence';
|
||||
|
||||
export {
|
||||
buildTaskString
|
||||
} from './string-builder';
|
||||
|
||||
export {
|
||||
shouldAddCompletionDate,
|
||||
addCompletionDate
|
||||
} from './completion-date';
|
||||
|
|
@ -28,17 +28,16 @@ export interface TodoInterfaceWithPositions extends TodoInterface {
|
|||
};
|
||||
}
|
||||
|
||||
class TodoImplementation implements TodoInterfaceWithPositions {
|
||||
private _task: string;
|
||||
private _isDone: boolean;
|
||||
private _priority: string | null;
|
||||
private _creationDate: string | null;
|
||||
private _completionDate: string | null;
|
||||
private _projects: string[];
|
||||
private _contexts: string[];
|
||||
private _keyValues: Record<string, string>;
|
||||
|
||||
private _positions: {
|
||||
function createTodo(
|
||||
task: string,
|
||||
isDone: boolean,
|
||||
priority: string | null,
|
||||
creationDate: string | null,
|
||||
completionDate: string | null,
|
||||
projects: string[],
|
||||
contexts: string[],
|
||||
keyValues: Record<string, string>,
|
||||
positions: {
|
||||
completion: ElementPosition | null;
|
||||
priority: ElementPosition | null;
|
||||
creationDate: ElementPosition | null;
|
||||
|
|
@ -46,59 +45,28 @@ class TodoImplementation implements TodoInterfaceWithPositions {
|
|||
projects: ElementPosition[];
|
||||
contexts: ElementPosition[];
|
||||
keyValues: ElementPosition[];
|
||||
}
|
||||
): TodoInterfaceWithPositions {
|
||||
return {
|
||||
task: () => task,
|
||||
isDone: () => isDone,
|
||||
priority: () => priority,
|
||||
creationDate: () => creationDate,
|
||||
completionDate: () => completionDate,
|
||||
projects: () => [...projects],
|
||||
contexts: () => [...contexts],
|
||||
keyValues: () => ({ ...keyValues }),
|
||||
dueDate: () => keyValues.due || null,
|
||||
getElementPositions: () => ({
|
||||
completion: positions.completion,
|
||||
priority: positions.priority,
|
||||
creationDate: positions.creationDate,
|
||||
completionDate: positions.completionDate,
|
||||
projects: [...positions.projects],
|
||||
contexts: [...positions.contexts],
|
||||
keyValues: [...positions.keyValues]
|
||||
})
|
||||
};
|
||||
|
||||
constructor(
|
||||
task: string,
|
||||
isDone: boolean,
|
||||
priority: string | null,
|
||||
creationDate: string | null,
|
||||
completionDate: string | null,
|
||||
projects: string[],
|
||||
contexts: string[],
|
||||
keyValues: Record<string, string>,
|
||||
positions: {
|
||||
completion: ElementPosition | null;
|
||||
priority: ElementPosition | null;
|
||||
creationDate: ElementPosition | null;
|
||||
completionDate: ElementPosition | null;
|
||||
projects: ElementPosition[];
|
||||
contexts: ElementPosition[];
|
||||
keyValues: ElementPosition[];
|
||||
}
|
||||
) {
|
||||
this._task = task;
|
||||
this._isDone = isDone;
|
||||
this._priority = priority;
|
||||
this._creationDate = creationDate;
|
||||
this._completionDate = completionDate;
|
||||
this._projects = projects;
|
||||
this._contexts = contexts;
|
||||
this._keyValues = keyValues;
|
||||
this._positions = positions;
|
||||
}
|
||||
|
||||
task(): string { return this._task; }
|
||||
isDone(): boolean { return this._isDone; }
|
||||
priority(): string | null { return this._priority; }
|
||||
creationDate(): string | null { return this._creationDate; }
|
||||
completionDate(): string | null { return this._completionDate; }
|
||||
projects(): string[] { return [...this._projects]; }
|
||||
contexts(): string[] { return [...this._contexts]; }
|
||||
keyValues(): Record<string, string> { return { ...this._keyValues }; }
|
||||
dueDate(): string | null { return this._keyValues.due || null; }
|
||||
|
||||
getElementPositions() {
|
||||
return {
|
||||
completion: this._positions.completion,
|
||||
priority: this._positions.priority,
|
||||
creationDate: this._positions.creationDate,
|
||||
completionDate: this._positions.completionDate,
|
||||
projects: [...this._positions.projects],
|
||||
contexts: [...this._positions.contexts],
|
||||
keyValues: [...this._positions.keyValues]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTodo(line: string): TodoInterfaceWithPositions {
|
||||
|
|
@ -113,7 +81,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
const contexts: string[] = [];
|
||||
const keyValues: Record<string, string> = {};
|
||||
|
||||
// 位置情報を保持
|
||||
const positions: {
|
||||
completion: ElementPosition | null;
|
||||
priority: ElementPosition | null;
|
||||
|
|
@ -132,51 +99,41 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
keyValues: []
|
||||
};
|
||||
|
||||
// 先頭の空白をスキップ
|
||||
while (pos < length && /\s/.test(line[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
// 1. 完了フラグ(x)のチェック
|
||||
if (pos < length && line[pos] === 'x' && (pos + 1 >= length || /\s/.test(line[pos + 1]))) {
|
||||
isCompleted = true;
|
||||
positions.completion = { value: 'x', start: pos, end: pos + 1 };
|
||||
pos++;
|
||||
// 完了フラグ後の空白をスキップ
|
||||
while (pos < length && /\s/.test(line[pos])) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 優先度のチェック(完了していない場合のみ)
|
||||
// 完了タスクの場合は優先度をスキップ
|
||||
if (pos < length && line[pos] === '(') {
|
||||
const remaining = line.substring(pos);
|
||||
const priorityMatch = remaining.match(/^\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/);
|
||||
if (priorityMatch) {
|
||||
if (!isCompleted) {
|
||||
priority = priorityMatch[1];
|
||||
}
|
||||
priority = priorityMatch[1];
|
||||
positions.priority = {
|
||||
value: priorityMatch[0],
|
||||
start: pos,
|
||||
end: pos + priorityMatch[0].length
|
||||
};
|
||||
pos += priorityMatch[0].length;
|
||||
// 優先度後の空白をスキップ
|
||||
while (pos < length && /\s/.test(line[pos])) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 完了日と作成日のチェック
|
||||
if (pos < length) {
|
||||
const remaining = line.substring(pos);
|
||||
const firstDateMatch = remaining.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (firstDateMatch) {
|
||||
const firstDateEnd = pos + firstDateMatch[1].length;
|
||||
// 第一の日付の後の空白をスキップして第二の日付を探す
|
||||
let nextPos = firstDateEnd;
|
||||
while (nextPos < length && /\s/.test(line[nextPos])) {
|
||||
nextPos++;
|
||||
|
|
@ -187,7 +144,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
|
||||
if (isCompleted) {
|
||||
if (secondDateMatch) {
|
||||
// 完了タスクで2つの日付がある場合: 最初が完了日、次が作成日
|
||||
completionDate = firstDateMatch[1];
|
||||
positions.completionDate = {
|
||||
value: firstDateMatch[1],
|
||||
|
|
@ -203,7 +159,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
};
|
||||
pos = nextPos + secondDateMatch[1].length;
|
||||
} else {
|
||||
// 完了タスクで1つの日付のみの場合: 完了日
|
||||
completionDate = firstDateMatch[1];
|
||||
positions.completionDate = {
|
||||
value: firstDateMatch[1],
|
||||
|
|
@ -213,7 +168,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
pos = firstDateEnd;
|
||||
}
|
||||
} else {
|
||||
// 完了タスクでない場合: 作成日
|
||||
creationDate = firstDateMatch[1];
|
||||
positions.creationDate = {
|
||||
value: firstDateMatch[1],
|
||||
|
|
@ -223,21 +177,35 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
pos = firstDateEnd;
|
||||
}
|
||||
|
||||
// 日付後の空白をスキップ
|
||||
while (pos < length && /\s/.test(line[pos])) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for priority after dates (for completed tasks like "x 2023-05-08 (A) 2023-05-07 Task")
|
||||
if (pos < length && line[pos] === '(' && priority === null) {
|
||||
const remaining = line.substring(pos);
|
||||
const priorityMatch = remaining.match(/^\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/);
|
||||
if (priorityMatch) {
|
||||
priority = priorityMatch[1];
|
||||
positions.priority = {
|
||||
value: priorityMatch[0],
|
||||
start: pos,
|
||||
end: pos + priorityMatch[0].length
|
||||
};
|
||||
pos += priorityMatch[0].length;
|
||||
while (pos < length && /\s/.test(line[pos])) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 残りの部分(タスク本文)を解析
|
||||
const taskStartPos = pos;
|
||||
const textParts: string[] = [];
|
||||
|
||||
while (pos < length) {
|
||||
const remaining = line.substring(pos);
|
||||
|
||||
// プロジェクトタグのチェック
|
||||
const projectMatch = remaining.match(/^(\+[^\s]+)/);
|
||||
if (projectMatch) {
|
||||
const projectName = projectMatch[1].substring(1);
|
||||
|
|
@ -251,7 +219,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
continue;
|
||||
}
|
||||
|
||||
// コンテキストタグのチェック
|
||||
const contextMatch = remaining.match(/^(@[^\s]+)/);
|
||||
if (contextMatch) {
|
||||
const contextName = contextMatch[1].substring(1);
|
||||
|
|
@ -265,7 +232,6 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
continue;
|
||||
}
|
||||
|
||||
// key:value形式のチェック
|
||||
const keyValueMatch = remaining.match(/^([a-zA-Z_][a-zA-Z0-9_]*:[^\s]+)/);
|
||||
if (keyValueMatch) {
|
||||
const colonIndex = keyValueMatch[1].indexOf(':');
|
||||
|
|
@ -283,23 +249,20 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
continue;
|
||||
}
|
||||
|
||||
// 通常のテキストまたは空白
|
||||
const textMatch = remaining.match(/^([^\s]+|\s+)/);
|
||||
if (textMatch) {
|
||||
if (textMatch[1].trim()) { // 空白でない場合のみテキストとして追加
|
||||
if (textMatch[1].trim()) {
|
||||
textParts.push(textMatch[1]);
|
||||
}
|
||||
pos += textMatch[1].length;
|
||||
} else {
|
||||
// フォールバック
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
// タスク本文はテキスト部分のみを結合
|
||||
const taskText = textParts.join(' ').trim();
|
||||
|
||||
return new TodoImplementation(
|
||||
return createTodo(
|
||||
taskText,
|
||||
isCompleted,
|
||||
priority,
|
||||
|
|
@ -310,4 +273,20 @@ export function parseTodo(line: string): TodoInterfaceWithPositions {
|
|||
keyValues,
|
||||
positions
|
||||
);
|
||||
}
|
||||
|
||||
export function isCompletedTask(line: string): boolean {
|
||||
return line.trimStart().startsWith('x ');
|
||||
}
|
||||
|
||||
export function isDueDateKeyValue(keyValue: string): boolean {
|
||||
return keyValue.startsWith('due:');
|
||||
}
|
||||
|
||||
export function isRecurrenceKeyValue(keyValue: string): boolean {
|
||||
return keyValue.startsWith('rec:');
|
||||
}
|
||||
|
||||
export function isCommentLine(line: string): boolean {
|
||||
return line.trim().startsWith('#');
|
||||
}
|
||||
126
src/utils/todotxt-core/recurrence.ts
Normal file
126
src/utils/todotxt-core/recurrence.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { TodoInterface, TodoInterfaceWithPositions, parseTodo } from './parser';
|
||||
|
||||
export interface RecurrenceInfo {
|
||||
interval: 'd' | 'b' | 'w' | 'm' | 'y';
|
||||
amount: number;
|
||||
isStrict: boolean;
|
||||
}
|
||||
|
||||
export function parseRecurrence(recValue: string): RecurrenceInfo | null {
|
||||
if (!recValue) return null;
|
||||
|
||||
const isStrict = recValue.startsWith('+');
|
||||
const cleanValue = isStrict ? recValue.substring(1) : recValue;
|
||||
|
||||
const match = cleanValue.match(/^(\d*)([dbwmy])$/);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = match[1] ? parseInt(match[1], 10) : 1;
|
||||
const interval = match[2] as 'd' | 'b' | 'w' | 'm' | 'y';
|
||||
|
||||
if (amount < 1) return null;
|
||||
|
||||
return {
|
||||
interval,
|
||||
amount,
|
||||
isStrict
|
||||
};
|
||||
}
|
||||
|
||||
export function addInterval(dateStr: string, recInfo: RecurrenceInfo): string {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
switch (recInfo.interval) {
|
||||
case 'd':
|
||||
date.setDate(date.getDate() + recInfo.amount);
|
||||
break;
|
||||
case 'w':
|
||||
date.setDate(date.getDate() + (recInfo.amount * 7));
|
||||
break;
|
||||
case 'm':
|
||||
date.setMonth(date.getMonth() + recInfo.amount);
|
||||
break;
|
||||
case 'y':
|
||||
date.setFullYear(date.getFullYear() + recInfo.amount);
|
||||
break;
|
||||
case 'b': {
|
||||
let daysAdded = 0;
|
||||
while (daysAdded < recInfo.amount) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
const dayOfWeek = date.getDay();
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
|
||||
daysAdded++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getCurrentDate(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getNextRecurringTask(todo: TodoInterfaceWithPositions): TodoInterface | null {
|
||||
const keyValues = todo.keyValues();
|
||||
const recValue = keyValues.rec;
|
||||
|
||||
if (!recValue) return null;
|
||||
|
||||
const recInfo = parseRecurrence(recValue);
|
||||
if (!recInfo) return null;
|
||||
|
||||
const completionDate = todo.completionDate() || getCurrentDate();
|
||||
let nextDueDate: string;
|
||||
|
||||
if (recInfo.isStrict) {
|
||||
// rec:+1d - Calculate from original due date
|
||||
const originalDueDate = todo.dueDate();
|
||||
if (originalDueDate) {
|
||||
nextDueDate = addInterval(originalDueDate, recInfo);
|
||||
} else {
|
||||
nextDueDate = addInterval(completionDate, recInfo);
|
||||
}
|
||||
} else {
|
||||
// rec:1d - Calculate from completion date
|
||||
nextDueDate = addInterval(completionDate, recInfo);
|
||||
}
|
||||
|
||||
let newTaskStr = '';
|
||||
|
||||
const originalPriority = todo.priority();
|
||||
if (originalPriority) {
|
||||
newTaskStr += `(${originalPriority}) `;
|
||||
}
|
||||
|
||||
// Use completion date as creation date for new recurring task
|
||||
newTaskStr += `${completionDate} `;
|
||||
|
||||
newTaskStr += todo.task();
|
||||
|
||||
for (const project of todo.projects()) {
|
||||
newTaskStr += ` +${project}`;
|
||||
}
|
||||
|
||||
for (const context of todo.contexts()) {
|
||||
newTaskStr += ` @${context}`;
|
||||
}
|
||||
|
||||
const newKeyValues = { ...keyValues, due: nextDueDate };
|
||||
for (const [key, value] of Object.entries(newKeyValues)) {
|
||||
newTaskStr += ` ${key}:${value}`;
|
||||
}
|
||||
|
||||
return parseTodo(newTaskStr);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ export function sortTodosByPriority(todos: TodoInterface[], options: SortOptions
|
|||
const { completedTasksLast = false } = options;
|
||||
|
||||
return [...todos].sort((a, b) => {
|
||||
// Handle completed tasks first if option is enabled
|
||||
if (completedTasksLast) {
|
||||
if (a.isDone() !== b.isDone()) {
|
||||
return a.isDone() ? 1 : -1;
|
||||
|
|
@ -15,13 +14,10 @@ export function sortTodosByPriority(todos: TodoInterface[], options: SortOptions
|
|||
const priorityA = a.priority();
|
||||
const priorityB = b.priority();
|
||||
|
||||
// Handle null priorities (tasks without priority go last)
|
||||
if (priorityA === null && priorityB === null) return 0;
|
||||
if (priorityA === null) return 1;
|
||||
if (priorityB === null) return -1;
|
||||
|
||||
// Compare priorities
|
||||
// Numeric priorities come first, then alphabetic
|
||||
const isNumericA = /^\d+$/.test(priorityA);
|
||||
const isNumericB = /^\d+$/.test(priorityB);
|
||||
|
||||
|
|
@ -32,74 +28,56 @@ export function sortTodosByPriority(todos: TodoInterface[], options: SortOptions
|
|||
if (isNumericA && !isNumericB) return -1;
|
||||
if (!isNumericA && isNumericB) return 1;
|
||||
|
||||
// Both are alphabetic
|
||||
return priorityA.localeCompare(priorityB);
|
||||
});
|
||||
}
|
||||
|
||||
export function sortTodosByProject(todos: TodoInterface[], options: SortOptions = {}): TodoInterface[] {
|
||||
function sortTodosByStringArray(
|
||||
todos: TodoInterface[],
|
||||
getStringArray: (todo: TodoInterface) => string[],
|
||||
options: SortOptions = {}
|
||||
): TodoInterface[] {
|
||||
const { caseSensitive = false } = options;
|
||||
|
||||
return [...todos].sort((a, b) => {
|
||||
const projectsA = a.projects();
|
||||
const projectsB = b.projects();
|
||||
const itemsA = getStringArray(a);
|
||||
const itemsB = getStringArray(b);
|
||||
|
||||
// Handle tasks without projects (go last)
|
||||
if (projectsA.length === 0 && projectsB.length === 0) return 0;
|
||||
if (projectsA.length === 0) return 1;
|
||||
if (projectsB.length === 0) return -1;
|
||||
if (itemsA.length === 0 && itemsB.length === 0) return 0;
|
||||
if (itemsA.length === 0) return 1;
|
||||
if (itemsB.length === 0) return -1;
|
||||
|
||||
// Use first project for comparison
|
||||
const projectA = projectsA[0];
|
||||
const projectB = projectsB[0];
|
||||
const itemA = itemsA[0];
|
||||
const itemB = itemsB[0];
|
||||
|
||||
if (caseSensitive) {
|
||||
return projectA.localeCompare(projectB);
|
||||
return itemA.localeCompare(itemB);
|
||||
} else {
|
||||
return projectA.toLowerCase().localeCompare(projectB.toLowerCase());
|
||||
return itemA.toLowerCase().localeCompare(itemB.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sortTodosByProject(todos: TodoInterface[], options: SortOptions = {}): TodoInterface[] {
|
||||
return sortTodosByStringArray(todos, (todo) => todo.projects(), options);
|
||||
}
|
||||
|
||||
export function sortTodosByContext(todos: TodoInterface[], options: SortOptions = {}): TodoInterface[] {
|
||||
const { caseSensitive = false } = options;
|
||||
|
||||
return [...todos].sort((a, b) => {
|
||||
const contextsA = a.contexts();
|
||||
const contextsB = b.contexts();
|
||||
|
||||
// Handle tasks without contexts (go last)
|
||||
if (contextsA.length === 0 && contextsB.length === 0) return 0;
|
||||
if (contextsA.length === 0) return 1;
|
||||
if (contextsB.length === 0) return -1;
|
||||
|
||||
// Use first context for comparison
|
||||
const contextA = contextsA[0];
|
||||
const contextB = contextsB[0];
|
||||
|
||||
if (caseSensitive) {
|
||||
return contextA.localeCompare(contextB);
|
||||
} else {
|
||||
return contextA.toLowerCase().localeCompare(contextB.toLowerCase());
|
||||
}
|
||||
});
|
||||
return sortTodosByStringArray(todos, (todo) => todo.contexts(), options);
|
||||
}
|
||||
|
||||
export function sortTodosByDueDate(todos: TodoInterface[], options: SortOptions = {}): TodoInterface[] {
|
||||
export function sortTodosByDueDate(todos: TodoInterface[]): TodoInterface[] {
|
||||
return [...todos].sort((a, b) => {
|
||||
const dueDateA = a.dueDate();
|
||||
const dueDateB = b.dueDate();
|
||||
|
||||
// Handle tasks without due dates (go last)
|
||||
if (dueDateA === null && dueDateB === null) return 0;
|
||||
if (dueDateA === null) return 1;
|
||||
if (dueDateB === null) return -1;
|
||||
|
||||
// Try to parse dates
|
||||
const dateA = new Date(dueDateA);
|
||||
const dateB = new Date(dueDateB);
|
||||
|
||||
// Handle invalid dates (go after valid dates but before null)
|
||||
const isValidA = !isNaN(dateA.getTime());
|
||||
const isValidB = !isNaN(dateB.getTime());
|
||||
|
||||
|
|
@ -110,7 +88,6 @@ export function sortTodosByDueDate(todos: TodoInterface[], options: SortOptions
|
|||
if (isValidA && !isValidB) return -1;
|
||||
if (!isValidA && isValidB) return 1;
|
||||
|
||||
// Both invalid dates, use string comparison
|
||||
return dueDateA.localeCompare(dueDateB);
|
||||
});
|
||||
}
|
||||
30
src/utils/todotxt-core/string-builder.ts
Normal file
30
src/utils/todotxt-core/string-builder.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { TodoInterface } from './parser';
|
||||
|
||||
export function buildTaskString(task: TodoInterface): string {
|
||||
let taskStr = '';
|
||||
|
||||
if (task.priority()) {
|
||||
taskStr += `(${task.priority()}) `;
|
||||
}
|
||||
|
||||
if (task.creationDate()) {
|
||||
taskStr += `${task.creationDate()} `;
|
||||
}
|
||||
|
||||
taskStr += task.task();
|
||||
|
||||
for (const project of task.projects()) {
|
||||
taskStr += ` +${project}`;
|
||||
}
|
||||
|
||||
for (const context of task.contexts()) {
|
||||
taskStr += ` @${context}`;
|
||||
}
|
||||
|
||||
const keyValues = task.keyValues();
|
||||
for (const [key, value] of Object.entries(keyValues)) {
|
||||
taskStr += ` ${key}:${value}`;
|
||||
}
|
||||
|
||||
return taskStr;
|
||||
}
|
||||
51
style.md
Normal file
51
style.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Style Settings 問題調査結果
|
||||
|
||||
## 問題
|
||||
macOSでは表示されるStyle Settingsの設定が、iOS、Windows、Androidで表示されない。
|
||||
|
||||
## 原因分析
|
||||
|
||||
### 1. Style Settings統合方法の問題
|
||||
- 現在の実装:styles.css内に`@settings`コメントブロックを配置
|
||||
- 問題点:この方法はStyle Settingsプラグインの正式な統合方法ではない
|
||||
- Style Settingsは通常、以下のいずれかの方法で統合される:
|
||||
- テーマの場合:theme.css内の`@settings`ブロック
|
||||
- プラグインの場合:CSS変数の動的設定またはstyle-settings.yamlファイル
|
||||
|
||||
### 2. モバイルプラットフォームでの制限
|
||||
- モバイル版Obsidianでは、CSS内の`@settings`コメントブロックの解析が制限される可能性
|
||||
- Style Settingsプラグインがモバイルでプラグインのstyles.cssを正しく読み込めない
|
||||
|
||||
### 3. 現在の実装の不整合
|
||||
- settings.ts:色設定の保存機能はあるが、UIで色選択ができない(トグルのみ)
|
||||
- CSS変数の適用:保存された色設定をCSS変数として適用するコードが存在しない
|
||||
- styles.css:CSS変数を参照しているが、これらの変数が設定されていない
|
||||
|
||||
## 解決策
|
||||
|
||||
### 方法1:CSS変数の動的設定(推奨)
|
||||
```typescript
|
||||
// main.tsに追加
|
||||
updateCSSVariables() {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--todo-txt-mode-project-color', this.settings.projectColor);
|
||||
root.style.setProperty('--todo-txt-mode-context-color', this.settings.contextColor);
|
||||
// 他の色設定も同様に...
|
||||
}
|
||||
```
|
||||
|
||||
### 方法2:Style Settings依存の削除
|
||||
- 独自の色選択UIを実装
|
||||
- settings.tsに色選択用のカラーピッカーを追加
|
||||
- すべてのプラットフォームで一貫した動作を保証
|
||||
|
||||
## 影響範囲
|
||||
- Mac:現状でも動作しているが、実際には色のカスタマイズはできない
|
||||
- iOS/Windows/Android:Style Settings統合が表示されない
|
||||
- 全プラットフォーム:色のカスタマイズ機能が実質的に使用できない
|
||||
|
||||
## 推奨アクション
|
||||
1. styles.cssから`@settings`ブロックを削除
|
||||
2. main.tsでCSS変数を動的に設定
|
||||
3. settings.tsに色選択UIを追加
|
||||
4. 全プラットフォームでテスト
|
||||
35
styles.css
35
styles.css
|
|
@ -42,14 +42,21 @@ settings:
|
|||
description: Color for completion dates in completed tasks (x 2011-03-02)
|
||||
type: variable-color
|
||||
format: hex
|
||||
default: '#FF9800'
|
||||
default: '#E6CC80'
|
||||
|
||||
- id: todo-txt-mode-creation-date-color
|
||||
title: Creation Date Color
|
||||
description: Color for creation dates (2011-03-01 Task or x date1 2011-03-01 Task)
|
||||
type: variable-color
|
||||
format: hex
|
||||
default: '#9C27B0'
|
||||
default: '#C8A2C8'
|
||||
|
||||
- id: todo-txt-mode-recurring-task-color
|
||||
title: Recurring Task Color
|
||||
description: Color for recurring tasks (rec:value)
|
||||
type: variable-color
|
||||
format: hex
|
||||
default: '#607D8B'
|
||||
*/
|
||||
|
||||
/* Todo.txt Mode スタイル定義 */
|
||||
|
|
@ -89,22 +96,24 @@ settings:
|
|||
|
||||
/* 完了日(x 2011-03-02) */
|
||||
.todo-txt-mode-completion-date {
|
||||
color: var(--todo-txt-mode-completion-date-color, #FF9800);
|
||||
font-size: 90%;
|
||||
font-weight: 600;
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--todo-txt-mode-completion-date-color, #E6CC80);
|
||||
font-size: 85%;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 作成日(2011-03-01 Task or x date1 2011-03-01 Task) */
|
||||
.todo-txt-mode-creation-date {
|
||||
color: var(--todo-txt-mode-creation-date-color, #9C27B0);
|
||||
color: var(--todo-txt-mode-creation-date-color, #C8A2C8);
|
||||
font-size: 85%;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 繰り返しタスク(rec:value) */
|
||||
.todo-txt-mode-recurring-task {
|
||||
color: var(--todo-txt-mode-recurring-task-color, #607D8B);
|
||||
font-size: 90%;
|
||||
font-weight: 600;
|
||||
background-color: rgba(156, 39, 176, 0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
text-decoration: underline;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ファイル識別のためのマーカー */
|
||||
|
|
|
|||
165
todo.txt
165
todo.txt
|
|
@ -1,165 +0,0 @@
|
|||
Simple task without any parameters
|
||||
(A) Priority A task
|
||||
(B) Priority B task
|
||||
(C) Priority C task
|
||||
(Z) Priority Z task
|
||||
(123) Numeric priority task
|
||||
(1) Single digit priority task
|
||||
(999) Three digit priority task
|
||||
2025-01-15 Task with creation date only
|
||||
(A) 2025-01-15 Priority A with creation date
|
||||
(B) 2025-01-15 Priority B with creation date
|
||||
(123) 2025-01-15 Numeric priority with creation date
|
||||
Task with +project
|
||||
Task with +MultiWordProject
|
||||
Task with multiple +project1 +project2 +project3
|
||||
Task with @context
|
||||
Task with @home @phone multiple contexts
|
||||
Task with @MultiWordContext
|
||||
Task with key:value pair due:2025-12-31
|
||||
Task with multiple pairs due:2025-12-31 rec:1w
|
||||
2025-01-15 Creation date with +project
|
||||
2025-01-15 Creation date with @context
|
||||
2025-01-15 Creation date with due:2025-12-31
|
||||
(A) Task with priority and +project
|
||||
(A) Task with priority and @context
|
||||
(A) Task with priority and due:2025-12-31
|
||||
(A) 2025-01-15 Priority, creation date, and +project
|
||||
(A) 2025-01-15 Priority, creation date, and @context
|
||||
(A) 2025-01-15 Priority, creation date, and due:2025-12-31
|
||||
Task with +project @context
|
||||
Task with +project due:2025-12-31
|
||||
Task with @context due:2025-12-31
|
||||
Task with +project @context due:2025-12-31
|
||||
2025-01-15 Task with date +project @context
|
||||
2025-01-15 Task with date +project due:2025-12-31
|
||||
2025-01-15 Task with date @context due:2025-12-31
|
||||
2025-01-15 Task with date +project @context due:2025-12-31
|
||||
(A) Task with priority +project @context
|
||||
(A) Task with priority +project due:2025-12-31
|
||||
(A) Task with priority @context due:2025-12-31
|
||||
(A) Task with priority +project @context due:2025-12-31
|
||||
(A) 2025-01-15 All parameters +project @context due:2025-12-31
|
||||
(B) 2025-01-14 Different priority and date +project2 @home due:2025-11-30
|
||||
(123) 2025-01-13 Numeric priority all params +projectX @work due:2025-10-31
|
||||
x Simple completed task
|
||||
x 2025-01-16 Completed task with completion date
|
||||
x 2025-01-16 2025-01-15 Completed task with completion and creation dates
|
||||
x 2025-01-16 Completed task with +project
|
||||
x 2025-01-16 Completed task with @context
|
||||
x 2025-01-16 Completed task with due:2025-12-31
|
||||
x 2025-01-16 2025-01-15 Completed with dates and +project
|
||||
x 2025-01-16 2025-01-15 Completed with dates and @context
|
||||
x 2025-01-16 2025-01-15 Completed with dates and due:2025-12-31
|
||||
x 2025-01-16 Completed with +project @context
|
||||
x 2025-01-16 Completed with +project due:2025-12-31
|
||||
x 2025-01-16 Completed with @context due:2025-12-31
|
||||
x 2025-01-16 Completed with +project @context due:2025-12-31
|
||||
x 2025-01-16 2025-01-15 Completed all params +project @context due:2025-12-31
|
||||
+project Task starting with project tag
|
||||
@context Task starting with context tag
|
||||
due:2025-12-31 Task starting with key value
|
||||
Task with special chars in +project-name @context_name
|
||||
Task with numbers in +project123 @context456
|
||||
Task with dots in +project.name @context.place
|
||||
Complex task with +project1 +project2 @home @work due:2025-12-31 rec:1m pri:H
|
||||
(1a) Mixed alphanumeric priority task
|
||||
(A1) Letter first mixed priority task
|
||||
Task with leading spaces
|
||||
Task with trailing spaces
|
||||
(A) 2025-01-15 Task with leading spaces and params
|
||||
Task with time due:2025-12-31T14:30:00
|
||||
Multiple same contexts @home @home @home
|
||||
Multiple same projects +projectA +projectA +projectA
|
||||
rec:+1w Recurrence with plus sign
|
||||
Custom key custom:value in task
|
||||
Multiple custom keys foo:bar baz:qux in task
|
||||
Task with URL https://example.com/path
|
||||
Task with email user@example.com might have @ but not context
|
||||
(0) Zero priority task
|
||||
(ABC) Multi-letter priority task
|
||||
(999999) Very large numeric priority
|
||||
Task with unicode émojis 🎯 and special chars
|
||||
@context+project Wrong order but both present
|
||||
t:2025-01-15 Short key for date
|
||||
p:1 Short key for priority
|
||||
x 2025-01-16 Completed with double space
|
||||
x 2025-01-16 2025-01-15 Double space between dates
|
||||
(A) 2025-01-15 Double space after priority
|
||||
Task with +CamelCaseProject @PascalCaseContext
|
||||
Task with +snake_case_project @kebab-case-context
|
||||
+123project Project starting with numbers
|
||||
@123context Context starting with numbers
|
||||
Task with key:value:with:colons might break parser
|
||||
Escaped special chars \+not-project \@not-context
|
||||
Task with +über @café unicode in tags
|
||||
+project@email.com Might confuse parser
|
||||
Task with (parentheses) in description not priority
|
||||
Task with [brackets] and {braces} in description
|
||||
due:2025-12-31 @context Order matters for key:value
|
||||
@context due:2025-12-31 Different order same elements
|
||||
Long task description that contains many words and might wrap in some views but should still parse correctly with +project @context due:2025-12-31
|
||||
Task with emoji project +🚀rocket +📱mobile +🎯target
|
||||
Task with emoji context @🏠home @💼work @🛒shopping
|
||||
Task with emoji in description 🎉 Complete the feature 🚀 +dev @office
|
||||
(A) 2025-01-15 Priority task with emojis 🔥 +🚀deploy @🏠home due:2025-12-31
|
||||
x 2025-01-16 Completed emoji task 🎉 +🚀project @🏠home
|
||||
Mixed emoji and text +emoji🎯project @context🔥fire
|
||||
Multiple emojis in one tag +🚀🎯🔥 @🏠💼🛒
|
||||
Emoji with skin tone +👋🏻project @👨💻dev
|
||||
Complex emoji +👨👩👧👦family @🏳️🌈pride
|
||||
Zero-width joiner emoji +🧑🚀astronaut @👨⚕️doctor
|
||||
Task with Japanese text タスク +プロジェクト @コンテキスト
|
||||
Task with Chinese 任务 +项目 @上下文
|
||||
Task with Korean 작업 +프로젝트 @컨텍스트
|
||||
Task with Arabic مهمة +مشروع @سياق
|
||||
Task with Hebrew משימה +פרויקט @הקשר
|
||||
Task with Cyrillic задача +проект @контекст
|
||||
Task with mixed scripts +日本語project @中文context
|
||||
RTL text עברית task with +project @context due:2025-12-31
|
||||
Combining marks task̃ +projec̈t @conte͂xt
|
||||
Task with !@#$%^&*() special chars in description
|
||||
Task with +project!name @context#tag
|
||||
Task with +pro-ject_name @con.text_name
|
||||
Task with +project/subproject @context\subcontext
|
||||
Task with +project|pipe @context&and
|
||||
Task with quotes "quoted +project" and 'single @quotes'
|
||||
Task with backticks `code +example` @context
|
||||
Task with brackets [not a link] +project
|
||||
Task with angle brackets <not html> @context
|
||||
Task with curly braces {not json} +project
|
||||
Task with backslash\\ in middle
|
||||
Task with zerowidthspace between words
|
||||
Task with softhyphen in word
|
||||
(A) 2025-01-15 Task +project1 +project2 +project3 @context1 @context2 @context3 due:2025-12-31 rec:1w pri:H custom:value foo:bar
|
||||
x (B) 2025-01-16 2025-01-15 Complete +🚀 +📱 @🏠 @💼 due:2025-12-31 done:2025-01-16
|
||||
(123456789) Very long numeric priority +project
|
||||
(A1B2C3) Complex alphanumeric priority @context
|
||||
Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task
|
||||
Task with very long project name +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers
|
||||
Task with many projects +p1 +p2 +p3 +p4 +p5 +p6 +p7 +p8 +p9 +p10 +p11 +p12 +p13 +p14 +p15 +p16 +p17 +p18 +p19 +p20
|
||||
Task with many contexts @c1 @c2 @c3 @c4 @c5 @c6 @c7 @c8 @c9 @c10 @c11 @c12 @c13 @c14 @c15 @c16 @c17 @c18 @c19 @c20
|
||||
Task with many key:value pairs a:1 b:2 c:3 d:4 e:5 f:6 g:7 h:8 i:9 j:10 k:11 l:12 m:13 n:14 o:15
|
||||
Extremely long task description that goes on and on and on with many words to test how the parser handles very long lines that might exceed buffer limits in some implementations and includes +project @context due:2025-12-31 and continues even further with more text
|
||||
+project Task with only project no description after
|
||||
@context Task with only context no description after
|
||||
due:2025-12-31 Task with only key:value no description after
|
||||
(A) Priority only no task description
|
||||
x Completed marker only no description
|
||||
x 2025-01-16 Completed with date only no description
|
||||
2025-01-15 Date only no description
|
||||
Task with HTML tags <b>bold</b> text
|
||||
Task with & HTML entity & test
|
||||
Task with < and > comparison operators
|
||||
Task with && and || logical operators
|
||||
Task with -> arrow notation
|
||||
Task with => fat arrow
|
||||
Task with ... ellipsis
|
||||
Task with file.extension.txt mentions
|
||||
Task with https://example.com/+project/@context/page
|
||||
Heavily indented task with many spaces
|
||||
Task with tab indentation
|
||||
Mixed spaces and tabs indentation
|
||||
Task with trailing newline
|
||||
(A) No space after priority works as valid format
|
||||
Task without trailing newline
|
||||
|
|
@ -16,7 +16,8 @@
|
|||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
],
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
|
|
|
|||
Loading…
Reference in a new issue