diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1fe14fe --- /dev/null +++ b/.eslintrc.json @@ -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" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9bc5f59..465af9f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ data.json .DS_Store **/.claude/settings.local.json + +coverage diff --git a/CLAUDE.md b/CLAUDE.md index 44b65af..d78bd72 100644 --- a/CLAUDE.md +++ b/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使用とリソース管理 \ No newline at end of file +4. **余計なコメント不要**: コードを読んで理解できない特別な意味を持つ箇所以外はコメント不要 + +## 重要なテスト対象機能 +- **繰り返しタスク機能**: `rec:d`、`rec:w`、`rec:m`、`rec:y`形式の処理 +- **構文ハイライト**: CodeMirror ViewPluginの装飾適用 +- **タスク監視**: ファイル変更の検知と処理 \ No newline at end of file diff --git a/README.md b/README.md index cceffa0..47391ad 100644 --- a/README.md +++ b/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)をご覧ください。 diff --git a/TODOTXT.md b/TODOTXT.md new file mode 100644 index 0000000..a5e68b5 --- /dev/null +++ b/TODOTXT.md @@ -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. **シンプルさを保つ** - 必要な情報だけを記述し、過度に複雑にしない diff --git a/jest.config.js b/jest.config.js index 80db71d..5f6db66 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ module.exports = { preset: 'ts-jest', - testEnvironment: 'node', + testEnvironment: 'jsdom', roots: ['/src'], testMatch: ['**/__tests__/**/*.test.ts'], transform: { diff --git a/no-todo.txt b/no-todo.txt deleted file mode 100644 index 34a357b..0000000 --- a/no-todo.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 03eafc4..b95d6e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-todo-txt-mode", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-todo-txt-mode", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "devDependencies": { "@types/jest": "^30.0.0", @@ -15,7 +15,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", @@ -957,7 +959,6 @@ "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -977,7 +978,6 @@ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -988,7 +988,6 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1013,7 +1012,6 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1025,7 +1023,6 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1041,7 +1038,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1056,8 +1052,7 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -2134,6 +2129,16 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2244,6 +2249,18 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2275,6 +2292,13 @@ "@types/estree": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2326,6 +2350,55 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.29.0.tgz", + "integrity": "sha512-3Eos6uP1nyLOBayc/VUdKZikV90HahXE5Dx9L5YlSd/7ylQPXhLk1BYb29SDgnBnTp+jmSZUU0QxUiyHgW4p7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.29.0", + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/typescript-estree": "5.29.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@typescript-eslint/parser": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz", @@ -2399,6 +2472,55 @@ } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.29.0.tgz", + "integrity": "sha512-3Eos6uP1nyLOBayc/VUdKZikV90HahXE5Dx9L5YlSd/7ylQPXhLk1BYb29SDgnBnTp+jmSZUU0QxUiyHgW4p7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.29.0", + "@typescript-eslint/types": "5.29.0", + "@typescript-eslint/typescript-estree": "5.29.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@typescript-eslint/types": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.29.0.tgz", @@ -2441,31 +2563,6 @@ } } }, - "node_modules/@typescript-eslint/utils": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.29.0.tgz", - "integrity": "sha512-3Eos6uP1nyLOBayc/VUdKZikV90HahXE5Dx9L5YlSd/7ylQPXhLk1BYb29SDgnBnTp+jmSZUU0QxUiyHgW4p7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "@typescript-eslint/scope-manager": "5.29.0", - "@typescript-eslint/types": "5.29.0", - "@typescript-eslint/typescript-estree": "5.29.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.29.0.tgz", @@ -2489,8 +2586,15 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/acorn": { "version": "8.15.0", @@ -2498,7 +2602,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2506,24 +2609,59 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2609,8 +2747,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/array-union": { "version": "2.1.0", @@ -2629,6 +2766,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2862,6 +3006,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3006,6 +3164,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3083,6 +3254,48 @@ "node": ">= 8" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3101,6 +3314,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", @@ -3121,8 +3341,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -3134,6 +3353,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3173,7 +3402,6 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -3181,6 +3409,35 @@ "node": ">=6.0.0" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3224,6 +3481,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3234,6 +3504,55 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", @@ -3288,7 +3607,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3296,6 +3614,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -3303,7 +3643,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3355,17 +3694,20 @@ } }, "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=8.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-utils": { @@ -3410,42 +3752,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -3478,7 +3790,6 @@ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -3486,17 +3797,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -3510,7 +3810,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -3520,23 +3820,12 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3597,8 +3886,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -3642,8 +3930,7 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.19.1", @@ -3671,7 +3958,6 @@ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -3731,7 +4017,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -3749,7 +4034,6 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -3764,8 +4048,24 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/fs.realpath": { "version": "1.0.0", @@ -3826,6 +4126,31 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3836,6 +4161,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3877,7 +4216,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -3891,7 +4229,6 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -3923,6 +4260,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3935,8 +4285,7 @@ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/has-flag": { "version": "4.0.0", @@ -3948,6 +4297,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3961,6 +4339,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3968,6 +4359,35 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3978,6 +4398,19 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3994,7 +4427,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -4137,11 +4569,17 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4679,6 +5117,67 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -5660,7 +6159,6 @@ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5668,6 +6166,52 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5686,8 +6230,7 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -5701,16 +6244,14 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -5731,7 +6272,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -5762,7 +6302,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -5784,7 +6323,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -5807,8 +6345,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -5853,6 +6390,16 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5884,6 +6431,29 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5968,6 +6538,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/obsidian": { "version": "1.8.7", "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", @@ -6015,7 +6592,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -6050,7 +6626,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -6077,7 +6652,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -6104,6 +6678,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6256,7 +6843,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -6323,13 +6909,25 @@ "node": ">= 6" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6351,6 +6949,13 @@ ], "license": "MIT" }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6402,6 +7007,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6452,7 +7064,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -6485,7 +7096,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6520,6 +7130,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -6740,6 +7370,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6760,8 +7397,7 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -6783,6 +7419,35 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ts-jest": { "version": "29.4.0", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", @@ -6885,7 +7550,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -6909,7 +7573,6 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -6931,6 +7594,16 @@ "node": ">=4.2.0" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6968,11 +7641,21 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -6996,6 +7679,19 @@ "license": "MIT", "peer": true }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -7006,6 +7702,53 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7028,7 +7771,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7072,6 +7814,45 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index b1955e3..ab1fe90 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/remove-test-comments.js b/remove-test-comments.js new file mode 100644 index 0000000..ee4baec --- /dev/null +++ b/remove-test-comments.js @@ -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 }; \ No newline at end of file diff --git a/src/__tests__/syntax.test.ts b/src/__tests__/syntax.test.ts deleted file mode 100644 index cbe2656..0000000 --- a/src/__tests__/syntax.test.ts +++ /dev/null @@ -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'); - }); - }); -}); \ No newline at end of file diff --git a/src/__tests__/task-watcher.test.ts b/src/__tests__/task-watcher.test.ts new file mode 100644 index 0000000..f52a663 --- /dev/null +++ b/src/__tests__/task-watcher.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 41abbaf..5b8f325 100644 --- a/src/main.ts +++ b/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 }; moveCompletedTasks: () => Promise; 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() { diff --git a/src/movetasks.ts b/src/movetasks.ts index 7422abd..e0fa1ec 100644 --- a/src/movetasks.ts +++ b/src/movetasks.ts @@ -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; }); } diff --git a/src/settings.ts b/src/settings.ts index c2b612f..abf752f 100644 --- a/src/settings.ts +++ b/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'); diff --git a/src/sort.ts b/src/sort.ts index 004a508..cd7b368 100644 --- a/src/sort.ts +++ b/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 + }; } \ No newline at end of file diff --git a/src/syntax.ts b/src/syntax.ts index f5330a2..27fe62c 100644 --- a/src/syntax.ts +++ b/src/syntax.ts @@ -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" } diff --git a/src/task-watcher.ts b/src/task-watcher.ts new file mode 100644 index 0000000..42f638c --- /dev/null +++ b/src/task-watcher.ts @@ -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(); + 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, + 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 + } + }); +} \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/completion-date.test.ts b/src/utils/todotxt-core/__tests__/completion-date.test.ts new file mode 100644 index 0000000..274b3cc --- /dev/null +++ b/src/utils/todotxt-core/__tests__/completion-date.test.ts @@ -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$'); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-basic.test.ts b/src/utils/todotxt-core/__tests__/parser-basic.test.ts new file mode 100644 index 0000000..a8f0f14 --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-basic.test.ts @@ -0,0 +1,81 @@ +import { parseTodo } from '../parser'; + +describe('Todo.txt Parser - Basic Functionality', () => { + const expectBasicTaskProperties = (todo: ReturnType, 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); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-dates.test.ts b/src/utils/todotxt-core/__tests__/parser-dates.test.ts new file mode 100644 index 0000000..a29024b --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-dates.test.ts @@ -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); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-keyvalues.test.ts b/src/utils/todotxt-core/__tests__/parser-keyvalues.test.ts new file mode 100644 index 0000000..f937152 --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-keyvalues.test.ts @@ -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); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-positions.test.ts b/src/utils/todotxt-core/__tests__/parser-positions.test.ts new file mode 100644 index 0000000..c3bd663 --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-positions.test.ts @@ -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)); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-priority.test.ts b/src/utils/todotxt-core/__tests__/parser-priority.test.ts new file mode 100644 index 0000000..091cb6a --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-priority.test.ts @@ -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); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser-tags.test.ts b/src/utils/todotxt-core/__tests__/parser-tags.test.ts new file mode 100644 index 0000000..154526f --- /dev/null +++ b/src/utils/todotxt-core/__tests__/parser-tags.test.ts @@ -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); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/parser.test.ts b/src/utils/todotxt-core/__tests__/parser.test.ts index 418de02..6913999 100644 --- a/src/utils/todotxt-core/__tests__/parser.test.ts +++ b/src/utils/todotxt-core/__tests__/parser.test.ts @@ -1,1492 +1,239 @@ -import { parseTodo } from '../parser'; - -describe('Todo Interface Factory Pattern', () => { - describe('Basic functionality', () => { - test('simple task without any parameters', () => { - const todo = parseTodo('Simple task without any parameters'); - - expect(todo.task()).toBe('Simple task without any parameters'); - expect(todo.isDone()).toBe(false); - 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(); +import { parseTodo, isCompletedTask, isDueDateKeyValue, isRecurrenceKeyValue, isCommentLine } from '../parser'; +describe('Todo.txt Parser - Integration Tests', () => { + describe('Factory Pattern Behavior', () => { + it('returns TodoInterface with all required methods', () => { + const todo = parseTodo('Test task'); + expect(typeof todo.task).toBe('function'); + expect(typeof todo.isDone).toBe('function'); + expect(typeof todo.priority).toBe('function'); + expect(typeof todo.creationDate).toBe('function'); + expect(typeof todo.completionDate).toBe('function'); + expect(typeof todo.projects).toBe('function'); + expect(typeof todo.contexts).toBe('function'); + expect(typeof todo.keyValues).toBe('function'); + expect(typeof todo.dueDate).toBe('function'); + expect(typeof todo.getElementPositions).toBe('function'); }); - - test('completed task', () => { - const todo = parseTodo('x Simple completed task'); - - expect(todo.task()).toBe('Simple completed task'); - expect(todo.isDone()).toBe(true); - 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(); + it('creates immutable todo objects', () => { + const todoText = 'Task with +project @context'; + const todo1 = parseTodo(todoText); + const todo2 = parseTodo(todoText); + expect(todo1).not.toBe(todo2); + expect(todo1.task()).toBe(todo2.task()); + expect(todo1.projects()).toEqual(todo2.projects()); + expect(todo1.contexts()).toEqual(todo2.contexts()); }); - - test('task with priority', () => { - const todo = parseTodo('(A) Priority A task'); - - expect(todo.task()).toBe('Priority A task'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with numeric priority', () => { - const todo = parseTodo('(123) Numeric priority task'); - - expect(todo.task()).toBe('Numeric priority task'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBe('123'); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with creation date', () => { - const todo = parseTodo('2025-01-15 Task with creation date only'); - - expect(todo.task()).toBe('Task with creation date only'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('completed task with completion and creation dates', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed task with completion and creation dates'); - - expect(todo.task()).toBe('Completed task with completion and creation dates'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with project', () => { - const todo = parseTodo('Task with +project'); - - expect(todo.task()).toBe('Task with'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with multiple projects', () => { - const todo = parseTodo('Task with multiple +project1 +project2 +project3'); - - expect(todo.task()).toBe('Task with multiple'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual(['project1', 'project2', 'project3']); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with context', () => { - const todo = parseTodo('Task with @context'); - - expect(todo.task()).toBe('Task with'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with multiple contexts', () => { - const todo = parseTodo('Task with @home @phone multiple contexts'); - - expect(todo.task()).toBe('Task with multiple contexts'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual(['home', 'phone']); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('task with key:value pair', () => { - const todo = parseTodo('Task with key:value pair due:2025-12-31'); - - expect(todo.task()).toBe('Task with pair'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({ key: 'value', due: '2025-12-31' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('task with multiple key:value pairs', () => { - const todo = parseTodo('Task with multiple pairs due:2025-12-31 rec:1w'); - - expect(todo.task()).toBe('Task with multiple pairs'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({ due: '2025-12-31', rec: '1w' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('complex example from user request', () => { - const todo = parseTodo('x 2024-01-10 2024-01-05 Renew gym membership +fitness'); - - expect(todo.task()).toBe('Renew gym membership'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2024-01-05'); - expect(todo.completionDate()).toBe('2024-01-10'); - expect(todo.projects()).toEqual(['fitness']); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('all parameters example', () => { - const todo = parseTodo('(A) 2025-01-15 All parameters +project @context due:2025-12-31'); - - expect(todo.task()).toBe('All parameters'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('completed all parameters example', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed all params +project @context due:2025-12-31'); - - expect(todo.task()).toBe('Completed all params'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31' }); + it('ensures dueDate method consistency with keyValues', () => { + const todo = parseTodo('Task due:2025-12-31'); + expect(todo.dueDate()).toBe(todo.keyValues().due); expect(todo.dueDate()).toBe('2025-12-31'); }); }); - - describe('Todo.txt samples', () => { - test('sample 1: Simple task without any parameters', () => { - const todo = parseTodo('Simple task without any parameters'); - expect(todo.task()).toBe('Simple task without any parameters'); - expect(todo.isDone()).toBe(false); - 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(); - }); - - test('sample 2: (A) Priority A task', () => { - const todo = parseTodo('(A) Priority A task'); - expect(todo.task()).toBe('Priority A task'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - expect(todo.keyValues()).toEqual({}); - expect(todo.dueDate()).toBeNull(); - }); - - test('sample 6: (123) Numeric priority task', () => { - const todo = parseTodo('(123) Numeric priority task'); - expect(todo.task()).toBe('Numeric priority task'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBe('123'); - }); - - test('sample 9: 2025-01-15 Task with creation date only', () => { - const todo = parseTodo('2025-01-15 Task with creation date only'); - expect(todo.task()).toBe('Task with creation date only'); - expect(todo.isDone()).toBe(false); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBeNull(); - }); - - test('sample 13: Task with +project', () => { - const todo = parseTodo('Task with +project'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 15: Task with multiple +project1 +project2 +project3', () => { - const todo = parseTodo('Task with multiple +project1 +project2 +project3'); - expect(todo.task()).toBe('Task with multiple'); - expect(todo.projects()).toEqual(['project1', 'project2', 'project3']); - }); - - test('sample 16: Task with @context', () => { - const todo = parseTodo('Task with @context'); - expect(todo.task()).toBe('Task with'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 17: Task with @home @phone multiple contexts', () => { - const todo = parseTodo('Task with @home @phone multiple contexts'); - expect(todo.task()).toBe('Task with multiple contexts'); - expect(todo.contexts()).toEqual(['home', 'phone']); - }); - - test('sample 19: Task with key:value pair due:2025-12-31', () => { - const todo = parseTodo('Task with key:value pair due:2025-12-31'); - expect(todo.task()).toBe('Task with pair'); - expect(todo.keyValues()).toEqual({ key: 'value', due: '2025-12-31' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 20: Task with multiple pairs due:2025-12-31 rec:1w', () => { - const todo = parseTodo('Task with multiple pairs due:2025-12-31 rec:1w'); - expect(todo.task()).toBe('Task with multiple pairs'); - expect(todo.keyValues()).toEqual({ due: '2025-12-31', rec: '1w' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 42: (A) 2025-01-15 All parameters +project @context due:2025-12-31', () => { - const todo = parseTodo('(A) 2025-01-15 All parameters +project @context due:2025-12-31'); - expect(todo.task()).toBe('All parameters'); - expect(todo.isDone()).toBe(false); + describe('Complex Integration Patterns', () => { + it('parses task with all possible elements', () => { + const todoText = '(A) 2025-01-15 Complex task +project1 +project2 @context1 @context2 due:2025-12-31 rec:1w note:test'; + const todo = parseTodo(todoText); expect(todo.priority()).toBe('A'); expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBeNull(); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 45: x Simple completed task', () => { - const todo = parseTodo('x Simple completed task'); - expect(todo.task()).toBe('Simple completed task'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBeNull(); - expect(todo.completionDate()).toBeNull(); - }); - - test('sample 47: x 2025-01-16 2025-01-15 Completed task with completion and creation dates', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed task with completion and creation dates'); - expect(todo.task()).toBe('Completed task with completion and creation dates'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBe('2025-01-16'); - }); - - test('sample 58: x 2025-01-16 2025-01-15 Completed all params +project @context due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed all params +project @context due:2025-12-31'); - expect(todo.task()).toBe('Completed all params'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 65: Complex task with +project1 +project2 @home @work due:2025-12-31 rec:1m pri:H', () => { - const todo = parseTodo('Complex task with +project1 +project2 @home @work due:2025-12-31 rec:1m pri:H'); - expect(todo.task()).toBe('Complex task with'); + expect(todo.task()).toBe('Complex task'); expect(todo.projects()).toEqual(['project1', 'project2']); - expect(todo.contexts()).toEqual(['home', 'work']); - expect(todo.keyValues()).toEqual({ - due: '2025-12-31', - rec: '1m', - pri: 'H' - }); + expect(todo.contexts()).toEqual(['context1', 'context2']); expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 102: Task with emoji project +🚀rocket +📱mobile +🎯target', () => { - const todo = parseTodo('Task with emoji project +🚀rocket +📱mobile +🎯target'); - expect(todo.task()).toBe('Task with emoji project'); - expect(todo.projects()).toEqual(['🚀rocket', '📱mobile', '🎯target']); - }); - - test('sample 140: Task with many projects +p1 +p2 +p3 +p4 +p5 +p6 +p7 +p8 +p9 +p10 +p11 +p12 +p13 +p14 +p15 +p16 +p17 +p18 +p19 +p20', () => { - const todo = parseTodo('Task with many projects +p1 +p2 +p3 +p4 +p5 +p6 +p7 +p8 +p9 +p10 +p11 +p12 +p13 +p14 +p15 +p16 +p17 +p18 +p19 +p20'); - expect(todo.task()).toBe('Task with many projects'); - expect(todo.projects()).toHaveLength(20); - expect(todo.projects()).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9', 'p10', 'p11', 'p12', 'p13', 'p14', 'p15', 'p16', 'p17', 'p18', 'p19', 'p20']); - }); - - test('sample 141: Task with many contexts @c1 @c2 @c3 @c4 @c5 @c6 @c7 @c8 @c9 @c10 @c11 @c12 @c13 @c14 @c15 @c16 @c17 @c18 @c19 @c20', () => { - const todo = parseTodo('Task with many contexts @c1 @c2 @c3 @c4 @c5 @c6 @c7 @c8 @c9 @c10 @c11 @c12 @c13 @c14 @c15 @c16 @c17 @c18 @c19 @c20'); - expect(todo.task()).toBe('Task with many contexts'); - expect(todo.contexts()).toHaveLength(20); - expect(todo.contexts()).toEqual(['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'c10', 'c11', 'c12', 'c13', 'c14', 'c15', 'c16', 'c17', 'c18', 'c19', 'c20']); - }); - - test('sample 142: 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', () => { - const todo = parseTodo('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'); - expect(todo.task()).toBe('Task with many pairs'); - expect(todo.keyValues()).toEqual({ - key: 'value', - 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' - }); - }); - - test('sample 76: Multiple custom keys foo:bar baz:qux in task', () => { - const todo = parseTodo('Multiple custom keys foo:bar baz:qux in task'); - expect(todo.task()).toBe('Multiple custom keys in task'); - expect(todo.keyValues()).toEqual({ foo: 'bar', baz: 'qux' }); - }); - - test('sample 95: Task with +über @café unicode in tags', () => { - const todo = parseTodo('Task with +über @café unicode in tags'); - expect(todo.task()).toBe('Task with unicode in tags'); - expect(todo.projects()).toEqual(['über']); - expect(todo.contexts()).toEqual(['café']); - }); - - test('sample 147: (A) Priority only no task description', () => { - const todo = parseTodo('(A) Priority only no task description'); - expect(todo.task()).toBe('Priority only no task description'); - expect(todo.priority()).toBe('A'); - }); - - test('sample 148: x Completed marker only no description', () => { - const todo = parseTodo('x Completed marker only no description'); - expect(todo.task()).toBe('Completed marker only no description'); - expect(todo.isDone()).toBe(true); - }); - - test('sample 150: 2025-01-15 Date only no description', () => { - const todo = parseTodo('2025-01-15 Date only no description'); - expect(todo.task()).toBe('Date only no description'); - expect(todo.creationDate()).toBe('2025-01-15'); - }); - - test('sample 69: Task with time due:2025-12-31T14:30:00', () => { - const todo = parseTodo('Task with time due:2025-12-31T14:30:00'); - expect(todo.task()).toBe('Task with time'); - expect(todo.keyValues()).toEqual({ due: '2025-12-31T14:30:00' }); - expect(todo.dueDate()).toBe('2025-12-31T14:30:00'); - }); - - test('sample 93: Task with key:value:with:colons might break parser', () => { - const todo = parseTodo('Task with key:value:with:colons might break parser'); - expect(todo.task()).toBe('Task with might break parser'); - expect(todo.keyValues()).toEqual({ key: 'value:with:colons' }); - }); - - test('sample 78: Task with email user@example.com might have @ but not context', () => { - const todo = parseTodo('Task with email user@example.com might have @ but not context'); - expect(todo.task()).toBe('Task with email user@example.com might have @ but not context'); - expect(todo.contexts()).toEqual([]); - }); - - test('sample 135: x (B) 2025-01-16 2025-01-15 Complete +🚀 +📱 @🏠 @💼 due:2025-12-31 done:2025-01-16', () => { - const todo = parseTodo('x (B) 2025-01-16 2025-01-15 Complete +🚀 +📱 @🏠 @💼 due:2025-12-31 done:2025-01-16'); - expect(todo.task()).toBe('Complete'); - expect(todo.isDone()).toBe(true); - expect(todo.priority()).toBeNull(); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.projects()).toEqual(['🚀', '📱']); - expect(todo.contexts()).toEqual(['🏠', '💼']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31', done: '2025-01-16' }); - }); - - test('sample 3: (B) Priority B task', () => { - const todo = parseTodo('(B) Priority B task'); - expect(todo.priority()).toBe('B'); - expect(todo.task()).toBe('Priority B task'); - expect(todo.isDone()).toBe(false); - }); - - test('sample 4: (C) Priority C task', () => { - const todo = parseTodo('(C) Priority C task'); - expect(todo.priority()).toBe('C'); - expect(todo.task()).toBe('Priority C task'); - }); - - test('sample 5: (Z) Priority Z task', () => { - const todo = parseTodo('(Z) Priority Z task'); - expect(todo.priority()).toBe('Z'); - expect(todo.task()).toBe('Priority Z task'); - }); - - test('sample 7: (1) Single digit priority task', () => { - const todo = parseTodo('(1) Single digit priority task'); - expect(todo.priority()).toBe('1'); - expect(todo.task()).toBe('Single digit priority task'); - }); - - test('sample 8: (999) Three digit priority task', () => { - const todo = parseTodo('(999) Three digit priority task'); - expect(todo.priority()).toBe('999'); - expect(todo.task()).toBe('Three digit priority task'); - }); - - test('sample 10: (A) 2025-01-15 Priority A with creation date', () => { - const todo = parseTodo('(A) 2025-01-15 Priority A with creation date'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority A with creation date'); - }); - - test('sample 11: (B) 2025-01-15 Priority B with creation date', () => { - const todo = parseTodo('(B) 2025-01-15 Priority B with creation date'); - expect(todo.priority()).toBe('B'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority B with creation date'); - }); - - test('sample 12: (123) 2025-01-15 Numeric priority with creation date', () => { - const todo = parseTodo('(123) 2025-01-15 Numeric priority with creation date'); - expect(todo.priority()).toBe('123'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Numeric priority with creation date'); - }); - - test('sample 14: Task with +MultiWordProject', () => { - const todo = parseTodo('Task with +MultiWordProject'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['MultiWordProject']); - }); - - test('sample 18: Task with @MultiWordContext', () => { - const todo = parseTodo('Task with @MultiWordContext'); - expect(todo.task()).toBe('Task with'); - expect(todo.contexts()).toEqual(['MultiWordContext']); - }); - - test('sample 21: 2025-01-15 Creation date with +project', () => { - const todo = parseTodo('2025-01-15 Creation date with +project'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Creation date with'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 22: 2025-01-15 Creation date with @context', () => { - const todo = parseTodo('2025-01-15 Creation date with @context'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Creation date with'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 23: 2025-01-15 Creation date with due:2025-12-31', () => { - const todo = parseTodo('2025-01-15 Creation date with due:2025-12-31'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Creation date with'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 24: (A) Task with priority and +project', () => { - const todo = parseTodo('(A) Task with priority and +project'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority and'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 25: (A) Task with priority and @context', () => { - const todo = parseTodo('(A) Task with priority and @context'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority and'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 26: (A) Task with priority and due:2025-12-31', () => { - const todo = parseTodo('(A) Task with priority and due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority and'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 27: (A) 2025-01-15 Priority, creation date, and +project', () => { - const todo = parseTodo('(A) 2025-01-15 Priority, creation date, and +project'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority, creation date, and'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 28: (A) 2025-01-15 Priority, creation date, and @context', () => { - const todo = parseTodo('(A) 2025-01-15 Priority, creation date, and @context'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority, creation date, and'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 29: (A) 2025-01-15 Priority, creation date, and due:2025-12-31', () => { - const todo = parseTodo('(A) 2025-01-15 Priority, creation date, and due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority, creation date, and'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 30: Task with +project @context', () => { - const todo = parseTodo('Task with +project @context'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 31: Task with +project due:2025-12-31', () => { - const todo = parseTodo('Task with +project due:2025-12-31'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 32: Task with @context due:2025-12-31', () => { - const todo = parseTodo('Task with @context due:2025-12-31'); - expect(todo.task()).toBe('Task with'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 33: Task with +project @context due:2025-12-31', () => { - const todo = parseTodo('Task with +project @context due:2025-12-31'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 34: 2025-01-15 Task with date +project @context', () => { - const todo = parseTodo('2025-01-15 Task with date +project @context'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task with date'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 35: 2025-01-15 Task with date +project due:2025-12-31', () => { - const todo = parseTodo('2025-01-15 Task with date +project due:2025-12-31'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task with date'); - expect(todo.projects()).toEqual(['project']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 36: 2025-01-15 Task with date @context due:2025-12-31', () => { - const todo = parseTodo('2025-01-15 Task with date @context due:2025-12-31'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task with date'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 37: 2025-01-15 Task with date +project @context due:2025-12-31', () => { - const todo = parseTodo('2025-01-15 Task with date +project @context due:2025-12-31'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task with date'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 38: (A) Task with priority +project @context', () => { - const todo = parseTodo('(A) Task with priority +project @context'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 39: (A) Task with priority +project due:2025-12-31', () => { - const todo = parseTodo('(A) Task with priority +project due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority'); - expect(todo.projects()).toEqual(['project']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 40: (A) Task with priority @context due:2025-12-31', () => { - const todo = parseTodo('(A) Task with priority @context due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 41: (A) Task with priority +project @context due:2025-12-31', () => { - const todo = parseTodo('(A) Task with priority +project @context due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('Task with priority'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 43: (B) 2025-01-14 Different priority and date +project2 @home due:2025-11-30', () => { - const todo = parseTodo('(B) 2025-01-14 Different priority and date +project2 @home due:2025-11-30'); - expect(todo.priority()).toBe('B'); - expect(todo.creationDate()).toBe('2025-01-14'); - expect(todo.task()).toBe('Different priority and date'); - expect(todo.projects()).toEqual(['project2']); - expect(todo.contexts()).toEqual(['home']); - expect(todo.dueDate()).toBe('2025-11-30'); - }); - - test('sample 44: (123) 2025-01-13 Numeric priority all params +projectX @work due:2025-10-31', () => { - const todo = parseTodo('(123) 2025-01-13 Numeric priority all params +projectX @work due:2025-10-31'); - expect(todo.priority()).toBe('123'); - expect(todo.creationDate()).toBe('2025-01-13'); - expect(todo.task()).toBe('Numeric priority all params'); - expect(todo.projects()).toEqual(['projectX']); - expect(todo.contexts()).toEqual(['work']); - expect(todo.dueDate()).toBe('2025-10-31'); - }); - - test('sample 46: x 2025-01-16 Completed task with completion date', () => { - const todo = parseTodo('x 2025-01-16 Completed task with completion date'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed task with completion date'); - }); - - test('sample 48: x 2025-01-16 Completed task with +project', () => { - const todo = parseTodo('x 2025-01-16 Completed task with +project'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed task with'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 49: x 2025-01-16 Completed task with @context', () => { - const todo = parseTodo('x 2025-01-16 Completed task with @context'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed task with'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 50: x 2025-01-16 Completed task with due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 Completed task with due:2025-12-31'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed task with'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 51: x 2025-01-16 2025-01-15 Completed with dates and +project', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed with dates and +project'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Completed with dates and'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 52: x 2025-01-16 2025-01-15 Completed with dates and @context', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed with dates and @context'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Completed with dates and'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 53: x 2025-01-16 2025-01-15 Completed with dates and due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Completed with dates and due:2025-12-31'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Completed with dates and'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 54: x 2025-01-16 Completed with +project @context', () => { - const todo = parseTodo('x 2025-01-16 Completed with +project @context'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 55: x 2025-01-16 Completed with +project due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 Completed with +project due:2025-12-31'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 56: x 2025-01-16 Completed with @context due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 Completed with @context due:2025-12-31'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 57: x 2025-01-16 Completed with +project @context due:2025-12-31', () => { - const todo = parseTodo('x 2025-01-16 Completed with +project @context due:2025-12-31'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 59: +project Task starting with project tag', () => { - const todo = parseTodo('+project Task starting with project tag'); - expect(todo.task()).toBe('Task starting with project tag'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 60: @context Task starting with context tag', () => { - const todo = parseTodo('@context Task starting with context tag'); - expect(todo.task()).toBe('Task starting with context tag'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 61: due:2025-12-31 Task starting with key value', () => { - const todo = parseTodo('due:2025-12-31 Task starting with key value'); - expect(todo.task()).toBe('Task starting with key value'); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 62: Task with special chars in +project-name @context_name', () => { - const todo = parseTodo('Task with special chars in +project-name @context_name'); - expect(todo.task()).toBe('Task with special chars in'); - expect(todo.projects()).toEqual(['project-name']); - expect(todo.contexts()).toEqual(['context_name']); - }); - - test('sample 63: Task with numbers in +project123 @context456', () => { - const todo = parseTodo('Task with numbers in +project123 @context456'); - expect(todo.task()).toBe('Task with numbers in'); - expect(todo.projects()).toEqual(['project123']); - expect(todo.contexts()).toEqual(['context456']); - }); - - test('sample 64: Task with dots in +project.name @context.place', () => { - const todo = parseTodo('Task with dots in +project.name @context.place'); - expect(todo.task()).toBe('Task with dots in'); - expect(todo.projects()).toEqual(['project.name']); - expect(todo.contexts()).toEqual(['context.place']); - }); - - test('sample 66: (1a) Mixed alphanumeric priority task', () => { - const todo = parseTodo('(1a) Mixed alphanumeric priority task'); - expect(todo.priority()).toBe('1a'); - expect(todo.task()).toBe('Mixed alphanumeric priority task'); - }); - - test('sample 67: (A1) Letter first mixed priority task', () => { - const todo = parseTodo('(A1) Letter first mixed priority task'); - expect(todo.priority()).toBe('A1'); - expect(todo.task()).toBe('Letter first mixed priority task'); - }); - - test('sample 68: " Task with leading spaces"', () => { - const todo = parseTodo(' Task with leading spaces'); - expect(todo.task()).toBe('Task with leading spaces'); - }); - - test('sample 70: " (A) 2025-01-15 Task with leading spaces and params "', () => { - const todo = parseTodo(' (A) 2025-01-15 Task with leading spaces and params '); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task with leading spaces and params'); - }); - - test('sample 72: Multiple same contexts @home @home @home', () => { - const todo = parseTodo('Multiple same contexts @home @home @home'); - expect(todo.task()).toBe('Multiple same contexts'); - expect(todo.contexts()).toEqual(['home', 'home', 'home']); - }); - - test('sample 73: Multiple same projects +projectA +projectA +projectA', () => { - const todo = parseTodo('Multiple same projects +projectA +projectA +projectA'); - expect(todo.task()).toBe('Multiple same projects'); - expect(todo.projects()).toEqual(['projectA', 'projectA', 'projectA']); - }); - - test('sample 74: rec:+1w Recurrence with plus sign', () => { - const todo = parseTodo('rec:+1w Recurrence with plus sign'); - expect(todo.task()).toBe('Recurrence with plus sign'); - expect(todo.keyValues()).toEqual({ rec: '+1w' }); - }); - - test('sample 75: Custom key custom:value in task', () => { - const todo = parseTodo('Custom key custom:value in task'); - expect(todo.task()).toBe('Custom key in task'); - expect(todo.keyValues()).toEqual({ custom: 'value' }); - }); - - test('sample 77: Task with URL https://example.com/path', () => { - const todo = parseTodo('Task with URL https://example.com/path'); - expect(todo.task()).toBe('Task with URL'); - expect(todo.keyValues()).toEqual({ 'https': '//example.com/path' }); - }); - - test('sample 79: (0) Zero priority task', () => { - const todo = parseTodo('(0) Zero priority task'); - expect(todo.priority()).toBe('0'); - expect(todo.task()).toBe('Zero priority task'); - }); - - test('sample 80: (ABC) Multi-letter priority task', () => { - const todo = parseTodo('(ABC) Multi-letter priority task'); - expect(todo.priority()).toBe('ABC'); - expect(todo.task()).toBe('Multi-letter priority task'); - }); - - test('sample 81: (999999) Very large numeric priority', () => { - const todo = parseTodo('(999999) Very large numeric priority +project'); - expect(todo.priority()).toBe('999999'); - expect(todo.task()).toBe('Very large numeric priority'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 82: Task with unicode émojis 🎯 and special chars', () => { - const todo = parseTodo('Task with unicode émojis 🎯 and special chars'); - expect(todo.task()).toBe('Task with unicode émojis 🎯 and special chars'); - }); - - test('sample 83: @context+project Wrong order but both present', () => { - const todo = parseTodo('@context+project Wrong order but both present'); - expect(todo.task()).toBe('Wrong order but both present'); - expect(todo.contexts()).toEqual(['context+project']); - }); - - test('sample 84: t:2025-01-15 Short key for date', () => { - const todo = parseTodo('t:2025-01-15 Short key for date'); - expect(todo.task()).toBe('Short key for date'); - expect(todo.keyValues()).toEqual({ t: '2025-01-15' }); - }); - - test('sample 85: p:1 Short key for priority', () => { - const todo = parseTodo('p:1 Short key for priority'); - expect(todo.task()).toBe('Short key for priority'); - expect(todo.keyValues()).toEqual({ p: '1' }); - }); - - test('sample 86: x 2025-01-16 Completed with double space', () => { - const todo = parseTodo('x 2025-01-16 Completed with double space'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with double space'); - }); - - test('sample 87: x 2025-01-16 2025-01-15 Double space between dates', () => { - const todo = parseTodo('x 2025-01-16 2025-01-15 Double space between dates'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Double space between dates'); - }); - - test('sample 88: (A) 2025-01-15 Double space after priority', () => { - const todo = parseTodo('(A) 2025-01-15 Double space after priority'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Double space after priority'); - }); - - test('sample 89: Task with +CamelCaseProject @PascalCaseContext', () => { - const todo = parseTodo('Task with +CamelCaseProject @PascalCaseContext'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['CamelCaseProject']); - expect(todo.contexts()).toEqual(['PascalCaseContext']); - }); - - test('sample 90: Task with +snake_case_project @kebab-case-context', () => { - const todo = parseTodo('Task with +snake_case_project @kebab-case-context'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['snake_case_project']); - expect(todo.contexts()).toEqual(['kebab-case-context']); - }); - - test('sample 91: +123project Project starting with numbers', () => { - const todo = parseTodo('+123project Project starting with numbers'); - expect(todo.task()).toBe('Project starting with numbers'); - expect(todo.projects()).toEqual(['123project']); - }); - - test('sample 92: @123context Context starting with numbers', () => { - const todo = parseTodo('@123context Context starting with numbers'); - expect(todo.task()).toBe('Context starting with numbers'); - expect(todo.contexts()).toEqual(['123context']); - }); - - test('sample 94: Escaped special chars \\+not-project \\@not-context', () => { - const todo = parseTodo('Escaped special chars \\+not-project \\@not-context'); - expect(todo.task()).toBe('Escaped special chars \\+not-project \\@not-context'); - expect(todo.projects()).toEqual([]); - expect(todo.contexts()).toEqual([]); - }); - - test('sample 96: +project@email.com Might confuse parser', () => { - const todo = parseTodo('+project@email.com Might confuse parser'); - expect(todo.task()).toBe('Might confuse parser'); - expect(todo.projects()).toEqual(['project@email.com']); - }); - - test('sample 97: Task with (parentheses) in description not priority', () => { - const todo = parseTodo('Task with (parentheses) in description not priority'); - expect(todo.task()).toBe('Task with (parentheses) in description not priority'); - expect(todo.priority()).toBeNull(); - }); - - test('sample 98: Task with [brackets] and {braces} in description', () => { - const todo = parseTodo('Task with [brackets] and {braces} in description'); - expect(todo.task()).toBe('Task with [brackets] and {braces} in description'); - }); - - test('sample 99: due:2025-12-31 @context Order matters for key:value', () => { - const todo = parseTodo('due:2025-12-31 @context Order matters for key:value'); - expect(todo.task()).toBe('Order matters for'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.keyValues()).toEqual({ due: '2025-12-31', key: 'value' }); - }); - - test('sample 100: @context due:2025-12-31 Different order same elements', () => { - const todo = parseTodo('@context due:2025-12-31 Different order same elements'); - expect(todo.task()).toBe('Different order same elements'); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 101: 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', () => { - const todo = parseTodo('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'); - expect(todo.task()).toBe('Long task description that contains many words and might wrap in some views but should still parse correctly with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 103: Task with emoji context @🏠home @💼work @🛒shopping', () => { - const todo = parseTodo('Task with emoji context @🏠home @💼work @🛒shopping'); - expect(todo.task()).toBe('Task with emoji context'); - expect(todo.contexts()).toEqual(['🏠home', '💼work', '🛒shopping']); - }); - - test('sample 104: Task with emoji in description 🎉 Complete the feature 🚀 +dev @office', () => { - const todo = parseTodo('Task with emoji in description 🎉 Complete the feature 🚀 +dev @office'); - expect(todo.task()).toBe('Task with emoji in description 🎉 Complete the feature 🚀'); - expect(todo.projects()).toEqual(['dev']); - expect(todo.contexts()).toEqual(['office']); - }); - - test('sample 105: (A) 2025-01-15 Priority task with emojis 🔥 +🚀deploy @🏠home due:2025-12-31', () => { - const todo = parseTodo('(A) 2025-01-15 Priority task with emojis 🔥 +🚀deploy @🏠home due:2025-12-31'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Priority task with emojis 🔥'); - expect(todo.projects()).toEqual(['🚀deploy']); - expect(todo.contexts()).toEqual(['🏠home']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 106: x 2025-01-16 Completed emoji task 🎉 +🚀project @🏠home', () => { - const todo = parseTodo('x 2025-01-16 Completed emoji task 🎉 +🚀project @🏠home'); - expect(todo.isDone()).toBe(true); - expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed emoji task 🎉'); - expect(todo.projects()).toEqual(['🚀project']); - expect(todo.contexts()).toEqual(['🏠home']); - }); - - test('sample 107: Mixed emoji and text +emoji🎯project @context🔥fire', () => { - const todo = parseTodo('Mixed emoji and text +emoji🎯project @context🔥fire'); - expect(todo.task()).toBe('Mixed emoji and text'); - expect(todo.projects()).toEqual(['emoji🎯project']); - expect(todo.contexts()).toEqual(['context🔥fire']); - }); - - test('sample 108: Multiple emojis in one tag +🚀🎯🔥 @🏠💼🛒', () => { - const todo = parseTodo('Multiple emojis in one tag +🚀🎯🔥 @🏠💼🛒'); - expect(todo.task()).toBe('Multiple emojis in one tag'); - expect(todo.projects()).toEqual(['🚀🎯🔥']); - expect(todo.contexts()).toEqual(['🏠💼🛒']); - }); - - test('sample 109: Emoji with skin tone +👋🏻project @👨‍💻dev', () => { - const todo = parseTodo('Emoji with skin tone +👋🏻project @👨‍💻dev'); - expect(todo.task()).toBe('Emoji with skin tone'); - expect(todo.projects()).toEqual(['👋🏻project']); - expect(todo.contexts()).toEqual(['👨‍💻dev']); - }); - - test('sample 110: Complex emoji +👨‍👩‍👧‍👦family @🏳️‍🌈pride', () => { - const todo = parseTodo('Complex emoji +👨‍👩‍👧‍👦family @🏳️‍🌈pride'); - expect(todo.task()).toBe('Complex emoji'); - expect(todo.projects()).toEqual(['👨‍👩‍👧‍👦family']); - expect(todo.contexts()).toEqual(['🏳️‍🌈pride']); - }); - - test('sample 111: Zero-width joiner emoji +🧑‍🚀astronaut @👨‍⚕️doctor', () => { - const todo = parseTodo('Zero-width joiner emoji +🧑‍🚀astronaut @👨‍⚕️doctor'); - expect(todo.task()).toBe('Zero-width joiner emoji'); - expect(todo.projects()).toEqual(['🧑‍🚀astronaut']); - expect(todo.contexts()).toEqual(['👨‍⚕️doctor']); - }); - - test('sample 112: Task with Japanese text タスク +プロジェクト @コンテキスト', () => { - const todo = parseTodo('Task with Japanese text タスク +プロジェクト @コンテキスト'); - expect(todo.task()).toBe('Task with Japanese text タスク'); - expect(todo.projects()).toEqual(['プロジェクト']); - expect(todo.contexts()).toEqual(['コンテキスト']); - }); - - test('sample 113: Task with Chinese 任务 +项目 @上下文', () => { - const todo = parseTodo('Task with Chinese 任务 +项目 @上下文'); - expect(todo.task()).toBe('Task with Chinese 任务'); - expect(todo.projects()).toEqual(['项目']); - expect(todo.contexts()).toEqual(['上下文']); - }); - - test('sample 114: Task with Korean 작업 +프로젝트 @컨텍스트', () => { - const todo = parseTodo('Task with Korean 작업 +프로젝트 @컨텍스트'); - expect(todo.task()).toBe('Task with Korean 작업'); - expect(todo.projects()).toEqual(['프로젝트']); - expect(todo.contexts()).toEqual(['컨텍스트']); - }); - - test('sample 115: Task with Arabic مهمة +مشروع @سياق', () => { - const todo = parseTodo('Task with Arabic مهمة +مشروع @سياق'); - expect(todo.task()).toBe('Task with Arabic مهمة'); - expect(todo.projects()).toEqual(['مشروع']); - expect(todo.contexts()).toEqual(['سياق']); - }); - - test('sample 116: Task with Hebrew משימה +פרויקט @הקשר', () => { - const todo = parseTodo('Task with Hebrew משימה +פרויקט @הקשר'); - expect(todo.task()).toBe('Task with Hebrew משימה'); - expect(todo.projects()).toEqual(['פרויקט']); - expect(todo.contexts()).toEqual(['הקשר']); - }); - - test('sample 117: Task with Cyrillic задача +проект @контекст', () => { - const todo = parseTodo('Task with Cyrillic задача +проект @контекст'); - expect(todo.task()).toBe('Task with Cyrillic задача'); - expect(todo.projects()).toEqual(['проект']); - expect(todo.contexts()).toEqual(['контекст']); - }); - - test('sample 118: Task with mixed scripts +日本語project @中文context', () => { - const todo = parseTodo('Task with mixed scripts +日本語project @中文context'); - expect(todo.task()).toBe('Task with mixed scripts'); - expect(todo.projects()).toEqual(['日本語project']); - expect(todo.contexts()).toEqual(['中文context']); - }); - - test('sample 119: RTL text עברית task with +project @context due:2025-12-31', () => { - const todo = parseTodo('RTL text עברית task with +project @context due:2025-12-31'); - expect(todo.task()).toBe('RTL text עברית task with'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 120: Combining marks task̃ +projec̈t @conte͂xt', () => { - const todo = parseTodo('Combining marks task̃ +projec̈t @conte͂xt'); - expect(todo.task()).toBe('Combining marks task̃'); - expect(todo.projects()).toEqual(['projec̈t']); - expect(todo.contexts()).toEqual(['conte͂xt']); - }); - - test('sample 121: Task with !@#$%^&*() special chars in description', () => { - const todo = parseTodo('Task with !@#$%^&*() special chars in description'); - expect(todo.task()).toBe('Task with !@#$%^&*() special chars in description'); - }); - - test('sample 122: Task with +project!name @context#tag', () => { - const todo = parseTodo('Task with +project!name @context#tag'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project!name']); - expect(todo.contexts()).toEqual(['context#tag']); - }); - - test('sample 123: Task with +pro-ject_name @con.text_name', () => { - const todo = parseTodo('Task with +pro-ject_name @con.text_name'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['pro-ject_name']); - expect(todo.contexts()).toEqual(['con.text_name']); - }); - - test('sample 124: Task with +project/subproject @context\\subcontext', () => { - const todo = parseTodo('Task with +project/subproject @context\\subcontext'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project/subproject']); - expect(todo.contexts()).toEqual(['context\\subcontext']); - }); - - test('sample 125: Task with +project|pipe @context&and', () => { - const todo = parseTodo('Task with +project|pipe @context&and'); - expect(todo.task()).toBe('Task with'); - expect(todo.projects()).toEqual(['project|pipe']); - expect(todo.contexts()).toEqual(['context&and']); - }); - - test('sample 126: Task with quotes "quoted +project" and \'single @quotes\'', () => { - const todo = parseTodo('Task with quotes "quoted +project" and \'single @quotes\''); - expect(todo.task()).toBe('Task with quotes "quoted and \'single'); - expect(todo.projects()).toEqual(['project"']); - expect(todo.contexts()).toEqual(['quotes\'']); - }); - - test('sample 127: Task with backticks `code +example` @context', () => { - const todo = parseTodo('Task with backticks `code +example` @context'); - expect(todo.task()).toBe('Task with backticks `code'); - expect(todo.projects()).toEqual(['example`']); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 128: Task with brackets [not a link] +project', () => { - const todo = parseTodo('Task with brackets [not a link] +project'); - expect(todo.task()).toBe('Task with brackets [not a link]'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 129: Task with angle brackets @context', () => { - const todo = parseTodo('Task with angle brackets @context'); - expect(todo.task()).toBe('Task with angle brackets '); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 130: Task with curly braces {not json} +project', () => { - const todo = parseTodo('Task with curly braces {not json} +project'); - expect(todo.task()).toBe('Task with curly braces {not json}'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 131: Task with backslash\\\\ in middle', () => { - const todo = parseTodo('Task with backslash\\\\ in middle'); - expect(todo.task()).toBe('Task with backslash\\\\ in middle'); - }); - - test('sample 132: Task with zero​width​space between words', () => { - const todo = parseTodo('Task with zero​width​space between words'); - expect(todo.task()).toBe('Task with zero​width​space between words'); - }); - - test('sample 133: Task with soft­hyphen in word', () => { - const todo = parseTodo('Task with soft­hyphen in word'); - expect(todo.task()).toBe('Task with soft­hyphen in word'); - }); - - test('sample 134: (A) 2025-01-15 Task +project1 +project2 +project3 @context1 @context2 @context3 due:2025-12-31 rec:1w pri:H custom:value foo:bar', () => { - const todo = parseTodo('(A) 2025-01-15 Task +project1 +project2 +project3 @context1 @context2 @context3 due:2025-12-31 rec:1w pri:H custom:value foo:bar'); - expect(todo.priority()).toBe('A'); - expect(todo.creationDate()).toBe('2025-01-15'); - expect(todo.task()).toBe('Task'); - expect(todo.projects()).toEqual(['project1', 'project2', 'project3']); - expect(todo.contexts()).toEqual(['context1', 'context2', 'context3']); expect(todo.keyValues()).toEqual({ due: '2025-12-31', rec: '1w', - pri: 'H', - custom: 'value', - foo: 'bar' + note: 'test' }); - expect(todo.dueDate()).toBe('2025-12-31'); }); - - test('sample 136: (123456789) Very long numeric priority +project', () => { - const todo = parseTodo('(123456789) Very long numeric priority +project'); - expect(todo.priority()).toBe('123456789'); - expect(todo.task()).toBe('Very long numeric priority'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 137: (A1B2C3) Complex alphanumeric priority @context', () => { - const todo = parseTodo('(A1B2C3) Complex alphanumeric priority @context'); - expect(todo.priority()).toBe('A1B2C3'); - expect(todo.task()).toBe('Complex alphanumeric priority'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 138: Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task', () => { - const todo = parseTodo('Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task'); - expect(todo.creationDate()).toBeNull(); - expect(todo.task()).toBe('Multiple dates 2025-01-15 2025-01-16 2025-01-17 in task'); - }); - - test('sample 139: Task with very long project name +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers', () => { - const todo = parseTodo('Task with very long project name +ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers'); - expect(todo.task()).toBe('Task with very long project name'); - expect(todo.projects()).toEqual(['ThisIsAVeryLongProjectNameThatMightCauseIssuesWithSomeSystemsThatHaveLengthLimitsOnIdentifiers']); - }); - - test('sample 143: 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', () => { - const todo = parseTodo('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'); - expect(todo.task()).toBe('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 and continues even further with more text'); - expect(todo.projects()).toEqual(['project']); - expect(todo.contexts()).toEqual(['context']); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 144: +project Task with only project no description after', () => { - const todo = parseTodo('+project Task with only project no description after'); - expect(todo.task()).toBe('Task with only project no description after'); - expect(todo.projects()).toEqual(['project']); - }); - - test('sample 145: @context Task with only context no description after', () => { - const todo = parseTodo('@context Task with only context no description after'); - expect(todo.task()).toBe('Task with only context no description after'); - expect(todo.contexts()).toEqual(['context']); - }); - - test('sample 146: due:2025-12-31 Task with only key:value no description after', () => { - const todo = parseTodo('due:2025-12-31 Task with only key:value no description after'); - expect(todo.task()).toBe('Task with only no description after'); - expect(todo.keyValues()).toEqual({ due: '2025-12-31', key: 'value' }); - expect(todo.dueDate()).toBe('2025-12-31'); - }); - - test('sample 149: x 2025-01-16 Completed with date only no description', () => { - const todo = parseTodo('x 2025-01-16 Completed with date only no description'); + it('parses completed task with all elements', () => { + const todoText = 'x 2025-01-16 2025-01-15 Completed +project @context due:2025-12-31'; + const todo = parseTodo(todoText); expect(todo.isDone()).toBe(true); expect(todo.completionDate()).toBe('2025-01-16'); - expect(todo.task()).toBe('Completed with date only no description'); + expect(todo.creationDate()).toBe('2025-01-15'); + expect(todo.task()).toBe('Completed'); + expect(todo.projects()).toEqual(['project']); + expect(todo.contexts()).toEqual(['context']); + expect(todo.dueDate()).toBe('2025-12-31'); }); - - test('sample 151: Task with HTML tags bold text', () => { - const todo = parseTodo('Task with HTML tags bold text'); - expect(todo.task()).toBe('Task with HTML tags bold text'); - }); - - test('sample 152: Task with & HTML entity & test', () => { - const todo = parseTodo('Task with & HTML entity & test'); - expect(todo.task()).toBe('Task with & HTML entity & test'); - }); - - test('sample 153: Task with < and > comparison operators', () => { - const todo = parseTodo('Task with < and > comparison operators'); - expect(todo.task()).toBe('Task with < and > comparison operators'); - }); - - test('sample 154: Task with && and || logical operators', () => { - const todo = parseTodo('Task with && and || logical operators'); - expect(todo.task()).toBe('Task with && and || logical operators'); - }); - - test('sample 155: Task with -> arrow notation', () => { - const todo = parseTodo('Task with -> arrow notation'); - expect(todo.task()).toBe('Task with -> arrow notation'); - }); - - test('sample 156: Task with => fat arrow', () => { - const todo = parseTodo('Task with => fat arrow'); - expect(todo.task()).toBe('Task with => fat arrow'); - }); - - test('sample 157: Task with ... ellipsis', () => { - const todo = parseTodo('Task with ... ellipsis'); - expect(todo.task()).toBe('Task with ... ellipsis'); - }); - - test('sample 158: Task with file.extension.txt mentions', () => { - const todo = parseTodo('Task with file.extension.txt mentions'); - expect(todo.task()).toBe('Task with file.extension.txt mentions'); - }); - - test('sample 159: Task with https://example.com/+project/@context/page', () => { - const todo = parseTodo('Task with https://example.com/+project/@context/page'); - expect(todo.task()).toBe('Task with'); - expect(todo.keyValues()).toEqual({ 'https': '//example.com/+project/@context/page' }); - }); - - test('sample 160: " Heavily indented task with many spaces"', () => { - const todo = parseTodo(' Heavily indented task with many spaces'); - expect(todo.task()).toBe('Heavily indented task with many spaces'); - }); - - test('sample 161: Task with tab indentation', () => { - const todo = parseTodo('\t\tTask with tab indentation'); - expect(todo.task()).toBe('Task with tab indentation'); - }); - - test('sample 162: Mixed spaces and tabs indentation', () => { - const todo = parseTodo('\t \tMixed spaces and tabs indentation'); - expect(todo.task()).toBe('Mixed spaces and tabs indentation'); - }); - - test('sample 163: Task with trailing newline', () => { - const todo = parseTodo('Task with trailing newline'); - expect(todo.task()).toBe('Task with trailing newline'); - }); - - test('sample 164: (A) No space after priority works as valid format', () => { - const todo = parseTodo('(A) No space after priority works as valid format'); + it('handles very long task descriptions efficiently', () => { + const longDescription = 'Very '.repeat(100) + 'long task'; + const todoText = `(A) 2025-01-15 ${longDescription} +project @context due:2025-12-31`; + const todo = parseTodo(todoText); + expect(todo.task()).toBe(longDescription); expect(todo.priority()).toBe('A'); - expect(todo.task()).toBe('No space after priority works as valid format'); + expect(todo.projects()).toEqual(['project']); }); - - test('sample 165: Task without trailing newline', () => { - const todo = parseTodo('Task without trailing newline'); - expect(todo.task()).toBe('Task without trailing newline'); + it('gracefully handles malformed input', () => { + const malformedInputs = [ + '(((A))) Multiple parentheses', + '+++ Multiple plus signs', + '@@@ Multiple at signs', + 'key::: Multiple colons', + '::::: Only colons' + ]; + malformedInputs.forEach(input => { + expect(() => parseTodo(input)).not.toThrow(); + const todo = parseTodo(input); + expect(typeof todo.task()).toBe('string'); + }); }); }); - - describe('Position information tests', () => { - test('basic position accuracy', () => { - const line = 'x (A) 2016-05-20 task +project @context due:2025-12-31'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - // 各位置情報をチェック - expect(positions.completion).toEqual({ value: 'x', start: 0, end: 1 }); - expect(positions.priority).toEqual({ value: '(A)', start: 2, end: 5 }); - expect(positions.completionDate).toEqual({ value: '2016-05-20', start: 6, end: 16 }); // 完了タスクで1つの日付は完了日 - expect(positions.creationDate).toBeNull(); - expect(positions.projects).toEqual([{ value: '+project', start: 22, end: 30 }]); - expect(positions.contexts).toEqual([{ value: '@context', start: 31, end: 39 }]); - expect(positions.keyValues).toEqual([{ value: 'due:2025-12-31', start: 40, end: 54 }]); - }); - - test('position accuracy with spaces', () => { - const line = ' x (A) 2016-05-20 task'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - expect(positions.completion).toEqual({ value: 'x', start: 2, end: 3 }); - expect(positions.priority).toEqual({ value: '(A)', start: 6, end: 9 }); - expect(positions.completionDate).toEqual({ value: '2016-05-20', start: 12, end: 22 }); // 完了タスクで1つの日付は完了日 - expect(positions.creationDate).toBeNull(); - }); - - test('position accuracy with emojis', () => { - const line = 'Task with 🚀 emoji +🎯project @🏠home'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - // 各要素が元の文字列から正確に抽出できることを確認 - positions.projects.forEach(project => { - const extractedValue = line.substring(project.start, project.end); - expect(extractedValue).toBe(project.value); - }); - - positions.contexts.forEach(context => { - const extractedValue = line.substring(context.start, context.end); - expect(extractedValue).toBe(context.value); + describe('Legacy Compatibility Samples', () => { + it('parses standard Todo.txt format examples', () => { + const samples = [ + { + text: 'Call Mom +Family @phone', + expected: { + task: 'Call Mom', + projects: ['Family'], + contexts: ['phone'] + } + }, + { + text: '(A) Thank Mom for the birthday card +Family @calls', + expected: { + priority: 'A', + task: 'Thank Mom for the birthday card', + projects: ['Family'], + contexts: ['calls'] + } + }, + { + text: 'x 2011-03-02 2011-03-01 Call Mom +Family @calls', + expected: { + isDone: true, + completionDate: '2011-03-02', + creationDate: '2011-03-01', + task: 'Call Mom', + projects: ['Family'], + contexts: ['calls'] + } + } + ]; + samples.forEach(sample => { + const todo = parseTodo(sample.text); + Object.entries(sample.expected).forEach(([key, value]) => { + if (key === 'isDone') { + expect(todo.isDone()).toBe(value); + } else if (key === 'projects') { + expect(todo.projects()).toEqual(value); + } else if (key === 'contexts') { + expect(todo.contexts()).toEqual(value); + } else if (key === 'completionDate') { + expect(todo.completionDate()).toBe(value); + } else if (key === 'creationDate') { + expect(todo.creationDate()).toBe(value); + } else if (key === 'task') { + expect(todo.task()).toBe(value); + } else if (key === 'priority') { + expect(todo.priority()).toBe(value); + } + }); }); }); - - test('position accuracy complex example', () => { - const line = '(A) 2025-01-15 Complex task +project1 +project2 @context1 @context2 due:2025-12-31 rec:1w'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - // すべての要素の位置情報が正確であることを確認 - if (positions.priority) { - const extractedValue = line.substring(positions.priority.start, positions.priority.end); - expect(extractedValue).toBe(positions.priority.value); - } - - if (positions.creationDate) { - const extractedValue = line.substring(positions.creationDate.start, positions.creationDate.end); - expect(extractedValue).toBe(positions.creationDate.value); - } - - positions.projects.forEach(project => { - const extractedValue = line.substring(project.start, project.end); - expect(extractedValue).toBe(project.value); - }); - - positions.contexts.forEach(context => { - const extractedValue = line.substring(context.start, context.end); - expect(extractedValue).toBe(context.value); - }); - - positions.keyValues.forEach(keyValue => { - const extractedValue = line.substring(keyValue.start, keyValue.end); - expect(extractedValue).toBe(keyValue.value); - }); + }); +}); +describe('isCompletedTask', () => { + const completedTaskTests = [ + // True cases + { text: 'x Simple completed task', expected: true }, + { text: 'x (A) Priority completed task', expected: true }, + { text: 'x 2023-12-20 Task with completion date', expected: true }, + { text: 'x 2023-12-20 2023-12-15 Task with both dates', expected: true }, + { text: ' x Indented completed task', expected: true }, + { text: '\tx Tab indented completed task', expected: true }, + { text: '\t x Mixed indented completed task', expected: true }, + { text: 'x Task +project @context due:2023-12-20', expected: true }, + { text: ' x (B) 2023-12-20 Complex task +work @office', expected: true }, + // False cases + { text: 'Incomplete task', expected: false }, + { text: '(A) Priority incomplete task', expected: false }, + { text: '2023-12-20 Task with creation date', expected: false }, + { text: 'Call Mom +Family @phone', expected: false }, + { text: 'x', expected: false }, + { text: 'xCompleted task', expected: false }, + { text: 'X Uppercase x', expected: false }, + { text: 'xx Double x', expected: false }, + { text: '', expected: false }, + { text: ' ', expected: false }, + { text: '\t\t', expected: false }, + { text: 'Not x in middle', expected: false }, + { text: 'End with x', expected: false } + ]; + + completedTaskTests.forEach(({ text, expected }) => { + it(`should return ${expected} for "${text}"`, () => { + expect(isCompletedTask(text)).toBe(expected); }); - - test('completed task with completion date position', () => { - const line = 'x 2025-01-16 2025-01-15 Completed task'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - expect(positions.completion).toEqual({ value: 'x', start: 0, end: 1 }); - expect(positions.completionDate).toEqual({ value: '2025-01-16', start: 2, end: 12 }); - expect(positions.creationDate).toEqual({ value: '2025-01-15', start: 13, end: 23 }); + }); +}); +describe('isDueDateKeyValue', () => { + const dueDateTests = [ + { text: 'due:2023-12-20', expected: true }, + { text: 'due:2023-01-01', expected: true }, + { text: 'due:', expected: true }, + { text: 'rec:d', expected: false }, + { text: 'priority:A', expected: false }, + { text: 'project:work', expected: false }, + { text: 'duedate:2023-12-20', expected: false }, + { text: 'due 2023-12-20', expected: false }, + { text: '', expected: false }, + { text: 'due', expected: false }, + { text: ':due:2023-12-20', expected: false } + ]; + + dueDateTests.forEach(({ text, expected }) => { + it(`should return ${expected} for "${text}"`, () => { + expect(isDueDateKeyValue(text)).toBe(expected); }); - - test('completed task with single date (completion date)', () => { - const line = 'x 2025-01-15 Single date completed task'; - const todo = parseTodo(line); - const positions = todo.getElementPositions(); - - expect(positions.completion).toEqual({ value: 'x', start: 0, end: 1 }); - expect(positions.completionDate).toEqual({ value: '2025-01-15', start: 2, end: 12 }); // 完了タスクで1つの日付は完了日 - expect(positions.creationDate).toBeNull(); + }); +}); +describe('isRecurrenceKeyValue', () => { + const recurrenceTests = [ + { text: 'rec:d', expected: true }, + { text: 'rec:w', expected: true }, + { text: 'rec:m', expected: true }, + { text: 'rec:+1d', expected: true }, + { text: 'rec:', expected: true }, + { text: 'due:2023-12-20', expected: false }, + { text: 'priority:A', expected: false }, + { text: 'repeat:d', expected: false }, + { text: 'rec d', expected: false }, + { text: '', expected: false }, + { text: 'rec', expected: false }, + { text: ':rec:d', expected: false } + ]; + + recurrenceTests.forEach(({ text, expected }) => { + it(`should return ${expected} for "${text}"`, () => { + expect(isRecurrenceKeyValue(text)).toBe(expected); }); - - test('position information should be null for missing elements', () => { - const line = 'Simple task without any parameters'; - 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('isCommentLine', () => { + const commentLineTests = [ + { text: '# This is a comment', expected: true }, + { text: '#Another comment', expected: true }, + { text: '# Comment with multiple words', expected: true }, + { text: '#', expected: true }, + { text: ' # Indented comment', expected: true }, + { text: '\t# Tab indented comment', expected: true }, + { text: ' \t # Mixed whitespace comment', expected: true }, + { text: '## Double hash', expected: true }, + { text: 'Task without hash', expected: false }, + { text: '(A) Priority task', expected: false }, + { text: 'x Completed task', expected: false }, + { text: 'Task with # hash in middle', expected: false }, + { text: 'Task ending with #', expected: false }, + { text: '', expected: false }, + { text: ' ', expected: false }, + { text: '\t\t', expected: false } + ]; + + commentLineTests.forEach(({ text, expected }) => { + it(`should return ${expected} for "${text}"`, () => { + expect(isCommentLine(text)).toBe(expected); }); }); }); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/recurrence.test.ts b/src/utils/todotxt-core/__tests__/recurrence.test.ts new file mode 100644 index 0000000..591a7dc --- /dev/null +++ b/src/utils/todotxt-core/__tests__/recurrence.test.ts @@ -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); + }); + } + }); + }); +}); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/sorter.test.ts b/src/utils/todotxt-core/__tests__/sorter.test.ts index 7f1b8ea..5ada5e6 100644 --- a/src/utils/todotxt-core/__tests__/sorter.test.ts +++ b/src/utils/todotxt-core/__tests__/sorter.test.ts @@ -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]); + } + } + }); + }); }); }); }); \ No newline at end of file diff --git a/src/utils/todotxt-core/__tests__/string-builder.test.ts b/src/utils/todotxt-core/__tests__/string-builder.test.ts new file mode 100644 index 0000000..db7150d --- /dev/null +++ b/src/utils/todotxt-core/__tests__/string-builder.test.ts @@ -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()); + }); + }); + }); \ No newline at end of file diff --git a/src/utils/todotxt-core/completion-date.ts b/src/utils/todotxt-core/completion-date.ts new file mode 100644 index 0000000..91c103f --- /dev/null +++ b/src/utils/todotxt-core/completion-date.ts @@ -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}`; +} \ No newline at end of file diff --git a/src/utils/todotxt-core/index.ts b/src/utils/todotxt-core/index.ts index a3c60b1..cb6fdcf 100644 --- a/src/utils/todotxt-core/index.ts +++ b/src/utils/todotxt-core/index.ts @@ -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'; \ No newline at end of file +} 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'; \ No newline at end of file diff --git a/src/utils/todotxt-core/parser.ts b/src/utils/todotxt-core/parser.ts index f6d442a..779a518 100644 --- a/src/utils/todotxt-core/parser.ts +++ b/src/utils/todotxt-core/parser.ts @@ -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; - - private _positions: { +function createTodo( + task: string, + isDone: boolean, + priority: string | null, + creationDate: string | null, + completionDate: string | null, + projects: string[], + contexts: string[], + keyValues: Record, + 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, - 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 { 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 = {}; - // 位置情報を保持 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('#'); } \ No newline at end of file diff --git a/src/utils/todotxt-core/recurrence.ts b/src/utils/todotxt-core/recurrence.ts new file mode 100644 index 0000000..30785c5 --- /dev/null +++ b/src/utils/todotxt-core/recurrence.ts @@ -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); +} \ No newline at end of file diff --git a/src/utils/todotxt-core/sorter.ts b/src/utils/todotxt-core/sorter.ts index 601e1c3..d859981 100644 --- a/src/utils/todotxt-core/sorter.ts +++ b/src/utils/todotxt-core/sorter.ts @@ -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); }); } \ No newline at end of file diff --git a/src/utils/todotxt-core/string-builder.ts b/src/utils/todotxt-core/string-builder.ts new file mode 100644 index 0000000..5c9819b --- /dev/null +++ b/src/utils/todotxt-core/string-builder.ts @@ -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; +} \ No newline at end of file diff --git a/style.md b/style.md new file mode 100644 index 0000000..0b9f956 --- /dev/null +++ b/style.md @@ -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. 全プラットフォームでテスト \ No newline at end of file diff --git a/styles.css b/styles.css index a3a2783..4af22a0 100644 --- a/styles.css +++ b/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; } /* ファイル識別のためのマーカー */ diff --git a/todo.txt b/todo.txt deleted file mode 100644 index 9f69550..0000000 --- a/todo.txt +++ /dev/null @@ -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 @context -Task with curly braces {not json} +project -Task with backslash\\ in middle -Task with zero​width​space between words -Task with soft­hyphen 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 bold 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 \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c44b729..42a0922 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "ES5", "ES6", "ES7" - ] + ], + "esModuleInterop": true }, "include": [ "**/*.ts"